sideby 0.9.2 → 0.10.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 +1 -0
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +5 -0
- package/dist/src/cli/commands.js +95 -0
- package/dist/src/cli/json-schemas.js +24 -0
- package/dist/src/cli/main.js +8 -2
- 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/handoff-chain.d.ts +2 -0
- package/dist/src/core/handoff-run.js +7 -0
- 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/families/claude/handoff.js +1 -0
- package/dist/src/handoff/hook.js +12 -4
- package/dist/src/handoff/messages.d.ts +2 -0
- package/dist/src/handoff/messages.js +4 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +24 -1
- package/dist/src/panel/i18n.d.ts +16 -0
- package/dist/src/panel/i18n.js +32 -0
- package/dist/src/panel/page-script.js +56 -0
- package/dist/src/runtime.d.ts +7 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/types.d.ts +1 -1
- package/package.json +2 -2
- package/schemas/remote-control.json +125 -0
- package/skills/sideby/SKILL.md +4 -3
package/README.md
CHANGED
|
@@ -72,6 +72,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
72
72
|
| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |
|
|
73
73
|
| Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |
|
|
74
74
|
| See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |
|
|
75
|
+
| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |
|
|
75
76
|
| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
|
|
76
77
|
| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |
|
|
77
78
|
| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
|
package/dist/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"
|
|
1
|
+
{"id":"20261015T150956-8c8f4c"}
|
|
@@ -43,6 +43,11 @@ export declare function cmdResume(a: ParsedArgs, io: Io): Promise<number>;
|
|
|
43
43
|
/** `sideby next <family>`: recommend the Account with the most room, then start it (spec §3.13). */
|
|
44
44
|
export declare function cmdNext(a: ParsedArgs, io: Io): Promise<number>;
|
|
45
45
|
export declare function cmdDoctor(a: ParsedArgs, io: Io): Promise<number>;
|
|
46
|
+
/**
|
|
47
|
+
* `sideby remote-control [off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
|
|
48
|
+
* claude.ai by themselves, and turning that off for every Account (a preview with exit 10 until --yes).
|
|
49
|
+
*/
|
|
50
|
+
export declare function cmdRemoteControl(a: ParsedArgs, io: Io): Promise<number>;
|
|
46
51
|
/**
|
|
47
52
|
* `sideby model <account> [<model>|default] [--effort <level>|default] [--yes]` (spec §3.19, ADR-0014): shows the
|
|
48
53
|
* Account's model and effort; with a change, shows it (exit 10) and writes it with --yes.
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runHost } from '../core/launch.js';
|
|
|
10
10
|
import { accountModel, setAccountModel } from '../core/model-choice.js';
|
|
11
11
|
import { resolvePaths, tildify } from '../core/paths.js';
|
|
12
12
|
import { USAGE_DAYS } from '../core/quota-levels.js';
|
|
13
|
+
import { groupsToTurnOff, groupsToUndo, } from '../core/remote-control.js';
|
|
13
14
|
import { SHELLS } from '../core/shell-init.js';
|
|
14
15
|
import { suggestAlias, suggestName } from '../panel/page-logic.js';
|
|
15
16
|
import { createRuntime, UnknownFamilyError } from '../runtime.js';
|
|
@@ -413,6 +414,100 @@ async function cmdTuneChange(a, io, action) {
|
|
|
413
414
|
io.out(r.message);
|
|
414
415
|
return r.ok ? 0 : 1;
|
|
415
416
|
}
|
|
417
|
+
const RC_STATE = {
|
|
418
|
+
on: 'on',
|
|
419
|
+
default: 'default',
|
|
420
|
+
off: 'off',
|
|
421
|
+
missing: 'no file',
|
|
422
|
+
blocked: 'cannot change',
|
|
423
|
+
};
|
|
424
|
+
function rcGroupLine(g, home) {
|
|
425
|
+
const state = RC_STATE[g.state].padEnd(13);
|
|
426
|
+
const paint = g.state === 'on'
|
|
427
|
+
? c.yellow
|
|
428
|
+
: g.state === 'off'
|
|
429
|
+
? c.green
|
|
430
|
+
: g.state === 'default'
|
|
431
|
+
? (x) => x
|
|
432
|
+
: c.dim;
|
|
433
|
+
const where = g.reason ? tilde(g.reason, home) : tildify(g.file, home);
|
|
434
|
+
return ` ${paint(state)} ${c.bold(g.accounts.join(', '))} ${c.dim(where)}`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `sideby remote-control [off|undo] [--yes] [--json]` (spec §3.22, ADR-0017): whether Claude sessions connect to
|
|
438
|
+
* claude.ai by themselves, and turning that off for every Account (a preview with exit 10 until --yes).
|
|
439
|
+
*/
|
|
440
|
+
export async function cmdRemoteControl(a, io) {
|
|
441
|
+
const [action, extra] = a.positionals;
|
|
442
|
+
if (extra !== undefined || (action !== undefined && action !== 'off' && action !== 'undo'))
|
|
443
|
+
throw new UsageError('usage: sideby remote-control [off|undo] [--yes] [--json]');
|
|
444
|
+
const wantJson = a.flags.has('json');
|
|
445
|
+
const rt = await createRuntime();
|
|
446
|
+
const home = rt.paths.home;
|
|
447
|
+
const show = (gs) => gs.map((g) => ({ ...g, file: tildify(g.file, home) }));
|
|
448
|
+
const out = (data) => json(io, { action: action ?? 'status', ...data });
|
|
449
|
+
const groups = await rt.remoteControl();
|
|
450
|
+
if (!action) {
|
|
451
|
+
if (wantJson) {
|
|
452
|
+
out({ groups: show(groups) });
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
if (groups.length === 0) {
|
|
456
|
+
io.out('No Claude accounts found.');
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
io.out('Remote Control: whether Claude sessions connect to claude.ai/code and the Claude app by themselves');
|
|
460
|
+
for (const g of groups)
|
|
461
|
+
io.out(rcGroupLine(g, home));
|
|
462
|
+
io.out(c.dim(' on: every session connects; default: Claude Code or your organization decides; off: only when you run /remote-control'));
|
|
463
|
+
if (groupsToTurnOff(groups).length)
|
|
464
|
+
io.out(c.cyan('\nRun `sideby remote-control off` so no session connects by itself (it shows the change first).'));
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
const todo = action === 'off' ? groupsToTurnOff(groups) : groupsToUndo(groups);
|
|
468
|
+
if (todo.length === 0) {
|
|
469
|
+
const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
|
|
470
|
+
if (wantJson)
|
|
471
|
+
out({ applied: false, groups: show(groups), message: r.message });
|
|
472
|
+
else
|
|
473
|
+
io.out(tilde(r.message, home));
|
|
474
|
+
return r.ok ? 0 : 1;
|
|
475
|
+
}
|
|
476
|
+
if (!a.flags.has('yes')) {
|
|
477
|
+
if (wantJson)
|
|
478
|
+
out({
|
|
479
|
+
applied: false,
|
|
480
|
+
groups: show(todo),
|
|
481
|
+
message: `Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`,
|
|
482
|
+
});
|
|
483
|
+
else {
|
|
484
|
+
io.out(action === 'off'
|
|
485
|
+
? 'Turn off Remote Control at startup?'
|
|
486
|
+
: 'Put back the Remote Control setting sideby replaced?');
|
|
487
|
+
for (const g of todo) {
|
|
488
|
+
const to = action === 'off' ? 'false' : 'what it was before';
|
|
489
|
+
const from = g.state === 'default' ? 'not set' : g.state === 'on' ? 'true' : 'false';
|
|
490
|
+
io.out(` ${tildify(g.file, home)}: remoteControlAtStartup ${from} -> ${to}`);
|
|
491
|
+
io.out(c.dim(` used by ${g.accounts.join(', ')}`));
|
|
492
|
+
}
|
|
493
|
+
io.out(action === 'off'
|
|
494
|
+
? 'Sessions already running stay connected until you restart them. You can still start it in a session with /remote-control, and undo this with `sideby remote-control undo`.'
|
|
495
|
+
: 'New sessions go back to the previous setting.');
|
|
496
|
+
io.out(`Run again with --yes to ${action === 'off' ? 'turn it off' : 'undo'}.`);
|
|
497
|
+
}
|
|
498
|
+
return 10;
|
|
499
|
+
}
|
|
500
|
+
const r = action === 'off' ? await rt.remoteControlOff() : await rt.remoteControlUndo();
|
|
501
|
+
const results = r.results.map((x) => ({ ...x, file: tildify(x.file, home) }));
|
|
502
|
+
if (wantJson)
|
|
503
|
+
out({ applied: r.ok, results, message: r.message });
|
|
504
|
+
else {
|
|
505
|
+
for (const x of results)
|
|
506
|
+
io.out(` ${x.ok ? c.green('ok') : c.red('failed')} ${x.file}: ${x.message}`);
|
|
507
|
+
io.out(r.message);
|
|
508
|
+
}
|
|
509
|
+
return r.ok ? 0 : 1;
|
|
510
|
+
}
|
|
416
511
|
const FROM = {
|
|
417
512
|
sideby: 'set by sideby',
|
|
418
513
|
host: 'from the host config',
|
|
@@ -235,6 +235,30 @@ export const OUTPUT_SCHEMAS = {
|
|
|
235
235
|
})),
|
|
236
236
|
message: Type.Optional(Str),
|
|
237
237
|
}, { title: 'sideby tune apply|undo --json' }),
|
|
238
|
+
'remote-control': Type.Object({
|
|
239
|
+
schemaVersion: Version,
|
|
240
|
+
action: Type.Union([Type.Literal('status'), Type.Literal('off'), Type.Literal('undo')]),
|
|
241
|
+
applied: Type.Optional(Type.Boolean({ description: 'True once the change was written (or undone).' })),
|
|
242
|
+
groups: Type.Optional(Type.Array(Type.Object({
|
|
243
|
+
file: Str,
|
|
244
|
+
accounts: Type.Array(Str, { description: 'Claude accounts whose settings.json is this file.' }),
|
|
245
|
+
state: Type.Union([
|
|
246
|
+
Type.Literal('on'),
|
|
247
|
+
Type.Literal('default'),
|
|
248
|
+
Type.Literal('off'),
|
|
249
|
+
Type.Literal('missing'),
|
|
250
|
+
Type.Literal('blocked'),
|
|
251
|
+
], {
|
|
252
|
+
description: 'on: remoteControlAtStartup is true; default: not set; off: false; missing: no settings.json; blocked: cannot be changed safely (see reason).',
|
|
253
|
+
}),
|
|
254
|
+
reason: Type.Optional(Str),
|
|
255
|
+
undo: Type.Boolean({
|
|
256
|
+
description: 'sideby turned it off here and can put the earlier value back.',
|
|
257
|
+
}),
|
|
258
|
+
}), { description: 'For status, every file; for a preview, the files that would change.' })),
|
|
259
|
+
results: Type.Optional(Type.Array(Type.Object({ file: Str, accounts: Type.Array(Str), ok: Type.Boolean(), message: Str }))),
|
|
260
|
+
message: Type.Optional(Str),
|
|
261
|
+
}, { title: 'sideby remote-control [off|undo] --json' }),
|
|
238
262
|
model: Type.Object({
|
|
239
263
|
schemaVersion: Version,
|
|
240
264
|
account: Str,
|
package/dist/src/cli/main.js
CHANGED
|
@@ -34,6 +34,9 @@ Usage:
|
|
|
34
34
|
from your last 7 days of sessions (read-only)
|
|
35
35
|
sideby model <account> [<model>|default] [--effort <level>|default] [--yes]
|
|
36
36
|
show or set the model and effort sideby starts this account with
|
|
37
|
+
sideby remote-control [off|undo] [--yes]
|
|
38
|
+
whether Claude sessions connect to claude.ai by themselves;
|
|
39
|
+
off stops that for every account (shows the change first)
|
|
37
40
|
sideby handoff status what Auto Handoff still needs on this machine
|
|
38
41
|
sideby handoff enable [--yes] turn it on (shows the config change first); --same-family,
|
|
39
42
|
--order claude=codex001,cc002, --threshold n, --prepare-at n
|
|
@@ -56,8 +59,8 @@ Usage:
|
|
|
56
59
|
sideby plugins list loaded plugins and load errors
|
|
57
60
|
|
|
58
61
|
Options:
|
|
59
|
-
--json machine-readable output (list, new, next, alias, doctor, quota, tune,
|
|
60
|
-
desktop)
|
|
62
|
+
--json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
|
|
63
|
+
families, plugins, app, desktop)
|
|
61
64
|
-h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
|
|
62
65
|
-v, --version show the version
|
|
63
66
|
|
|
@@ -179,6 +182,7 @@ const COMMANDS = {
|
|
|
179
182
|
quota: { flags: ['json', 'yes'] },
|
|
180
183
|
tune: { flags: ['json', 'yes'] },
|
|
181
184
|
model: { flags: ['json', 'yes'], valued: ['effort'] },
|
|
185
|
+
'remote-control': { flags: ['json', 'yes'] },
|
|
182
186
|
handoff: {
|
|
183
187
|
flags: ['json', 'yes', 'same-family', 'no-same-family'],
|
|
184
188
|
valued: ['brief', 'to', 'order', 'prepare-at', 'threshold', 'wait-minutes', 'countdown'],
|
|
@@ -283,6 +287,8 @@ export async function main(argv) {
|
|
|
283
287
|
return await cmds.cmdTune(parsed, io);
|
|
284
288
|
case 'model':
|
|
285
289
|
return await cmds.cmdModel(parsed, io);
|
|
290
|
+
case 'remote-control':
|
|
291
|
+
return await cmds.cmdRemoteControl(parsed, io);
|
|
286
292
|
case 'handoff':
|
|
287
293
|
return await cmds.cmdHandoff(parsed, io);
|
|
288
294
|
case 'ui':
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Account } from '../types.ts';
|
|
2
|
+
export type Json = Record<string, unknown>;
|
|
3
|
+
export declare const isObj: (v: unknown) => v is Json;
|
|
4
|
+
export declare function render(obj: Json, original: string): string;
|
|
5
|
+
export declare function readObject(file: string): Promise<{
|
|
6
|
+
text: string;
|
|
7
|
+
obj: Json;
|
|
8
|
+
} | {
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Where the Account's settings really live, and whether `settings.json` is a link to it. */
|
|
12
|
+
export declare function locate(account: Account): Promise<{
|
|
13
|
+
file: string;
|
|
14
|
+
link: boolean;
|
|
15
|
+
exists: boolean;
|
|
16
|
+
broken?: boolean;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// One Claude Account's `settings.json`, as the writers of a single key use it (tune apply, ADR-0012; Remote Control,
|
|
2
|
+
// ADR-0017): where it really lives, a safe read, and rendering it back with the file's own indentation.
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { lstatOrNull, realpathOrNull } from './fs-safe.js';
|
|
6
|
+
const MAX_BYTES = 5_000_000;
|
|
7
|
+
export const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
8
|
+
function detectIndent(text) {
|
|
9
|
+
const m = /^([ \t]+)"/m.exec(text);
|
|
10
|
+
if (!m)
|
|
11
|
+
return 2;
|
|
12
|
+
const lead = m[1];
|
|
13
|
+
return lead.startsWith('\t') ? '\t' : lead.length;
|
|
14
|
+
}
|
|
15
|
+
export function render(obj, original) {
|
|
16
|
+
const out = JSON.stringify(obj, null, detectIndent(original));
|
|
17
|
+
return original.endsWith('\n') || original === '' ? `${out}\n` : out;
|
|
18
|
+
}
|
|
19
|
+
export async function readObject(file) {
|
|
20
|
+
const st = await stat(file).catch(() => null);
|
|
21
|
+
if (!st?.isFile())
|
|
22
|
+
return { error: 'it is not a regular file' };
|
|
23
|
+
if (st.size > MAX_BYTES)
|
|
24
|
+
return { error: 'it is too large to be a settings file' };
|
|
25
|
+
const text = await readFile(file, 'utf8');
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { error: 'it is not valid JSON' };
|
|
32
|
+
}
|
|
33
|
+
if (!isObj(parsed))
|
|
34
|
+
return { error: 'it is not a JSON object' };
|
|
35
|
+
return { text, obj: parsed };
|
|
36
|
+
}
|
|
37
|
+
/** Where the Account's settings really live, and whether `settings.json` is a link to it. */
|
|
38
|
+
export async function locate(account) {
|
|
39
|
+
const path = join(account.dir, 'settings.json');
|
|
40
|
+
const lst = await lstatOrNull(path);
|
|
41
|
+
// Every path is canonical (no link anywhere in it), so two Accounts that share a file compare equal and the undo
|
|
42
|
+
// record is found from either of them, even when HOME itself sits behind a link.
|
|
43
|
+
if (!lst) {
|
|
44
|
+
const dir = (await realpathOrNull(account.dir)) ?? account.dir;
|
|
45
|
+
return { file: join(dir, 'settings.json'), link: false, exists: false };
|
|
46
|
+
}
|
|
47
|
+
const real = await realpathOrNull(path);
|
|
48
|
+
if (!lst.isSymbolicLink())
|
|
49
|
+
return { file: real ?? path, link: false, exists: true };
|
|
50
|
+
return real
|
|
51
|
+
? { file: real, link: true, exists: true }
|
|
52
|
+
: { file: path, link: true, exists: false, broken: true };
|
|
53
|
+
}
|
|
@@ -18,6 +18,8 @@ export interface PlanFile {
|
|
|
18
18
|
threshold: number;
|
|
19
19
|
/** False when the session cannot write its Brief: no preparing step, and the threshold only ends the turn. */
|
|
20
20
|
agentBrief?: boolean;
|
|
21
|
+
/** The parent reads `to` from a manual request; a parent from before 0.9.3 does not write it and ignores `to`. */
|
|
22
|
+
acceptsTo?: true;
|
|
21
23
|
}
|
|
22
24
|
/** What the hook already did in one run. */
|
|
23
25
|
export interface RunState {
|
|
@@ -8,6 +8,7 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { HOOK_FAMILIES } from '../handoff/hook.js';
|
|
9
9
|
import { firstPrompt } from '../handoff/messages.js';
|
|
10
10
|
import { aliasArgs } from './config.js';
|
|
11
|
+
import { UserError } from './errors.js';
|
|
11
12
|
import { finishBrief } from './handoff-brief.js';
|
|
12
13
|
import { briefPath, chainDir, ensureChainDir, FILES, HANDOFF_ENV, newId, readJsonFile, writeJsonFile, } from './handoff-chain.js';
|
|
13
14
|
import { runHost, sidebyBinProblem, spawnHost } from './launch.js';
|
|
@@ -232,6 +233,7 @@ opts = {}) {
|
|
|
232
233
|
prepareAt: settings.prepareAt,
|
|
233
234
|
threshold: settings.threshold,
|
|
234
235
|
...(elig.agentBrief === false ? { agentBrief: false } : {}),
|
|
236
|
+
acceptsTo: true,
|
|
235
237
|
};
|
|
236
238
|
await writeJsonFile(join(dir, FILES.plan), plan);
|
|
237
239
|
}
|
|
@@ -347,6 +349,11 @@ export async function readyDecision(rt, env, to) {
|
|
|
347
349
|
const ref = env.SIDEBY_ACCOUNT;
|
|
348
350
|
if (!env[HANDOFF_ENV.run] || !dir || !ref)
|
|
349
351
|
return null;
|
|
352
|
+
if (to !== undefined) {
|
|
353
|
+
const plan = await readJsonFile(join(dir, FILES.plan));
|
|
354
|
+
if (plan && plan.runId === env[HANDOFF_ENV.run] && !plan.acceptsTo)
|
|
355
|
+
throw new UserError(`this session was started by an older sideby, which cannot hand over to a named account; exit it and start ${to} yourself (\`sideby run ${to}\`), or run \`sideby handoff ready\` without --to`, { code: 'parent-too-old' });
|
|
356
|
+
}
|
|
350
357
|
const account = await rt.resolve(ref);
|
|
351
358
|
const chain = await readJsonFile(join(dir, FILES.chain));
|
|
352
359
|
return decide(rt, account, chain?.hops.map((h) => h.ref) ?? [account.ref], to);
|
|
@@ -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/handoff/hook.js
CHANGED
|
@@ -7,7 +7,7 @@ import { FILES, HANDOFF_ENV, readJsonFile, writeJsonFile, } from '../core/handof
|
|
|
7
7
|
import { claudeHookReader } from '../families/claude/handoff-hook.js';
|
|
8
8
|
import { codexHookReader } from '../families/codex/handoff-hook.js';
|
|
9
9
|
import { grokHookReader } from '../families/grok/handoff-hook.js';
|
|
10
|
-
import { endTurnMessage, prepareMessage, stopMessage, thresholdMessage } from './messages.js';
|
|
10
|
+
import { endTurnMessage, prepareMessage, sessionStartMessage, stopMessage, thresholdMessage, } from './messages.js';
|
|
11
11
|
import { field } from './reader.js';
|
|
12
12
|
export const HOOK_COMMAND = 'sideby handoff-hook';
|
|
13
13
|
const READERS = {
|
|
@@ -17,7 +17,7 @@ const READERS = {
|
|
|
17
17
|
};
|
|
18
18
|
/** Families whose hooks this entry understands; only they can start a Handoff. */
|
|
19
19
|
export const HOOK_FAMILIES = Object.keys(READERS);
|
|
20
|
-
const EVENTS = new Set(['PostToolUse', 'Stop', 'StopFailure']);
|
|
20
|
+
const EVENTS = new Set(['SessionStart', 'PostToolUse', 'Stop', 'StopFailure']);
|
|
21
21
|
async function readAll(stream) {
|
|
22
22
|
const chunks = [];
|
|
23
23
|
try {
|
|
@@ -29,8 +29,8 @@ async function readAll(stream) {
|
|
|
29
29
|
}
|
|
30
30
|
return Buffer.concat(chunks).toString('utf8');
|
|
31
31
|
}
|
|
32
|
-
const contextOutput = (text) => ({
|
|
33
|
-
hookSpecificOutput: { hookEventName
|
|
32
|
+
const contextOutput = (text, hookEventName = 'PostToolUse') => ({
|
|
33
|
+
hookSpecificOutput: { hookEventName, additionalContext: text },
|
|
34
34
|
});
|
|
35
35
|
const blockOutput = (reason) => ({ decision: 'block', reason });
|
|
36
36
|
async function mtimeMs(path) {
|
|
@@ -67,6 +67,14 @@ export async function decide(opts) {
|
|
|
67
67
|
return;
|
|
68
68
|
await writeJsonFile(readyFile, { runId, at: now.toISOString(), ...session, ...r });
|
|
69
69
|
};
|
|
70
|
+
// The session learns it may move the task on when the user asks; without this it tells the user to start the
|
|
71
|
+
// other account by hand.
|
|
72
|
+
if (event === 'SessionStart') {
|
|
73
|
+
const ref = env.SIDEBY_ACCOUNT;
|
|
74
|
+
return ref && usable
|
|
75
|
+
? { output: contextOutput(sessionStartMessage(ref, usable.brief), 'SessionStart') }
|
|
76
|
+
: {};
|
|
77
|
+
}
|
|
70
78
|
const threshold = usable?.threshold ?? 95;
|
|
71
79
|
const pressure = reader.pressure ? await reader.pressure(input, env, now) : null;
|
|
72
80
|
if (event === 'StopFailure') {
|
|
@@ -3,6 +3,8 @@ export declare function thresholdMessage(brief: string, pressure: number): strin
|
|
|
3
3
|
/** At the threshold when the session cannot write a Brief: sideby puts one together from its records. */
|
|
4
4
|
export declare function endTurnMessage(pressure: number): string;
|
|
5
5
|
export declare function stopMessage(brief: string): string;
|
|
6
|
+
/** At session start (Claude): this session may hand its task to another account when the user asks. */
|
|
7
|
+
export declare function sessionStartMessage(ref: string, brief: string): string;
|
|
6
8
|
declare const WHY: {
|
|
7
9
|
readonly threshold: 'its quota ran low';
|
|
8
10
|
readonly limit: 'its quota ran out';
|
|
@@ -21,6 +21,10 @@ export function endTurnMessage(pressure) {
|
|
|
21
21
|
export function stopMessage(brief) {
|
|
22
22
|
return `sideby: before you stop, update the handoff brief at ${brief} (${TEMPLATE}; ${RULES}), then end your turn.`;
|
|
23
23
|
}
|
|
24
|
+
/** At session start (Claude): this session may hand its task to another account when the user asks. */
|
|
25
|
+
export function sessionStartMessage(ref, brief) {
|
|
26
|
+
return `sideby: this session runs in ${ref} under sideby with Auto Handoff. If the user wants this task to go on in another account ("continue in cc008", "hand it back to cc008"), do not ask them to start that account: write a handoff brief to ${brief} (${TEMPLATE}; ${RULES}), run \`sideby handoff ready --to <account>\` (an account or short command), tell the user the account it prints, and end your turn. sideby then ends this session and starts that account in this terminal.`;
|
|
27
|
+
}
|
|
24
28
|
const WHY = {
|
|
25
29
|
threshold: 'its quota ran low',
|
|
26
30
|
limit: 'its quota ran out',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type HandoffSettings } from '../core/config.ts';
|
|
3
3
|
import { type AccountModel } from '../core/model-choice.ts';
|
|
4
|
+
import type { RemoteControlGroup } from '../core/remote-control.ts';
|
|
4
5
|
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
5
6
|
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
6
7
|
import { type PanelTheme } from './theme.ts';
|
|
@@ -94,7 +95,12 @@ export interface PanelState {
|
|
|
94
95
|
settings: HandoffSettings;
|
|
95
96
|
starters: string[];
|
|
96
97
|
};
|
|
98
|
+
/** Claude settings files grouped as `sideby remote-control` shows them (spec §3.22); absent without Claude Accounts. */
|
|
99
|
+
remoteControl?: RemoteControlView[];
|
|
97
100
|
}
|
|
98
101
|
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
99
102
|
export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
|
|
103
|
+
export type RemoteControlView = Omit<RemoteControlGroup, 'file'> & {
|
|
104
|
+
file: string;
|
|
105
|
+
};
|
|
100
106
|
export declare function createPanelHandler(opts: PanelHandlerOptions): PanelHandler;
|
|
@@ -197,13 +197,17 @@ async function accountState(rt, account, families) {
|
|
|
197
197
|
...(errors.length ? { error: errors.join('; ') } : {}),
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
|
+
function rcView(groups, home) {
|
|
201
|
+
return groups.map((g) => ({ ...g, file: tildify(g.file, home) }));
|
|
202
|
+
}
|
|
200
203
|
async function buildState(rt, opts) {
|
|
201
204
|
const ctx = rt.readContext();
|
|
202
|
-
const [families, accounts, history, quotaSetups] = await Promise.all([
|
|
205
|
+
const [families, accounts, history, quotaSetups, remoteControl] = await Promise.all([
|
|
203
206
|
rt.familyInfo(),
|
|
204
207
|
rt.accounts(),
|
|
205
208
|
rt.doctorHistory(),
|
|
206
209
|
rt.quotaSetups(),
|
|
210
|
+
rt.remoteControl(),
|
|
207
211
|
]);
|
|
208
212
|
const health = healthByAccount(history);
|
|
209
213
|
const aliases = aliasesByAccount(aliasRefs(rt.config.aliases), accounts);
|
|
@@ -239,6 +243,7 @@ async function buildState(rt, opts) {
|
|
|
239
243
|
...(history ? { history } : {}),
|
|
240
244
|
...(rt.config.aliases ? { configAliases: aliasRefs(rt.config.aliases) } : {}),
|
|
241
245
|
...(Object.keys(handoff).length ? { handoff } : {}),
|
|
246
|
+
...(remoteControl.length ? { remoteControl: rcView(remoteControl, ctx.home) } : {}),
|
|
242
247
|
autoHandoff: {
|
|
243
248
|
settings: rt.handoffSettings(),
|
|
244
249
|
starters: [...rt.families.values()]
|
|
@@ -400,6 +405,24 @@ export function createPanelHandler(opts) {
|
|
|
400
405
|
return { ref: found.ref, undone: r.ok, message: r.message };
|
|
401
406
|
},
|
|
402
407
|
},
|
|
408
|
+
// Remote Control off by default (spec §3.22, ADR-0017): writes `remoteControlAtStartup: false` into each Claude
|
|
409
|
+
// settings file that turns it on or leaves it to the default; the page shows the files first, from the state.
|
|
410
|
+
'/api/remote-control/off': {
|
|
411
|
+
async run() {
|
|
412
|
+
const rt = await getRuntime();
|
|
413
|
+
const r = await rt.remoteControlOff();
|
|
414
|
+
const home = rt.paths.home;
|
|
415
|
+
return { ...r, results: r.results.map((x) => ({ ...x, file: tildify(x.file, home) })) };
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
'/api/remote-control/undo': {
|
|
419
|
+
async run() {
|
|
420
|
+
const rt = await getRuntime();
|
|
421
|
+
const r = await rt.remoteControlUndo();
|
|
422
|
+
const home = rt.paths.home;
|
|
423
|
+
return { ...r, results: r.results.map((x) => ({ ...x, file: tildify(x.file, home) })) };
|
|
424
|
+
},
|
|
425
|
+
},
|
|
403
426
|
// Auto Handoff (spec §3.17): readiness reads only; configure shows the config change unless `confirm`.
|
|
404
427
|
'/api/handoff/status': {
|
|
405
428
|
writes: () => false,
|
package/dist/src/panel/i18n.d.ts
CHANGED
|
@@ -457,6 +457,22 @@ declare const en: {
|
|
|
457
457
|
'empty.install': string;
|
|
458
458
|
'empty.noFamilies': string;
|
|
459
459
|
'empty.create': string;
|
|
460
|
+
'rc.title': string;
|
|
461
|
+
'rc.body.one': string;
|
|
462
|
+
'rc.body.other': string;
|
|
463
|
+
'rc.turnOff': string;
|
|
464
|
+
'rc.keep': string;
|
|
465
|
+
'rc.hidden': string;
|
|
466
|
+
'rc.readOnly': string;
|
|
467
|
+
'rc.confirmTitle': string;
|
|
468
|
+
'rc.change': string;
|
|
469
|
+
'rc.notSet': string;
|
|
470
|
+
'rc.usedBy': string;
|
|
471
|
+
'rc.confirmNote': string;
|
|
472
|
+
'rc.confirm': string;
|
|
473
|
+
'rc.done': string;
|
|
474
|
+
'rc.failed': string;
|
|
475
|
+
'rc.undone': string;
|
|
460
476
|
'banner.readOnly': string;
|
|
461
477
|
'banner.plugins.one': string;
|
|
462
478
|
'banner.plugins.other': string;
|
package/dist/src/panel/i18n.js
CHANGED
|
@@ -459,6 +459,22 @@ const en = {
|
|
|
459
459
|
'empty.install': 'Install',
|
|
460
460
|
'empty.noFamilies': 'No tools are registered. Check sideby plugins.',
|
|
461
461
|
'empty.create': 'Create your first account',
|
|
462
|
+
'rc.title': 'Remote Control starts by itself.',
|
|
463
|
+
'rc.body.one': 'Sessions of {refs} connect to claude.ai and the Claude app, where every device signed in to that account can read them.',
|
|
464
|
+
'rc.body.other': 'Sessions of {n} accounts ({refs}) connect to claude.ai and the Claude app, where every device signed in to that account can read them.',
|
|
465
|
+
'rc.turnOff': 'Turn all off',
|
|
466
|
+
'rc.keep': 'Keep it on',
|
|
467
|
+
'rc.hidden': 'Hidden in this browser. sideby remote-control off turns it off later.',
|
|
468
|
+
'rc.readOnly': 'Run sideby remote-control off to turn it off.',
|
|
469
|
+
'rc.confirmTitle': 'Turn off Remote Control at startup?',
|
|
470
|
+
'rc.change': 'remoteControlAtStartup {from} → false',
|
|
471
|
+
'rc.notSet': 'not set',
|
|
472
|
+
'rc.usedBy': 'used by {refs}',
|
|
473
|
+
'rc.confirmNote': 'Running sessions stay connected until you restart them. You can still start it in a session with /remote-control.',
|
|
474
|
+
'rc.confirm': 'Turn off',
|
|
475
|
+
'rc.done': 'Remote Control is off for new sessions. Restart sessions that are running.',
|
|
476
|
+
'rc.failed': 'Could not turn it off: {msg}',
|
|
477
|
+
'rc.undone': 'Undone. New sessions go back to the previous setting.',
|
|
462
478
|
'banner.readOnly': 'Read-only view. Use the sideby CLI to fix or create accounts.',
|
|
463
479
|
'banner.plugins.one': '{n} plugin problem.',
|
|
464
480
|
'banner.plugins.other': '{n} plugin problems.',
|
|
@@ -922,6 +938,22 @@ const zh = {
|
|
|
922
938
|
'empty.install': '安装',
|
|
923
939
|
'empty.noFamilies': '没有注册任何工具,请检查 sideby plugins。',
|
|
924
940
|
'empty.create': '创建第一个账号',
|
|
941
|
+
'rc.title': 'Remote Control 会自动打开。',
|
|
942
|
+
'rc.body.one': '{refs} 的会话会连到 claude.ai 和 Claude 手机 App,登录这个账号的设备都能看到对话。',
|
|
943
|
+
'rc.body.other': '{n} 个账号({refs})的会话会连到 claude.ai 和 Claude 手机 App,登录这个账号的设备都能看到对话。',
|
|
944
|
+
'rc.turnOff': '全部关闭',
|
|
945
|
+
'rc.keep': '保持开启',
|
|
946
|
+
'rc.hidden': '本浏览器不再提示。以后可以运行 sideby remote-control off 关闭。',
|
|
947
|
+
'rc.readOnly': '运行 sideby remote-control off 关闭。',
|
|
948
|
+
'rc.confirmTitle': '关闭 Remote Control 自动打开?',
|
|
949
|
+
'rc.change': 'remoteControlAtStartup {from} → false',
|
|
950
|
+
'rc.notSet': '未设置',
|
|
951
|
+
'rc.usedBy': '用于 {refs}',
|
|
952
|
+
'rc.confirmNote': '已经开着的会话要重开才断开。需要时仍可在会话里运行 /remote-control 手动打开。',
|
|
953
|
+
'rc.confirm': '确认关闭',
|
|
954
|
+
'rc.done': '新会话不会再自动打开 Remote Control。开着的会话要重开。',
|
|
955
|
+
'rc.failed': '没能关闭:{msg}',
|
|
956
|
+
'rc.undone': '已撤销,新会话恢复原来的设置。',
|
|
925
957
|
'banner.readOnly': '只读视图。修复或创建账号请用 sideby 命令行。',
|
|
926
958
|
'banner.plugins.one': '{n} 个插件问题。',
|
|
927
959
|
'banner.plugins.other': '{n} 个插件问题。',
|
|
@@ -69,6 +69,7 @@ const MAIN = String.raw `
|
|
|
69
69
|
for (const k of Object.keys(CHOICES)) if (CHOICES[k].includes(raw[k])) p[k] = raw[k]
|
|
70
70
|
if (raw.hideEmail === true) p.hideEmail = true
|
|
71
71
|
if (typeof raw.ahHintAt === 'number') p.ahHintAt = raw.ahHintAt
|
|
72
|
+
if (raw.rcHide === true) p.rcHide = true
|
|
72
73
|
if (typeof raw.family === 'string') p.family = raw.family
|
|
73
74
|
if (raw.collapsed && typeof raw.collapsed === 'object')
|
|
74
75
|
for (const k of Object.keys(raw.collapsed)) if (raw.collapsed[k] === true) p.collapsed[k] = true
|
|
@@ -2380,6 +2381,59 @@ const MAIN = String.raw `
|
|
|
2380
2381
|
setKids(box, kids)
|
|
2381
2382
|
}
|
|
2382
2383
|
|
|
2384
|
+
// ---------- Remote Control (spec §3.22) ----------
|
|
2385
|
+
// Shown while some Claude settings file turns Remote Control on. "Turn all off" first lists each file, the change
|
|
2386
|
+
// and the accounts it reaches, in the banner itself; the toast after it carries the undo.
|
|
2387
|
+
const rc = { confirm: false, busy: false }
|
|
2388
|
+
const shortRef = (ref) => ref.replace(/^claude:/, '')
|
|
2389
|
+
function remoteControlBanner() {
|
|
2390
|
+
const groups = S.state.remoteControl || []
|
|
2391
|
+
// API Accounts never sign in to claude.ai, so Remote Control cannot reach their sessions.
|
|
2392
|
+
const api = new Set(S.state.accounts.filter((a) => a.kind === 'api').map((a) => a.ref))
|
|
2393
|
+
const refs = groups.filter((g) => g.state === 'on').flatMap((g) => g.accounts).filter((r) => !api.has(r)).map(shortRef)
|
|
2394
|
+
if (!refs.length || prefs.rcHide) return null
|
|
2395
|
+
const todo = groups.filter((g) => g.state === 'on' || g.state === 'default')
|
|
2396
|
+
const text = h('div', null, h('b', null, t('rc.title') + ' '), tn('rc.body', refs.length, { refs: refs.join(', ') }))
|
|
2397
|
+
if (BOOT.readOnly) return h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null, text, ' ', t('rc.readOnly')))
|
|
2398
|
+
if (!rc.confirm)
|
|
2399
|
+
return h('div', { class: 'banner banner-warn', role: 'status' }, text,
|
|
2400
|
+
h('div', { class: 'banner-actions' },
|
|
2401
|
+
h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => { rc.confirm = true; renderBanners() } }, t('rc.turnOff')),
|
|
2402
|
+
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.rcHide = true; savePrefs(); renderBanners(); toast(t('rc.hidden')) } }, t('rc.keep'))))
|
|
2403
|
+
const lines = todo.map((g) => h('li', null, h('code', null, g.file), ' ',
|
|
2404
|
+
t('rc.change', { from: g.state === 'on' ? 'true' : t('rc.notSet') }), ' ',
|
|
2405
|
+
h('span', { class: 'muted' }, t('rc.usedBy', { refs: g.accounts.map(shortRef).join(', ') }))))
|
|
2406
|
+
return h('div', { class: 'banner banner-warn', role: 'status' },
|
|
2407
|
+
h('div', null, h('b', null, t('rc.confirmTitle')), h('ul', null, lines), h('div', { class: 'small' }, t('rc.confirmNote'))),
|
|
2408
|
+
h('div', { class: 'banner-actions' },
|
|
2409
|
+
h('button', { class: 'btn btn-sm btn-primary', type: 'button', disabled: rc.busy, onclick: remoteControlOff }, rc.busy ? spinner() : null, t('rc.confirm')),
|
|
2410
|
+
h('button', { class: 'btn btn-sm', type: 'button', disabled: rc.busy, onclick: () => { rc.confirm = false; renderBanners() } }, t('btn.cancel'))))
|
|
2411
|
+
}
|
|
2412
|
+
async function remoteControlOff() {
|
|
2413
|
+
rc.busy = true
|
|
2414
|
+
renderBanners()
|
|
2415
|
+
try {
|
|
2416
|
+
const r = await api('/api/remote-control/off', {})
|
|
2417
|
+
if (r.ok) {
|
|
2418
|
+
// Hide the banner now; the reload that follows confirms it.
|
|
2419
|
+
S.state.remoteControl = (S.state.remoteControl || []).map((g) => g.state === 'on' || g.state === 'default' ? { ...g, state: 'off', undo: true } : g)
|
|
2420
|
+
toast(t('rc.done'), { action: { label: t('toast.undo'), run: remoteControlUndo }, duration: 12000 })
|
|
2421
|
+
}
|
|
2422
|
+
else toast(t('rc.failed', { msg: r.message }), { kind: 'fail' })
|
|
2423
|
+
} catch (e) { toast(t('rc.failed', { msg: e.message }), { kind: 'fail' }) }
|
|
2424
|
+
rc.busy = false
|
|
2425
|
+
rc.confirm = false
|
|
2426
|
+
renderBanners()
|
|
2427
|
+
await load({ silent: true })
|
|
2428
|
+
}
|
|
2429
|
+
async function remoteControlUndo() {
|
|
2430
|
+
try {
|
|
2431
|
+
const r = await api('/api/remote-control/undo', {})
|
|
2432
|
+
toast(r.ok ? t('rc.undone') : r.message, { kind: r.ok ? 'ok' : 'fail' })
|
|
2433
|
+
} catch (e) { toast(t('toast.undoFailed', { msg: e.message }), { kind: 'fail' }) }
|
|
2434
|
+
await load({ silent: true })
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2383
2437
|
// ---------- banners ----------
|
|
2384
2438
|
function renderBanners() {
|
|
2385
2439
|
const out = []
|
|
@@ -2394,6 +2448,8 @@ const MAIN = String.raw `
|
|
|
2394
2448
|
h('div', { class: 'banner-actions' },
|
|
2395
2449
|
h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => setPage('handoff') }, t('ah.setUp')),
|
|
2396
2450
|
h('button', { class: 'btn btn-sm', type: 'button', onclick: () => { prefs.ahHintAt = Date.now(); savePrefs(); renderBanners() } }, t('ah.dismiss')))))
|
|
2451
|
+
const rcBanner = remoteControlBanner()
|
|
2452
|
+
if (rcBanner) out.push(rcBanner)
|
|
2397
2453
|
const errs = S.state.pluginErrors
|
|
2398
2454
|
if (errs.length) out.push(h('div', { class: 'banner banner-warn', role: 'status' }, h('div', null,
|
|
2399
2455
|
h('b', null, tn('banner.plugins', errs.length) + ' '), t('banner.pluginsHint'),
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type HandoffDecision } from './core/handoff-select.ts';
|
|
|
9
9
|
import { type DoctorHistory } from './core/last-doctor.ts';
|
|
10
10
|
import { type HostLaunch, type PreparedLaunch } from './core/launch.ts';
|
|
11
11
|
import { type Paths } from './core/paths.ts';
|
|
12
|
+
import { type RemoteControlChange, type RemoteControlGroup } from './core/remote-control.ts';
|
|
12
13
|
import { type Shell, type ShellInitWrite } from './core/shell-init.ts';
|
|
13
14
|
import { type TuneEntry, type TuneGroup } from './core/tuning.ts';
|
|
14
15
|
import { type TuneApplyPlan, type TuneChange, type TuneUndoRecord } from './core/tuning-apply.ts';
|
|
@@ -227,6 +228,12 @@ export interface Runtime {
|
|
|
227
228
|
}>;
|
|
228
229
|
/** What could be undone for the Account, or null. */
|
|
229
230
|
tuneUndoInfo(account: Account): Promise<TuneUndoRecord | null>;
|
|
231
|
+
/** Whether each Claude settings file starts Remote Control by itself (spec §3.22, ADR-0017); grouped by file. */
|
|
232
|
+
remoteControl(): Promise<RemoteControlGroup[]>;
|
|
233
|
+
/** Sets `remoteControlAtStartup` to false wherever it is on or left to the default, remembering how to undo it. */
|
|
234
|
+
remoteControlOff(): Promise<RemoteControlChange>;
|
|
235
|
+
/** Puts back what `remoteControlOff` replaced, only where the key still holds false. */
|
|
236
|
+
remoteControlUndo(): Promise<RemoteControlChange>;
|
|
230
237
|
/**
|
|
231
238
|
* Which Account of a Family to move on to (spec §3.13). Throws a UserError with code `no-quota-source`
|
|
232
239
|
* when the Family cannot tell, `no-accounts` when it has none.
|
package/dist/src/runtime.js
CHANGED
|
@@ -13,6 +13,7 @@ import { mergeDoctorHistory, readDoctorHistory, writeDoctorHistory, } from './co
|
|
|
13
13
|
import { prepareLaunch, which } from './core/launch.js';
|
|
14
14
|
import { memoStore } from './core/memo-store.js';
|
|
15
15
|
import { resolvePaths } from './core/paths.js';
|
|
16
|
+
import { remoteControlGroups, turnRemoteControlOff, undoRemoteControlOff, } from './core/remote-control.js';
|
|
16
17
|
import { findResumeRoute } from './core/resume.js';
|
|
17
18
|
import { SAME_LOGIN_FAMILIES, sameLoginGroups, sharedQuota } from './core/same-login.js';
|
|
18
19
|
import { shareMeaning } from './core/share-meaning.js';
|
|
@@ -574,6 +575,15 @@ export async function createRuntime(opts = {}) {
|
|
|
574
575
|
onlyClaude(account);
|
|
575
576
|
return undoTune(account, readContext());
|
|
576
577
|
},
|
|
578
|
+
async remoteControl() {
|
|
579
|
+
return remoteControlGroups(await accounts(), readContext());
|
|
580
|
+
},
|
|
581
|
+
async remoteControlOff() {
|
|
582
|
+
return turnRemoteControlOff(await accounts(), readContext());
|
|
583
|
+
},
|
|
584
|
+
async remoteControlUndo() {
|
|
585
|
+
return undoRemoteControlOff(await accounts(), readContext());
|
|
586
|
+
},
|
|
577
587
|
async tuneUndoInfo(account) {
|
|
578
588
|
return account.family === 'claude' ? tuneUndoInfo(account, readContext()) : null;
|
|
579
589
|
},
|
package/dist/src/types.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ export interface FamilyDef {
|
|
|
112
112
|
handoff?: FamilyHandoff;
|
|
113
113
|
}
|
|
114
114
|
/** A Host hook event sideby's handoff hook listens to. */
|
|
115
|
-
export type HandoffHookEvent = 'PostToolUse' | 'Stop' | 'StopFailure';
|
|
115
|
+
export type HandoffHookEvent = 'SessionStart' | 'PostToolUse' | 'Stop' | 'StopFailure';
|
|
116
116
|
/** Whether one run may start a Handoff, or start as the next Account of one, and why not. */
|
|
117
117
|
export interface HandoffEligibility {
|
|
118
118
|
start: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sideby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Adonis0123 (https://github.com/Adonis0123)",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
},
|
|
78
78
|
"packageManager": "pnpm@10.15.0",
|
|
79
79
|
"readmeFilename": "README.md",
|
|
80
|
-
"readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Why not just an alias?\n\n`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |\n| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |\n| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |\n\ncc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's \"open terminal\" can start one provider without changing the global config.\n\n## Quick start\n\nRequires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) (\"Set up sideby for a user\") instead.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\n<img alt=\"Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif\" width=\"1280\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <img alt=\"sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\" width=\"1280\">\n</picture>\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |\n| See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| Reopen a session from Orca and the like in the account that holds it | `\"resumeRouting\": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |\n| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.\n- It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
|
|
80
|
+
"readme": "<p align=\"center\">\n <img alt=\"sideby: work, personal and API-key accounts, one terminal each, one shared config\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg\" width=\"880\">\n</p>\n\n<p align=\"center\">\n <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>\n Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.\n</p>\n\n<p align=\"center\">\n <a href=\"https://www.npmjs.com/package/sideby\"><img alt=\"npm\" src=\"https://img.shields.io/npm/v/sideby\"></a>\n <a href=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml\"><img alt=\"CI\" src=\"https://github.com/Adonis0123/sideby/actions/workflows/ci.yml/badge.svg\"></a>\n <img alt=\"Node 22.18+\" src=\"https://img.shields.io/badge/node-%E2%89%A522.18-339933\">\n <a href=\"LICENSE\"><img alt=\"MIT\" src=\"https://img.shields.io/badge/license-MIT-blue\"></a>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh-CN.md\">中文</a></p>\n\n## Why not just an alias?\n\n`CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)\n\n| | At the same time | Skills, hooks, settings | Quota and usage | Setup |\n|---|---|---|---|---|\n| `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |\n| [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |\n| sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |\n\ncc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's \"open terminal\" can start one provider without changing the global config.\n\n## Quick start\n\nRequires Node 22.18+ and at least one Host CLI (Claude Code, Codex, Grok Build or pi) installed. AI agents: follow the setup steps in [`llms.txt`](llms.txt) (\"Set up sideby for a user\") instead.\n\n```sh\nnpm i -g sideby\nsideby new claude work # create ~/.claude-work, sharing ~/.claude's skills and hooks\nsideby login claude:work # sign in once\nsideby run work # Claude Code on the work account\nsideby ui # every account's quota at http://127.0.0.1:17420\n```\n\n`sideby run claude:main` keeps using `~/.claude` in another terminal at the same time. Arguments after `--` go to the Host: `sideby run work -- --resume`. To try it without installing, run `npx sideby`.\n\n## Set up with your AI agent\n\nPaste this into Claude Code, Codex or any coding agent:\n\n```text\nInstall the sideby CLI (npm package `sideby`) and its agent skill, then set up a second Claude Code account for me.\nFollow \"Set up sideby for a user\" in https://raw.githubusercontent.com/Adonis0123/sideby/main/llms.txt\n```\n\nThe agent checks Node and your Host CLIs, installs sideby and the skill, creates the account, tells you what it shares with your main one and makes its short command work. You sign in once in your browser; the agent gives you that one command. Once the skill is installed, short requests work too: \"add another Claude account, cc008\", \"what do my Codex accounts share?\", \"which account still has quota?\".\n\n## What it looks like\n\n<img alt=\"Two terminals: the personal account and the work account running at the same time, both linked to the main account's skills and settings, then the local quota panel, and at 95% Auto Handoff moving work's session to the next account\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif\" width=\"1280\">\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\">\n <img alt=\"sideby panel: every account's 5h and 7d quota, 7-day token usage and health in one page\" src=\"https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel.png\" width=\"1280\">\n</picture>\n\n## What else it does\n\n| You want to | Run | Guide |\n|---|---|---|\n| Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |\n| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |\n| See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |\n| Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |\n| See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |\n| Stop Claude sessions from connecting to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` (or the panel's banner) | [Remote Control](docs/guide/usage.md#remote-control) |\n| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |\n| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |\n| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |\n| Reopen a session from Orca and the like in the account that holds it | `\"resumeRouting\": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |\n| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |\n| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |\n| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |\n| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |\n\n## Supported hosts\n\n| Host | Account directories | Sign-in | Quota | Usage (7 days) |\n|---|---|---|---|---|\n| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |\n| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |\n| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | no public source | no public source |\n| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |\n\nsideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).\n\n## Platforms\n\nmacOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.\n\n## Roadmap\n\n**Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.\n\n## Safe by design\n\n- It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.\n- It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.\n- `doctor --fix` never replaces a real file and never changes where a link points.\n\nDetails and the terms-of-service question are in the [FAQ](docs/guide/faq.md).\n\nUsage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).\n\n## Docs\n\n- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes\n- [Commands](docs/guide/commands.md): every command, exit codes and `--json`\n- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions\n- [Panel](docs/guide/panel.md): the panel, panel app and embedding\n- [Plugins](docs/guide/plugins.md): write your own Family or launch hook\n- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall\n- [Changelog](https://github.com/Adonis0123/sideby/releases)\n\n## For AI agents\n\n- [`llms.txt`](llms.txt): setup steps, commands, JSON contract and plugin API in one index. `sideby families --json` states what each Host's accounts share.\n- [`skills/sideby/SKILL.md`](skills/sideby/SKILL.md): one Agent Skill for seeing, repairing and creating accounts and for moving on when one hits its limit. Install with `npx skills add Adonis0123/sideby -g`; `sideby doctor` warns when the installed copy is for another sideby version.\n- [`AGENTS.md`](AGENTS.md): rules for agents working on this repository.\n\n## License\n\n[MIT](LICENSE)\n"
|
|
81
81
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "sideby/remote-control.json",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": [
|
|
6
|
+
"schemaVersion",
|
|
7
|
+
"action"
|
|
8
|
+
],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schemaVersion": {
|
|
11
|
+
"type": "number",
|
|
12
|
+
"const": 1
|
|
13
|
+
},
|
|
14
|
+
"action": {
|
|
15
|
+
"anyOf": [
|
|
16
|
+
{
|
|
17
|
+
"type": "string",
|
|
18
|
+
"const": "status"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "string",
|
|
22
|
+
"const": "off"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "string",
|
|
26
|
+
"const": "undo"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"applied": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"description": "True once the change was written (or undone)."
|
|
33
|
+
},
|
|
34
|
+
"groups": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"required": [
|
|
39
|
+
"file",
|
|
40
|
+
"accounts",
|
|
41
|
+
"state",
|
|
42
|
+
"undo"
|
|
43
|
+
],
|
|
44
|
+
"properties": {
|
|
45
|
+
"file": {
|
|
46
|
+
"type": "string"
|
|
47
|
+
},
|
|
48
|
+
"accounts": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"description": "Claude accounts whose settings.json is this file."
|
|
54
|
+
},
|
|
55
|
+
"state": {
|
|
56
|
+
"anyOf": [
|
|
57
|
+
{
|
|
58
|
+
"type": "string",
|
|
59
|
+
"const": "on"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"type": "string",
|
|
63
|
+
"const": "default"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "string",
|
|
67
|
+
"const": "off"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "string",
|
|
71
|
+
"const": "missing"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "string",
|
|
75
|
+
"const": "blocked"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"description": "on: remoteControlAtStartup is true; default: not set; off: false; missing: no settings.json; blocked: cannot be changed safely (see reason)."
|
|
79
|
+
},
|
|
80
|
+
"reason": {
|
|
81
|
+
"type": "string"
|
|
82
|
+
},
|
|
83
|
+
"undo": {
|
|
84
|
+
"type": "boolean",
|
|
85
|
+
"description": "sideby turned it off here and can put the earlier value back."
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"description": "For status, every file; for a preview, the files that would change."
|
|
90
|
+
},
|
|
91
|
+
"results": {
|
|
92
|
+
"type": "array",
|
|
93
|
+
"items": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"required": [
|
|
96
|
+
"file",
|
|
97
|
+
"accounts",
|
|
98
|
+
"ok",
|
|
99
|
+
"message"
|
|
100
|
+
],
|
|
101
|
+
"properties": {
|
|
102
|
+
"file": {
|
|
103
|
+
"type": "string"
|
|
104
|
+
},
|
|
105
|
+
"accounts": {
|
|
106
|
+
"type": "array",
|
|
107
|
+
"items": {
|
|
108
|
+
"type": "string"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"ok": {
|
|
112
|
+
"type": "boolean"
|
|
113
|
+
},
|
|
114
|
+
"message": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"message": {
|
|
121
|
+
"type": "string"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"title": "sideby remote-control [off|undo] --json"
|
|
125
|
+
}
|
package/skills/sideby/SKILL.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sideby
|
|
3
|
-
description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command.
|
|
3
|
+
description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, moving the current task to another account ("用 cc008 继续", "交回 cc008"), adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command (inside an Auto Handoff session, `sideby handoff ready --to` moves the task itself).
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.
|
|
5
|
+
version: "0.10.0"
|
|
6
6
|
requires:
|
|
7
7
|
bins: [sideby, node]
|
|
8
8
|
cliHelp: sideby --help
|
|
@@ -20,7 +20,7 @@ Do every step you can yourself. Only browser sign-in, secrets and interactive se
|
|
|
20
20
|
- **Never sign in for the user.** Give them `sideby login <ref>` as one line to run in their own terminal (Claude Code: `! sideby login <ref>`). OAuth needs their browser and their decision.
|
|
21
21
|
- **Never put a secret on the command line or in chat.** For API accounts, the user edits `proxy.env` themselves.
|
|
22
22
|
- **Never start a Host session from your shell.** `sideby run`, `sideby next` (without `--dry-run`) and `sideby login` open an interactive session; give the user the command instead.
|
|
23
|
-
- **Never switch, rotate or proxy accounts yourself.** Do not work around sideby by editing config, copying session files or moving credentials. `sideby next` only recommends. Moving a running task on its own is Auto Handoff, which the user turns on: use `sideby handoff enable` (exit 10, then their yes), never hand-edit `config.json`. Inside such a session
|
|
23
|
+
- **Never switch, rotate or proxy accounts yourself.** Do not work around sideby by editing config, copying session files or moving credentials. `sideby next` only recommends. Moving a running task on its own is Auto Handoff, which the user turns on: use `sideby handoff enable` (exit 10, then their yes), never hand-edit `config.json`. Inside such a session (the environment has `SIDEBY_HANDOFF_RUN`), when the user wants the task to go on in another account ("用 cc008 继续", "交回 cc008"), do not hand them a command to start it: update the brief, run `sideby handoff ready --to <account>`, tell them the account it prints, and end your turn; sideby starts that account in the same terminal.
|
|
24
24
|
- **Say what you change.** Read-only commands first. What the user asked for (the account they asked to create, its short command, the shell line that makes it work) you do, saying what you do. Changes they did not ask for (`doctor --fix` on other accounts, `quota setup`, `app install`) wait until they have seen the change and agreed.
|
|
25
25
|
- Do not edit Host config files (`settings.json`, `config.toml`, symlinks in account directories) by hand when a `sideby` command does the job.
|
|
26
26
|
|
|
@@ -46,6 +46,7 @@ Account refs are `<family>:<name>`; a bare `<name>` works only when it is unique
|
|
|
46
46
|
| Create an account with a given name, an API account, or a short command | `sideby new`, `sideby alias add` | `references/accounts.md` |
|
|
47
47
|
| Tune the auto-compact threshold ("which compaction setting costs the fewest tokens"), use the suggestion or undo it | `sideby tune --json`, `sideby tune apply` (exit 10 first), `sideby tune undo` | `references/diagnose.md` |
|
|
48
48
|
| See or set the model and effort an account starts with ("用哪个模型", "set effort to high for cc002") | `sideby model <acct> --json`; `sideby model <acct> <model\|default> --effort <level\|default>` (exit 10 first, then `--yes` after the user agrees) | — |
|
|
49
|
+
| Stop Claude sessions from connecting to claude.ai / the Claude app by themselves ("关闭 remote control", "别让别人远程看到对话") for every account | `sideby remote-control --json`, then `sideby remote-control off` (exits 10 with the change; rerun with `--yes` after they agree); `sideby remote-control undo` puts it back | — |
|
|
49
50
|
| See Claude quota (it shows `not-enabled`) | `sideby quota setup claude` (exits 10) | `references/quota.md` |
|
|
50
51
|
| See everything at once | `sideby ui` (local panel on `127.0.0.1:17420`; `--no-open` prints the address) | — |
|
|
51
52
|
| Start an account | give them `sideby run <ref> [-- host args]` | — |
|