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
|
@@ -132,9 +132,12 @@ export function hookEntryWorks(env) {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
/** The decision, or `none` with the reason when sideby cannot tell (an account removed mid-chain, say). */
|
|
135
|
-
async function decide(rt, account, used, to) {
|
|
135
|
+
async function decide(rt, account, used, to, manual = false) {
|
|
136
136
|
try {
|
|
137
|
-
return await rt.handoffDecision(account, used,
|
|
137
|
+
return await rt.handoffDecision(account, used, {
|
|
138
|
+
...(to !== undefined ? { to } : {}),
|
|
139
|
+
...(manual ? { manual: true } : {}),
|
|
140
|
+
});
|
|
138
141
|
}
|
|
139
142
|
catch (err) {
|
|
140
143
|
return { kind: 'none', reason: err.message };
|
|
@@ -230,6 +233,7 @@ opts = {}) {
|
|
|
230
233
|
at: new Date().toISOString(),
|
|
231
234
|
decision: d.kind,
|
|
232
235
|
...(d.kind === 'pick' ? { pick: d.ref } : {}),
|
|
236
|
+
...(d.kind === 'wait' ? { until: d.until } : {}),
|
|
233
237
|
prepareAt: settings.prepareAt,
|
|
234
238
|
threshold: settings.threshold,
|
|
235
239
|
...(elig.agentBrief === false ? { agentBrief: false } : {}),
|
|
@@ -246,27 +250,48 @@ opts = {}) {
|
|
|
246
250
|
await writePlan();
|
|
247
251
|
const refresh = setInterval(() => void writePlan(), PLAN_REFRESH_MS);
|
|
248
252
|
const host = spawnHost({ ...launch, args, env });
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
253
|
+
const exited = host.exited.then((code) => ({ kind: 'exit', code }));
|
|
254
|
+
// Nothing to move on to (or better to wait): leave the session alone, keep listening (the user may still ask to
|
|
255
|
+
// move on while it waits, ADR-0018), and say why once it ends.
|
|
256
|
+
let kept;
|
|
257
|
+
let outcome;
|
|
258
|
+
for (;;) {
|
|
259
|
+
const watcher = watchReady(dir, runId);
|
|
260
|
+
const got = await Promise.race([
|
|
261
|
+
exited,
|
|
262
|
+
watcher.ready.then((ready) => ({ kind: 'ready', ready })),
|
|
263
|
+
]);
|
|
264
|
+
watcher.stop();
|
|
265
|
+
if (got.kind === 'exit') {
|
|
266
|
+
outcome = got;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
const d = await decide(rt, account, used, got.ready.to, got.ready.trigger === 'manual');
|
|
270
|
+
if (d.kind === 'pick') {
|
|
271
|
+
outcome = { ...got, decision: d };
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
await rm(join(dir, FILES.ready), { force: true });
|
|
275
|
+
if (d.kind === 'wait')
|
|
276
|
+
await writeJsonFile(join(dir, FILES.waiting), {
|
|
277
|
+
runId,
|
|
278
|
+
until: d.until,
|
|
279
|
+
at: new Date().toISOString(),
|
|
280
|
+
});
|
|
281
|
+
kept = d;
|
|
282
|
+
}
|
|
254
283
|
clearInterval(refresh);
|
|
255
|
-
watcher.stop();
|
|
256
284
|
stopped = true;
|
|
257
285
|
await pending;
|
|
286
|
+
await rm(join(dir, FILES.waiting), { force: true });
|
|
258
287
|
if (outcome.kind === 'exit') {
|
|
259
|
-
|
|
288
|
+
if (kept)
|
|
289
|
+
io.err(`sideby: ${account.ref} did not hand over: ${explain(kept)}`);
|
|
290
|
+
else
|
|
291
|
+
await noteUnhandedHighQuota(rt, account, used, settings.threshold, io);
|
|
260
292
|
return outcome.code;
|
|
261
293
|
}
|
|
262
|
-
|
|
263
|
-
const decision = await decide(rt, account, used, outcome.ready.to);
|
|
264
|
-
if (decision.kind !== 'pick') {
|
|
265
|
-
await rm(join(dir, FILES.ready), { force: true });
|
|
266
|
-
const code = await host.exited;
|
|
267
|
-
io.err(`sideby: ${account.ref} did not hand over: ${explain(decision)}`);
|
|
268
|
-
return code;
|
|
269
|
-
}
|
|
294
|
+
const decision = outcome.decision;
|
|
270
295
|
// The session is ready: end the Host, give it time to save, never kill it.
|
|
271
296
|
host.child.kill('SIGTERM');
|
|
272
297
|
const ended = await Promise.race([
|
|
@@ -356,5 +381,5 @@ export async function readyDecision(rt, env, to) {
|
|
|
356
381
|
}
|
|
357
382
|
const account = await rt.resolve(ref);
|
|
358
383
|
const chain = await readJsonFile(join(dir, FILES.chain));
|
|
359
|
-
return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref], to);
|
|
384
|
+
return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref], to, true);
|
|
360
385
|
}
|
|
@@ -15,6 +15,8 @@ export interface SelectCandidate {
|
|
|
15
15
|
installed: boolean;
|
|
16
16
|
/** Whether its Family can start as the next Account (has `handoff`, and the run would be eligible). */
|
|
17
17
|
receive: boolean;
|
|
18
|
+
/** A session stopped at the limit continues by itself at the reset (`FamilyHandoff.resumesAtReset`); set for the origin. */
|
|
19
|
+
resumesAtReset?: boolean;
|
|
18
20
|
}
|
|
19
21
|
/** An Account the Handoff Order lists that was left out, and why (spec §3.21). */
|
|
20
22
|
export interface SkippedAccount {
|
|
@@ -55,4 +57,6 @@ export declare function selectNext(input: {
|
|
|
55
57
|
* an Account whose Quota came back); every other rule still applies.
|
|
56
58
|
*/
|
|
57
59
|
to?: string;
|
|
60
|
+
/** The user asked to hand over (`sideby handoff ready`): never wait for a reset (ADR-0018). */
|
|
61
|
+
manual?: boolean;
|
|
58
62
|
}): HandoffDecision;
|
|
@@ -15,7 +15,8 @@ export function sameOrganization(a, b) {
|
|
|
15
15
|
* reaches the threshold (spec §3.17 "选号" 1: plan.json and the Panel, decided before the moment comes).
|
|
16
16
|
*/
|
|
17
17
|
function waitUntil(origin, s, now, ahead) {
|
|
18
|
-
|
|
18
|
+
// Waiting only makes sense when the session goes on by itself at the reset (ADR-0018).
|
|
19
|
+
if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0 || origin.resumesAtReset !== true)
|
|
19
20
|
return undefined;
|
|
20
21
|
const open = origin.quota.windows.filter((w) => !windowReset(w.resetsAt, now));
|
|
21
22
|
const top = Math.max(0, ...open.map((w) => w.usedPercent));
|
|
@@ -60,7 +61,7 @@ export function selectNext(input) {
|
|
|
60
61
|
return { kind: 'none', reason: `${input.to} is not an account sideby can find` };
|
|
61
62
|
return toTarget(origin, target, s, new Set(s.crossOrganization.map(refOf)), now);
|
|
62
63
|
}
|
|
63
|
-
const until = waitUntil(origin, s, now, input.ahead === true);
|
|
64
|
+
const until = input.manual ? undefined : waitUntil(origin, s, now, input.ahead === true);
|
|
64
65
|
if (until)
|
|
65
66
|
return { kind: 'wait', until };
|
|
66
67
|
const allowed = new Set(s.crossOrganization.map(refOf));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Account, ReadContext } from '../types.ts';
|
|
2
|
+
export declare const RC_KEY = "remoteControlAtStartup";
|
|
3
|
+
/**
|
|
4
|
+
* `on`: every session connects by itself; `default`: no key, so the organization's or Claude Code's default decides
|
|
5
|
+
* (it may connect); `off`: never by itself; `missing`: the Account has no `settings.json`; `blocked`: see `reason`.
|
|
6
|
+
*/
|
|
7
|
+
export type RemoteControlState = 'on' | 'default' | 'off' | 'missing' | 'blocked';
|
|
8
|
+
export interface RemoteControlGroup {
|
|
9
|
+
/** The real settings file (canonical); for `missing`, where it would be. */
|
|
10
|
+
file: string;
|
|
11
|
+
/** Refs of the Accounts whose `settings.json` is `file`, in discovery order. */
|
|
12
|
+
accounts: string[];
|
|
13
|
+
state: RemoteControlState;
|
|
14
|
+
/** For `blocked` and `missing`: why, and what to do. */
|
|
15
|
+
reason?: string;
|
|
16
|
+
/** sideby turned it off here and can put the earlier value back. */
|
|
17
|
+
undo: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface RemoteControlUndoRecord {
|
|
20
|
+
file: string;
|
|
21
|
+
/** The value before sideby wrote `false`, or null when the key was not there. */
|
|
22
|
+
previous: boolean | null;
|
|
23
|
+
at: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RemoteControlChange {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
/** One line per group that was meant to change. */
|
|
28
|
+
results: {
|
|
29
|
+
file: string;
|
|
30
|
+
accounts: string[];
|
|
31
|
+
ok: boolean;
|
|
32
|
+
message: string;
|
|
33
|
+
}[];
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
/** Every Claude Account's setting, grouped by the file it lives in. Writes nothing. */
|
|
37
|
+
export declare function remoteControlGroups(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlGroup[]>;
|
|
38
|
+
/** The groups `off` would change: those that are on, or follow a default that may be on. */
|
|
39
|
+
export declare function groupsToTurnOff(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
|
|
40
|
+
/** Sets `remoteControlAtStartup` to false in every group that is on or follows the default, remembering each old value. */
|
|
41
|
+
export declare function turnRemoteControlOff(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlChange>;
|
|
42
|
+
/** Groups sideby can put back: it wrote `false` there and the key still holds it. */
|
|
43
|
+
export declare function groupsToUndo(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
|
|
44
|
+
/** Puts back what each file had before `off`, only while the key is still `false`. */
|
|
45
|
+
export declare function undoRemoteControlOff(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlChange>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Remote Control off by default (spec §3.22, ADR-0017): reads and writes one key, `remoteControlAtStartup`, of each
|
|
2
|
+
// Claude Account's `settings.json`. Accounts that share a file (a link to the Main Account's) form one group and one
|
|
3
|
+
// write. Nothing but that key is ever read out of the file into an output, an error or the undo record; writes go
|
|
4
|
+
// through `writeIfUnchanged`, keep every other key, and never create, replace or retarget a link.
|
|
5
|
+
import { mkdir, readFile, rm, stat } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { locate, readObject, render } from './claude-settings-file.js';
|
|
8
|
+
import { sha256, writeFileAtomic, writeIfUnchanged } from './fs-safe.js';
|
|
9
|
+
export const RC_KEY = 'remoteControlAtStartup';
|
|
10
|
+
const NO_SETTINGS = 'this account has no settings.json; run `sideby doctor --fix` so it shares the main one, then try again';
|
|
11
|
+
function stateOf(v) {
|
|
12
|
+
return v === true ? 'on' : v === false ? 'off' : 'default';
|
|
13
|
+
}
|
|
14
|
+
function recordPath(ctx, file) {
|
|
15
|
+
return join(ctx.stateDir, 'remote-control', `${sha256(file).slice(0, 16)}.json`);
|
|
16
|
+
}
|
|
17
|
+
async function readRecord(ctx, file) {
|
|
18
|
+
try {
|
|
19
|
+
const r = JSON.parse(await readFile(recordPath(ctx, file), 'utf8'));
|
|
20
|
+
if (r.file === file && (r.previous === null || typeof r.previous === 'boolean'))
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// no record
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Every Claude Account's setting, grouped by the file it lives in. Writes nothing. */
|
|
29
|
+
export async function remoteControlGroups(accounts, ctx) {
|
|
30
|
+
const byFile = new Map();
|
|
31
|
+
for (const account of accounts) {
|
|
32
|
+
if (account.family !== 'claude')
|
|
33
|
+
continue;
|
|
34
|
+
const where = await locate(account);
|
|
35
|
+
const have = byFile.get(where.file);
|
|
36
|
+
if (have) {
|
|
37
|
+
have.accounts.push(account.ref);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const group = {
|
|
41
|
+
file: where.file,
|
|
42
|
+
accounts: [account.ref],
|
|
43
|
+
state: 'missing',
|
|
44
|
+
undo: false,
|
|
45
|
+
};
|
|
46
|
+
byFile.set(where.file, group);
|
|
47
|
+
if (where.broken) {
|
|
48
|
+
group.state = 'blocked';
|
|
49
|
+
group.reason =
|
|
50
|
+
'settings.json is a link to a file that does not exist; run `sideby doctor` to see how to fix it';
|
|
51
|
+
}
|
|
52
|
+
else if (!where.exists) {
|
|
53
|
+
group.reason = NO_SETTINGS;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const read = await readObject(where.file);
|
|
57
|
+
if ('error' in read) {
|
|
58
|
+
group.state = 'blocked';
|
|
59
|
+
group.reason = `settings.json cannot be changed safely: ${read.error}. Fix or move the file, then try again`;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
group.state = stateOf(read.obj[RC_KEY]);
|
|
63
|
+
group.undo = group.state === 'off' && (await readRecord(ctx, where.file)) !== null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...byFile.values()];
|
|
68
|
+
}
|
|
69
|
+
/** The groups `off` would change: those that are on, or follow a default that may be on. */
|
|
70
|
+
export function groupsToTurnOff(groups) {
|
|
71
|
+
return groups.filter((g) => g.state === 'on' || g.state === 'default');
|
|
72
|
+
}
|
|
73
|
+
async function writeKey(file, change, expect) {
|
|
74
|
+
const read = await readObject(file);
|
|
75
|
+
if ('error' in read)
|
|
76
|
+
return `settings.json cannot be changed safely: ${read.error}.`;
|
|
77
|
+
const refused = expect(read.obj);
|
|
78
|
+
if (refused)
|
|
79
|
+
return refused;
|
|
80
|
+
const mode = (await stat(file)).mode & 0o777;
|
|
81
|
+
const wrote = await writeIfUnchanged(file, sha256(read.text), render(change({ ...read.obj }), read.text), mode);
|
|
82
|
+
return wrote ? null : 'settings.json changed while sideby was writing, so nothing was changed. Try again.';
|
|
83
|
+
}
|
|
84
|
+
/** Sets `remoteControlAtStartup` to false in every group that is on or follows the default, remembering each old value. */
|
|
85
|
+
export async function turnRemoteControlOff(accounts, ctx) {
|
|
86
|
+
const groups = await remoteControlGroups(accounts, ctx);
|
|
87
|
+
const todo = groupsToTurnOff(groups);
|
|
88
|
+
if (todo.length === 0) {
|
|
89
|
+
const stuck = groups.find((g) => g.state === 'blocked');
|
|
90
|
+
return stuck
|
|
91
|
+
? { ok: false, results: [], message: `Nothing was changed: ${stuck.reason}` }
|
|
92
|
+
: {
|
|
93
|
+
ok: true,
|
|
94
|
+
results: [],
|
|
95
|
+
message: 'Remote Control already does not start by itself; nothing changed.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const results = [];
|
|
99
|
+
for (const g of todo) {
|
|
100
|
+
let previous = null;
|
|
101
|
+
const failed = await writeKey(g.file, (obj) => {
|
|
102
|
+
previous = typeof obj[RC_KEY] === 'boolean' ? obj[RC_KEY] : null;
|
|
103
|
+
obj[RC_KEY] = false;
|
|
104
|
+
return obj;
|
|
105
|
+
}, (obj) => (obj[RC_KEY] === false ? 'It was turned off meanwhile; nothing changed.' : null));
|
|
106
|
+
if (failed) {
|
|
107
|
+
results.push({ file: g.file, accounts: g.accounts, ok: false, message: failed });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const record = { file: g.file, previous, at: new Date().toISOString() };
|
|
111
|
+
await mkdir(join(ctx.stateDir, 'remote-control'), { recursive: true });
|
|
112
|
+
await writeFileAtomic(recordPath(ctx, g.file), `${JSON.stringify(record, null, 2)}\n`, 0o600);
|
|
113
|
+
results.push({ file: g.file, accounts: g.accounts, ok: true, message: 'Turned off.' });
|
|
114
|
+
}
|
|
115
|
+
const ok = results.every((r) => r.ok);
|
|
116
|
+
return {
|
|
117
|
+
ok,
|
|
118
|
+
results,
|
|
119
|
+
message: ok
|
|
120
|
+
? 'Done. New sessions no longer connect to claude.ai by themselves; restart sessions that are already running. You can still start it in a session with /remote-control.'
|
|
121
|
+
: 'Some files could not be changed; see each line.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Groups sideby can put back: it wrote `false` there and the key still holds it. */
|
|
125
|
+
export function groupsToUndo(groups) {
|
|
126
|
+
return groups.filter((g) => g.undo);
|
|
127
|
+
}
|
|
128
|
+
/** Puts back what each file had before `off`, only while the key is still `false`. */
|
|
129
|
+
export async function undoRemoteControlOff(accounts, ctx) {
|
|
130
|
+
const todo = groupsToUndo(await remoteControlGroups(accounts, ctx));
|
|
131
|
+
if (todo.length === 0)
|
|
132
|
+
return { ok: false, results: [], message: 'There is nothing from sideby to undo.' };
|
|
133
|
+
const results = [];
|
|
134
|
+
for (const g of todo) {
|
|
135
|
+
const record = await readRecord(ctx, g.file);
|
|
136
|
+
if (!record)
|
|
137
|
+
continue;
|
|
138
|
+
const failed = await writeKey(g.file, (obj) => {
|
|
139
|
+
if (record.previous === null)
|
|
140
|
+
delete obj[RC_KEY];
|
|
141
|
+
else
|
|
142
|
+
obj[RC_KEY] = record.previous;
|
|
143
|
+
return obj;
|
|
144
|
+
}, (obj) => obj[RC_KEY] === false ? null : 'The setting was changed after sideby set it, so it was left alone.');
|
|
145
|
+
if (!failed)
|
|
146
|
+
await rm(recordPath(ctx, g.file), { force: true });
|
|
147
|
+
results.push({ file: g.file, accounts: g.accounts, ok: !failed, message: failed ?? 'Put back.' });
|
|
148
|
+
}
|
|
149
|
+
const ok = results.length > 0 && results.every((r) => r.ok);
|
|
150
|
+
return {
|
|
151
|
+
ok,
|
|
152
|
+
results,
|
|
153
|
+
message: ok
|
|
154
|
+
? 'Undone. New sessions go back to the previous setting.'
|
|
155
|
+
: 'Some files could not be put back; see each line.',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -5,49 +5,19 @@
|
|
|
5
5
|
// shares it; a link is never created, replaced or retargeted.
|
|
6
6
|
import { mkdir, readFile, rm, stat } from 'node:fs/promises';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { isObj, locate, readObject, render } from './claude-settings-file.js';
|
|
8
9
|
import { UserError } from './errors.js';
|
|
9
|
-
import { fileHash,
|
|
10
|
+
import { fileHash, sha256, writeFileAtomic, writeIfUnchanged } from './fs-safe.js';
|
|
10
11
|
import { MAX_COMPACT_WINDOW, MIN_COMPACT_WINDOW } from './tuning.js';
|
|
11
12
|
export const TUNE_KEY = 'autoCompactWindow';
|
|
12
13
|
export const MIN_WINDOW = MIN_COMPACT_WINDOW;
|
|
13
14
|
export const MAX_WINDOW = MAX_COMPACT_WINDOW;
|
|
14
|
-
const MAX_BYTES = 5_000_000;
|
|
15
|
-
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
15
|
function checkValue(value) {
|
|
17
16
|
if (value === null)
|
|
18
17
|
return;
|
|
19
18
|
if (!Number.isInteger(value) || value < MIN_WINDOW || value > MAX_WINDOW)
|
|
20
19
|
throw new UserError(`${value} is not a usable size: give a whole number of tokens from ${MIN_WINDOW} to ${MAX_WINDOW}, for example 160000`, { code: 'bad-value' });
|
|
21
20
|
}
|
|
22
|
-
function detectIndent(text) {
|
|
23
|
-
const m = /^([ \t]+)"/m.exec(text);
|
|
24
|
-
if (!m)
|
|
25
|
-
return 2;
|
|
26
|
-
const lead = m[1];
|
|
27
|
-
return lead.startsWith('\t') ? '\t' : lead.length;
|
|
28
|
-
}
|
|
29
|
-
function render(obj, original) {
|
|
30
|
-
const out = JSON.stringify(obj, null, detectIndent(original));
|
|
31
|
-
return original.endsWith('\n') || original === '' ? `${out}\n` : out;
|
|
32
|
-
}
|
|
33
|
-
async function readObject(file) {
|
|
34
|
-
const st = await stat(file).catch(() => null);
|
|
35
|
-
if (!st?.isFile())
|
|
36
|
-
return { error: 'it is not a regular file' };
|
|
37
|
-
if (st.size > MAX_BYTES)
|
|
38
|
-
return { error: 'it is too large to be a settings file' };
|
|
39
|
-
const text = await readFile(file, 'utf8');
|
|
40
|
-
let parsed;
|
|
41
|
-
try {
|
|
42
|
-
parsed = JSON.parse(text);
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return { error: 'it is not valid JSON' };
|
|
46
|
-
}
|
|
47
|
-
if (!isObj(parsed))
|
|
48
|
-
return { error: 'it is not a JSON object' };
|
|
49
|
-
return { text, obj: parsed };
|
|
50
|
-
}
|
|
51
21
|
/** The sizes models save for themselves under `modelSettings`: only those `autoCompactWindow` numbers are read. */
|
|
52
22
|
function overridesOf(obj) {
|
|
53
23
|
const ms = obj.modelSettings;
|
|
@@ -61,23 +31,6 @@ function overridesOf(obj) {
|
|
|
61
31
|
}
|
|
62
32
|
return out;
|
|
63
33
|
}
|
|
64
|
-
/** Where the Account's settings really live, and whether `settings.json` is a link to it. */
|
|
65
|
-
async function locate(account) {
|
|
66
|
-
const path = join(account.dir, 'settings.json');
|
|
67
|
-
const lst = await lstatOrNull(path);
|
|
68
|
-
// Every path is canonical (no link anywhere in it), so two Accounts that share a file compare equal and the undo
|
|
69
|
-
// record is found from either of them, even when HOME itself sits behind a link.
|
|
70
|
-
if (!lst) {
|
|
71
|
-
const dir = (await realpathOrNull(account.dir)) ?? account.dir;
|
|
72
|
-
return { file: join(dir, 'settings.json'), link: false, exists: false };
|
|
73
|
-
}
|
|
74
|
-
const real = await realpathOrNull(path);
|
|
75
|
-
if (!lst.isSymbolicLink())
|
|
76
|
-
return { file: real ?? path, link: false, exists: true };
|
|
77
|
-
return real
|
|
78
|
-
? { file: real, link: true, exists: true }
|
|
79
|
-
: { file: path, link: true, exists: false, broken: true };
|
|
80
|
-
}
|
|
81
34
|
function recordPath(ctx, file) {
|
|
82
35
|
return join(ctx.stateDir, 'tune', `${sha256(file).slice(0, 16)}.json`);
|
|
83
36
|
}
|
package/dist/src/desktop/icon.js
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
|
-
// The sideby mark drawn without dependencies: the Panel header's logo (
|
|
2
|
-
//
|
|
3
|
-
// antialiasing and encoded as PNG, SVG or ICNS.
|
|
1
|
+
// The sideby mark drawn without dependencies: the Panel header's logo (two faces side by side on a light rounded
|
|
2
|
+
// tile, core/brand-mark.ts) rasterised with analytic antialiasing and encoded as PNG, SVG or ICNS.
|
|
4
3
|
import { deflateSync } from 'node:zlib';
|
|
4
|
+
import { MARK, MARK_COLORS, MARK_TILE_VIEWBOX, markSvgBody } from '../core/brand-mark.js';
|
|
5
5
|
/** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
|
|
6
6
|
export const APP_MARKER = 'sideby-app-install';
|
|
7
|
-
//
|
|
8
|
-
// 18.5,18.5 facing left and 21.5,21.5 facing right, so they sit 3 apart and mirror through the centre).
|
|
7
|
+
// The Panel's logo tile (page-styles.ts: .logo 40×40, radius 10), with the mark in MARK_TILE_VIEWBOX units on it.
|
|
9
8
|
const TILE = { size: 40, radius: 10, color: [0xe5, 0xf2, 0xfb] };
|
|
10
|
-
const MARK = { color: [0x3f, 0x8f, 0xc4], opacities: [1, 0.55] };
|
|
11
|
-
const HALVES = [
|
|
12
|
-
{ cx: 18.5, cy: 18.5, r: 10.5, side: -1 },
|
|
13
|
-
{ cx: 21.5, cy: 21.5, r: 10.5, side: 1 },
|
|
14
|
-
];
|
|
15
9
|
// The tile sits on the macOS icon grid: 824 of 1024 units, centred.
|
|
16
10
|
const GRID = { canvas: 1024, tile: 824 };
|
|
11
|
+
const rgb = (hex) => [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
|
|
17
12
|
function shapes(size) {
|
|
18
13
|
const tilePx = (size * GRID.tile) / GRID.canvas;
|
|
19
14
|
const off = (size - tilePx) / 2;
|
|
20
|
-
const u = tilePx /
|
|
15
|
+
const u = tilePx / MARK_TILE_VIEWBOX.size;
|
|
16
|
+
const place = ([x, y, w, h, r]) => ({
|
|
17
|
+
x: off + (x - MARK_TILE_VIEWBOX.x) * u,
|
|
18
|
+
y: off + (y - MARK_TILE_VIEWBOX.y) * u,
|
|
19
|
+
w: w * u,
|
|
20
|
+
h: h * u,
|
|
21
|
+
r: r * u,
|
|
22
|
+
});
|
|
21
23
|
return {
|
|
22
|
-
tile: { x: off, y: off, w: tilePx, h: tilePx, r: TILE.radius *
|
|
23
|
-
|
|
24
|
+
tile: { x: off, y: off, w: tilePx, h: tilePx, r: (TILE.radius * tilePx) / TILE.size },
|
|
25
|
+
back: place(MARK.back),
|
|
26
|
+
front: place(MARK.front),
|
|
27
|
+
gap: place(MARK.gap),
|
|
28
|
+
backEyes: MARK.backEyes.map(place),
|
|
29
|
+
frontEyes: MARK.frontEyes.map(place),
|
|
24
30
|
};
|
|
25
31
|
}
|
|
26
32
|
/** Signed distance from (px, py) to a rounded rectangle; negative inside. */
|
|
@@ -32,20 +38,18 @@ function roundedRectDistance(px, py, s) {
|
|
|
32
38
|
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
|
|
33
39
|
return outside + Math.min(Math.max(qx, qy), 0) - s.r;
|
|
34
40
|
}
|
|
35
|
-
/** Signed distance from (px, py) to a half disc: the disc intersected with the half-plane on its side. */
|
|
36
|
-
function halfDistance(px, py, h) {
|
|
37
|
-
return Math.max(Math.hypot(px - h.cx, py - h.cy) - h.r, (px - h.cx) * -h.side);
|
|
38
|
-
}
|
|
39
41
|
/** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
|
|
40
42
|
export function renderIcon(size) {
|
|
41
|
-
const
|
|
43
|
+
const sh = shapes(size);
|
|
44
|
+
const d = (r) => (x, y) => roundedRectDistance(x, y, r);
|
|
45
|
+
// Inside `r` and outside the gap around the front face.
|
|
46
|
+
const cut = (r) => (x, y) => Math.max(roundedRectDistance(x, y, r), -roundedRectDistance(x, y, sh.gap));
|
|
42
47
|
const layers = [
|
|
43
|
-
{ dist: (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
})),
|
|
48
|
+
{ dist: d(sh.tile), color: TILE.color },
|
|
49
|
+
{ dist: cut(sh.back), color: rgb(MARK_COLORS.back) },
|
|
50
|
+
...sh.backEyes.map((e) => ({ dist: cut(e), color: rgb(MARK_COLORS.eye) })),
|
|
51
|
+
{ dist: d(sh.front), color: rgb(MARK_COLORS.front) },
|
|
52
|
+
...sh.frontEyes.map((e) => ({ dist: d(e), color: rgb(MARK_COLORS.eye) })),
|
|
49
53
|
];
|
|
50
54
|
const px = new Uint8Array(size * size * 4);
|
|
51
55
|
for (let y = 0; y < size; y++)
|
|
@@ -56,8 +60,7 @@ export function renderIcon(size) {
|
|
|
56
60
|
let b = 0;
|
|
57
61
|
let a = 0;
|
|
58
62
|
for (const l of layers) {
|
|
59
|
-
const
|
|
60
|
-
const la = cover * l.opacity;
|
|
63
|
+
const la = Math.min(1, Math.max(0, 0.5 - l.dist(x + 0.5, y + 0.5)));
|
|
61
64
|
if (la === 0)
|
|
62
65
|
continue;
|
|
63
66
|
r = (l.color[0] / 255) * la + r * (1 - la);
|
|
@@ -157,17 +160,14 @@ export function encodeIcns(pngs) {
|
|
|
157
160
|
}
|
|
158
161
|
/** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
|
|
159
162
|
export function iconSvg() {
|
|
160
|
-
const { tile
|
|
163
|
+
const { tile } = shapes(GRID.canvas);
|
|
161
164
|
const hex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
162
|
-
const
|
|
163
|
-
const rect = (s, fill) => ` <rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" rx="${s.r}" fill="${fill}"/>`;
|
|
164
|
-
// From the top of the cut to its bottom, bulging to the half's side.
|
|
165
|
-
const half = (h, fill, opacity) => ` <path d="M${h.cx} ${h.cy - h.r}A${h.r} ${h.r} 0 0 ${h.side > 0 ? 1 : 0} ${h.cx} ${h.cy + h.r}Z" fill="${fill}"${opacityAttr(opacity)}/>`;
|
|
165
|
+
const V = MARK_TILE_VIEWBOX;
|
|
166
166
|
return [
|
|
167
167
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID.canvas} ${GRID.canvas}" width="${GRID.canvas}" height="${GRID.canvas}">`,
|
|
168
168
|
` <!-- ${APP_MARKER} -->`,
|
|
169
|
-
rect
|
|
170
|
-
|
|
169
|
+
` <rect x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" rx="${tile.r}" fill="${hex(TILE.color)}"/>`,
|
|
170
|
+
` <svg x="${tile.x}" y="${tile.y}" width="${tile.w}" height="${tile.h}" viewBox="${V.x} ${V.y} ${V.size} ${V.size}">${markSvgBody('gap', MARK_COLORS.front)}</svg>`,
|
|
171
171
|
'</svg>',
|
|
172
172
|
'',
|
|
173
173
|
].join('\n');
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Account's own settings hooks (verified 2026-10-09), and the Brief's directory through `--add-dir`.
|
|
3
3
|
import { mkdir } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { locate, readObject } from '../../core/claude-settings-file.js';
|
|
5
6
|
import { writeFileAtomic } from '../../core/fs-safe.js';
|
|
6
7
|
import { carryArgs, hasOption, promptAfterDashes } from '../shared/handoff-args.js';
|
|
7
8
|
import { claudeBrief } from './brief.js';
|
|
@@ -113,4 +114,13 @@ export const claudeHandoff = {
|
|
|
113
114
|
return { start: true, receive: true };
|
|
114
115
|
},
|
|
115
116
|
briefFromSession: claudeBrief,
|
|
117
|
+
// Only `autoContinueAtUsageLimit` is read (ADR-0018); Claude Code's default is true. Managed and project settings
|
|
118
|
+
// and `--settings` can set it too; sideby does not read them.
|
|
119
|
+
async resumesAtReset(account) {
|
|
120
|
+
const where = await locate(account);
|
|
121
|
+
if (!where.exists)
|
|
122
|
+
return true;
|
|
123
|
+
const read = await readObject(where.file);
|
|
124
|
+
return 'error' in read || read.obj.autoContinueAtUsageLimit !== false;
|
|
125
|
+
},
|
|
116
126
|
};
|
|
@@ -25,3 +25,28 @@ export interface DesktopAppDef {
|
|
|
25
25
|
}
|
|
26
26
|
/** The Desktop App of a built-in Family id, if it has one. */
|
|
27
27
|
export declare function desktopDef(familyId: string): DesktopAppDef | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* A desktop app that signs in apart from every Account (Grok Bot), so each of its windows is an App Profile with its
|
|
30
|
+
* own App Data, named by the user (ADR-0019).
|
|
31
|
+
*/
|
|
32
|
+
export interface ProfileAppDef {
|
|
33
|
+
/** The ref prefix: `<id>:<name>`, and `<id>` alone for the Main Profile. */
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
bundleId: string;
|
|
37
|
+
bundleNames: readonly string[];
|
|
38
|
+
installUrl: string;
|
|
39
|
+
/** `open` arguments for a non-main Profile; `app` is the bundle path, `appData` the Profile's folder. */
|
|
40
|
+
openArgs(app: string, appData: string): string[];
|
|
41
|
+
/** Inherited variables cleared before `open`, which would point the app at other data. */
|
|
42
|
+
clearVars: readonly string[];
|
|
43
|
+
/** The Main Profile's app data folder, relative to HOME. */
|
|
44
|
+
mainUserData: string;
|
|
45
|
+
/** The app's own status file in an app data folder, whose boolean `signedIn` sideby reads. */
|
|
46
|
+
statusFile: string;
|
|
47
|
+
/** The Family whose users may look for this app under an Account (grok), for a pointer in its error. */
|
|
48
|
+
relatedFamily: string;
|
|
49
|
+
}
|
|
50
|
+
export declare const PROFILE_APPS: readonly ProfileAppDef[];
|
|
51
|
+
/** The app with App Profiles related to a Family id, if any. */
|
|
52
|
+
export declare function profileAppFor(familyId: string): ProfileAppDef | undefined;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
// Desktop Apps of the built-in Families (spec §3.20, ADR-0015)
|
|
2
|
-
// FamilyDef, so plugin Families cannot declare one in v1.
|
|
1
|
+
// Desktop Apps of the built-in Families (spec §3.20, ADR-0015) and apps with App Profiles (ADR-0019). Internal on
|
|
2
|
+
// purpose: not part of the public FamilyDef, so plugin Families cannot declare one in v1.
|
|
3
3
|
import { claudeDesktop } from './claude/desktop.js';
|
|
4
4
|
import { codexDesktop } from './codex/desktop.js';
|
|
5
|
+
import { grokBot } from './grok/bot.js';
|
|
5
6
|
const DESKTOP_APPS = new Map([claudeDesktop, codexDesktop].map((d) => [d.family, d]));
|
|
6
7
|
/** The Desktop App of a built-in Family id, if it has one. */
|
|
7
8
|
export function desktopDef(familyId) {
|
|
8
9
|
return DESKTOP_APPS.get(familyId);
|
|
9
10
|
}
|
|
11
|
+
export const PROFILE_APPS = [grokBot];
|
|
12
|
+
/** The app with App Profiles related to a Family id, if any. */
|
|
13
|
+
export function profileAppFor(familyId) {
|
|
14
|
+
return PROFILE_APPS.find((p) => p.relatedFamily === familyId);
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ProfileAppDef } from '../desktop.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Grok Bot (spec §3.20 "Grok Bot 窗口", ADR-0019). It signs in with a Cursor account, apart from every grok Account,
|
|
4
|
+
* so its windows are App Profiles. `--user-data-dir` is the app's isolated mode: userData and its data root
|
|
5
|
+
* (`<dir>/sand-data`) move there. SAND_DATA_ROOT and SAND_USER_DATA_DIR would override it; all of this is internal to
|
|
6
|
+
* the app and may change.
|
|
7
|
+
*/
|
|
8
|
+
export declare const grokBot: ProfileAppDef;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok Bot (spec §3.20 "Grok Bot 窗口", ADR-0019). It signs in with a Cursor account, apart from every grok Account,
|
|
3
|
+
* so its windows are App Profiles. `--user-data-dir` is the app's isolated mode: userData and its data root
|
|
4
|
+
* (`<dir>/sand-data`) move there. SAND_DATA_ROOT and SAND_USER_DATA_DIR would override it; all of this is internal to
|
|
5
|
+
* the app and may change.
|
|
6
|
+
*/
|
|
7
|
+
export const grokBot = {
|
|
8
|
+
id: 'grok-bot',
|
|
9
|
+
title: 'Grok Bot',
|
|
10
|
+
bundleId: 'com.anysphere.sand',
|
|
11
|
+
bundleNames: ['Grok Bot.app'],
|
|
12
|
+
installUrl: 'https://x.ai/bot/',
|
|
13
|
+
openArgs: (app, appData) => ['-n', '-a', app, '--args', `--user-data-dir=${appData}`],
|
|
14
|
+
clearVars: ['SAND_DATA_ROOT', 'SAND_USER_DATA_DIR'],
|
|
15
|
+
mainUserData: 'Library/Application Support/Grok Bot',
|
|
16
|
+
statusFile: 'desktop-status.json',
|
|
17
|
+
relatedFamily: 'grok',
|
|
18
|
+
};
|