sideby 0.9.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -16
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +9 -1
- package/dist/src/cli/commands.js +145 -5
- package/dist/src/cli/json-schemas.js +55 -1
- package/dist/src/cli/main.js +23 -7
- package/dist/src/core/app-profiles.d.ts +50 -0
- package/dist/src/core/app-profiles.js +206 -0
- package/dist/src/core/brand-mark.d.ts +29 -0
- package/dist/src/core/brand-mark.js +42 -0
- package/dist/src/core/claude-settings-file.d.ts +17 -0
- package/dist/src/core/claude-settings-file.js +53 -0
- package/dist/src/core/desktop-app.d.ts +18 -1
- package/dist/src/core/desktop-app.js +22 -12
- package/dist/src/core/handoff-chain.d.ts +16 -2
- package/dist/src/core/handoff-chain.js +19 -6
- package/dist/src/core/handoff-run.js +43 -18
- package/dist/src/core/handoff-select.d.ts +4 -0
- package/dist/src/core/handoff-select.js +3 -2
- package/dist/src/core/remote-control.d.ts +45 -0
- package/dist/src/core/remote-control.js +157 -0
- package/dist/src/core/tuning-apply.js +2 -49
- package/dist/src/desktop/icon.js +33 -33
- package/dist/src/families/claude/handoff.js +10 -0
- package/dist/src/families/desktop.d.ts +25 -0
- package/dist/src/families/desktop.js +8 -2
- package/dist/src/families/grok/bot.d.ts +8 -0
- package/dist/src/families/grok/bot.js +18 -0
- package/dist/src/handoff/hook.js +21 -4
- package/dist/src/handoff/messages.d.ts +4 -0
- package/dist/src/handoff/messages.js +12 -0
- package/dist/src/panel/handler.d.ts +9 -0
- package/dist/src/panel/handler.js +61 -2
- package/dist/src/panel/i18n.d.ts +46 -0
- package/dist/src/panel/i18n.js +92 -0
- package/dist/src/panel/page-script.js +205 -5
- package/dist/src/panel/page-styles.js +15 -0
- package/dist/src/panel/page.js +2 -1
- package/dist/src/runtime.d.ts +10 -1
- package/dist/src/runtime.js +17 -0
- package/dist/src/types.d.ts +5 -0
- package/package.json +2 -2
- package/schemas/desktop-remove.json +33 -0
- package/schemas/desktop.json +2 -1
- package/schemas/handoff-ready.json +4 -0
- package/schemas/list.json +47 -0
- package/schemas/remote-control.json +125 -0
- package/skills/sideby/SKILL.md +4 -1
- package/skills/sideby/references/handoff.md +1 -1
|
@@ -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,29 @@
|
|
|
1
|
+
export type RoundedRect = readonly [x: number, y: number, w: number, h: number, r: number];
|
|
2
|
+
export declare const MARK: {
|
|
3
|
+
readonly back: readonly [12, 82, 128, 104, 44];
|
|
4
|
+
readonly front: readonly [116, 70, 128, 116, 46];
|
|
5
|
+
/** Cut out of the back face (and its eyes) so the two faces never touch. */
|
|
6
|
+
readonly gap: readonly [104, 58, 152, 140, 58];
|
|
7
|
+
readonly backEyes: readonly [readonly [45, 119, 14, 26, 7], readonly [83, 119, 14, 26, 7]];
|
|
8
|
+
readonly frontEyes: readonly [readonly [155, 111, 14, 26, 7], readonly [193, 111, 14, 26, 7]];
|
|
9
|
+
};
|
|
10
|
+
export declare const MARK_COLORS: {
|
|
11
|
+
readonly front: '#3f8fc4';
|
|
12
|
+
readonly back: '#f2b35e';
|
|
13
|
+
readonly eye: '#1e2a44';
|
|
14
|
+
};
|
|
15
|
+
/** The square the mark is drawn in when it sits on a tile (about 78% of the tile is mark). */
|
|
16
|
+
export declare const MARK_TILE_VIEWBOX: {
|
|
17
|
+
readonly x: -20.5;
|
|
18
|
+
readonly y: -20.5;
|
|
19
|
+
readonly size: 297;
|
|
20
|
+
};
|
|
21
|
+
export declare function roundedRectPath([x, y, w, h, r]: RoundedRect): string;
|
|
22
|
+
/**
|
|
23
|
+
* The mark as SVG children (no outer <svg>). The front face has no fill of its own, so it takes the surrounding
|
|
24
|
+
* `fill` (the Panel's `--logo-fg`, which Portals can set with `logoColor`); the back face and the eyes are fixed.
|
|
25
|
+
* `id` names the gap mask; keep it unique per document.
|
|
26
|
+
*/
|
|
27
|
+
export declare function markSvgBody(id: string, front?: string): string;
|
|
28
|
+
/** The Panel's header logo: the mark sized for its 40 px tile. */
|
|
29
|
+
export declare const MARK_SVG: string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The sideby mark: two friendly faces side by side, the amber one behind and the blue one in front, cut apart by a
|
|
2
|
+
// gap that follows the front face (ADR-0020). One source for the Panel header, the empty state and the app icon.
|
|
3
|
+
// Geometry is on a 256-unit grid centred on (128, 128); rounded rectangles are [x, y, width, height, radius].
|
|
4
|
+
export const MARK = {
|
|
5
|
+
back: [12, 82, 128, 104, 44],
|
|
6
|
+
front: [116, 70, 128, 116, 46],
|
|
7
|
+
/** Cut out of the back face (and its eyes) so the two faces never touch. */
|
|
8
|
+
gap: [104, 58, 152, 140, 58],
|
|
9
|
+
backEyes: [
|
|
10
|
+
[45, 119, 14, 26, 7],
|
|
11
|
+
[83, 119, 14, 26, 7],
|
|
12
|
+
],
|
|
13
|
+
frontEyes: [
|
|
14
|
+
[155, 111, 14, 26, 7],
|
|
15
|
+
[193, 111, 14, 26, 7],
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
export const MARK_COLORS = { front: '#3f8fc4', back: '#f2b35e', eye: '#1e2a44' };
|
|
19
|
+
/** The square the mark is drawn in when it sits on a tile (about 78% of the tile is mark). */
|
|
20
|
+
export const MARK_TILE_VIEWBOX = { x: -20.5, y: -20.5, size: 297 };
|
|
21
|
+
export function roundedRectPath([x, y, w, h, r]) {
|
|
22
|
+
return (`M${x + r} ${y}H${x + w - r}A${r} ${r} 0 0 1 ${x + w} ${y + r}V${y + h - r}A${r} ${r} 0 0 1 ${x + w - r} ${y + h}` +
|
|
23
|
+
`H${x + r}A${r} ${r} 0 0 1 ${x} ${y + h - r}V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The mark as SVG children (no outer <svg>). The front face has no fill of its own, so it takes the surrounding
|
|
27
|
+
* `fill` (the Panel's `--logo-fg`, which Portals can set with `logoColor`); the back face and the eyes are fixed.
|
|
28
|
+
* `id` names the gap mask; keep it unique per document.
|
|
29
|
+
*/
|
|
30
|
+
export function markSvgBody(id, front) {
|
|
31
|
+
const eyes = (list) => list.map(roundedRectPath).join('');
|
|
32
|
+
const frontFill = front ? ` fill="${front}"` : '';
|
|
33
|
+
return (`<mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="256" height="256">` +
|
|
34
|
+
`<rect width="256" height="256" fill="#fff"/><path fill="#000" d="${roundedRectPath(MARK.gap)}"/></mask>` +
|
|
35
|
+
`<g mask="url(#${id})"><path fill="${MARK_COLORS.back}" d="${roundedRectPath(MARK.back)}"/>` +
|
|
36
|
+
`<path fill="${MARK_COLORS.eye}" d="${eyes(MARK.backEyes)}"/></g>` +
|
|
37
|
+
`<path${frontFill} d="${roundedRectPath(MARK.front)}"/>` +
|
|
38
|
+
`<path fill="${MARK_COLORS.eye}" d="${eyes(MARK.frontEyes)}"/>`);
|
|
39
|
+
}
|
|
40
|
+
const V = MARK_TILE_VIEWBOX;
|
|
41
|
+
/** The Panel's header logo: the mark sized for its 40 px tile. */
|
|
42
|
+
export const MARK_SVG = `<svg viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody('sideby-mark-gap')}</svg>`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Account } from '../types.ts';
|
|
2
|
+
export type Json = Record<string, unknown>;
|
|
3
|
+
export declare const isObj: (v: unknown) => v is Json;
|
|
4
|
+
export declare function render(obj: Json, original: string): string;
|
|
5
|
+
export declare function readObject(file: string): Promise<{
|
|
6
|
+
text: string;
|
|
7
|
+
obj: Json;
|
|
8
|
+
} | {
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Where the Account's settings really live, and whether `settings.json` is a link to it. */
|
|
12
|
+
export declare function locate(account: Account): Promise<{
|
|
13
|
+
file: string;
|
|
14
|
+
link: boolean;
|
|
15
|
+
exists: boolean;
|
|
16
|
+
broken?: boolean;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// One Claude Account's `settings.json`, as the writers of a single key use it (tune apply, ADR-0012; Remote Control,
|
|
2
|
+
// ADR-0017): where it really lives, a safe read, and rendering it back with the file's own indentation.
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { lstatOrNull, realpathOrNull } from './fs-safe.js';
|
|
6
|
+
const MAX_BYTES = 5_000_000;
|
|
7
|
+
export const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
8
|
+
function detectIndent(text) {
|
|
9
|
+
const m = /^([ \t]+)"/m.exec(text);
|
|
10
|
+
if (!m)
|
|
11
|
+
return 2;
|
|
12
|
+
const lead = m[1];
|
|
13
|
+
return lead.startsWith('\t') ? '\t' : lead.length;
|
|
14
|
+
}
|
|
15
|
+
export function render(obj, original) {
|
|
16
|
+
const out = JSON.stringify(obj, null, detectIndent(original));
|
|
17
|
+
return original.endsWith('\n') || original === '' ? `${out}\n` : out;
|
|
18
|
+
}
|
|
19
|
+
export async function readObject(file) {
|
|
20
|
+
const st = await stat(file).catch(() => null);
|
|
21
|
+
if (!st?.isFile())
|
|
22
|
+
return { error: 'it is not a regular file' };
|
|
23
|
+
if (st.size > MAX_BYTES)
|
|
24
|
+
return { error: 'it is too large to be a settings file' };
|
|
25
|
+
const text = await readFile(file, 'utf8');
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { error: 'it is not valid JSON' };
|
|
32
|
+
}
|
|
33
|
+
if (!isObj(parsed))
|
|
34
|
+
return { error: 'it is not a JSON object' };
|
|
35
|
+
return { text, obj: parsed };
|
|
36
|
+
}
|
|
37
|
+
/** Where the Account's settings really live, and whether `settings.json` is a link to it. */
|
|
38
|
+
export async function locate(account) {
|
|
39
|
+
const path = join(account.dir, 'settings.json');
|
|
40
|
+
const lst = await lstatOrNull(path);
|
|
41
|
+
// Every path is canonical (no link anywhere in it), so two Accounts that share a file compare equal and the undo
|
|
42
|
+
// record is found from either of them, even when HOME itself sits behind a link.
|
|
43
|
+
if (!lst) {
|
|
44
|
+
const dir = (await realpathOrNull(account.dir)) ?? account.dir;
|
|
45
|
+
return { file: join(dir, 'settings.json'), link: false, exists: false };
|
|
46
|
+
}
|
|
47
|
+
const real = await realpathOrNull(path);
|
|
48
|
+
if (!lst.isSymbolicLink())
|
|
49
|
+
return { file: real ?? path, link: false, exists: true };
|
|
50
|
+
return real
|
|
51
|
+
? { file: real, link: true, exists: true }
|
|
52
|
+
: { file: path, link: true, exists: false, broken: true };
|
|
53
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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' });
|
|
@@ -14,6 +14,8 @@ export interface PlanFile {
|
|
|
14
14
|
at: string;
|
|
15
15
|
decision: 'pick' | 'wait' | 'none';
|
|
16
16
|
pick?: string;
|
|
17
|
+
/** For `wait`: when the full window resets and the session goes on by itself. */
|
|
18
|
+
until?: string;
|
|
17
19
|
prepareAt: number;
|
|
18
20
|
threshold: number;
|
|
19
21
|
/** False when the session cannot write its Brief: no preparing step, and the threshold only ends the turn. */
|
|
@@ -26,6 +28,8 @@ export interface RunState {
|
|
|
26
28
|
prepareSentAt?: string;
|
|
27
29
|
thresholdSentAt?: string;
|
|
28
30
|
stopBlocked?: boolean;
|
|
31
|
+
/** The user was told this run waits for a reset instead of moving on (ADR-0018). */
|
|
32
|
+
waitNoticeSentAt?: string;
|
|
29
33
|
sessionId?: string;
|
|
30
34
|
transcriptPath?: string;
|
|
31
35
|
}
|
|
@@ -42,6 +46,12 @@ export interface ReadyFile {
|
|
|
42
46
|
sessionId?: string;
|
|
43
47
|
transcriptPath?: string;
|
|
44
48
|
}
|
|
49
|
+
/** The parent kept a session that hit its limit, to wait for the reset at `until` (ADR-0018). */
|
|
50
|
+
export interface WaitingFile {
|
|
51
|
+
runId: string;
|
|
52
|
+
until: string;
|
|
53
|
+
at: string;
|
|
54
|
+
}
|
|
45
55
|
export interface ChainHop {
|
|
46
56
|
ref: string;
|
|
47
57
|
startedAt: string;
|
|
@@ -57,6 +67,7 @@ export declare const FILES: {
|
|
|
57
67
|
readonly plan: 'plan.json';
|
|
58
68
|
readonly ready: 'ready.json';
|
|
59
69
|
readonly request: 'request.json';
|
|
70
|
+
readonly waiting: 'waiting.json';
|
|
60
71
|
readonly chain: 'chain.json';
|
|
61
72
|
readonly run: (runId: string) => string;
|
|
62
73
|
};
|
|
@@ -73,7 +84,10 @@ export declare function writeJsonFile(path: string, value: unknown): Promise<voi
|
|
|
73
84
|
/**
|
|
74
85
|
* `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
|
|
75
86
|
* started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600); `to` is the
|
|
76
|
-
* Account ref to hand over to.
|
|
87
|
+
* Account ref to hand over to. While the session waits for a reset (`waiting.json`, typed as `! sideby handoff ready`),
|
|
88
|
+
* no turn will end to pick up a request, so it writes `ready.json` at once (`now`).
|
|
77
89
|
* Returns the request, or null outside such a run.
|
|
78
90
|
*/
|
|
79
|
-
export declare function requestHandoff(env: Readonly<Record<string, string | undefined>>, briefFile?: string, to?: string): Promise<ReadyFile
|
|
91
|
+
export declare function requestHandoff(env: Readonly<Record<string, string | undefined>>, briefFile?: string, to?: string, now?: Date): Promise<(ReadyFile & {
|
|
92
|
+
now: boolean;
|
|
93
|
+
}) | null>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The files one Handoff chain keeps under `<state>/handoffs/<chain>/` (spec §3.17). The handoff hook runs on every
|
|
2
2
|
// tool call, so this module imports only Node built-ins and leaf modules (never the Runtime or a Family index).
|
|
3
|
-
// The parent `sideby run` writes `plan.json
|
|
4
|
-
// `sideby handoff ready` writes `request.json
|
|
3
|
+
// The parent `sideby run` writes `plan.json`, `chain.json` and, while a session waits for a reset, `waiting.json`; the
|
|
4
|
+
// hook writes `run-<id>.json` and `ready.json`; `sideby handoff ready` writes `request.json` (`ready.json` while the
|
|
5
|
+
// session waits). Every file is mode 600 in a mode 700 directory.
|
|
5
6
|
import { randomBytes } from 'node:crypto';
|
|
6
7
|
import { chmod, mkdir, readFile } from 'node:fs/promises';
|
|
7
8
|
import { join } from 'node:path';
|
|
@@ -16,6 +17,7 @@ export const FILES = {
|
|
|
16
17
|
plan: 'plan.json',
|
|
17
18
|
ready: 'ready.json',
|
|
18
19
|
request: 'request.json',
|
|
20
|
+
waiting: 'waiting.json',
|
|
19
21
|
chain: 'chain.json',
|
|
20
22
|
run: (runId) => `run-${runId}.json`,
|
|
21
23
|
};
|
|
@@ -51,10 +53,11 @@ export async function writeJsonFile(path, value) {
|
|
|
51
53
|
/**
|
|
52
54
|
* `sideby handoff ready` (spec §3.17): asks the parent to hand over after this turn. Works only inside a run sideby
|
|
53
55
|
* started with Auto Handoff; `briefFile`, when given, is copied into the chain directory (mode 600); `to` is the
|
|
54
|
-
* Account ref to hand over to.
|
|
56
|
+
* Account ref to hand over to. While the session waits for a reset (`waiting.json`, typed as `! sideby handoff ready`),
|
|
57
|
+
* no turn will end to pick up a request, so it writes `ready.json` at once (`now`).
|
|
55
58
|
* Returns the request, or null outside such a run.
|
|
56
59
|
*/
|
|
57
|
-
export async function requestHandoff(env, briefFile, to) {
|
|
60
|
+
export async function requestHandoff(env, briefFile, to, now = new Date()) {
|
|
58
61
|
const runId = env[HANDOFF_ENV.run];
|
|
59
62
|
const dir = env[HANDOFF_ENV.dir];
|
|
60
63
|
if (!runId || !dir)
|
|
@@ -66,11 +69,21 @@ export async function requestHandoff(env, briefFile, to) {
|
|
|
66
69
|
}
|
|
67
70
|
const request = {
|
|
68
71
|
runId,
|
|
69
|
-
at:
|
|
72
|
+
at: now.toISOString(),
|
|
70
73
|
trigger: 'manual',
|
|
71
74
|
...(brief ? { brief, briefSource: 'user' } : {}),
|
|
72
75
|
...(to ? { to } : {}),
|
|
73
76
|
};
|
|
77
|
+
const waiting = await readJsonFile(join(dir, FILES.waiting));
|
|
78
|
+
if (waiting && waiting.runId === runId && Date.parse(waiting.until) > now.getTime()) {
|
|
79
|
+
const state = (await readJsonFile(join(dir, FILES.run(runId)))) ?? {};
|
|
80
|
+
await writeJsonFile(join(dir, FILES.ready), {
|
|
81
|
+
...request,
|
|
82
|
+
...(state.sessionId ? { sessionId: state.sessionId } : {}),
|
|
83
|
+
...(state.transcriptPath ? { transcriptPath: state.transcriptPath } : {}),
|
|
84
|
+
});
|
|
85
|
+
return { ...request, now: true };
|
|
86
|
+
}
|
|
74
87
|
await writeJsonFile(join(dir, FILES.request), request);
|
|
75
|
-
return request;
|
|
88
|
+
return { ...request, now: false };
|
|
76
89
|
}
|