sideby 0.12.1 → 0.13.1

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 (86) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +5 -2
  4. package/dist/src/cli/commands.js +165 -20
  5. package/dist/src/cli/json-schemas.js +100 -2
  6. package/dist/src/cli/main.js +72 -4
  7. package/dist/src/core/brand-mark.d.ts +5 -7
  8. package/dist/src/core/brand-mark.js +18 -5
  9. package/dist/src/core/browser-profiles.d.ts +71 -0
  10. package/dist/src/core/browser-profiles.js +361 -0
  11. package/dist/src/core/config.d.ts +15 -0
  12. package/dist/src/core/config.js +13 -1
  13. package/dist/src/core/create.js +2 -1
  14. package/dist/src/core/desktop-app.d.ts +3 -1
  15. package/dist/src/core/desktop-app.js +29 -8
  16. package/dist/src/core/launch.d.ts +7 -0
  17. package/dist/src/core/launch.js +22 -2
  18. package/dist/src/core/leveldb-keys.d.ts +14 -0
  19. package/dist/src/core/leveldb-keys.js +291 -0
  20. package/dist/src/core/remote-control.d.ts +17 -6
  21. package/dist/src/core/remote-control.js +40 -17
  22. package/dist/src/core/skill-check.d.ts +13 -0
  23. package/dist/src/core/skill-check.js +29 -1
  24. package/dist/src/core/update.d.ts +55 -3
  25. package/dist/src/core/update.js +124 -4
  26. package/dist/src/core/version.d.ts +11 -0
  27. package/dist/src/core/version.js +26 -0
  28. package/dist/src/desktop/app.d.ts +2 -0
  29. package/dist/src/desktop/app.js +39 -3
  30. package/dist/src/families/browser.d.ts +20 -0
  31. package/dist/src/families/browser.js +23 -0
  32. package/dist/src/families/claude/browser.d.ts +3 -0
  33. package/dist/src/families/claude/browser.js +14 -0
  34. package/dist/src/families/claude/desktop.js +1 -0
  35. package/dist/src/families/codex/browser.d.ts +7 -0
  36. package/dist/src/families/codex/browser.js +26 -0
  37. package/dist/src/families/desktop.d.ts +5 -1
  38. package/dist/src/families/pi/index.js +2 -0
  39. package/dist/src/panel/handler.d.ts +9 -1
  40. package/dist/src/panel/handler.js +54 -6
  41. package/dist/src/panel/i18n.d.ts +74 -4
  42. package/dist/src/panel/i18n.js +160 -20
  43. package/dist/src/panel/page-logic.d.ts +23 -0
  44. package/dist/src/panel/page-logic.js +44 -0
  45. package/dist/src/panel/page-script.js +587 -183
  46. package/dist/src/panel/page-styles.js +61 -18
  47. package/dist/src/panel/page.d.ts +5 -0
  48. package/dist/src/panel/page.js +10 -9
  49. package/dist/src/runtime.d.ts +6 -1
  50. package/dist/src/runtime.js +22 -11
  51. package/dist/src/types.d.ts +2 -0
  52. package/package.json +5 -3
  53. package/schemas/alias.json +62 -0
  54. package/schemas/app-install.json +62 -0
  55. package/schemas/app-uninstall.json +62 -0
  56. package/schemas/browser-open.json +109 -0
  57. package/schemas/browser-switch.json +104 -0
  58. package/schemas/browser.json +161 -0
  59. package/schemas/config.json +11 -0
  60. package/schemas/desktop-remove.json +62 -0
  61. package/schemas/desktop.json +62 -0
  62. package/schemas/doctor.json +62 -0
  63. package/schemas/error.json +62 -0
  64. package/schemas/families.json +68 -0
  65. package/schemas/handoff-disable.json +62 -0
  66. package/schemas/handoff-enable.json +62 -0
  67. package/schemas/handoff-ready.json +62 -0
  68. package/schemas/handoff-setup.json +62 -0
  69. package/schemas/handoff-status.json +62 -0
  70. package/schemas/handoff-teardown.json +62 -0
  71. package/schemas/list.json +68 -0
  72. package/schemas/model.json +62 -0
  73. package/schemas/new.json +62 -0
  74. package/schemas/next.json +62 -0
  75. package/schemas/plugins.json +62 -0
  76. package/schemas/quota-setup.json +62 -0
  77. package/schemas/quota-teardown.json +62 -0
  78. package/schemas/quota.json +62 -0
  79. package/schemas/remote-control.json +66 -0
  80. package/schemas/tune-change.json +62 -0
  81. package/schemas/tune.json +62 -0
  82. package/schemas/update.json +111 -1
  83. package/skills/sideby/SKILL.md +8 -4
  84. package/skills/sideby/references/diagnose.md +1 -1
  85. package/skills/sideby/references/setup.md +1 -1
  86. package/skills/sideby/references/update.md +16 -0
@@ -3,6 +3,7 @@
3
3
  // program sideby runs here (`open`, `ps`, `osascript`, `plutil`, `mdfind`) goes through `DesktopDeps.exec`, so
4
4
  // tests never start a real app.
5
5
  import { execFile } from 'node:child_process';
6
+ import { readFileSync } from 'node:fs';
6
7
  import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
7
8
  import { basename, join } from 'node:path';
8
9
  import { desktopDef, profileAppFor } from '../families/desktop.js';
@@ -28,12 +29,27 @@ const overrides = new WeakMap();
28
29
  export function bindDesktopDeps(rt, deps) {
29
30
  overrides.set(rt, deps);
30
31
  }
32
+ /** This Linux runs under WSL: `WSL_DISTRO_NAME` is set, or the kernel says Microsoft. */
33
+ function isWsl() {
34
+ if (process.platform !== 'linux')
35
+ return false;
36
+ if (process.env.WSL_DISTRO_NAME)
37
+ return true;
38
+ try {
39
+ return /microsoft/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'));
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
31
45
  export function depsFor(rt) {
32
46
  const o = overrides.get(rt) ?? {};
47
+ const wslMount = 'platform' in o || 'wslMount' in o ? o.wslMount : isWsl() ? '/mnt' : undefined;
33
48
  return {
34
49
  platform: o.platform ?? process.platform,
35
50
  appDirs: o.appDirs ?? ['/Applications', join(rt.paths.home, 'Applications')],
36
51
  exec: o.exec ?? defaultExec,
52
+ ...(wslMount ? { wslMount } : {}),
37
53
  };
38
54
  }
39
55
  /** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
@@ -351,7 +367,7 @@ export async function withDesktopApps(rt, accounts, statuses) {
351
367
  * Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
352
368
  * only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
353
369
  * Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
354
- * a fix may only add a missing link inside it, never replace a real file.
370
+ * a fix may only add a missing link inside it or sync a file's `key` there, never replace a real file.
355
371
  */
356
372
  export async function checkAppData(plugins, home, account) {
357
373
  if (account.isMain || account.kind === 'api')
@@ -367,13 +383,18 @@ export async function checkAppData(plugins, home, account) {
367
383
  const item = { path: f.path, mode: 'link' };
368
384
  const mainPath = join(home, f.mainPath);
369
385
  const p = join(dir, item.path);
370
- const res = await checkItem({ account, item, mainPath, accountPath: p, accountDir: dir, force: false });
386
+ const ctx = { account, item, mainPath, accountPath: p, accountDir: dir, force: false };
387
+ let res = await checkItem(ctx);
388
+ // The Desktop App writes its own file when the Main Account had none at first open, and replaces the link when
389
+ // it saves its preferences: not the user's doing, and a new link would be replaced again. With a `key`, only
390
+ // that key has to match the main file; `--fix` syncs it inside the App Data.
391
+ const own = res.results.some((r) => r.code === 'link.real-file');
392
+ if (own && f.key)
393
+ res = await checkItem({ ...ctx, item: { path: f.path, mode: 'json-key', key: f.key } });
371
394
  for (const r of res.results) {
372
395
  if (r.level === 'ok')
373
396
  continue;
374
- // The Desktop App writes its own file when the Main Account had none at first open, and may replace the link
375
- // later: not the user's doing, so a warning with the way to share, never a failure and never replaced.
376
- const own = r.code === 'link.real-file'
397
+ const mine = r.code === 'link.real-file'
377
398
  ? {
378
399
  level: 'warn',
379
400
  message: `${item.path} is ${def.title}'s own copy for this account, not a link to the main account's`,
@@ -382,9 +403,9 @@ export async function checkAppData(plugins, home, account) {
382
403
  : {};
383
404
  out.push({
384
405
  ...r,
385
- ...own,
386
- item: `app-data/${item.path}`,
387
- message: `${def.title} data: ${own.message ?? r.message}`,
406
+ ...mine,
407
+ item: `app-data/${r.item.startsWith(item.path) ? r.item : item.path}`,
408
+ message: `${def.title} data: ${mine.message ?? r.message}`,
388
409
  });
389
410
  }
390
411
  }
@@ -16,6 +16,13 @@ export interface PreparedLaunch {
16
16
  export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env' | 'title'>;
17
17
  /** Finds an executable on PATH; returns null when the Host is not installed. */
18
18
  export declare function which(bin: string, env: Env): Promise<string | null>;
19
+ /**
20
+ * The first PATH entry named `bin` that exists but cannot run, such as a link left dangling by an interrupted
21
+ * upgrade; null when there is none. Only meaningful when `which` found nothing.
22
+ */
23
+ export declare function brokenOnPath(bin: string, env: Env): Promise<string | null>;
24
+ /** What to tell a user whose Host cannot run: the install command when the Family has one, else the page. */
25
+ export declare function installHint(family: Pick<FamilyDef, 'installUrl' | 'installCommand'>): string;
19
26
  /**
20
27
  * Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
21
28
  * tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { access, constants } from 'node:fs/promises';
3
3
  import { constants as osConstants } from 'node:os';
4
4
  import { delimiter, join } from 'node:path';
5
- import { realpathOrNull } from './fs-safe.js';
5
+ import { lstatOrNull, realpathOrNull } from './fs-safe.js';
6
6
  import { readSecretFile } from './secret-file.js';
7
7
  /** Finds an executable on PATH; returns null when the Host is not installed. */
8
8
  export async function which(bin, env) {
@@ -20,6 +20,26 @@ export async function which(bin, env) {
20
20
  }
21
21
  return null;
22
22
  }
23
+ /**
24
+ * The first PATH entry named `bin` that exists but cannot run, such as a link left dangling by an interrupted
25
+ * upgrade; null when there is none. Only meaningful when `which` found nothing.
26
+ */
27
+ export async function brokenOnPath(bin, env) {
28
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
29
+ if (!dir)
30
+ continue;
31
+ const p = join(dir, bin);
32
+ if (await lstatOrNull(p))
33
+ return p;
34
+ }
35
+ return null;
36
+ }
37
+ /** What to tell a user whose Host cannot run: the install command when the Family has one, else the page. */
38
+ export function installHint(family) {
39
+ return family.installCommand
40
+ ? `\`${family.installCommand}\` (or see ${family.installUrl})`
41
+ : family.installUrl;
42
+ }
23
43
  /**
24
44
  * Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
25
45
  * tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
@@ -141,7 +161,7 @@ export function spawnHost(p) {
141
161
  child.once('error', (err) => {
142
162
  cleanup();
143
163
  const msg = err.code === 'ENOENT'
144
- ? `${p.family.title} is not installed (\`${p.bin}\` not found on PATH); install it: ${p.family.installUrl}`
164
+ ? `${p.family.title} is not installed (\`${p.bin}\` not found on PATH); install it: ${installHint(p.family)}`
145
165
  : `could not start ${p.bin}: ${err.message}`;
146
166
  process.stderr.write(`sideby: ${msg}\n`);
147
167
  resolve(1);
@@ -0,0 +1,14 @@
1
+ /** Files larger than this are skipped and reported. */
2
+ export declare const MAX_LEVELDB_FILE: number;
3
+ /** Snappy raw-format decompression (https://github.com/google/snappy/blob/main/format_description.txt). */
4
+ export declare function snappyDecompress(src: Uint8Array): Uint8Array;
5
+ /**
6
+ * The Local Storage key names of `origins` (`https://claude.ai`), from a `Local Storage/leveldb` directory. A key
7
+ * deleted later is left out. `changedAt` is the newest modification time of the files read. Files that are too large
8
+ * or unreadable go to `problems` (their name only).
9
+ */
10
+ export declare function localStorageKeys(dir: string, origins: readonly string[]): Promise<{
11
+ keys: Map<string, Set<string>>;
12
+ changedAt?: number;
13
+ problems: string[];
14
+ }>;
@@ -0,0 +1,291 @@
1
+ // Reads the key names of a Chrome Local Storage LevelDB directory, read-only (spec §3.24, ADR-0023). Only keys are
2
+ // kept: a value's bytes are stepped over and never turned into text, compared or returned. A table block is Snappy
3
+ // compressed as a whole, keys and values together, so it is decompressed into a buffer that is dropped after its keys
4
+ // are taken. Nothing is written, locked or copied; a record cut short (Chrome writing while we read) is skipped.
5
+ import { readdir, readFile, stat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ /** Files larger than this are skipped and reported. */
8
+ export const MAX_LEVELDB_FILE = 64 * 1024 * 1024;
9
+ class Cut extends Error {
10
+ }
11
+ function varint(buf, pos) {
12
+ let result = 0;
13
+ let shift = 0;
14
+ for (let i = pos; i < buf.length && shift <= 35; i++) {
15
+ const b = buf[i];
16
+ result += (b & 0x7f) * 2 ** shift;
17
+ if (b < 0x80)
18
+ return [result, i + 1];
19
+ shift += 7;
20
+ }
21
+ throw new Cut();
22
+ }
23
+ /** Snappy raw-format decompression (https://github.com/google/snappy/blob/main/format_description.txt). */
24
+ export function snappyDecompress(src) {
25
+ const [len, start] = varint(src, 0);
26
+ if (len > MAX_LEVELDB_FILE)
27
+ throw new Cut();
28
+ const out = new Uint8Array(len);
29
+ let o = 0;
30
+ let i = start;
31
+ while (i < src.length) {
32
+ const tag = src[i++];
33
+ const kind = tag & 3;
34
+ if (kind === 0) {
35
+ let n = tag >> 2;
36
+ if (n >= 60) {
37
+ const extra = n - 59;
38
+ n = 0;
39
+ for (let k = 0; k < extra; k++)
40
+ n += (src[i + k] ?? 0) * 256 ** k;
41
+ i += extra;
42
+ }
43
+ n += 1;
44
+ if (i + n > src.length || o + n > len)
45
+ throw new Cut();
46
+ out.set(src.subarray(i, i + n), o);
47
+ i += n;
48
+ o += n;
49
+ continue;
50
+ }
51
+ let n;
52
+ let off;
53
+ if (kind === 1) {
54
+ n = ((tag >> 2) & 7) + 4;
55
+ off = ((tag >> 5) << 8) | (src[i++] ?? 0);
56
+ }
57
+ else if (kind === 2) {
58
+ n = (tag >> 2) + 1;
59
+ off = (src[i] ?? 0) | ((src[i + 1] ?? 0) << 8);
60
+ i += 2;
61
+ }
62
+ else {
63
+ n = (tag >> 2) + 1;
64
+ off =
65
+ ((src[i] ?? 0) | ((src[i + 1] ?? 0) << 8) | ((src[i + 2] ?? 0) << 16)) + (src[i + 3] ?? 0) * 2 ** 24;
66
+ i += 4;
67
+ }
68
+ if (off === 0 || off > o || o + n > len)
69
+ throw new Cut();
70
+ for (let k = 0; k < n; k++, o++)
71
+ out[o] = out[o - off];
72
+ }
73
+ if (o !== len)
74
+ throw new Cut();
75
+ return out;
76
+ }
77
+ /** A `.log` file: 32 KiB blocks of records that together form WriteBatches. */
78
+ function readLog(buf, sink) {
79
+ const BLOCK = 32768;
80
+ const parts = [];
81
+ let pos = 0;
82
+ while (pos + 7 <= buf.length) {
83
+ const left = BLOCK - (pos % BLOCK);
84
+ if (left < 7) {
85
+ pos += left;
86
+ continue;
87
+ }
88
+ const len = buf[pos + 4] | (buf[pos + 5] << 8);
89
+ const type = buf[pos + 6];
90
+ const data = buf.subarray(pos + 7, pos + 7 + len);
91
+ pos += 7 + len;
92
+ if (type === 0 || data.length < len)
93
+ break;
94
+ if (type === 1)
95
+ batch(data, sink);
96
+ else if (type === 2)
97
+ parts.splice(0, parts.length, data);
98
+ else if (type === 3)
99
+ parts.push(data);
100
+ else if (type === 4 && parts.length) {
101
+ parts.push(data);
102
+ batch(concat(parts), sink);
103
+ parts.length = 0;
104
+ }
105
+ }
106
+ }
107
+ function concat(parts) {
108
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
109
+ let o = 0;
110
+ for (const p of parts) {
111
+ out.set(p, o);
112
+ o += p.length;
113
+ }
114
+ return out;
115
+ }
116
+ function batch(b, sink) {
117
+ if (b.length < 12)
118
+ return;
119
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
120
+ let seq = view.getUint32(0, true) + view.getUint32(4, true) * 2 ** 32;
121
+ const count = view.getUint32(8, true);
122
+ let pos = 12;
123
+ try {
124
+ for (let n = 0; n < count && pos < b.length; n++, seq++) {
125
+ const tag = b[pos++];
126
+ const [kl, p1] = varint(b, pos);
127
+ const key = b.subarray(p1, p1 + kl);
128
+ pos = p1 + kl;
129
+ if (pos > b.length)
130
+ throw new Cut();
131
+ if (tag === 1) {
132
+ const [vl, p2] = varint(b, pos);
133
+ pos = p2 + vl; // the value is stepped over
134
+ sink(key, seq, true);
135
+ }
136
+ else if (tag === 0)
137
+ sink(key, seq, false);
138
+ else
139
+ break;
140
+ }
141
+ }
142
+ catch (e) {
143
+ if (!(e instanceof Cut))
144
+ throw e;
145
+ }
146
+ }
147
+ /** One block of a table: prefix-compressed entries, then the restart array. Calls `each` with key and value range. */
148
+ function blockEntries(block, each) {
149
+ if (block.length < 4)
150
+ return;
151
+ const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
152
+ const restarts = view.getUint32(block.length - 4, true);
153
+ const end = block.length - 4 - restarts * 4;
154
+ if (end < 0)
155
+ return;
156
+ let pos = 0;
157
+ let last = new Uint8Array(0);
158
+ while (pos < end) {
159
+ const [shared, p1] = varint(block, pos);
160
+ const [unshared, p2] = varint(block, p1);
161
+ const [vlen, p3] = varint(block, p2);
162
+ if (shared > last.length || p3 + unshared + vlen > end)
163
+ throw new Cut();
164
+ const key = new Uint8Array(shared + unshared);
165
+ key.set(last.subarray(0, shared), 0);
166
+ key.set(block.subarray(p3, p3 + unshared), shared);
167
+ each(key, p3 + unshared, p3 + unshared + vlen);
168
+ last = key;
169
+ pos = p3 + unshared + vlen;
170
+ }
171
+ }
172
+ function readBlock(file, offset, size) {
173
+ if (offset + size + 5 > file.length)
174
+ throw new Cut();
175
+ const raw = file.subarray(offset, offset + size);
176
+ const type = file[offset + size];
177
+ if (type === 0)
178
+ return raw;
179
+ if (type === 1)
180
+ return snappyDecompress(raw);
181
+ throw new Cut();
182
+ }
183
+ /** A `.ldb` (or `.sst`) table: footer → index block → data blocks. Only internal keys are taken from data blocks. */
184
+ function readTable(buf, sink) {
185
+ if (buf.length < 48)
186
+ return;
187
+ const footer = buf.subarray(buf.length - 48);
188
+ const magic = new DataView(footer.buffer, footer.byteOffset + 40, 8);
189
+ if (magic.getUint32(0, true) !== 0x8b80fb57 || magic.getUint32(4, true) !== 0xdb477524)
190
+ return;
191
+ let [, p] = varint(footer, 0) // metaindex offset
192
+ ;
193
+ [, p] = varint(footer, p); // metaindex size
194
+ const [indexOff, p2] = varint(footer, p);
195
+ const [indexSize] = varint(footer, p2);
196
+ const index = readBlock(buf, indexOff, indexSize);
197
+ blockEntries(index, (_key, vs, ve) => {
198
+ const handle = index.subarray(vs, ve);
199
+ const [off, q] = varint(handle, 0);
200
+ const [size] = varint(handle, q);
201
+ let block;
202
+ try {
203
+ block = readBlock(buf, off, size);
204
+ }
205
+ catch (e) {
206
+ if (e instanceof Cut)
207
+ return;
208
+ throw e;
209
+ }
210
+ blockEntries(block, (ikey) => {
211
+ if (ikey.length < 8)
212
+ return;
213
+ const t = new DataView(ikey.buffer, ikey.byteOffset + ikey.length - 8, 8);
214
+ const tag = t.getUint32(0, true) + t.getUint32(4, true) * 2 ** 32;
215
+ sink(ikey.subarray(0, ikey.length - 8), Math.floor(tag / 256), tag % 256 === 1);
216
+ });
217
+ });
218
+ }
219
+ const latin1 = new TextDecoder('latin1');
220
+ const utf16 = new TextDecoder('utf-16le');
221
+ /**
222
+ * The Local Storage key names of `origins` (`https://claude.ai`), from a `Local Storage/leveldb` directory. A key
223
+ * deleted later is left out. `changedAt` is the newest modification time of the files read. Files that are too large
224
+ * or unreadable go to `problems` (their name only).
225
+ */
226
+ export async function localStorageKeys(dir, origins) {
227
+ const problems = [];
228
+ const keys = new Map(origins.map((o) => [o, new Set()]));
229
+ let names;
230
+ try {
231
+ names = await readdir(dir);
232
+ }
233
+ catch {
234
+ return { keys, problems };
235
+ }
236
+ const prefixes = origins.map((o) => Buffer.from(`_${o}\u0000`, 'latin1'));
237
+ const states = new Map();
238
+ const sink = (key, seq, live) => {
239
+ const i = prefixes.findIndex((p) => key.length > p.length && Buffer.from(key.subarray(0, p.length)).equals(p));
240
+ if (i < 0)
241
+ return;
242
+ const prefix = prefixes[i];
243
+ const enc = key[prefix.length];
244
+ const body = key.subarray(prefix.length + 1);
245
+ const name = enc === 1 ? latin1.decode(body) : enc === 0 ? utf16.decode(body) : undefined;
246
+ if (name === undefined)
247
+ return;
248
+ const id = `${i}\u0000${name}`;
249
+ const prev = states.get(id);
250
+ if (!prev || seq >= prev.seq)
251
+ states.set(id, { origin: origins[i], name, seq, live });
252
+ };
253
+ let changedAt;
254
+ for (const name of names.sort()) {
255
+ const table = name.endsWith('.ldb') || name.endsWith('.sst');
256
+ if (!table && !name.endsWith('.log'))
257
+ continue;
258
+ const file = join(dir, name);
259
+ try {
260
+ const st = await stat(file);
261
+ if (!st.isFile())
262
+ continue;
263
+ if (st.size > MAX_LEVELDB_FILE) {
264
+ problems.push(`${name} is larger than 64 MB and was skipped`);
265
+ continue;
266
+ }
267
+ changedAt = Math.max(changedAt ?? 0, st.mtimeMs);
268
+ const buf = new Uint8Array(await readFile(file));
269
+ try {
270
+ if (table)
271
+ readTable(buf, sink);
272
+ else
273
+ readLog(buf, sink);
274
+ }
275
+ catch (e) {
276
+ if (!(e instanceof Cut))
277
+ throw e;
278
+ problems.push(`${name} could not be read completely`);
279
+ }
280
+ }
281
+ catch (e) {
282
+ if (e instanceof Cut)
283
+ continue;
284
+ problems.push(`${name} could not be read`);
285
+ }
286
+ }
287
+ for (const s of states.values())
288
+ if (s.live)
289
+ keys.get(s.origin)?.add(s.name);
290
+ return { keys, ...(changedAt === undefined ? {} : { changedAt }), problems };
291
+ }
@@ -13,13 +13,15 @@ export interface RemoteControlGroup {
13
13
  state: RemoteControlState;
14
14
  /** For `blocked` and `missing`: why, and what to do. */
15
15
  reason?: string;
16
- /** sideby turned it off here and can put the earlier value back. */
16
+ /** sideby set it here and can put the earlier value back. */
17
17
  undo: boolean;
18
18
  }
19
19
  export interface RemoteControlUndoRecord {
20
20
  file: string;
21
- /** The value before sideby wrote `false`, or null when the key was not there. */
21
+ /** The value before sideby wrote, or null when the key was not there. */
22
22
  previous: boolean | null;
23
+ /** The value sideby wrote; records from before `on` existed lack it and mean `false`. */
24
+ wrote?: boolean;
23
25
  at: string;
24
26
  }
25
27
  export interface RemoteControlChange {
@@ -37,9 +39,18 @@ export interface RemoteControlChange {
37
39
  export declare function remoteControlGroups(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlGroup[]>;
38
40
  /** The groups `off` would change: those that are on, or follow a default that may be on. */
39
41
  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. */
42
+ /** The groups `on` would change: those that are off, or follow a default that may be off. */
43
+ export declare function groupsToTurnOn(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
44
+ /**
45
+ * Sets `remoteControlAtStartup` to `to` in every group that does not hold it yet (a default counts as either),
46
+ * remembering each old value for undo.
47
+ */
48
+ export declare function setRemoteControl(accounts: readonly Account[], ctx: ReadContext, to: boolean): Promise<RemoteControlChange>;
49
+ /** Sets `remoteControlAtStartup` to false in every group that is on or follows the default. */
50
+ export declare const turnRemoteControlOff: (accounts: readonly Account[], ctx: ReadContext) => Promise<RemoteControlChange>;
51
+ /** Sets `remoteControlAtStartup` to true in every group that is off or follows the default. */
52
+ export declare const turnRemoteControlOn: (accounts: readonly Account[], ctx: ReadContext) => Promise<RemoteControlChange>;
53
+ /** Groups sideby can put back: it wrote there and the key still holds what it wrote. */
43
54
  export declare function groupsToUndo(groups: readonly RemoteControlGroup[]): RemoteControlGroup[];
44
- /** Puts back what each file had before `off`, only while the key is still `false`. */
55
+ /** Puts back what each file had before `on` or `off`, only while the key still holds what sideby wrote. */
45
56
  export declare function undoRemoteControlOff(accounts: readonly Account[], ctx: ReadContext): Promise<RemoteControlChange>;
@@ -1,4 +1,4 @@
1
- // Remote Control off by default (spec §3.22, ADR-0017): reads and writes one key, `remoteControlAtStartup`, of each
1
+ // Remote Control at startup (spec §3.22, ADR-0017): reads and writes one key, `remoteControlAtStartup`, of each
2
2
  // Claude Account's `settings.json`. Accounts that share a file (a link to the Main Account's) form one group and one
3
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
4
  // through `writeIfUnchanged`, keep every other key, and never create, replace or retarget a link.
@@ -60,7 +60,8 @@ export async function remoteControlGroups(accounts, ctx) {
60
60
  }
61
61
  else {
62
62
  group.state = stateOf(read.obj[RC_KEY]);
63
- group.undo = group.state === 'off' && (await readRecord(ctx, where.file)) !== null;
63
+ const record = await readRecord(ctx, where.file);
64
+ group.undo = record !== null && read.obj[RC_KEY] === (record.wrote ?? false);
64
65
  }
65
66
  }
66
67
  }
@@ -70,6 +71,10 @@ export async function remoteControlGroups(accounts, ctx) {
70
71
  export function groupsToTurnOff(groups) {
71
72
  return groups.filter((g) => g.state === 'on' || g.state === 'default');
72
73
  }
74
+ /** The groups `on` would change: those that are off, or follow a default that may be off. */
75
+ export function groupsToTurnOn(groups) {
76
+ return groups.filter((g) => g.state === 'off' || g.state === 'default');
77
+ }
73
78
  async function writeKey(file, change, expect) {
74
79
  const read = await readObject(file);
75
80
  if ('error' in read)
@@ -81,18 +86,23 @@ async function writeKey(file, change, expect) {
81
86
  const wrote = await writeIfUnchanged(file, sha256(read.text), render(change({ ...read.obj }), read.text), mode);
82
87
  return wrote ? null : 'settings.json changed while sideby was writing, so nothing was changed. Try again.';
83
88
  }
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) {
89
+ /**
90
+ * Sets `remoteControlAtStartup` to `to` in every group that does not hold it yet (a default counts as either),
91
+ * remembering each old value for undo.
92
+ */
93
+ export async function setRemoteControl(accounts, ctx, to) {
86
94
  const groups = await remoteControlGroups(accounts, ctx);
87
- const todo = groupsToTurnOff(groups);
95
+ const todo = to ? groupsToTurnOn(groups) : groupsToTurnOff(groups);
88
96
  if (todo.length === 0) {
89
- const stuck = groups.find((g) => g.state === 'blocked');
97
+ const stuck = groups.find((g) => g.state === 'blocked' || (to && g.state === 'missing'));
90
98
  return stuck
91
99
  ? { ok: false, results: [], message: `Nothing was changed: ${stuck.reason}` }
92
100
  : {
93
101
  ok: true,
94
102
  results: [],
95
- message: 'Remote Control already does not start by itself; nothing changed.',
103
+ message: to
104
+ ? 'Remote Control already starts by itself; nothing changed.'
105
+ : 'Remote Control already does not start by itself; nothing changed.',
96
106
  };
97
107
  }
98
108
  const results = [];
@@ -100,32 +110,43 @@ export async function turnRemoteControlOff(accounts, ctx) {
100
110
  let previous = null;
101
111
  const failed = await writeKey(g.file, (obj) => {
102
112
  previous = typeof obj[RC_KEY] === 'boolean' ? obj[RC_KEY] : null;
103
- obj[RC_KEY] = false;
113
+ obj[RC_KEY] = to;
104
114
  return obj;
105
- }, (obj) => (obj[RC_KEY] === false ? 'It was turned off meanwhile; nothing changed.' : null));
115
+ }, (obj) => (obj[RC_KEY] === to ? `It was turned ${to ? 'on' : 'off'} meanwhile; nothing changed.` : null));
106
116
  if (failed) {
107
117
  results.push({ file: g.file, accounts: g.accounts, ok: false, message: failed });
108
118
  continue;
109
119
  }
110
- const record = { file: g.file, previous, at: new Date().toISOString() };
120
+ const record = {
121
+ file: g.file,
122
+ previous,
123
+ wrote: to,
124
+ at: new Date().toISOString(),
125
+ };
111
126
  await mkdir(join(ctx.stateDir, 'remote-control'), { recursive: true });
112
127
  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.' });
128
+ results.push({ file: g.file, accounts: g.accounts, ok: true, message: to ? 'Turned on.' : 'Turned off.' });
114
129
  }
115
130
  const ok = results.every((r) => r.ok);
116
131
  return {
117
132
  ok,
118
133
  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.',
134
+ message: !ok
135
+ ? 'Some files could not be changed; see each line.'
136
+ : to
137
+ ? 'Done. New sessions connect to claude.ai and the Claude app by themselves, where every device signed in to that account can read them; restart sessions that are already running.'
138
+ : '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.',
122
139
  };
123
140
  }
124
- /** Groups sideby can put back: it wrote `false` there and the key still holds it. */
141
+ /** Sets `remoteControlAtStartup` to false in every group that is on or follows the default. */
142
+ export const turnRemoteControlOff = (accounts, ctx) => setRemoteControl(accounts, ctx, false);
143
+ /** Sets `remoteControlAtStartup` to true in every group that is off or follows the default. */
144
+ export const turnRemoteControlOn = (accounts, ctx) => setRemoteControl(accounts, ctx, true);
145
+ /** Groups sideby can put back: it wrote there and the key still holds what it wrote. */
125
146
  export function groupsToUndo(groups) {
126
147
  return groups.filter((g) => g.undo);
127
148
  }
128
- /** Puts back what each file had before `off`, only while the key is still `false`. */
149
+ /** Puts back what each file had before `on` or `off`, only while the key still holds what sideby wrote. */
129
150
  export async function undoRemoteControlOff(accounts, ctx) {
130
151
  const todo = groupsToUndo(await remoteControlGroups(accounts, ctx));
131
152
  if (todo.length === 0)
@@ -141,7 +162,9 @@ export async function undoRemoteControlOff(accounts, ctx) {
141
162
  else
142
163
  obj[RC_KEY] = record.previous;
143
164
  return obj;
144
- }, (obj) => obj[RC_KEY] === false ? null : 'The setting was changed after sideby set it, so it was left alone.');
165
+ }, (obj) => obj[RC_KEY] === (record.wrote ?? false)
166
+ ? null
167
+ : 'The setting was changed after sideby set it, so it was left alone.');
145
168
  if (!failed)
146
169
  await rm(recordPath(ctx, g.file), { force: true });
147
170
  results.push({ file: g.file, accounts: g.accounts, ok: !failed, message: failed ?? 'Put back.' });
@@ -2,6 +2,19 @@ import type { FamilyDef, Finding } from '../types.ts';
2
2
  export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
3
3
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
4
4
  export declare function skillVersionOf(text: string): string | null;
5
+ /**
6
+ * The versions of the sideby skills an agent reads on this machine (`null`: a skill without one): where
7
+ * `npx skills add -g` puts it (`~/.agents/skills`) and Claude Code's user skills. Read for a `--json` output's
8
+ * `_notice`, so it looks at two fixed files only; Doctor checks every Family's main directory.
9
+ */
10
+ export declare function installedSkillVersions(home: string): Promise<(string | null)[]>;
11
+ /** The same skills with the real folder each lives in (for `sideby update`, which may update them). */
12
+ export declare function installedSkills(home: string): Promise<{
13
+ dir: string;
14
+ version: string | null;
15
+ }[]>;
16
+ /** The command `sideby update` runs to update an installed sideby skill: it keeps the agents it was installed for. */
17
+ export declare const SKILL_UPDATE: readonly ['npx', '-y', 'skills', 'update', 'sideby', '-g', '-y'];
5
18
  /**
6
19
  * Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
7
20
  * `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read