sideby 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +24 -18
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +11 -1
  4. package/dist/src/cli/commands.js +144 -7
  5. package/dist/src/cli/json-schemas.js +56 -1
  6. package/dist/src/cli/main.js +119 -48
  7. package/dist/src/core/app-profiles.d.ts +50 -0
  8. package/dist/src/core/app-profiles.js +206 -0
  9. package/dist/src/core/brand-mark.d.ts +83 -0
  10. package/dist/src/core/brand-mark.js +88 -0
  11. package/dist/src/core/config.d.ts +16 -0
  12. package/dist/src/core/config.js +17 -2
  13. package/dist/src/core/desktop-app.d.ts +18 -1
  14. package/dist/src/core/desktop-app.js +22 -12
  15. package/dist/src/core/handoff-chain.d.ts +16 -2
  16. package/dist/src/core/handoff-chain.js +19 -6
  17. package/dist/src/core/handoff-run.js +43 -18
  18. package/dist/src/core/handoff-select.d.ts +4 -0
  19. package/dist/src/core/handoff-select.js +3 -2
  20. package/dist/src/core/update.d.ts +209 -0
  21. package/dist/src/core/update.js +631 -0
  22. package/dist/src/desktop/icon.js +293 -62
  23. package/dist/src/families/claude/handoff.js +10 -0
  24. package/dist/src/families/desktop.d.ts +25 -0
  25. package/dist/src/families/desktop.js +8 -2
  26. package/dist/src/families/grok/bot.d.ts +8 -0
  27. package/dist/src/families/grok/bot.js +18 -0
  28. package/dist/src/handoff/hook.js +21 -4
  29. package/dist/src/handoff/messages.d.ts +4 -0
  30. package/dist/src/handoff/messages.js +12 -0
  31. package/dist/src/panel/background.d.ts +2 -0
  32. package/dist/src/panel/background.js +7 -0
  33. package/dist/src/panel/handler.d.ts +35 -0
  34. package/dist/src/panel/handler.js +132 -6
  35. package/dist/src/panel/i18n.d.ts +59 -0
  36. package/dist/src/panel/i18n.js +118 -0
  37. package/dist/src/panel/page-script.js +282 -6
  38. package/dist/src/panel/page-styles.js +21 -2
  39. package/dist/src/panel/page.d.ts +2 -0
  40. package/dist/src/panel/page.js +6 -3
  41. package/dist/src/runtime.d.ts +3 -1
  42. package/dist/src/runtime.js +7 -0
  43. package/dist/src/types.d.ts +5 -0
  44. package/dist/testing/index.js +2 -0
  45. package/package.json +2 -2
  46. package/schemas/config.json +15 -0
  47. package/schemas/desktop-remove.json +33 -0
  48. package/schemas/desktop.json +2 -1
  49. package/schemas/handoff-ready.json +4 -0
  50. package/schemas/list.json +47 -0
  51. package/schemas/update.json +115 -0
  52. package/skills/sideby/SKILL.md +7 -1
  53. package/skills/sideby/references/handoff.md +1 -1
@@ -0,0 +1,50 @@
1
+ import { type ProfileAppDef } from '../families/desktop.ts';
2
+ import type { Runtime } from '../runtime.ts';
3
+ import { type DesktopOpenResult } from './desktop-app.ts';
4
+ export interface AppProfileStatus {
5
+ /** `grok-bot:<name>`; the Main Profile is `grok-bot:main`. */
6
+ ref: string;
7
+ /** The app as messages name it, for example `Grok Bot`. */
8
+ app: string;
9
+ name: string;
10
+ isMain: boolean;
11
+ /** The Profile's app data folder; absent for the Main Profile, which uses the app's default. */
12
+ appData?: string;
13
+ installed: boolean;
14
+ /** That Profile's own window runs now. */
15
+ running: boolean;
16
+ /** What the app last wrote about its sign-in; absent when unknown. */
17
+ signedIn?: boolean;
18
+ }
19
+ export interface AppProfileRemoveResult {
20
+ ref: string;
21
+ app: string;
22
+ appData: string;
23
+ /** False when only the plan was shown (no `--yes`). */
24
+ removed: boolean;
25
+ }
26
+ /** The app and Profile name a ref names, or undefined when it is not an App Profile ref. */
27
+ export declare function parseProfileRef(ref: string): {
28
+ def: ProfileAppDef;
29
+ name: string;
30
+ } | undefined;
31
+ /** Where the App Profiles of an app live: `~/Library/Application Support/sideby/<app>`. */
32
+ export declare function profilesRoot(home: string, def: ProfileAppDef): string;
33
+ /**
34
+ * Every App Profile with its state, for `sideby list` and the Panel: only on macOS, and per app only when it is
35
+ * installed or has Profiles. The Main Profile first. Never throws.
36
+ */
37
+ export declare function listAppProfiles(rt: Runtime): Promise<AppProfileStatus[]>;
38
+ /**
39
+ * Opens an App Profile's window, creating its folder first when it is new, or brings its running window to the front.
40
+ * Never starts a second window for the same Profile and never ends a process.
41
+ */
42
+ export declare function openAppProfile(rt: Runtime, ref: string): Promise<DesktopOpenResult>;
43
+ /**
44
+ * Removes a non-main App Profile's folder, which signs that window out and deletes its local data. Without `yes` it
45
+ * only returns what would go. Refuses the Main Profile, a running window, a missing folder and anything that is not a
46
+ * real folder inside sideby's own; never follows a link.
47
+ */
48
+ export declare function removeAppProfile(rt: Runtime, ref: string, opts: {
49
+ yes: boolean;
50
+ }): Promise<AppProfileRemoveResult>;
@@ -0,0 +1,206 @@
1
+ // App Profiles (spec §3.20 "Grok Bot 窗口", ADR-0019): named windows of a desktop app that signs in apart from every
2
+ // Account, each with its own App Data under ~/Library/Application Support/sideby/<app>/<name>/. Opening and focusing
3
+ // work as for a Family's Desktop App (core/desktop-app.ts); every program still goes through `DesktopDeps.exec`.
4
+ import { chmod, mkdir, readdir, readFile, rm } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { PROFILE_APPS } from '../families/desktop.js';
7
+ import { ACCOUNT_NAME } from './account-name.js';
8
+ import { activate, cleanEnv, depsFor, findApp, instanceOf, mainArgs, notInstalled, notMac, processes, } from './desktop-app.js';
9
+ import { UserError } from './errors.js';
10
+ import { lstatOrNull } from './fs-safe.js';
11
+ const MAIN = 'main';
12
+ /** The app and Profile name a ref names, or undefined when it is not an App Profile ref. */
13
+ export function parseProfileRef(ref) {
14
+ for (const def of PROFILE_APPS) {
15
+ if (ref === def.id)
16
+ return { def, name: MAIN };
17
+ if (ref.startsWith(`${def.id}:`))
18
+ return { def, name: ref.slice(def.id.length + 1) };
19
+ }
20
+ return undefined;
21
+ }
22
+ /** Where the App Profiles of an app live: `~/Library/Application Support/sideby/<app>`. */
23
+ export function profilesRoot(home, def) {
24
+ return join(home, 'Library', 'Application Support', 'sideby', def.id);
25
+ }
26
+ function appDataOf(home, def, name) {
27
+ return name === MAIN ? null : join(profilesRoot(home, def), name);
28
+ }
29
+ /** `signedIn` from the app's own status file: a boolean next to a version, a pid and a start time, not a credential. */
30
+ async function signedIn(file) {
31
+ try {
32
+ const v = JSON.parse(await readFile(file, 'utf8'));
33
+ const s = v && typeof v === 'object' ? v.signedIn : undefined;
34
+ return typeof s === 'boolean' ? s : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** Profile names: the real folders under the app's root with a valid name, sorted. Links and files are skipped. */
41
+ async function profileNames(root) {
42
+ let entries;
43
+ try {
44
+ entries = await readdir(root);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ const out = [];
50
+ for (const e of entries)
51
+ if (e !== MAIN && ACCOUNT_NAME.test(e) && (await lstatOrNull(join(root, e)))?.isDirectory())
52
+ out.push(e);
53
+ return out.sort();
54
+ }
55
+ function checkName(def, name) {
56
+ if (name !== MAIN && !ACCOUNT_NAME.test(name))
57
+ throw new UserError(`"${name}" is not a valid ${def.title} window name; use lowercase letters, digits and dashes, starting with a letter or digit (for example \`sideby desktop ${def.id}:work\`)`, { code: 'invalid-name' });
58
+ }
59
+ /**
60
+ * Every App Profile with its state, for `sideby list` and the Panel: only on macOS, and per app only when it is
61
+ * installed or has Profiles. The Main Profile first. Never throws.
62
+ */
63
+ export async function listAppProfiles(rt) {
64
+ const deps = depsFor(rt);
65
+ if (deps.platform !== 'darwin')
66
+ return [];
67
+ const home = rt.paths.home;
68
+ const out = [];
69
+ try {
70
+ let procs = null;
71
+ for (const def of PROFILE_APPS) {
72
+ const app = await findApp(def, deps);
73
+ const names = await profileNames(profilesRoot(home, def));
74
+ if (!app && !names.length)
75
+ continue;
76
+ if (app && !procs)
77
+ procs = (await processes(deps)) ?? [];
78
+ for (const name of [MAIN, ...names]) {
79
+ const appData = appDataOf(home, def, name);
80
+ const status = join(appData ?? join(home, def.mainUserData), def.statusFile);
81
+ const s = await signedIn(status);
82
+ out.push({
83
+ ref: `${def.id}:${name}`,
84
+ app: def.title,
85
+ name,
86
+ isMain: name === MAIN,
87
+ ...(appData ? { appData } : {}),
88
+ installed: app !== null,
89
+ running: app ? Boolean(instanceOf(app, appData, procs ?? [])) : false,
90
+ ...(s === undefined ? {} : { signedIn: s }),
91
+ });
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ // A display nicety: a failure shows no Profiles.
97
+ }
98
+ return out;
99
+ }
100
+ async function located(rt, ref) {
101
+ const parsed = parseProfileRef(ref);
102
+ if (!parsed)
103
+ throw new UserError(`${ref} is not an app window; use \`sideby desktop grok-bot:<name>\``);
104
+ const { def, name } = parsed;
105
+ checkName(def, name);
106
+ const deps = depsFor(rt);
107
+ if (deps.platform !== 'darwin')
108
+ throw notMac(deps.platform);
109
+ const app = await findApp(def, deps, { spotlight: true });
110
+ if (!app)
111
+ throw notInstalled(def, deps);
112
+ return { deps, def, name, app, appData: appDataOf(rt.paths.home, def, name) };
113
+ }
114
+ /**
115
+ * Opens an App Profile's window, creating its folder first when it is new, or brings its running window to the front.
116
+ * Never starts a second window for the same Profile and never ends a process.
117
+ */
118
+ export async function openAppProfile(rt, ref) {
119
+ const { deps, def, name, app, appData } = await located(rt, ref);
120
+ const listed = await processes(deps);
121
+ const procs = listed ?? [];
122
+ const base = {
123
+ ref: `${def.id}:${name}`,
124
+ family: def.id,
125
+ app: def.title,
126
+ appPath: app.path,
127
+ ...(appData ? { appData } : {}),
128
+ createdAppData: false,
129
+ linked: [],
130
+ notices: [],
131
+ };
132
+ const running = instanceOf(app, appData, procs);
133
+ if (running) {
134
+ const focused = await activate(deps, running.pid);
135
+ return {
136
+ ...base,
137
+ action: focused ? 'focused' : 'already-running',
138
+ notices: focused
139
+ ? []
140
+ : [
141
+ `${def.title} is already open as ${base.ref}; switch to it from the Dock (sideby does not start a second one)`,
142
+ ],
143
+ };
144
+ }
145
+ let created = false;
146
+ const notices = [];
147
+ if (appData) {
148
+ const st = await lstatOrNull(appData);
149
+ if (st && !st.isDirectory())
150
+ throw new UserError(`${appData} is not a folder; move it away, then try again (sideby keeps the ${def.title} window ${base.ref} there)`, { code: 'app-data-invalid' });
151
+ if (!st) {
152
+ await mkdir(appData, { recursive: true, mode: 0o700 });
153
+ await chmod(appData, 0o700);
154
+ created = true;
155
+ }
156
+ if (created || !(await readdir(appData)).length) {
157
+ const others = procs.some((p) => mainArgs(app, p) !== null);
158
+ notices.push(`New ${def.title} window ${base.ref}: sign in inside it with the account you want for this window.${others ? ` Close the other ${def.title} windows while you sign in, because the browser may send the sign-in back to one of them.` : ''}`);
159
+ }
160
+ }
161
+ // As for a Desktop App: `-n` also for the Main Profile, unless a running one cannot be ruled out.
162
+ const args = appData ? def.openArgs(app.path, appData) : listed ? ['-n', '-a', app.path] : ['-a', app.path];
163
+ const r = await deps.exec('/usr/bin/open', args, cleanEnv(rt.env, def.clearVars));
164
+ if (r.code !== 0)
165
+ throw new UserError(`could not open ${def.title} as ${base.ref}: ${r.stderr.trim() || `open exited with ${r.code}`}; try opening ${app.path} once by hand, then try again`, { code: 'open-failed' });
166
+ return { ...base, action: 'started', createdAppData: created, command: ['/usr/bin/open', ...args], notices };
167
+ }
168
+ /**
169
+ * Removes a non-main App Profile's folder, which signs that window out and deletes its local data. Without `yes` it
170
+ * only returns what would go. Refuses the Main Profile, a running window, a missing folder and anything that is not a
171
+ * real folder inside sideby's own; never follows a link.
172
+ */
173
+ export async function removeAppProfile(rt, ref, opts) {
174
+ const parsed = parseProfileRef(ref);
175
+ if (!parsed)
176
+ throw new UserError(`${ref} is not an app window; use \`sideby desktop grok-bot:<name> --remove\``);
177
+ const { def, name } = parsed;
178
+ checkName(def, name);
179
+ const full = `${def.id}:${name}`;
180
+ if (name === MAIN)
181
+ throw new UserError(`${full} is ${def.title}'s own data, which sideby does not remove; sign out inside ${def.title} instead`, { code: 'main-profile' });
182
+ const deps = depsFor(rt);
183
+ if (deps.platform !== 'darwin')
184
+ throw notMac(deps.platform);
185
+ const appData = appDataOf(rt.paths.home, def, name);
186
+ const st = await lstatOrNull(appData);
187
+ if (!st)
188
+ throw new UserError(`there is no ${def.title} window ${full}; \`sideby list\` shows the ones there are`, {
189
+ code: 'no-profile',
190
+ });
191
+ if (!st.isDirectory())
192
+ throw new UserError(`${appData} is not a folder sideby made; it was left alone, move it away by hand`, {
193
+ code: 'app-data-invalid',
194
+ });
195
+ const app = await findApp(def, deps, { spotlight: true });
196
+ // Without a process list an open window cannot be ruled out, and deleting under a running app breaks it.
197
+ const procs = app ? await processes(deps) : [];
198
+ if (procs === null || (app && instanceOf(app, appData, procs)))
199
+ throw new UserError(procs === null
200
+ ? `could not check whether ${def.title} is open as ${full}; quit that window, then try again`
201
+ : `${def.title} is open as ${full}; quit that window first, then remove it`, { code: 'app-running' });
202
+ if (!opts.yes)
203
+ return { ref: full, app: def.title, appData, removed: false };
204
+ await rm(appData, { recursive: true, force: true });
205
+ return { ref: full, app: def.title, appData, removed: true };
206
+ }
@@ -0,0 +1,83 @@
1
+ /** An outlined shape of one buddy, as SVG path data on its 64-unit grid. */
2
+ export interface BuddyPath {
3
+ d: string;
4
+ /** `body`, `shade`: the buddy's own colours; `none`: an open line. */
5
+ fill: 'body' | 'shade' | 'none';
6
+ /** Line colour; `chevron` is the buddy's accent colour. Default ink. */
7
+ stroke?: 'ink' | 'chevron';
8
+ /** Line width on the 64-unit grid, before the buddy's scale. Default: the outline width. */
9
+ width?: number;
10
+ }
11
+ /** A filled ellipse without outline: the eyes and their highlights. */
12
+ export interface BuddyDot {
13
+ cx: number;
14
+ cy: number;
15
+ rx: number;
16
+ ry: number;
17
+ fill: 'ink' | 'light';
18
+ }
19
+ /** Back to front: feet, arms, body, then the face. */
20
+ export declare const BUDDY: {
21
+ paths: readonly BuddyPath[];
22
+ eyes: readonly BuddyDot[];
23
+ };
24
+ /** The eye highlights sit towards where the buddy looks: `1` right, `-1` left. */
25
+ export declare function highlights(look: 1 | -1): BuddyDot[];
26
+ export interface BuddyColors {
27
+ body: string;
28
+ shade: string;
29
+ chevron: string;
30
+ }
31
+ export declare const MARK_COLORS: {
32
+ readonly blue: {
33
+ readonly body: '#3f8fc4';
34
+ readonly shade: '#29658e';
35
+ readonly chevron: '#f2b35e';
36
+ };
37
+ readonly amber: {
38
+ readonly body: '#f2b35e';
39
+ readonly shade: '#c98a35';
40
+ readonly chevron: '#3f8fc4';
41
+ };
42
+ readonly ink: '#1e2a44';
43
+ readonly light: '#f6f0e5';
44
+ };
45
+ export interface PlacedBuddy {
46
+ colors: BuddyColors;
47
+ /** Where the buddy looks (and which way its eye highlights sit). */
48
+ look: 1 | -1;
49
+ /** 64-unit grid → mark units: translate by (x, y), then scale. */
50
+ x: number;
51
+ y: number;
52
+ scale: number;
53
+ }
54
+ /** The two buddies in mark units, back first. The blue one in front is the one a Portal's `logoColor` recolours. */
55
+ export declare const MARK: {
56
+ line: number;
57
+ placedLine: number;
58
+ back: PlacedBuddy;
59
+ front: PlacedBuddy;
60
+ };
61
+ /** The square the pair is drawn in, in mark units (the Panel's 40 px tile and the app icon tile). */
62
+ export declare const MARK_VIEWBOX: {
63
+ readonly x: -2.5;
64
+ readonly y: 3;
65
+ readonly size: 69;
66
+ };
67
+ /** The square around one buddy at full size, for the small cut (favicon). */
68
+ export declare const BUDDY_VIEWBOX: {
69
+ readonly x: 3;
70
+ readonly y: 10.5;
71
+ readonly size: 58;
72
+ };
73
+ /**
74
+ * The mark as SVG children (no outer <svg>). With `front` omitted the blue buddy's body has no fill of its own, so it
75
+ * takes the surrounding `fill` (the Panel's `--logo-fg`, which Portals set with `logoColor`); everything else is fixed.
76
+ */
77
+ export declare function markSvgBody(front?: string): string;
78
+ /** The Panel's header logo: the mark sized for its 40 px tile. */
79
+ export declare const MARK_SVG: string;
80
+ /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
81
+ export declare const ICON_SVG: string;
82
+ /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
83
+ export declare const FAVICON_SVG: string;
@@ -0,0 +1,88 @@
1
+ // The sideby mark: two buddies side by side, the amber one behind on the left and the blue one in front on the right,
2
+ // looking at each other (ADR-0022, the characters of the intro film). One source for the Panel header, the empty
3
+ // state, the Portal icon, the favicon and the app icon.
4
+ // One buddy is drawn on a 64-unit grid; the mark places two of them with a translate and a scale.
5
+ /** Back to front: feet, arms, body, then the face. */
6
+ export const BUDDY = {
7
+ paths: [
8
+ { d: 'M17 48 Q15 58 22 57 Q27 57 27 50 M37 50 Q37 57 42 57 Q49 58 47 48', fill: 'shade' },
9
+ { d: 'M14 37 C4 32 4 41 14 43 M50 37 C60 32 60 41 50 43', fill: 'body' },
10
+ {
11
+ d: 'M12 37 C12 27 17 23 25 23 H39 C47 23 52 27 52 37 C52 48 45 53 32 53 C19 53 12 48 12 37 Z',
12
+ fill: 'body',
13
+ },
14
+ { d: 'M30 27 L34 29.31 L30 31.62', fill: 'none', stroke: 'chevron', width: 2.5 },
15
+ { d: 'M29 46 Q32 49 35 46', fill: 'none', width: 2.5 },
16
+ ],
17
+ eyes: [
18
+ { cx: 22, cy: 38.5, rx: 4.5, ry: 6, fill: 'ink' },
19
+ { cx: 42, cy: 38.5, rx: 4.5, ry: 6, fill: 'ink' },
20
+ ],
21
+ };
22
+ /** The eye highlights sit towards where the buddy looks: `1` right, `-1` left. */
23
+ export function highlights(look) {
24
+ return BUDDY.eyes.map((e) => ({
25
+ cx: e.cx + 1.5 * look,
26
+ cy: e.cy + (look > 0 ? 0.5 : -2),
27
+ rx: 1.65,
28
+ ry: 2.1,
29
+ fill: 'light',
30
+ }));
31
+ }
32
+ export const MARK_COLORS = {
33
+ blue: { body: '#3f8fc4', shade: '#29658e', chevron: '#f2b35e' },
34
+ amber: { body: '#f2b35e', shade: '#c98a35', chevron: '#3f8fc4' },
35
+ ink: '#1e2a44',
36
+ light: '#f6f0e5',
37
+ };
38
+ /** The two buddies in mark units, back first. The blue one in front is the one a Portal's `logoColor` recolours. */
39
+ export const MARK = {
40
+ /** Outline width of a buddy drawn alone, on its 64-unit grid. */
41
+ line: 2.75,
42
+ /** Outline width of a placed buddy before its scale, so the outline stays about 2.5 mark units. */
43
+ placedLine: 3.6,
44
+ back: { colors: MARK_COLORS.amber, look: 1, x: -2, y: 2, scale: 0.7 },
45
+ front: { colors: MARK_COLORS.blue, look: -1, x: 22, y: 16, scale: 0.7 },
46
+ };
47
+ /** The square the pair is drawn in, in mark units (the Panel's 40 px tile and the app icon tile). */
48
+ export const MARK_VIEWBOX = { x: -2.5, y: 3, size: 69 };
49
+ /** The square around one buddy at full size, for the small cut (favicon). */
50
+ export const BUDDY_VIEWBOX = { x: 3, y: 10.5, size: 58 };
51
+ function buddySvg(b, line, bodyFill) {
52
+ const color = (fill) => {
53
+ if (fill === 'none')
54
+ return ' fill="none"';
55
+ if (fill === 'shade')
56
+ return ` fill="${b.colors.shade}"`;
57
+ return bodyFill === null ? '' : ` fill="${bodyFill ?? b.colors.body}"`;
58
+ };
59
+ const paths = BUDDY.paths
60
+ .map((p) => {
61
+ const stroke = p.stroke === 'chevron' ? ` stroke="${b.colors.chevron}"` : '';
62
+ const width = p.width ? ` stroke-width="${p.width}"` : '';
63
+ return `<path d="${p.d}"${color(p.fill)}${stroke}${width}/>`;
64
+ })
65
+ .join('');
66
+ const dots = [...BUDDY.eyes, ...highlights(b.look)]
67
+ .map((e) => `<ellipse cx="${e.cx}" cy="${e.cy}" rx="${e.rx}" ry="${e.ry}" fill="${e.fill === 'ink' ? MARK_COLORS.ink : MARK_COLORS.light}"/>`)
68
+ .join('');
69
+ const transform = b.scale === 1 && b.x === 0 && b.y === 0 ? '' : ` transform="translate(${b.x} ${b.y}) scale(${b.scale})"`;
70
+ return `<g${transform} stroke-width="${line}">${paths}<g stroke="none">${dots}</g></g>`;
71
+ }
72
+ const OUTLINE = `stroke="${MARK_COLORS.ink}" stroke-linecap="round" stroke-linejoin="round"`;
73
+ /**
74
+ * The mark as SVG children (no outer <svg>). With `front` omitted the blue buddy's body has no fill of its own, so it
75
+ * takes the surrounding `fill` (the Panel's `--logo-fg`, which Portals set with `logoColor`); everything else is fixed.
76
+ */
77
+ export function markSvgBody(front) {
78
+ return `<g ${OUTLINE}>${buddySvg(MARK.back, MARK.placedLine)}${buddySvg(MARK.front, MARK.placedLine, front ?? null)}</g>`;
79
+ }
80
+ const viewBox = (v) => `${v.x} ${v.y} ${v.size} ${v.size}`;
81
+ /** The Panel's header logo: the mark sized for its 40 px tile. */
82
+ export const MARK_SVG = `<svg viewBox="${viewBox(MARK_VIEWBOX)}">${markSvgBody()}</svg>`;
83
+ /** `<basePath>/icon.svg`: the whole mark on a transparent square, for a Portal's tab or menu (ADR-0006 amendment). */
84
+ export const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(MARK_VIEWBOX)}" role="img" aria-label="sideby">` +
85
+ `${markSvgBody(MARK_COLORS.blue.body)}</svg>`;
86
+ /** `<basePath>/favicon.svg`: the small cut, the blue buddy alone, for sizes below about 24 px (ADR-0022). */
87
+ export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox(BUDDY_VIEWBOX)}">` +
88
+ `<g ${OUTLINE}>${buddySvg({ ...MARK.front, x: 0, y: 0, scale: 1 }, MARK.line)}</g></svg>`;
@@ -62,6 +62,10 @@ export declare const ConfigSchema: Type.TObject<{
62
62
  order: Type.TOptional<Type.TArray<Type.TString>>;
63
63
  }>>>;
64
64
  }>>;
65
+ update: Type.TOptional<Type.TObject<{
66
+ check: Type.TOptional<Type.TBoolean>;
67
+ auto: Type.TOptional<Type.TBoolean>;
68
+ }>>;
65
69
  shellInitFile: Type.TOptional<Type.TObject<{
66
70
  zsh: Type.TOptional<Type.TString>;
67
71
  bash: Type.TOptional<Type.TString>;
@@ -85,6 +89,18 @@ export declare function editConfigHandoff(file: string, edit: (current: HandoffC
85
89
  before: HandoffConfig | undefined;
86
90
  after: HandoffConfig | undefined;
87
91
  }>;
92
+ export type UpdateConfig = NonNullable<Config['update']>;
93
+ /**
94
+ * The Panel's Auto Update switch (spec §3.23): changes the config `update` key with `edit` (see `editConfigKey` for
95
+ * how the file is written). With `dryRun`, shows the change without writing.
96
+ */
97
+ export declare function editConfigUpdate(file: string, edit: (current: UpdateConfig) => UpdateConfig, opts?: {
98
+ dryRun?: boolean;
99
+ }): Promise<{
100
+ changed: boolean;
101
+ before: UpdateConfig | undefined;
102
+ after: UpdateConfig | undefined;
103
+ }>;
88
104
  export type AccountsConfig = NonNullable<Config['accounts']>;
89
105
  /**
90
106
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
@@ -93,6 +93,14 @@ export const ConfigSchema = Type.Object({
93
93
  description: 'Make `sideby shell-init` also define Host-named functions (`claude`, `codex`, `grok`) that resume a session by id in the Account that holds it. Off by default.',
94
94
  })),
95
95
  handoff: Type.Optional(HandoffSchema),
96
+ update: Type.Optional(Type.Object({
97
+ check: Type.Optional(Type.Boolean({
98
+ description: 'Ask the npm registry once a day whether a newer sideby exists, and say so after a command and in the Panel. On by default; `NO_UPDATE_NOTIFIER` or `SIDEBY_NO_UPDATE_NOTIFIER` also turn it off.',
99
+ })),
100
+ auto: Type.Optional(Type.Boolean({
101
+ description: 'Install a newer version by itself after a check, when no `sideby run` is running and the version is not a major one. Off by default.',
102
+ })),
103
+ }, { additionalProperties: false, description: 'Update Check and Auto Update (ADR-0021).' })),
96
104
  shellInitFile: Type.Optional(Type.Object({
97
105
  zsh: Type.Optional(Type.String({ minLength: 1 })),
98
106
  bash: Type.Optional(Type.String({ minLength: 1 })),
@@ -158,8 +166,8 @@ async function configTarget(file) {
158
166
  return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
159
167
  }
160
168
  /**
161
- * Re-reads the config file, lets `edit` change one top-level `key` (the only two sideby writes are `aliases` and
162
- * `handoff`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
169
+ * Re-reads the config file, lets `edit` change one top-level `key` (sideby writes only `aliases`, `handoff`,
170
+ * `accounts` and `update`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
163
171
  * with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
164
172
  * file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
165
173
  * while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
@@ -224,6 +232,13 @@ async function editConfigAliases(file, retry, edit) {
224
232
  export function editConfigHandoff(file, edit, opts = {}) {
225
233
  return editConfigKey(file, 'handoff', 'run the command', (current) => edit(current ?? {}), opts);
226
234
  }
235
+ /**
236
+ * The Panel's Auto Update switch (spec §3.23): changes the config `update` key with `edit` (see `editConfigKey` for
237
+ * how the file is written). With `dryRun`, shows the change without writing.
238
+ */
239
+ export function editConfigUpdate(file, edit, opts = {}) {
240
+ return editConfigKey(file, 'update', 'change the setting', (current) => edit(current ?? {}), opts);
241
+ }
227
242
  /**
228
243
  * `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
229
244
  * written). With `dryRun`, shows the change without writing.
@@ -2,6 +2,7 @@ import { type DesktopAppDef } from '../families/desktop.ts';
2
2
  import type { LoadedPlugin } from '../plugins/loader.ts';
3
3
  import type { Runtime } from '../runtime.ts';
4
4
  import type { Account, CheckResult, Env } from '../types.ts';
5
+ import { UserError } from './errors.ts';
5
6
  export interface ExecResult {
6
7
  code: number;
7
8
  stdout: string;
@@ -20,6 +21,7 @@ export interface DesktopDeps {
20
21
  * `createRuntime` calls it, from its `desktop` option, which is the one way to replace the machine (tests).
21
22
  */
22
23
  export declare function bindDesktopDeps(rt: Runtime, deps: Partial<DesktopDeps>): void;
24
+ export declare function depsFor(rt: Runtime): DesktopDeps;
23
25
  /** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
24
26
  export declare function desktopAppOf(plugins: readonly LoadedPlugin[], familyId: string): DesktopAppDef | undefined;
25
27
  /** Where a non-main Account's App Data lives: `~/Library/Application Support/sideby/<family>/<name>`. */
@@ -33,15 +35,28 @@ export interface FoundApp {
33
35
  * Finds the app by bundle id: the usual names in the app folders first, then (with `spotlight`) Spotlight. Listing
34
36
  * Accounts leaves Spotlight out to stay fast; opening one asks it too.
35
37
  */
36
- export declare function findApp(def: DesktopAppDef, deps: DesktopDeps, opts?: {
38
+ export declare function findApp(def: Pick<DesktopAppDef, 'bundleId' | 'bundleNames'>, deps: DesktopDeps, opts?: {
37
39
  spotlight?: boolean;
38
40
  }): Promise<FoundApp | null>;
39
41
  export interface Proc {
40
42
  pid: number;
41
43
  command: string;
42
44
  }
45
+ /** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
46
+ export declare function processes(deps: DesktopDeps): Promise<Proc[] | null>;
47
+ /** The arguments after the app's own executable, when `p` is the app's main process (not a helper). */
48
+ export declare function mainArgs(app: FoundApp, p: Proc): string | null;
43
49
  /** The running instance of this Account: the one with its App Data, or for the Main Account the one without any. */
44
50
  export declare function instanceOf(app: FoundApp, appData: string | null, procs: readonly Proc[]): Proc | undefined;
51
+ /**
52
+ * Brings a running app to the front as a Dock click does: a reopen event (`aevt/rapp`) to that one process, so an
53
+ * app whose windows were all closed opens one again, then NSRunningApplication activation. Activation alone makes
54
+ * such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
55
+ * permission.
56
+ */
57
+ export declare function activate(deps: DesktopDeps, pid: number): Promise<boolean>;
58
+ /** The caller's environment without `clear`, `SIDEBY_*` and the variables of the agent session it may run in. */
59
+ export declare function cleanEnv(base: Env, clear: readonly string[]): Env;
45
60
  export interface DesktopOpenResult {
46
61
  ref: string;
47
62
  family: string;
@@ -61,6 +76,8 @@ export interface DesktopOpenResult {
61
76
  /** What the user should know: sign in first, close other windows while signing in. */
62
77
  notices: string[];
63
78
  }
79
+ export declare function notMac(platform: string): UserError;
80
+ export declare function notInstalled(def: Pick<DesktopAppDef, 'title' | 'installUrl'>, deps: DesktopDeps): UserError;
64
81
  /**
65
82
  * Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
66
83
  * same Account, never ends a process, never touches the app bundle or the Host account directory.
@@ -5,7 +5,7 @@
5
5
  import { execFile } from 'node:child_process';
6
6
  import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
7
7
  import { basename, join } from 'node:path';
8
- import { desktopDef } from '../families/desktop.js';
8
+ import { desktopDef, profileAppFor } from '../families/desktop.js';
9
9
  import { UserError } from './errors.js';
10
10
  import { lstatOrNull, statOrNull } from './fs-safe.js';
11
11
  import { checkItem } from './share-modes.js';
@@ -28,7 +28,7 @@ const overrides = new WeakMap();
28
28
  export function bindDesktopDeps(rt, deps) {
29
29
  overrides.set(rt, deps);
30
30
  }
31
- function depsFor(rt) {
31
+ export function depsFor(rt) {
32
32
  const o = overrides.get(rt) ?? {};
33
33
  return {
34
34
  platform: o.platform ?? process.platform,
@@ -102,7 +102,7 @@ export async function findApp(def, deps, opts = {}) {
102
102
  return null;
103
103
  }
104
104
  /** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
105
- async function processes(deps) {
105
+ export async function processes(deps) {
106
106
  const r = await deps.exec(PS, ['-axo', 'pid=,command=']);
107
107
  if (r.code !== 0)
108
108
  return null;
@@ -115,7 +115,7 @@ async function processes(deps) {
115
115
  return out;
116
116
  }
117
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) {
118
+ export function mainArgs(app, p) {
119
119
  const exe = `/${basename(app.path)}/Contents/MacOS/${app.executable}`;
120
120
  const at = p.command.indexOf(exe);
121
121
  if (at === -1)
@@ -145,7 +145,7 @@ export function instanceOf(app, appData, procs) {
145
145
  * such an app frontmost with nothing on screen (seen 2026-10-14 with the Codex app). Neither needs the Automation
146
146
  * permission.
147
147
  */
148
- async function activate(deps, pid) {
148
+ export async function activate(deps, pid) {
149
149
  const p = Math.trunc(pid);
150
150
  const script = [
151
151
  "ObjC.import('AppKit');",
@@ -162,11 +162,12 @@ async function activate(deps, pid) {
162
162
  }
163
163
  /** The environment `open` runs with: the caller's, without anything that would pick another Account (spec §3.5). */
164
164
  function openEnv(base, family, def) {
165
+ return cleanEnv(base, [...family.hijackVars, family.selectVar, ...def.clearVars]);
166
+ }
167
+ /** The caller's environment without `clear`, `SIDEBY_*` and the variables of the agent session it may run in. */
168
+ export function cleanEnv(base, clear) {
165
169
  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
+ for (const v of clear)
170
171
  delete env[v];
171
172
  for (const k of Object.keys(env))
172
173
  if (k.startsWith('SIDEBY_') || isSessionVar(k))
@@ -191,10 +192,19 @@ const SESSION_VARS = new Set([
191
192
  function isSessionVar(k) {
192
193
  return SESSION_VARS.has(k) || SESSION_PREFIXES.some((p) => k.startsWith(p));
193
194
  }
194
- function notMac(platform) {
195
+ /** `a`, `a and b`, `a, b and c`. */
196
+ function joinNames(ids) {
197
+ return ids.length < 2 ? ids.join('') : `${ids.slice(0, -1).join(', ')} and ${ids.at(-1)}`;
198
+ }
199
+ /** For a Family whose people use a separate app with App Profiles (grok: Grok Bot), how to open that instead. */
200
+ function profileHint(familyId) {
201
+ const p = profileAppFor(familyId);
202
+ return p ? `; for ${p.title}, which signs in on its own, use \`sideby desktop ${p.id}:<name>\`` : '';
203
+ }
204
+ export function notMac(platform) {
195
205
  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
206
  }
197
- function notInstalled(def, deps) {
207
+ export function notInstalled(def, deps) {
198
208
  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
209
  }
200
210
  /**
@@ -208,7 +218,7 @@ export async function openDesktopApp(rt, ref) {
208
218
  const def = family ? desktopAppOf(rt.plugins, account.family) : undefined;
209
219
  if (!family || !def) {
210
220
  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' });
221
+ throw new UserError(`${account.ref}: ${family?.title ?? account.family} has no desktop app sideby can open${have.length ? ` (only ${joinNames(have)} accounts do)` : ''}; use \`sideby run ${account.ref}\`${profileHint(account.family)}`, { code: 'no-desktop-app' });
212
222
  }
213
223
  if (account.kind === 'api')
214
224
  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' });