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,382 @@
|
|
|
1
|
+
// Opens a Family's Desktop App for one Account (spec §3.20, ADR-0015): macOS only, built-in claude and codex only.
|
|
2
|
+
// The app bundle is started unchanged with `open`; another Account's instance gets its own App Data folder. Every
|
|
3
|
+
// program sideby runs here (`open`, `ps`, `osascript`, `plutil`, `mdfind`) goes through `DesktopDeps.exec`, so
|
|
4
|
+
// tests never start a real app.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { desktopDef } from '../families/desktop.js';
|
|
9
|
+
import { UserError } from './errors.js';
|
|
10
|
+
import { lstatOrNull, statOrNull } from './fs-safe.js';
|
|
11
|
+
import { checkItem } from './share-modes.js';
|
|
12
|
+
const OPEN = '/usr/bin/open';
|
|
13
|
+
const PS = '/bin/ps';
|
|
14
|
+
const OSASCRIPT = '/usr/bin/osascript';
|
|
15
|
+
const PLUTIL = '/usr/bin/plutil';
|
|
16
|
+
const MDFIND = '/usr/bin/mdfind';
|
|
17
|
+
function defaultExec(cmd, args, env) {
|
|
18
|
+
return new Promise((resolve) => execFile(cmd, [...args], { timeout: 15_000, maxBuffer: 16 * 1024 * 1024, ...(env ? { env: env } : {}) }, (err, stdout, stderr) => {
|
|
19
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
20
|
+
resolve({ code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
const overrides = new WeakMap();
|
|
24
|
+
/**
|
|
25
|
+
* Internal: makes every Desktop App call made with this Runtime use `deps` instead of the machine. Only
|
|
26
|
+
* `createRuntime` calls it, from its `desktop` option, which is the one way to replace the machine (tests).
|
|
27
|
+
*/
|
|
28
|
+
export function bindDesktopDeps(rt, deps) {
|
|
29
|
+
overrides.set(rt, deps);
|
|
30
|
+
}
|
|
31
|
+
function depsFor(rt) {
|
|
32
|
+
const o = overrides.get(rt) ?? {};
|
|
33
|
+
return {
|
|
34
|
+
platform: o.platform ?? process.platform,
|
|
35
|
+
appDirs: o.appDirs ?? ['/Applications', join(rt.paths.home, 'Applications')],
|
|
36
|
+
exec: o.exec ?? defaultExec,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
|
|
40
|
+
export function desktopAppOf(plugins, familyId) {
|
|
41
|
+
const builtin = plugins.some((p) => p.source === 'builtin' && p.families.includes(familyId));
|
|
42
|
+
return builtin ? desktopDef(familyId) : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Where a non-main Account's App Data lives: `~/Library/Application Support/sideby/<family>/<name>`. */
|
|
45
|
+
export function appDataDir(home, account) {
|
|
46
|
+
return join(home, 'Library', 'Application Support', 'sideby', account.family, account.name);
|
|
47
|
+
}
|
|
48
|
+
const plistString = (xml, key) => {
|
|
49
|
+
const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(xml);
|
|
50
|
+
return m?.[1];
|
|
51
|
+
};
|
|
52
|
+
/** Reads string keys of an app's Info.plist: XML directly, a binary plist through `plutil`. */
|
|
53
|
+
async function bundleInfo(deps, app) {
|
|
54
|
+
const file = join(app, 'Contents', 'Info.plist');
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(file, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
if (text.startsWith('<?xml') || text.includes('<plist'))
|
|
63
|
+
return { id: plistString(text, 'CFBundleIdentifier'), exe: plistString(text, 'CFBundleExecutable') };
|
|
64
|
+
const read = async (key) => {
|
|
65
|
+
const r = await deps.exec(PLUTIL, ['-extract', key, 'raw', '-o', '-', file]);
|
|
66
|
+
return r.code === 0 ? r.stdout.trim() || undefined : undefined;
|
|
67
|
+
};
|
|
68
|
+
return { id: await read('CFBundleIdentifier'), exe: await read('CFBundleExecutable') };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Finds the app by bundle id: the usual names in the app folders first, then (with `spotlight`) Spotlight. Listing
|
|
72
|
+
* Accounts leaves Spotlight out to stay fast; opening one asks it too.
|
|
73
|
+
*/
|
|
74
|
+
export async function findApp(def, deps, opts = {}) {
|
|
75
|
+
const check = async (path) => {
|
|
76
|
+
if (!(await statOrNull(path))?.isDirectory())
|
|
77
|
+
return null;
|
|
78
|
+
const info = await bundleInfo(deps, path);
|
|
79
|
+
if (info.id !== def.bundleId)
|
|
80
|
+
return null;
|
|
81
|
+
return { path, executable: info.exe || basename(path, '.app') };
|
|
82
|
+
};
|
|
83
|
+
for (const dir of deps.appDirs)
|
|
84
|
+
for (const name of def.bundleNames) {
|
|
85
|
+
const found = await check(join(dir, name));
|
|
86
|
+
if (found)
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
if (!opts.spotlight)
|
|
90
|
+
return null;
|
|
91
|
+
const r = await deps.exec(MDFIND, [`kMDItemCFBundleIdentifier == "${def.bundleId}"`]);
|
|
92
|
+
if (r.code !== 0)
|
|
93
|
+
return null;
|
|
94
|
+
for (const line of r.stdout.split('\n')) {
|
|
95
|
+
const p = line.trim();
|
|
96
|
+
if (!p.endsWith('.app') || p.includes('/.Trash/'))
|
|
97
|
+
continue;
|
|
98
|
+
const found = await check(p);
|
|
99
|
+
if (found)
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
|
|
105
|
+
async function processes(deps) {
|
|
106
|
+
const r = await deps.exec(PS, ['-axo', 'pid=,command=']);
|
|
107
|
+
if (r.code !== 0)
|
|
108
|
+
return null;
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const line of r.stdout.split('\n')) {
|
|
111
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
112
|
+
if (m)
|
|
113
|
+
out.push({ pid: Number(m[1]), command: m[2].trimEnd() });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** The arguments after the app's own executable, when `p` is the app's main process (not a helper). */
|
|
118
|
+
function mainArgs(app, p) {
|
|
119
|
+
const exe = `/${basename(app.path)}/Contents/MacOS/${app.executable}`;
|
|
120
|
+
const at = p.command.indexOf(exe);
|
|
121
|
+
if (at === -1)
|
|
122
|
+
return null;
|
|
123
|
+
const rest = p.command.slice(at + exe.length);
|
|
124
|
+
return rest === '' || rest.startsWith(' ') ? rest : null;
|
|
125
|
+
}
|
|
126
|
+
/** The running instance of this Account: the one with its App Data, or for the Main Account the one without any. */
|
|
127
|
+
export function instanceOf(app, appData, procs) {
|
|
128
|
+
return procs.find((p) => {
|
|
129
|
+
const args = mainArgs(app, p);
|
|
130
|
+
if (args === null)
|
|
131
|
+
return false;
|
|
132
|
+
if (appData === null)
|
|
133
|
+
return !args.includes('--user-data-dir=');
|
|
134
|
+
const flag = ` --user-data-dir=${appData}`;
|
|
135
|
+
const at = args.indexOf(flag);
|
|
136
|
+
if (at === -1)
|
|
137
|
+
return false;
|
|
138
|
+
const after = args.slice(at + flag.length);
|
|
139
|
+
return after === '' || after.startsWith(' ') || after === '/' || after.startsWith('/ ');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Brings a running app to the front as a Dock click does: a reopen event (`aevt/rapp`) to that one process, so an
|
|
144
|
+
* app whose windows were all closed opens one again, then NSRunningApplication activation. Activation alone makes
|
|
145
|
+
* such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
|
|
146
|
+
* permission.
|
|
147
|
+
*/
|
|
148
|
+
async function activate(deps, pid) {
|
|
149
|
+
const p = Math.trunc(pid);
|
|
150
|
+
const script = [
|
|
151
|
+
"ObjC.import('AppKit');",
|
|
152
|
+
`const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${p});`,
|
|
153
|
+
'if (!app.isNil()) {',
|
|
154
|
+
` const target = $.NSAppleEventDescriptor.descriptorWithProcessIdentifier(${p});`,
|
|
155
|
+
' const reopen = $.NSAppleEventDescriptor.appleEventWithEventClassEventIDTargetDescriptorReturnIDTransactionID(0x61657674, 0x72617070, target, -1, 0);',
|
|
156
|
+
' reopen.sendEventWithOptionsTimeoutError(1, 5, null);',
|
|
157
|
+
'}',
|
|
158
|
+
"app.isNil() ? 'missing' : (app.activateWithOptions($.NSApplicationActivateAllWindows | $.NSApplicationActivateIgnoringOtherApps) ? 'ok' : 'refused')",
|
|
159
|
+
].join(' ');
|
|
160
|
+
const r = await deps.exec(OSASCRIPT, ['-l', 'JavaScript', '-e', script]);
|
|
161
|
+
return r.code === 0 && r.stdout.trim() === 'ok';
|
|
162
|
+
}
|
|
163
|
+
/** The environment `open` runs with: the caller's, without anything that would pick another Account (spec §3.5). */
|
|
164
|
+
function openEnv(base, family, def) {
|
|
165
|
+
const env = { ...base };
|
|
166
|
+
for (const v of family.hijackVars)
|
|
167
|
+
delete env[v];
|
|
168
|
+
delete env[family.selectVar];
|
|
169
|
+
for (const v of def.clearVars)
|
|
170
|
+
delete env[v];
|
|
171
|
+
for (const k of Object.keys(env))
|
|
172
|
+
if (k.startsWith('SIDEBY_') || isSessionVar(k))
|
|
173
|
+
delete env[k];
|
|
174
|
+
return env;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Variables an agent session sets for its own children; a Desktop App started from that session must not inherit
|
|
178
|
+
* them. Named one by one, so a user setting such as `CLAUDE_CODE_USE_BEDROCK` still reaches the app.
|
|
179
|
+
*/
|
|
180
|
+
const SESSION_PREFIXES = ['CODEX_COMPANION_', 'CODEX_SANDBOX'];
|
|
181
|
+
const SESSION_VARS = new Set([
|
|
182
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
183
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
184
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
185
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
186
|
+
'CLAUDE_PID',
|
|
187
|
+
'CLAUDE_PLUGIN_DATA',
|
|
188
|
+
'CLAUDECODE',
|
|
189
|
+
'CODEX_THREAD_ID',
|
|
190
|
+
]);
|
|
191
|
+
function isSessionVar(k) {
|
|
192
|
+
return SESSION_VARS.has(k) || SESSION_PREFIXES.some((p) => k.startsWith(p));
|
|
193
|
+
}
|
|
194
|
+
function notMac(platform) {
|
|
195
|
+
return new UserError(`opening a desktop app per account works on macOS only for now (this is ${platform}); use \`sideby run <account>\` for the terminal`, { code: 'not-macos' });
|
|
196
|
+
}
|
|
197
|
+
function notInstalled(def, deps) {
|
|
198
|
+
return new UserError(`${def.title} is not installed (looked in ${deps.appDirs.join(' and ')}); install it from ${def.installUrl}, then try again`, { code: 'app-not-installed' });
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
|
|
202
|
+
* same Account, never ends a process, never touches the app bundle or the Host account directory.
|
|
203
|
+
*/
|
|
204
|
+
export async function openDesktopApp(rt, ref) {
|
|
205
|
+
const deps = depsFor(rt);
|
|
206
|
+
const account = await rt.resolve(ref);
|
|
207
|
+
const family = rt.families.get(account.family);
|
|
208
|
+
const def = family ? desktopAppOf(rt.plugins, account.family) : undefined;
|
|
209
|
+
if (!family || !def) {
|
|
210
|
+
const have = [...rt.families.keys()].filter((id) => desktopAppOf(rt.plugins, id));
|
|
211
|
+
throw new UserError(`${account.ref}: ${family?.title ?? account.family} has no desktop app sideby can open${have.length ? ` (only ${have.join(' and ')} accounts do)` : ''}; use \`sideby run ${account.ref}\``, { code: 'no-desktop-app' });
|
|
212
|
+
}
|
|
213
|
+
if (account.kind === 'api')
|
|
214
|
+
throw new UserError(`${account.ref} is an API account; ${def.title} signs in with a subscription only, so open it for a subscription account, or use \`sideby run ${account.ref}\``, { code: 'api-account' });
|
|
215
|
+
if (deps.platform !== 'darwin')
|
|
216
|
+
throw notMac(deps.platform);
|
|
217
|
+
const app = await findApp(def, deps, { spotlight: true });
|
|
218
|
+
if (!app)
|
|
219
|
+
throw notInstalled(def, deps);
|
|
220
|
+
const home = rt.paths.home;
|
|
221
|
+
const appData = account.isMain ? null : appDataDir(home, account);
|
|
222
|
+
const listed = await processes(deps);
|
|
223
|
+
const procs = listed ?? [];
|
|
224
|
+
const base = {
|
|
225
|
+
ref: account.ref,
|
|
226
|
+
family: account.family,
|
|
227
|
+
app: def.title,
|
|
228
|
+
appPath: app.path,
|
|
229
|
+
...(appData ? { appData } : {}),
|
|
230
|
+
createdAppData: false,
|
|
231
|
+
linked: [],
|
|
232
|
+
notices: [],
|
|
233
|
+
};
|
|
234
|
+
const running = instanceOf(app, appData, procs);
|
|
235
|
+
if (running) {
|
|
236
|
+
const focused = await activate(deps, running.pid);
|
|
237
|
+
return {
|
|
238
|
+
...base,
|
|
239
|
+
action: focused ? 'focused' : 'already-running',
|
|
240
|
+
notices: focused
|
|
241
|
+
? []
|
|
242
|
+
: [
|
|
243
|
+
`${def.title} is already open for ${account.ref}; switch to it from the Dock (sideby does not start a second one)`,
|
|
244
|
+
],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
let created = false;
|
|
248
|
+
let fresh = false;
|
|
249
|
+
const linked = [];
|
|
250
|
+
if (appData) {
|
|
251
|
+
const st = await lstatOrNull(appData);
|
|
252
|
+
if (st && !st.isDirectory())
|
|
253
|
+
throw new UserError(`${appData} is not a folder; move it away, then try again (sideby keeps ${def.title}'s data for ${account.ref} there)`, { code: 'app-data-invalid' });
|
|
254
|
+
if (!st) {
|
|
255
|
+
await mkdir(appData, { recursive: true, mode: 0o700 });
|
|
256
|
+
await chmod(appData, 0o700);
|
|
257
|
+
created = true;
|
|
258
|
+
}
|
|
259
|
+
const shared = new Set(def.sharedFiles.map((f) => f.path));
|
|
260
|
+
fresh = created || (await readdir(appData)).every((e) => shared.has(e));
|
|
261
|
+
if (fresh)
|
|
262
|
+
for (const f of def.sharedFiles) {
|
|
263
|
+
const target = join(appData, f.path);
|
|
264
|
+
const main = join(home, f.mainPath);
|
|
265
|
+
// Never replace anything: link only where nothing is, and only to a file the Main Account has.
|
|
266
|
+
if ((await lstatOrNull(target)) || !(await statOrNull(main))?.isFile())
|
|
267
|
+
continue;
|
|
268
|
+
await symlink(main, target);
|
|
269
|
+
linked.push(f.path);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const notices = [];
|
|
273
|
+
if (!account.isMain && def.signInInApp && fresh) {
|
|
274
|
+
const others = procs.some((p) => mainArgs(app, p) !== null);
|
|
275
|
+
notices.push(others
|
|
276
|
+
? `First time for ${account.ref}: sign in inside ${def.title} with the account you use for ${account.ref}, not one it suggests from another window. Close the other ${def.title} windows while you sign in, because the browser may send the sign-in back to one of them`
|
|
277
|
+
: `First time for ${account.ref}: sign in inside ${def.title} with the account you use for ${account.ref}, not one it suggests from another window`);
|
|
278
|
+
}
|
|
279
|
+
if (!def.signInInApp) {
|
|
280
|
+
const login = await Promise.resolve()
|
|
281
|
+
.then(() => family.loginState?.(account))
|
|
282
|
+
.catch(() => undefined);
|
|
283
|
+
if (login === 'logged-out')
|
|
284
|
+
notices.push(`${account.ref} is not signed in yet; run \`sideby login ${account.ref}\` first. If ${def.title} offers "Welcome back" with your main account's email, do not pick it: that signs this account in as the main one. Choose to use another account`);
|
|
285
|
+
}
|
|
286
|
+
// The Main Account needs `-n` too: without it, `open` brings forward whichever instance runs, often another
|
|
287
|
+
// Account's window. A second Main instance cannot start here, since a running one was found above.
|
|
288
|
+
// Without a process list a running Main instance cannot be ruled out, so plain `open -a` (no second instance).
|
|
289
|
+
const mainArgsList = listed ? ['-n', '-a', app.path] : ['-a', app.path];
|
|
290
|
+
const args = appData ? def.openArgs(app.path, account.dir, appData) : mainArgsList;
|
|
291
|
+
const r = await deps.exec(OPEN, args, openEnv(rt.env, family, def));
|
|
292
|
+
if (r.code !== 0)
|
|
293
|
+
throw new UserError(`could not open ${def.title} for ${account.ref}: ${r.stderr.trim() || `open exited with ${r.code}`}; try opening ${app.path} once by hand, then try again`, { code: 'open-failed' });
|
|
294
|
+
return { ...base, action: 'started', createdAppData: created, linked, command: [OPEN, ...args], notices };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Desktop App state per Account ref for `sideby list` and the Panel: only on macOS, only Subscription Accounts of a
|
|
298
|
+
* Family that has one. One process listing for all of them; never throws.
|
|
299
|
+
*/
|
|
300
|
+
export async function desktopStatuses(rt, accounts) {
|
|
301
|
+
const out = new Map();
|
|
302
|
+
const deps = depsFor(rt);
|
|
303
|
+
if (deps.platform !== 'darwin')
|
|
304
|
+
return out;
|
|
305
|
+
try {
|
|
306
|
+
const eligible = accounts.filter((a) => a.kind !== 'api' && desktopAppOf(rt.plugins, a.family));
|
|
307
|
+
if (!eligible.length)
|
|
308
|
+
return out;
|
|
309
|
+
const apps = new Map();
|
|
310
|
+
for (const id of new Set(eligible.map((a) => a.family)))
|
|
311
|
+
apps.set(id, await findApp(desktopDef(id), deps));
|
|
312
|
+
const procs = [...apps.values()].some(Boolean) ? ((await processes(deps)) ?? []) : [];
|
|
313
|
+
for (const a of eligible) {
|
|
314
|
+
const app = apps.get(a.family) ?? null;
|
|
315
|
+
out.set(a.ref, {
|
|
316
|
+
app: desktopDef(a.family).title,
|
|
317
|
+
installed: app !== null,
|
|
318
|
+
running: app
|
|
319
|
+
? Boolean(instanceOf(app, a.isMain ? null : appDataDir(rt.paths.home, a), procs))
|
|
320
|
+
: false,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// A display nicety: a failure leaves the field out.
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Adds `desktopApp` to each status whose Account has one, for `sideby list` and the Panel. The process listing runs
|
|
331
|
+
* while `statuses` is still being built; the order of the statuses is kept.
|
|
332
|
+
*/
|
|
333
|
+
export async function withDesktopApps(rt, accounts, statuses) {
|
|
334
|
+
const [list, desktop] = await Promise.all([statuses, desktopStatuses(rt, accounts)]);
|
|
335
|
+
return list.map((s) => {
|
|
336
|
+
const d = desktop.get(s.ref);
|
|
337
|
+
return d ? { ...s, desktopApp: d } : s;
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
|
|
342
|
+
* only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
|
|
343
|
+
* Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
|
|
344
|
+
* a fix may only add a missing link inside it, never replace a real file.
|
|
345
|
+
*/
|
|
346
|
+
export async function checkAppData(plugins, home, account) {
|
|
347
|
+
if (account.isMain || account.kind === 'api')
|
|
348
|
+
return [];
|
|
349
|
+
const def = desktopAppOf(plugins, account.family);
|
|
350
|
+
if (!def?.sharedFiles.length)
|
|
351
|
+
return [];
|
|
352
|
+
const dir = appDataDir(home, account);
|
|
353
|
+
if (!(await lstatOrNull(dir))?.isDirectory())
|
|
354
|
+
return [];
|
|
355
|
+
const out = [];
|
|
356
|
+
for (const f of def.sharedFiles) {
|
|
357
|
+
const item = { path: f.path, mode: 'link' };
|
|
358
|
+
const mainPath = join(home, f.mainPath);
|
|
359
|
+
const p = join(dir, item.path);
|
|
360
|
+
const res = await checkItem({ account, item, mainPath, accountPath: p, accountDir: dir, force: false });
|
|
361
|
+
for (const r of res.results) {
|
|
362
|
+
if (r.level === 'ok')
|
|
363
|
+
continue;
|
|
364
|
+
// The Desktop App writes its own file when the Main Account had none at first open, and may replace the link
|
|
365
|
+
// later: not the user's doing, so a warning with the way to share, never a failure and never replaced.
|
|
366
|
+
const own = r.code === 'link.real-file'
|
|
367
|
+
? {
|
|
368
|
+
level: 'warn',
|
|
369
|
+
message: `${item.path} is ${def.title}'s own copy for this account, not a link to the main account's`,
|
|
370
|
+
hint: `${def.title} made it itself; that is fine. To share the main account's instead, quit that window, then: mv "${p}" "${p}.local" && ln -s "${mainPath}" "${p}"`,
|
|
371
|
+
}
|
|
372
|
+
: {};
|
|
373
|
+
out.push({
|
|
374
|
+
...r,
|
|
375
|
+
...own,
|
|
376
|
+
item: `app-data/${item.path}`,
|
|
377
|
+
message: `${def.title} data: ${own.message ?? r.message}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HookBus } from '../plugins/bus.ts';
|
|
2
|
-
import type { Account, FamilyDef, Finding } from '../types.ts';
|
|
2
|
+
import type { Account, CheckResult, FamilyDef, Finding } from '../types.ts';
|
|
3
3
|
export interface AccountReport {
|
|
4
4
|
ref: string;
|
|
5
5
|
family: string;
|
|
@@ -35,6 +35,11 @@ export interface DoctorInput {
|
|
|
35
35
|
force?: boolean;
|
|
36
36
|
/** This sideby's version: when set, installed sideby skills of the checked Families are compared with it. */
|
|
37
37
|
skillVersion?: string;
|
|
38
|
+
/**
|
|
39
|
+
* More core checks for an Account that is not the Main Account, merged after its Shared Items. They are not
|
|
40
|
+
* counted in `shared`; a result's `fix` runs with the others under `--fix` and must stay within what it checked.
|
|
41
|
+
*/
|
|
42
|
+
extraChecks?: (account: Account) => Promise<CheckResult[]>;
|
|
38
43
|
}
|
|
39
44
|
export declare function runDoctor(input: DoctorInput): Promise<DoctorReport>;
|
|
40
45
|
/** Applies every fixable Finding once, in order; one failing fix never stops the others. */
|
package/dist/src/core/doctor.js
CHANGED
|
@@ -43,6 +43,25 @@ async function checkAccount(input, family, account) {
|
|
|
43
43
|
if (r.level !== 'ok')
|
|
44
44
|
findings.push(toFinding(r, account.ref, 'core'));
|
|
45
45
|
}
|
|
46
|
+
// Extra checks (for example a Desktop App's data folder) add Findings but never count toward `shared`.
|
|
47
|
+
// A failing extra check costs only its own Findings, never the whole run.
|
|
48
|
+
try {
|
|
49
|
+
for (const r of (await input.extraChecks?.(account)) ?? [])
|
|
50
|
+
if (r.level !== 'ok')
|
|
51
|
+
findings.push(toFinding(r, account.ref, 'core'));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
findings.push({
|
|
55
|
+
level: 'warn',
|
|
56
|
+
account: account.ref,
|
|
57
|
+
item: 'extra',
|
|
58
|
+
code: 'extra-check.error',
|
|
59
|
+
message: `could not finish an extra check of ${account.ref}: ${e instanceof Error ? e.message : String(e)}`,
|
|
60
|
+
hint: 'run sideby doctor again; if it keeps failing, check that the folder it names is readable',
|
|
61
|
+
fixable: false,
|
|
62
|
+
source: 'core',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
46
65
|
if (looksLikeBackup(account.name))
|
|
47
66
|
findings.push({
|
|
48
67
|
level: 'warn',
|
|
@@ -10,6 +10,8 @@ export interface HandoffIo {
|
|
|
10
10
|
}
|
|
11
11
|
/** How long the Host gets to exit after SIGTERM (time for its SessionEnd hooks) before sideby gives up. */
|
|
12
12
|
export declare const EXIT_GRACE_MS = 10000;
|
|
13
|
+
/** Accounts the decision left out and why (spec §3.21), as `; skipped claude:main (…)`, or ''. */
|
|
14
|
+
export declare function skippedText(d: HandoffDecision): string;
|
|
13
15
|
export declare function explain(d: HandoffDecision): string;
|
|
14
16
|
/** `args` with `extra` before the first `--`, so a user's own `--` cannot turn sideby's options into prompt text. */
|
|
15
17
|
export declare function beforeDashes(args: readonly string[], extra: readonly string[]): string[];
|
|
@@ -18,14 +18,20 @@ const PLAN_REFRESH_MS = 60_000;
|
|
|
18
18
|
const READY_POLL_MS = 1000;
|
|
19
19
|
const hookCommand = (family) => (event) => `sideby handoff-hook ${family} ${event}`;
|
|
20
20
|
const clock = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
21
|
+
/** Accounts the decision left out and why (spec §3.21), as `; skipped claude:main (…)`, or ''. */
|
|
22
|
+
export function skippedText(d) {
|
|
23
|
+
if (d.kind === 'wait' || !d.skipped?.length)
|
|
24
|
+
return '';
|
|
25
|
+
return `; skipped ${d.skipped.map((x) => `${x.ref} (${x.reason})`).join(', ')}`;
|
|
26
|
+
}
|
|
21
27
|
export function explain(d) {
|
|
22
28
|
if (d.kind === 'wait')
|
|
23
29
|
return `the full window resets at ${clock(d.until)}, so waiting beats moving on`;
|
|
24
30
|
if (d.kind === 'none')
|
|
25
|
-
return d.earliestReset
|
|
31
|
+
return `${d.earliestReset
|
|
26
32
|
? `${d.reason}; ${d.earliestReset.ref} is back first, at ${clock(d.earliestReset.at)}`
|
|
27
|
-
: d.reason
|
|
28
|
-
return `next: ${d.ref}`;
|
|
33
|
+
: d.reason}${skippedText(d)}`;
|
|
34
|
+
return `next: ${d.ref}${skippedText(d)}`;
|
|
29
35
|
}
|
|
30
36
|
/** Resolves with the ready file once the hook writes one for `runId`; `stop()` ends the watch. */
|
|
31
37
|
function watchReady(dir, runId) {
|
|
@@ -287,7 +293,7 @@ opts = {}) {
|
|
|
287
293
|
await writeJsonFile(join(dir, FILES.chain), hops);
|
|
288
294
|
await rm(join(dir, FILES.ready), { force: true });
|
|
289
295
|
await rm(join(dir, FILES.request), { force: true });
|
|
290
|
-
io.err(`sideby: ${account.ref} saved its session and is handing over because ${WHY[ready.trigger]}; next is ${decision.ref}. Brief: ${done.path}`);
|
|
296
|
+
io.err(`sideby: ${account.ref} saved its session and is handing over because ${WHY[ready.trigger]}; next is ${decision.ref}${skippedText(decision)}. Brief: ${done.path}`);
|
|
291
297
|
if (settings.countdownSeconds > 0 &&
|
|
292
298
|
(await countdown(io, settings.countdownSeconds, decision.ref, account.ref))) {
|
|
293
299
|
io.err(`sideby: staying in ${account.ref}`);
|
|
@@ -9,14 +9,22 @@ export interface SelectCandidate {
|
|
|
9
9
|
login: LoginState | 'not-needed';
|
|
10
10
|
quota: QuotaResult;
|
|
11
11
|
identity?: AccountIdentity;
|
|
12
|
+
/** The other Accounts of its Same Login (spec §3.21); they share its Quota. */
|
|
13
|
+
sameLogin?: readonly string[];
|
|
12
14
|
/** Whether the Account's Host is on PATH. */
|
|
13
15
|
installed: boolean;
|
|
14
16
|
/** Whether its Family can start as the next Account (has `handoff`, and the run would be eligible). */
|
|
15
17
|
receive: boolean;
|
|
16
18
|
}
|
|
19
|
+
/** An Account the Handoff Order lists that was left out, and why (spec §3.21). */
|
|
20
|
+
export interface SkippedAccount {
|
|
21
|
+
ref: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
17
24
|
export type HandoffDecision = {
|
|
18
25
|
kind: 'pick';
|
|
19
26
|
ref: string;
|
|
27
|
+
skipped?: SkippedAccount[];
|
|
20
28
|
} | {
|
|
21
29
|
kind: 'wait';
|
|
22
30
|
until: string;
|
|
@@ -27,6 +35,7 @@ export type HandoffDecision = {
|
|
|
27
35
|
ref: string;
|
|
28
36
|
at: string;
|
|
29
37
|
};
|
|
38
|
+
skipped?: SkippedAccount[];
|
|
30
39
|
};
|
|
31
40
|
/** Organizations when both have one, else email domains when both have one, else the same. */
|
|
32
41
|
export declare function sameOrganization(a?: AccountIdentity, b?: AccountIdentity): boolean;
|
|
@@ -70,7 +70,14 @@ export function selectNext(input) {
|
|
|
70
70
|
const ranked = planHandoff(origin.family, own, now, { includeApi: true }).accounts.map((e) => byRef.get(e.ref));
|
|
71
71
|
list = [...ranked, ...input.candidates.filter((c) => c.family !== origin.family && c.kind === 'api')];
|
|
72
72
|
}
|
|
73
|
-
|
|
73
|
+
// The same login shares the origin's Quota, so taking over there gains nothing (spec §3.21).
|
|
74
|
+
const sameLogin = new Set(origin.sameLogin ?? []);
|
|
75
|
+
const skipped = list
|
|
76
|
+
.filter((c) => sameLogin.has(c.ref))
|
|
77
|
+
.map((c) => ({ ref: c.ref, reason: `same login as ${origin.ref}, so it shares the same quota` }));
|
|
78
|
+
const withSkipped = skipped.length ? { skipped } : {};
|
|
79
|
+
const eligible = list.filter((c) => !sameLogin.has(c.ref) &&
|
|
80
|
+
!used.has(c.ref) &&
|
|
74
81
|
c.installed &&
|
|
75
82
|
c.receive &&
|
|
76
83
|
// An API account pays per request, so it gets past no vendor's limit: sameFamily does not apply to it.
|
|
@@ -80,7 +87,7 @@ export function selectNext(input) {
|
|
|
80
87
|
const first = states.find(({ e }) => e.state === 'ready' || e.state === 'unknown') ??
|
|
81
88
|
states.find(({ e }) => e.state === 'api');
|
|
82
89
|
if (first)
|
|
83
|
-
return { kind: 'pick', ref: first.c.ref };
|
|
90
|
+
return { kind: 'pick', ref: first.c.ref, ...withSkipped };
|
|
84
91
|
const full = states
|
|
85
92
|
.filter(({ e }) => e.state === 'full' && e.resetsAt)
|
|
86
93
|
.sort((a, b) => Date.parse(a.e.resetsAt) - Date.parse(b.e.resetsAt))[0];
|
|
@@ -90,5 +97,6 @@ export function selectNext(input) {
|
|
|
90
97
|
? 'every account that may take over is full or signed out'
|
|
91
98
|
: 'no account may take over',
|
|
92
99
|
...(full ? { earliestReset: { ref: full.c.ref, at: full.e.resetsAt } } : {}),
|
|
100
|
+
...withSkipped,
|
|
93
101
|
};
|
|
94
102
|
}
|
|
@@ -68,6 +68,18 @@ export async function handoffStatus(rt) {
|
|
|
68
68
|
}
|
|
69
69
|
if (unknown.length)
|
|
70
70
|
notes.push(`order names ${unknown.join(', ')}, which sideby cannot find; they are skipped`);
|
|
71
|
+
// Same Login (spec §3.21): one note per group, so a skipped account in the order is no surprise.
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const a of accounts.filter((x) => x.family === f.id)) {
|
|
74
|
+
if (seen.has(a.ref))
|
|
75
|
+
continue;
|
|
76
|
+
const peers = await rt.sameLogin(a);
|
|
77
|
+
if (!peers.length)
|
|
78
|
+
continue;
|
|
79
|
+
for (const r of [a.ref, ...peers])
|
|
80
|
+
seen.add(r);
|
|
81
|
+
notes.push(`${[a.ref, ...peers].join(', ')} are signed in as the same login and share one quota; a handoff from one never goes to another`);
|
|
82
|
+
}
|
|
71
83
|
families.push({
|
|
72
84
|
family: f.id,
|
|
73
85
|
starts,
|
|
@@ -7,6 +7,8 @@ export interface HandoffCandidate {
|
|
|
7
7
|
kind: Account['kind'];
|
|
8
8
|
login: LoginState | 'not-needed';
|
|
9
9
|
quota: QuotaResult;
|
|
10
|
+
/** The other Accounts of its Same Login (spec §3.21). */
|
|
11
|
+
sameLogin?: readonly string[];
|
|
10
12
|
}
|
|
11
13
|
export interface HandoffEntry {
|
|
12
14
|
ref: string;
|
|
@@ -18,9 +20,18 @@ export interface HandoffEntry {
|
|
|
18
20
|
resetsAt?: string;
|
|
19
21
|
/** When the Quota was recorded; it is only as fresh as the Account's last session. */
|
|
20
22
|
observedAt?: string;
|
|
23
|
+
/** The Account the Quota was read from, when it is another Account of the same login (spec §3.21). */
|
|
24
|
+
quotaFrom?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The current Account, when this one is signed in as the same login (spec §3.21): it shares that Quota, so it is
|
|
27
|
+
* never picked.
|
|
28
|
+
*/
|
|
29
|
+
sameLoginAs?: string;
|
|
21
30
|
}
|
|
22
31
|
export interface HandoffPlan {
|
|
23
32
|
family: string;
|
|
33
|
+
/** The Account this process runs in (`SIDEBY_ACCOUNT`), when it belongs to the Family. */
|
|
34
|
+
from?: string;
|
|
24
35
|
/** The recommended Account, or null when none can be used now. */
|
|
25
36
|
pick: string | null;
|
|
26
37
|
/** Every Account, best first; the ones that cannot be picked come last. */
|
|
@@ -38,4 +49,5 @@ export interface HandoffPlan {
|
|
|
38
49
|
}
|
|
39
50
|
export declare function planHandoff(family: string, candidates: readonly HandoffCandidate[], now: Date, opts?: {
|
|
40
51
|
includeApi?: boolean;
|
|
52
|
+
from?: string;
|
|
41
53
|
}): HandoffPlan;
|
package/dist/src/core/handoff.js
CHANGED
|
@@ -9,6 +9,7 @@ function entry(c, now) {
|
|
|
9
9
|
kind: c.kind,
|
|
10
10
|
pressure,
|
|
11
11
|
...(c.quota.status === 'ok' ? { observedAt: c.quota.observedAt } : {}),
|
|
12
|
+
...(c.quota.status === 'ok' && c.quota.from ? { quotaFrom: c.quota.from } : {}),
|
|
12
13
|
};
|
|
13
14
|
if (c.kind === 'api')
|
|
14
15
|
return { ...base, state: 'api' };
|
|
@@ -29,6 +30,7 @@ function entry(c, now) {
|
|
|
29
30
|
return { ...base, state: 'full', ...(last === undefined ? {} : { resetsAt: new Date(last).toISOString() }) };
|
|
30
31
|
}
|
|
31
32
|
export function planHandoff(family, candidates, now, opts = {}) {
|
|
33
|
+
const current = opts.from ? candidates.find((c) => c.ref === opts.from) : undefined;
|
|
32
34
|
const byRef = new Map(candidates.map((c) => [c.ref, c]));
|
|
33
35
|
const tie = (a, b) => {
|
|
34
36
|
const x = byRef.get(a.ref);
|
|
@@ -36,15 +38,22 @@ export function planHandoff(family, candidates, now, opts = {}) {
|
|
|
36
38
|
return Number(y.isMain) - Number(x.isMain) || x.name.localeCompare(y.name, undefined, { numeric: true });
|
|
37
39
|
};
|
|
38
40
|
const accounts = candidates
|
|
39
|
-
.map((c) =>
|
|
40
|
-
|
|
41
|
+
.map((c) => {
|
|
42
|
+
const e = entry(c, now);
|
|
43
|
+
return current?.sameLogin?.includes(c.ref) ? { ...e, sameLoginAs: current.ref } : e;
|
|
44
|
+
})
|
|
45
|
+
.sort((a, b) =>
|
|
46
|
+
// A Same Login partner of the current Account can never be picked, so it goes with the others that cannot.
|
|
47
|
+
Number(Boolean(a.sameLoginAs)) - Number(Boolean(b.sameLoginAs)) ||
|
|
48
|
+
ORDER[a.state] - ORDER[b.state] ||
|
|
41
49
|
(a.state === 'ready' ? a.pressure - b.pressure : 0) ||
|
|
42
50
|
(a.state === 'full' ? resetTime(a) - resetTime(b) || 0 : 0) ||
|
|
43
51
|
tie(a, b));
|
|
44
|
-
const usable = (e) => e.
|
|
52
|
+
const usable = (e) => !e.sameLoginAs &&
|
|
53
|
+
(e.state === 'ready' || e.state === 'unknown' || (e.state === 'api' && Boolean(opts.includeApi)));
|
|
45
54
|
const pick = accounts.find(usable)?.ref ?? null;
|
|
46
55
|
const hasQuota = accounts.some((e) => e.state === 'ready' || e.state === 'full');
|
|
47
|
-
const plan = { family, pick, accounts, hasQuota };
|
|
56
|
+
const plan = { family, ...(current ? { from: current.ref } : {}), pick, accounts, hasQuota };
|
|
48
57
|
if (pick === null) {
|
|
49
58
|
const first = accounts.find((e) => e.state === 'full' && e.resetsAt);
|
|
50
59
|
if (first?.resetsAt)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Account, AccountIdentity, QuotaResult } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Families whose Identity tells one Quota apart. Codex is left out: its Identity is an email only, and one email can
|
|
4
|
+
* belong to several ChatGPT workspaces, each with its own Quota (ADR-0016).
|
|
5
|
+
*/
|
|
6
|
+
export declare const SAME_LOGIN_FAMILIES: ReadonlySet<string>;
|
|
7
|
+
export interface LoginMember {
|
|
8
|
+
ref: string;
|
|
9
|
+
family: string;
|
|
10
|
+
kind: Account['kind'];
|
|
11
|
+
identity?: AccountIdentity;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether two Accounts are the same login: same Family (one that groups), both subscriptions, the same email, and the
|
|
15
|
+
* same organization (both have one and it is equal, or neither has one). Without an email nothing groups.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isSameLogin(a: LoginMember, b: LoginMember): boolean;
|
|
18
|
+
/** Ref → the other refs of its Same Login, in the order given; Accounts without one are left out. */
|
|
19
|
+
export declare function sameLoginGroups(members: readonly LoginMember[]): Map<string, string[]>;
|
|
20
|
+
/**
|
|
21
|
+
* The Quota an Account of a Same Login shows: the freshest `ok` reading of the group (its own wins a tie), marked
|
|
22
|
+
* with `from` when it is another member's. Without any `ok` reading the Account keeps its own result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sharedQuota(ref: string, readings: ReadonlyMap<string, QuotaResult>, peers: readonly string[]): QuotaResult;
|