sideby 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +7 -0
  4. package/dist/src/cli/commands.js +96 -2
  5. package/dist/src/cli/json-schemas.js +52 -0
  6. package/dist/src/cli/main.js +31 -2
  7. package/dist/src/core/config.d.ts +14 -0
  8. package/dist/src/core/config.js +9 -0
  9. package/dist/src/core/desktop-app.d.ts +96 -0
  10. package/dist/src/core/desktop-app.js +371 -0
  11. package/dist/src/core/doctor.d.ts +6 -1
  12. package/dist/src/core/doctor.js +19 -0
  13. package/dist/src/core/model-choice.d.ts +41 -0
  14. package/dist/src/core/model-choice.js +128 -0
  15. package/dist/src/core/tuning-apply.js +1 -1
  16. package/dist/src/families/claude/desktop.d.ts +6 -0
  17. package/dist/src/families/claude/desktop.js +28 -0
  18. package/dist/src/families/claude/index.js +2 -0
  19. package/dist/src/families/claude/model.d.ts +2 -0
  20. package/dist/src/families/claude/model.js +42 -0
  21. package/dist/src/families/claude/usage.d.ts +2 -0
  22. package/dist/src/families/claude/usage.js +13 -1
  23. package/dist/src/families/codex/desktop.d.ts +7 -0
  24. package/dist/src/families/codex/desktop.js +25 -0
  25. package/dist/src/families/codex/index.js +2 -0
  26. package/dist/src/families/codex/model.d.ts +2 -0
  27. package/dist/src/families/codex/model.js +59 -0
  28. package/dist/src/families/codex/rollout.d.ts +5 -0
  29. package/dist/src/families/codex/rollout.js +45 -3
  30. package/dist/src/families/desktop.d.ts +27 -0
  31. package/dist/src/families/desktop.js +9 -0
  32. package/dist/src/families/shared/model-args.d.ts +7 -0
  33. package/dist/src/families/shared/model-args.js +28 -0
  34. package/dist/src/panel/handler.d.ts +3 -0
  35. package/dist/src/panel/handler.js +63 -5
  36. package/dist/src/panel/i18n.d.ts +27 -0
  37. package/dist/src/panel/i18n.js +54 -0
  38. package/dist/src/panel/page-logic.js +2 -2
  39. package/dist/src/panel/page-script.js +143 -2
  40. package/dist/src/panel/page-styles.js +5 -1
  41. package/dist/src/runtime.d.ts +16 -0
  42. package/dist/src/runtime.js +5 -0
  43. package/dist/src/types.d.ts +29 -0
  44. package/package.json +3 -2
  45. package/schemas/desktop.json +80 -0
  46. package/schemas/list.json +20 -0
  47. package/schemas/model.json +169 -0
  48. package/schemas/quota.json +6 -0
  49. package/skills/sideby/SKILL.md +5 -2
@@ -0,0 +1,371 @@
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
+ /** Brings a running app to the front through NSRunningApplication (no Automation permission needed). */
143
+ async function activate(deps, pid) {
144
+ const script = [
145
+ "ObjC.import('AppKit');",
146
+ `const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Math.trunc(pid)});`,
147
+ "app.isNil() ? 'missing' : (app.activateWithOptions($.NSApplicationActivateAllWindows | $.NSApplicationActivateIgnoringOtherApps) ? 'ok' : 'refused')",
148
+ ].join(' ');
149
+ const r = await deps.exec(OSASCRIPT, ['-l', 'JavaScript', '-e', script]);
150
+ return r.code === 0 && r.stdout.trim() === 'ok';
151
+ }
152
+ /** The environment `open` runs with: the caller's, without anything that would pick another Account (spec §3.5). */
153
+ function openEnv(base, family, def) {
154
+ const env = { ...base };
155
+ for (const v of family.hijackVars)
156
+ delete env[v];
157
+ delete env[family.selectVar];
158
+ for (const v of def.clearVars)
159
+ delete env[v];
160
+ for (const k of Object.keys(env))
161
+ if (k.startsWith('SIDEBY_') || isSessionVar(k))
162
+ delete env[k];
163
+ return env;
164
+ }
165
+ /**
166
+ * Variables an agent session sets for its own children; a Desktop App started from that session must not inherit
167
+ * them. Named one by one, so a user setting such as `CLAUDE_CODE_USE_BEDROCK` still reaches the app.
168
+ */
169
+ const SESSION_PREFIXES = ['CODEX_COMPANION_', 'CODEX_SANDBOX'];
170
+ const SESSION_VARS = new Set([
171
+ 'CLAUDE_CODE_SESSION_ID',
172
+ 'CLAUDE_CODE_MESSAGING_TOKEN',
173
+ 'CLAUDE_CODE_ENTRYPOINT',
174
+ 'CLAUDE_CODE_CHILD_SESSION',
175
+ 'CLAUDE_PID',
176
+ 'CLAUDE_PLUGIN_DATA',
177
+ 'CLAUDECODE',
178
+ 'CODEX_THREAD_ID',
179
+ ]);
180
+ function isSessionVar(k) {
181
+ return SESSION_VARS.has(k) || SESSION_PREFIXES.some((p) => k.startsWith(p));
182
+ }
183
+ function notMac(platform) {
184
+ 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' });
185
+ }
186
+ function notInstalled(def, deps) {
187
+ 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' });
188
+ }
189
+ /**
190
+ * Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
191
+ * same Account, never ends a process, never touches the app bundle or the Host account directory.
192
+ */
193
+ export async function openDesktopApp(rt, ref) {
194
+ const deps = depsFor(rt);
195
+ const account = await rt.resolve(ref);
196
+ const family = rt.families.get(account.family);
197
+ const def = family ? desktopAppOf(rt.plugins, account.family) : undefined;
198
+ if (!family || !def) {
199
+ const have = [...rt.families.keys()].filter((id) => desktopAppOf(rt.plugins, id));
200
+ 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' });
201
+ }
202
+ if (account.kind === 'api')
203
+ 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' });
204
+ if (deps.platform !== 'darwin')
205
+ throw notMac(deps.platform);
206
+ const app = await findApp(def, deps, { spotlight: true });
207
+ if (!app)
208
+ throw notInstalled(def, deps);
209
+ const home = rt.paths.home;
210
+ const appData = account.isMain ? null : appDataDir(home, account);
211
+ const listed = await processes(deps);
212
+ const procs = listed ?? [];
213
+ const base = {
214
+ ref: account.ref,
215
+ family: account.family,
216
+ app: def.title,
217
+ appPath: app.path,
218
+ ...(appData ? { appData } : {}),
219
+ createdAppData: false,
220
+ linked: [],
221
+ notices: [],
222
+ };
223
+ const running = instanceOf(app, appData, procs);
224
+ if (running) {
225
+ const focused = await activate(deps, running.pid);
226
+ return {
227
+ ...base,
228
+ action: focused ? 'focused' : 'already-running',
229
+ notices: focused
230
+ ? []
231
+ : [
232
+ `${def.title} is already open for ${account.ref}; switch to it from the Dock (sideby does not start a second one)`,
233
+ ],
234
+ };
235
+ }
236
+ let created = false;
237
+ let fresh = false;
238
+ const linked = [];
239
+ if (appData) {
240
+ const st = await lstatOrNull(appData);
241
+ if (st && !st.isDirectory())
242
+ 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' });
243
+ if (!st) {
244
+ await mkdir(appData, { recursive: true, mode: 0o700 });
245
+ await chmod(appData, 0o700);
246
+ created = true;
247
+ }
248
+ const shared = new Set(def.sharedFiles.map((f) => f.path));
249
+ fresh = created || (await readdir(appData)).every((e) => shared.has(e));
250
+ if (fresh)
251
+ for (const f of def.sharedFiles) {
252
+ const target = join(appData, f.path);
253
+ const main = join(home, f.mainPath);
254
+ // Never replace anything: link only where nothing is, and only to a file the Main Account has.
255
+ if ((await lstatOrNull(target)) || !(await statOrNull(main))?.isFile())
256
+ continue;
257
+ await symlink(main, target);
258
+ linked.push(f.path);
259
+ }
260
+ }
261
+ const notices = [];
262
+ if (!account.isMain && def.signInInApp && fresh) {
263
+ const others = procs.some((p) => mainArgs(app, p) !== null);
264
+ notices.push(others
265
+ ? `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`
266
+ : `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`);
267
+ }
268
+ if (!def.signInInApp) {
269
+ const login = await Promise.resolve()
270
+ .then(() => family.loginState?.(account))
271
+ .catch(() => undefined);
272
+ if (login === 'logged-out')
273
+ 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`);
274
+ }
275
+ // The Main Account needs `-n` too: without it, `open` brings forward whichever instance runs, often another
276
+ // Account's window. A second Main instance cannot start here, since a running one was found above.
277
+ // Without a process list a running Main instance cannot be ruled out, so plain `open -a` (no second instance).
278
+ const mainArgsList = listed ? ['-n', '-a', app.path] : ['-a', app.path];
279
+ const args = appData ? def.openArgs(app.path, account.dir, appData) : mainArgsList;
280
+ const r = await deps.exec(OPEN, args, openEnv(rt.env, family, def));
281
+ if (r.code !== 0)
282
+ 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' });
283
+ return { ...base, action: 'started', createdAppData: created, linked, command: [OPEN, ...args], notices };
284
+ }
285
+ /**
286
+ * Desktop App state per Account ref for `sideby list` and the Panel: only on macOS, only Subscription Accounts of a
287
+ * Family that has one. One process listing for all of them; never throws.
288
+ */
289
+ export async function desktopStatuses(rt, accounts) {
290
+ const out = new Map();
291
+ const deps = depsFor(rt);
292
+ if (deps.platform !== 'darwin')
293
+ return out;
294
+ try {
295
+ const eligible = accounts.filter((a) => a.kind !== 'api' && desktopAppOf(rt.plugins, a.family));
296
+ if (!eligible.length)
297
+ return out;
298
+ const apps = new Map();
299
+ for (const id of new Set(eligible.map((a) => a.family)))
300
+ apps.set(id, await findApp(desktopDef(id), deps));
301
+ const procs = [...apps.values()].some(Boolean) ? ((await processes(deps)) ?? []) : [];
302
+ for (const a of eligible) {
303
+ const app = apps.get(a.family) ?? null;
304
+ out.set(a.ref, {
305
+ app: desktopDef(a.family).title,
306
+ installed: app !== null,
307
+ running: app
308
+ ? Boolean(instanceOf(app, a.isMain ? null : appDataDir(rt.paths.home, a), procs))
309
+ : false,
310
+ });
311
+ }
312
+ }
313
+ catch {
314
+ // A display nicety: a failure leaves the field out.
315
+ }
316
+ return out;
317
+ }
318
+ /**
319
+ * Adds `desktopApp` to each status whose Account has one, for `sideby list` and the Panel. The process listing runs
320
+ * while `statuses` is still being built; the order of the statuses is kept.
321
+ */
322
+ export async function withDesktopApps(rt, accounts, statuses) {
323
+ const [list, desktop] = await Promise.all([statuses, desktopStatuses(rt, accounts)]);
324
+ return list.map((s) => {
325
+ const d = desktop.get(s.ref);
326
+ return d ? { ...s, desktopApp: d } : s;
327
+ });
328
+ }
329
+ /**
330
+ * Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
331
+ * only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
332
+ * Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
333
+ * a fix may only add a missing link inside it, never replace a real file.
334
+ */
335
+ export async function checkAppData(plugins, home, account) {
336
+ if (account.isMain || account.kind === 'api')
337
+ return [];
338
+ const def = desktopAppOf(plugins, account.family);
339
+ if (!def?.sharedFiles.length)
340
+ return [];
341
+ const dir = appDataDir(home, account);
342
+ if (!(await lstatOrNull(dir))?.isDirectory())
343
+ return [];
344
+ const out = [];
345
+ for (const f of def.sharedFiles) {
346
+ const item = { path: f.path, mode: 'link' };
347
+ const mainPath = join(home, f.mainPath);
348
+ const p = join(dir, item.path);
349
+ const res = await checkItem({ account, item, mainPath, accountPath: p, accountDir: dir, force: false });
350
+ for (const r of res.results) {
351
+ if (r.level === 'ok')
352
+ continue;
353
+ // The Desktop App writes its own file when the Main Account had none at first open, and may replace the link
354
+ // later: not the user's doing, so a warning with the way to share, never a failure and never replaced.
355
+ const own = r.code === 'link.real-file'
356
+ ? {
357
+ level: 'warn',
358
+ message: `${item.path} is ${def.title}'s own copy for this account, not a link to the main account's`,
359
+ 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}"`,
360
+ }
361
+ : {};
362
+ out.push({
363
+ ...r,
364
+ ...own,
365
+ item: `app-data/${item.path}`,
366
+ message: `${def.title} data: ${own.message ?? r.message}`,
367
+ });
368
+ }
369
+ }
370
+ return out;
371
+ }
@@ -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. */
@@ -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',
@@ -0,0 +1,41 @@
1
+ import type { Runtime } from '../runtime.ts';
2
+ import type { Account, ModelChoice } from '../types.ts';
3
+ /** Where a shown value comes from: sideby's own argument, the Host's config, or the last session. */
4
+ export type ChoiceSource = 'sideby' | 'host' | 'last';
5
+ export interface AccountModel {
6
+ ref: string;
7
+ family: string;
8
+ /** Whether sideby can set it for this Account; when not, `reason` says why. */
9
+ settable: boolean;
10
+ reason?: string;
11
+ efforts: string[];
12
+ models: string[];
13
+ sideby: ModelChoice;
14
+ host: ModelChoice;
15
+ last: ModelChoice;
16
+ model?: {
17
+ value: string;
18
+ from: ChoiceSource;
19
+ };
20
+ effort?: {
21
+ value: string;
22
+ from: ChoiceSource;
23
+ };
24
+ }
25
+ /** `last` is the model and effort of the newest session, from the usage reader when the caller already has it. */
26
+ export declare function accountModel(rt: Runtime, account: Account, last?: ModelChoice): Promise<AccountModel>;
27
+ /** A change: `undefined` keeps what sideby sets now, `null` goes back to the Host's default. */
28
+ export interface ModelChange {
29
+ model?: string | null;
30
+ effort?: string | null;
31
+ }
32
+ /** Shows, or with `apply` writes, the change to the Account's model and effort arguments. */
33
+ export declare function setAccountModel(rt: Runtime, account: Account, change: ModelChange, opts: {
34
+ apply: boolean;
35
+ }): Promise<{
36
+ changed: boolean;
37
+ applied: boolean;
38
+ file: string;
39
+ diff: string;
40
+ args: string[];
41
+ }>;
@@ -0,0 +1,128 @@
1
+ // Model and effort per Account (spec §3.19, ADR-0014): what an Account starts with, and the one change sideby makes,
2
+ // to the model and effort arguments in the config's `accounts.<ref>.args`. No Host file is written.
3
+ import { join } from 'node:path';
4
+ import { MODEL_NAME } from '../families/shared/model-args.js';
5
+ import { editConfigAccounts } from './config.js';
6
+ import { UserError } from './errors.js';
7
+ import { lineDiff } from './handoff-status.js';
8
+ function pick(key, sideby, host, last) {
9
+ if (sideby[key])
10
+ return { value: sideby[key], from: 'sideby' };
11
+ if (host[key])
12
+ return { value: host[key], from: 'host' };
13
+ if (last[key])
14
+ return { value: last[key], from: 'last' };
15
+ return undefined;
16
+ }
17
+ /** `last` is the model and effort of the newest session, from the usage reader when the caller already has it. */
18
+ export async function accountModel(rt, account, last) {
19
+ const f = rt.families.get(account.family);
20
+ const mc = f?.modelChoice;
21
+ const base = {
22
+ ref: account.ref,
23
+ family: account.family,
24
+ efforts: [],
25
+ models: [],
26
+ sideby: {},
27
+ host: {},
28
+ last: {},
29
+ };
30
+ if (!mc)
31
+ return { ...base, settable: false, reason: `${f?.title ?? account.family} has no model choice in sideby` };
32
+ let seen = last;
33
+ if (!seen) {
34
+ const u = await rt.usage(account).catch(() => null);
35
+ seen =
36
+ u?.status === 'ok'
37
+ ? {
38
+ ...(u.lastModel ? { model: u.lastModel } : {}),
39
+ ...(u.lastEffort ? { effort: u.lastEffort } : {}),
40
+ }
41
+ : {};
42
+ }
43
+ const sideby = mc.fromArgs(rt.config.accounts?.[account.ref]?.args ?? []).choice;
44
+ // An API account's model is the one its proxy.env names (the Family's `model` reader); the effort still comes from settings.
45
+ const apiModel = account.kind === 'api' ? await f.model?.(account).catch(() => undefined) : undefined;
46
+ const configured = await mc.configured(account, sideby.model ?? apiModel, seen.model);
47
+ const host = apiModel ? { ...configured, model: apiModel } : configured;
48
+ const models = [...new Set([...mc.models, ...(seen.model ? [seen.model] : [])])];
49
+ const out = {
50
+ ...base,
51
+ settable: account.kind !== 'api',
52
+ efforts: [...mc.efforts],
53
+ models,
54
+ sideby,
55
+ host,
56
+ last: seen,
57
+ };
58
+ if (account.kind === 'api')
59
+ out.reason = 'an API account takes its model from proxy.env';
60
+ const m = pick('model', sideby, host, seen);
61
+ const e = pick('effort', sideby, host, seen);
62
+ if (m)
63
+ out.model = m;
64
+ if (e)
65
+ out.effort = e;
66
+ return out;
67
+ }
68
+ const pretty = (v) => JSON.stringify(v ?? {}, null, 2);
69
+ /** Shows, or with `apply` writes, the change to the Account's model and effort arguments. */
70
+ export async function setAccountModel(rt, account, change, opts) {
71
+ const f = rt.families.get(account.family);
72
+ const mc = f?.modelChoice;
73
+ if (!mc)
74
+ throw new UserError(`${f?.title ?? account.family} has no model choice in sideby`, {
75
+ code: 'unsupported',
76
+ });
77
+ if (account.kind === 'api')
78
+ throw new UserError(`${account.ref} is an API account: its model comes from proxy.env, so set it there`, {
79
+ code: 'api-account',
80
+ });
81
+ if (typeof change.model === 'string' && !MODEL_NAME.test(change.model))
82
+ throw new UserError(`"${change.model}" is not a model name sideby passes on: use letters, digits and . _ : / [ ] - only, for example ${mc.models[0] ?? 'gpt-5'}`, { code: 'bad-value' });
83
+ if (typeof change.effort === 'string' && !mc.efforts.includes(change.effort))
84
+ throw new UserError(`"${change.effort}" is not an effort level for ${f.title}; use one of: ${mc.efforts.join(', ')}, default`, {
85
+ code: 'bad-value',
86
+ });
87
+ let args = [];
88
+ const r = await editConfigAccounts(rt.paths.configFile, (current) => {
89
+ const now = current[account.ref]?.args ?? [];
90
+ const unchanged = (list) => list.length === now.length && list.every((x, i) => x === now[i]);
91
+ const { choice, rest } = mc.fromArgs(now);
92
+ const next = { ...choice };
93
+ for (const k of ['model', 'effort']) {
94
+ if (change[k] === null)
95
+ delete next[k];
96
+ else if (change[k] !== undefined)
97
+ next[k] = change[k];
98
+ }
99
+ // After a `--` everything is prompt text, so the choice goes in front of it.
100
+ const dd = rest.indexOf('--');
101
+ const add = mc.toArgs(next);
102
+ args = dd < 0 ? [...rest, ...add] : [...rest.slice(0, dd), ...add, ...rest.slice(dd)];
103
+ // Nothing to write, not even an empty `accounts` key in a config that has none.
104
+ if (unchanged(args))
105
+ return null;
106
+ const out = { ...current };
107
+ if (args.length)
108
+ out[account.ref] = { ...current[account.ref], args };
109
+ else {
110
+ const { args: _gone, ...others } = current[account.ref] ?? {};
111
+ if (Object.keys(others).length)
112
+ out[account.ref] = others;
113
+ else
114
+ delete out[account.ref];
115
+ }
116
+ return out;
117
+ }, { dryRun: !opts.apply });
118
+ const file = rt.paths.configFile;
119
+ return {
120
+ changed: r.changed,
121
+ applied: r.changed && opts.apply,
122
+ file,
123
+ diff: r.changed
124
+ ? `--- ${join('sideby', 'config.json')} (accounts)\n${lineDiff(pretty(r.before), pretty(r.after))}\n`
125
+ : '',
126
+ args,
127
+ };
128
+ }
@@ -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 || !st.isFile())
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
+ };
@@ -8,6 +8,7 @@ import { BUILTIN_LOGOS } from '../logos.js';
8
8
  import { dirWrittenAt, groupedEntryWrittenAt, resumeFlagSession, withoutResumeFlag, } from '../shared/sessions.js';
9
9
  import { claudeHandoff } from './handoff.js';
10
10
  import { CLAUDE_LAYOUT } from './layout.js';
11
+ import { claudeModelChoice } from './model.js';
11
12
  import { readClaudeQuota } from './quota.js';
12
13
  import { readClaudeTuning } from './tuning.js';
13
14
  import { readClaudeUsage } from './usage.js';
@@ -114,6 +115,7 @@ export const claudeFamily = {
114
115
  },
115
116
  loginState,
116
117
  model,
118
+ modelChoice: claudeModelChoice,
117
119
  identity: claudeIdentity,
118
120
  readQuota: readClaudeQuota,
119
121
  readUsage: readClaudeUsage,
@@ -0,0 +1,2 @@
1
+ import type { FamilyModelChoice } from '../../types.ts';
2
+ export declare const claudeModelChoice: FamilyModelChoice;