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.
- package/README.md +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +5 -2
- package/dist/src/cli/commands.js +165 -20
- package/dist/src/cli/json-schemas.js +100 -2
- package/dist/src/cli/main.js +72 -4
- package/dist/src/core/brand-mark.d.ts +5 -7
- package/dist/src/core/brand-mark.js +18 -5
- package/dist/src/core/browser-profiles.d.ts +71 -0
- package/dist/src/core/browser-profiles.js +361 -0
- package/dist/src/core/config.d.ts +15 -0
- package/dist/src/core/config.js +13 -1
- package/dist/src/core/create.js +2 -1
- package/dist/src/core/desktop-app.d.ts +3 -1
- package/dist/src/core/desktop-app.js +29 -8
- package/dist/src/core/launch.d.ts +7 -0
- package/dist/src/core/launch.js +22 -2
- package/dist/src/core/leveldb-keys.d.ts +14 -0
- package/dist/src/core/leveldb-keys.js +291 -0
- package/dist/src/core/remote-control.d.ts +17 -6
- package/dist/src/core/remote-control.js +40 -17
- package/dist/src/core/skill-check.d.ts +13 -0
- package/dist/src/core/skill-check.js +29 -1
- package/dist/src/core/update.d.ts +55 -3
- package/dist/src/core/update.js +124 -4
- package/dist/src/core/version.d.ts +11 -0
- package/dist/src/core/version.js +26 -0
- package/dist/src/desktop/app.d.ts +2 -0
- package/dist/src/desktop/app.js +39 -3
- package/dist/src/families/browser.d.ts +20 -0
- package/dist/src/families/browser.js +23 -0
- package/dist/src/families/claude/browser.d.ts +3 -0
- package/dist/src/families/claude/browser.js +14 -0
- package/dist/src/families/claude/desktop.js +1 -0
- package/dist/src/families/codex/browser.d.ts +7 -0
- package/dist/src/families/codex/browser.js +26 -0
- package/dist/src/families/desktop.d.ts +5 -1
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/panel/handler.d.ts +9 -1
- package/dist/src/panel/handler.js +54 -6
- package/dist/src/panel/i18n.d.ts +74 -4
- package/dist/src/panel/i18n.js +160 -20
- package/dist/src/panel/page-logic.d.ts +23 -0
- package/dist/src/panel/page-logic.js +44 -0
- package/dist/src/panel/page-script.js +587 -183
- package/dist/src/panel/page-styles.js +61 -18
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +10 -9
- package/dist/src/runtime.d.ts +6 -1
- package/dist/src/runtime.js +22 -11
- package/dist/src/types.d.ts +2 -0
- package/package.json +5 -3
- package/schemas/alias.json +62 -0
- package/schemas/app-install.json +62 -0
- package/schemas/app-uninstall.json +62 -0
- package/schemas/browser-open.json +109 -0
- package/schemas/browser-switch.json +104 -0
- package/schemas/browser.json +161 -0
- package/schemas/config.json +11 -0
- package/schemas/desktop-remove.json +62 -0
- package/schemas/desktop.json +62 -0
- package/schemas/doctor.json +62 -0
- package/schemas/error.json +62 -0
- package/schemas/families.json +68 -0
- package/schemas/handoff-disable.json +62 -0
- package/schemas/handoff-enable.json +62 -0
- package/schemas/handoff-ready.json +62 -0
- package/schemas/handoff-setup.json +62 -0
- package/schemas/handoff-status.json +62 -0
- package/schemas/handoff-teardown.json +62 -0
- package/schemas/list.json +68 -0
- package/schemas/model.json +62 -0
- package/schemas/new.json +62 -0
- package/schemas/next.json +62 -0
- package/schemas/plugins.json +62 -0
- package/schemas/quota-setup.json +62 -0
- package/schemas/quota-teardown.json +62 -0
- package/schemas/quota.json +62 -0
- package/schemas/remote-control.json +66 -0
- package/schemas/tune-change.json +62 -0
- package/schemas/tune.json +62 -0
- package/schemas/update.json +111 -1
- package/skills/sideby/SKILL.md +8 -4
- package/skills/sideby/references/diagnose.md +1 -1
- package/skills/sideby/references/setup.md +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
...
|
|
386
|
-
item: `app-data/${item.path}`,
|
|
387
|
-
message: `${def.title} data: ${
|
|
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
|
package/dist/src/core/launch.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
41
|
-
export declare function
|
|
42
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
85
|
-
|
|
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:
|
|
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] =
|
|
113
|
+
obj[RC_KEY] = to;
|
|
104
114
|
return obj;
|
|
105
|
-
}, (obj) => (obj[RC_KEY] ===
|
|
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 = {
|
|
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
|
-
? '
|
|
121
|
-
:
|
|
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
|
-
/**
|
|
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
|
|
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] ===
|
|
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
|