sideby 0.4.3 → 0.6.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 +2 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +5 -0
- package/dist/src/cli/commands.js +67 -0
- package/dist/src/cli/json-schemas.js +30 -0
- package/dist/src/cli/main.js +5 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +9 -0
- package/dist/src/core/handoff-enable.d.ts +52 -0
- package/dist/src/core/handoff-enable.js +169 -0
- package/dist/src/core/handoff-overview.d.ts +26 -0
- package/dist/src/core/handoff-overview.js +63 -0
- package/dist/src/core/handoff-run.js +2 -1
- package/dist/src/core/handoff-select.d.ts +2 -0
- package/dist/src/core/handoff-select.js +11 -5
- package/dist/src/core/model-choice.d.ts +41 -0
- package/dist/src/core/model-choice.js +128 -0
- package/dist/src/families/claude/index.js +2 -0
- package/dist/src/families/claude/model.d.ts +2 -0
- package/dist/src/families/claude/model.js +42 -0
- package/dist/src/families/claude/quota-cache.d.ts +7 -0
- package/dist/src/families/claude/quota-cache.js +40 -0
- package/dist/src/families/claude/usage.d.ts +2 -0
- package/dist/src/families/claude/usage.js +13 -1
- package/dist/src/families/codex/index.js +2 -0
- package/dist/src/families/codex/model.d.ts +2 -0
- package/dist/src/families/codex/model.js +59 -0
- package/dist/src/families/codex/rollout.d.ts +5 -0
- package/dist/src/families/codex/rollout.js +45 -3
- package/dist/src/families/grok/handoff.js +1 -1
- package/dist/src/families/shared/model-args.d.ts +7 -0
- package/dist/src/families/shared/model-args.js +28 -0
- package/dist/src/handoff/messages.js +1 -1
- package/dist/src/panel/handler.d.ts +3 -0
- package/dist/src/panel/handler.js +69 -0
- package/dist/src/panel/i18n.d.ts +67 -0
- package/dist/src/panel/i18n.js +134 -0
- package/dist/src/panel/page-script.js +328 -13
- package/dist/src/panel/page-styles.js +14 -1
- package/dist/src/panel/page.js +1 -0
- package/dist/src/quota/statusline-tap.js +13 -2
- package/dist/src/runtime.d.ts +5 -2
- package/dist/src/runtime.js +2 -1
- package/dist/src/types.d.ts +29 -0
- package/package.json +3 -2
- package/schemas/model.json +169 -0
- package/schemas/quota.json +6 -0
- package/skills/sideby/SKILL.md +2 -1
- package/skills/sideby/references/handoff.md +2 -2
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
55
55
|
|
|
56
56
|
## What it looks like
|
|
57
57
|
|
|
58
|
-
<img alt="Two terminals: the personal account and the work account running at the same time, then the local quota panel" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1280">
|
|
58
|
+
<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">
|
|
59
59
|
|
|
60
60
|
<picture>
|
|
61
61
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png">
|
|
@@ -71,6 +71,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
71
71
|
| Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |
|
|
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
|
+
| 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) |
|
|
74
75
|
| Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
|
|
75
76
|
| 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) |
|
|
76
77
|
| 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":"20261014T070544-66900c"}
|
|
@@ -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 model <account> [<model>|default] [--effort <level>|default] [--yes]` (spec §3.19, ADR-0014): shows the
|
|
48
|
+
* Account's model and effort; with a change, shows it (exit 10) and writes it with --yes.
|
|
49
|
+
*/
|
|
50
|
+
export declare function cmdModel(a: ParsedArgs, io: Io): Promise<number>;
|
|
46
51
|
/** `sideby tune [account|family]`: estimated Compaction Threshold per model (spec §3.18). */
|
|
47
52
|
export declare function cmdTune(a: ParsedArgs, io: Io): Promise<number>;
|
|
48
53
|
export declare function cmdQuota(a: ParsedArgs, io: Io): Promise<number>;
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -6,6 +6,7 @@ import { requestHandoff } from '../core/handoff-chain.js';
|
|
|
6
6
|
import { explain, readyDecision, runWithHandoff } from '../core/handoff-run.js';
|
|
7
7
|
import { configureHandoff, handoffHint, handoffStatus } from '../core/handoff-status.js';
|
|
8
8
|
import { runHost } from '../core/launch.js';
|
|
9
|
+
import { accountModel, setAccountModel } from '../core/model-choice.js';
|
|
9
10
|
import { resolvePaths, tildify } from '../core/paths.js';
|
|
10
11
|
import { USAGE_DAYS } from '../core/quota-levels.js';
|
|
11
12
|
import { SHELLS } from '../core/shell-init.js';
|
|
@@ -401,6 +402,72 @@ async function cmdTuneChange(a, io, action) {
|
|
|
401
402
|
io.out(r.message);
|
|
402
403
|
return r.ok ? 0 : 1;
|
|
403
404
|
}
|
|
405
|
+
const FROM = {
|
|
406
|
+
sideby: 'set by sideby',
|
|
407
|
+
host: 'from the host config',
|
|
408
|
+
last: 'last session',
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* `sideby model <account> [<model>|default] [--effort <level>|default] [--yes]` (spec §3.19, ADR-0014): shows the
|
|
412
|
+
* Account's model and effort; with a change, shows it (exit 10) and writes it with --yes.
|
|
413
|
+
*/
|
|
414
|
+
export async function cmdModel(a, io) {
|
|
415
|
+
const [ref, model] = a.positionals;
|
|
416
|
+
if (!ref || a.positionals.length > 2 || a.flags.get('effort') === true)
|
|
417
|
+
throw new UsageError('usage: sideby model <account> [<model>|default] [--effort <level>|default] [--yes] [--json]');
|
|
418
|
+
const wantJson = a.flags.has('json');
|
|
419
|
+
const rt = await createRuntime();
|
|
420
|
+
const account = await rt.resolve(ref);
|
|
421
|
+
const effort = a.flags.get('effort');
|
|
422
|
+
const view = await accountModel(rt, account);
|
|
423
|
+
const shown = {
|
|
424
|
+
account: account.ref,
|
|
425
|
+
family: account.family,
|
|
426
|
+
settable: view.settable,
|
|
427
|
+
...(view.reason ? { reason: view.reason } : {}),
|
|
428
|
+
efforts: view.efforts,
|
|
429
|
+
models: view.models,
|
|
430
|
+
sideby: view.sideby,
|
|
431
|
+
host: view.host,
|
|
432
|
+
last: view.last,
|
|
433
|
+
...(view.model ? { model: view.model } : {}),
|
|
434
|
+
...(view.effort ? { effort: view.effort } : {}),
|
|
435
|
+
};
|
|
436
|
+
if (model === undefined && effort === undefined) {
|
|
437
|
+
if (a.flags.has('yes'))
|
|
438
|
+
throw new UsageError('--yes needs a change: sideby model <account> <model> or --effort <level>');
|
|
439
|
+
if (wantJson)
|
|
440
|
+
json(io, shown);
|
|
441
|
+
else {
|
|
442
|
+
io.out(account.ref);
|
|
443
|
+
io.out(` model ${view.model ? `${view.model.value} (${FROM[view.model.from]})` : 'the host default'}`);
|
|
444
|
+
io.out(` effort ${view.effort ? `${view.effort.value} (${FROM[view.effort.from]})` : 'the host default'}`);
|
|
445
|
+
if (view.settable)
|
|
446
|
+
io.out(c.dim(`Change it: sideby model ${account.ref} <model|default> --effort <${view.efforts.join('|')}|default>`));
|
|
447
|
+
else if (view.reason)
|
|
448
|
+
io.out(c.dim(view.reason));
|
|
449
|
+
}
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const asChange = (v) => (v === undefined ? undefined : v === 'default' ? null : v);
|
|
453
|
+
const change = {
|
|
454
|
+
...(model !== undefined ? { model: asChange(model) } : {}),
|
|
455
|
+
...(typeof effort === 'string' ? { effort: asChange(effort) } : {}),
|
|
456
|
+
};
|
|
457
|
+
const yes = a.flags.has('yes');
|
|
458
|
+
const r = await setAccountModel(rt, account, change, { apply: yes });
|
|
459
|
+
const home = rt.paths.home;
|
|
460
|
+
const result = { ...r, file: tilde(r.file, home) };
|
|
461
|
+
if (wantJson)
|
|
462
|
+
json(io, { ...shown, change: result });
|
|
463
|
+
else if (!r.changed)
|
|
464
|
+
io.out('Nothing to change.');
|
|
465
|
+
else if (!yes)
|
|
466
|
+
io.out(`${r.diff}Run again with --yes to apply. It affects sessions started with sideby from now on.`);
|
|
467
|
+
else
|
|
468
|
+
io.out(`Saved. New sessions of ${account.ref} started with sideby use: ${r.args.join(' ') || 'the host defaults'}`);
|
|
469
|
+
return r.changed && !yes ? 10 : 0;
|
|
470
|
+
}
|
|
404
471
|
/** `sideby tune [account|family]`: estimated Compaction Threshold per model (spec §3.18). */
|
|
405
472
|
export async function cmdTune(a, io) {
|
|
406
473
|
if (a.positionals[0] === 'apply' || a.positionals[0] === 'undo')
|
|
@@ -6,6 +6,11 @@ const Str = Type.String();
|
|
|
6
6
|
const Level = Type.Union([Type.Literal('ok'), Type.Literal('warn'), Type.Literal('fail')]);
|
|
7
7
|
const Kind = Type.Union([Type.Literal('subscription'), Type.Literal('api')]);
|
|
8
8
|
const Version = Type.Literal(1);
|
|
9
|
+
const Choice = Type.Object({ model: Type.Optional(Str), effort: Type.Optional(Str) });
|
|
10
|
+
const Shown = Type.Object({
|
|
11
|
+
value: Str,
|
|
12
|
+
from: Type.Union([Type.Literal('sideby'), Type.Literal('host'), Type.Literal('last')]),
|
|
13
|
+
});
|
|
9
14
|
const AppPlatform = Type.Union([Type.Literal('darwin'), Type.Literal('linux')]);
|
|
10
15
|
const Account = Type.Object({
|
|
11
16
|
family: Str,
|
|
@@ -162,6 +167,8 @@ const Usage = Type.Union([
|
|
|
162
167
|
description: 'Tokens per local day (YYYY-MM-DD), oldest first, days without records as 0.',
|
|
163
168
|
})),
|
|
164
169
|
lastActivityAt: Type.Optional(Str),
|
|
170
|
+
lastModel: Type.Optional(Str),
|
|
171
|
+
lastEffort: Type.Optional(Str),
|
|
165
172
|
}),
|
|
166
173
|
Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
|
|
167
174
|
]);
|
|
@@ -219,6 +226,29 @@ export const OUTPUT_SCHEMAS = {
|
|
|
219
226
|
})),
|
|
220
227
|
message: Type.Optional(Str),
|
|
221
228
|
}, { title: 'sideby tune apply|undo --json' }),
|
|
229
|
+
model: Type.Object({
|
|
230
|
+
schemaVersion: Version,
|
|
231
|
+
account: Str,
|
|
232
|
+
family: Str,
|
|
233
|
+
settable: Type.Boolean({
|
|
234
|
+
description: 'Whether sideby can set the model and effort for this account.',
|
|
235
|
+
}),
|
|
236
|
+
reason: Type.Optional(Str),
|
|
237
|
+
efforts: Type.Array(Str),
|
|
238
|
+
models: Type.Array(Str, { description: 'Model names to offer; any other name may be given.' }),
|
|
239
|
+
sideby: Choice,
|
|
240
|
+
host: Choice,
|
|
241
|
+
last: Choice,
|
|
242
|
+
model: Type.Optional(Shown),
|
|
243
|
+
effort: Type.Optional(Shown),
|
|
244
|
+
change: Type.Optional(Type.Object({
|
|
245
|
+
changed: Type.Boolean(),
|
|
246
|
+
applied: Type.Boolean(),
|
|
247
|
+
file: Str,
|
|
248
|
+
diff: Str,
|
|
249
|
+
args: Type.Array(Str, { description: 'The account arguments in the config after the change.' }),
|
|
250
|
+
})),
|
|
251
|
+
}, { title: 'sideby model --json' }),
|
|
222
252
|
tune: Type.Object({
|
|
223
253
|
schemaVersion: Version,
|
|
224
254
|
accounts: Type.Array(Type.Object({ ref: Str, family: Str, tuning: Tuning })),
|
package/dist/src/cli/main.js
CHANGED
|
@@ -32,6 +32,8 @@ Usage:
|
|
|
32
32
|
sideby quota teardown claude undo it, restoring the original file
|
|
33
33
|
sideby tune [account|family] estimate the cheapest auto-compact threshold per model
|
|
34
34
|
from your last 7 days of sessions (read-only)
|
|
35
|
+
sideby model <account> [<model>|default] [--effort <level>|default] [--yes]
|
|
36
|
+
show or set the model and effort sideby starts this account with
|
|
35
37
|
sideby handoff status what Auto Handoff still needs on this machine
|
|
36
38
|
sideby handoff enable [--yes] turn it on (shows the config change first); --same-family,
|
|
37
39
|
--order claude=codex001,cc002, --threshold n, --prepare-at n
|
|
@@ -154,6 +156,7 @@ const COMMANDS = {
|
|
|
154
156
|
doctor: { flags: ['fix', 'force', 'json'] },
|
|
155
157
|
quota: { flags: ['json', 'yes'] },
|
|
156
158
|
tune: { flags: ['json', 'yes'] },
|
|
159
|
+
model: { flags: ['json', 'yes'], valued: ['effort'] },
|
|
157
160
|
handoff: {
|
|
158
161
|
flags: ['json', 'yes', 'same-family', 'no-same-family'],
|
|
159
162
|
valued: ['brief', 'order', 'prepare-at', 'threshold', 'wait-minutes', 'countdown'],
|
|
@@ -255,6 +258,8 @@ export async function main(argv) {
|
|
|
255
258
|
return await cmds.cmdQuota(parsed, io);
|
|
256
259
|
case 'tune':
|
|
257
260
|
return await cmds.cmdTune(parsed, io);
|
|
261
|
+
case 'model':
|
|
262
|
+
return await cmds.cmdModel(parsed, io);
|
|
258
263
|
case 'handoff':
|
|
259
264
|
return await cmds.cmdHandoff(parsed, io);
|
|
260
265
|
case 'ui':
|
|
@@ -85,6 +85,20 @@ export declare function editConfigHandoff(file: string, edit: (current: HandoffC
|
|
|
85
85
|
before: HandoffConfig | undefined;
|
|
86
86
|
after: HandoffConfig | undefined;
|
|
87
87
|
}>;
|
|
88
|
+
export type AccountsConfig = NonNullable<Config['accounts']>;
|
|
89
|
+
/**
|
|
90
|
+
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
|
91
|
+
* written). With `dryRun`, shows the change without writing.
|
|
92
|
+
*/
|
|
93
|
+
export declare function editConfigAccounts(file: string,
|
|
94
|
+
/** Returns null to leave the file as it is. */
|
|
95
|
+
edit: (current: AccountsConfig) => AccountsConfig | null, opts?: {
|
|
96
|
+
dryRun?: boolean;
|
|
97
|
+
}): Promise<{
|
|
98
|
+
changed: boolean;
|
|
99
|
+
before: AccountsConfig | undefined;
|
|
100
|
+
after: AccountsConfig | undefined;
|
|
101
|
+
}>;
|
|
88
102
|
/**
|
|
89
103
|
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
90
104
|
* `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
|
package/dist/src/core/config.js
CHANGED
|
@@ -224,6 +224,15 @@ async function editConfigAliases(file, retry, edit) {
|
|
|
224
224
|
export function editConfigHandoff(file, edit, opts = {}) {
|
|
225
225
|
return editConfigKey(file, 'handoff', 'run the command', (current) => edit(current ?? {}), opts);
|
|
226
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
|
229
|
+
* written). With `dryRun`, shows the change without writing.
|
|
230
|
+
*/
|
|
231
|
+
export function editConfigAccounts(file,
|
|
232
|
+
/** Returns null to leave the file as it is. */
|
|
233
|
+
edit, opts = {}) {
|
|
234
|
+
return editConfigKey(file, 'accounts', 'run the command', (current) => edit(current ?? {}), opts);
|
|
235
|
+
}
|
|
227
236
|
/**
|
|
228
237
|
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
229
238
|
* `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
export interface EnableStep {
|
|
3
|
+
/** `config`: the `handoff` key; `quota`: the Family's quota display; `hook`: its hook file; `copy`: only that hook file, into the other Accounts of the Family. */
|
|
4
|
+
id: 'config' | 'quota' | 'hook' | 'copy';
|
|
5
|
+
family?: string;
|
|
6
|
+
/** The file the step changes; for `copy`, the file that is copied. */
|
|
7
|
+
file?: string;
|
|
8
|
+
diff?: string;
|
|
9
|
+
/** For `copy`: the Accounts that get the file. */
|
|
10
|
+
accounts?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface EnableManual {
|
|
13
|
+
/** `codex-trust`: each Codex account trusts the hooks once, in `/hooks`; `setup-blocked`: a setup that cannot be applied. */
|
|
14
|
+
id: 'codex-trust' | 'setup-blocked';
|
|
15
|
+
family?: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface EnablePlan {
|
|
19
|
+
/** Everything is already in place; nothing would be written. */
|
|
20
|
+
alreadyOn: boolean;
|
|
21
|
+
/** Why nothing can be done from here (the sideby on PATH does not answer hooks); a person has to fix it first. */
|
|
22
|
+
blocked?: string;
|
|
23
|
+
steps: EnableStep[];
|
|
24
|
+
manual: EnableManual[];
|
|
25
|
+
/** Same-Family hand-over as it will be once the plan is applied. */
|
|
26
|
+
sameFamily: boolean;
|
|
27
|
+
/** Names this plan; a confirmation that sends it back as `expect` applies only this plan. */
|
|
28
|
+
key: string;
|
|
29
|
+
}
|
|
30
|
+
export interface EnableResult {
|
|
31
|
+
plan: EnablePlan;
|
|
32
|
+
results: {
|
|
33
|
+
id: EnableStep['id'];
|
|
34
|
+
family?: string;
|
|
35
|
+
accounts?: string[];
|
|
36
|
+
ok: boolean;
|
|
37
|
+
message: string;
|
|
38
|
+
}[];
|
|
39
|
+
/** The plan is no longer the one that was shown (`expect`), so nothing was written; `plan` is the new one. */
|
|
40
|
+
changed?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function planHandoffEnable(rt: Runtime, opts?: {
|
|
43
|
+
sameFamily?: boolean;
|
|
44
|
+
}): Promise<EnablePlan>;
|
|
45
|
+
/**
|
|
46
|
+
* Applies the plan in an order where "on" means "working": the setups first, the config key last. Stops at the first
|
|
47
|
+
* failure. With `expect`, writes nothing unless the plan is still the one that was shown.
|
|
48
|
+
*/
|
|
49
|
+
export declare function applyHandoffEnable(rt: Runtime, opts?: {
|
|
50
|
+
sameFamily?: boolean;
|
|
51
|
+
expect?: string;
|
|
52
|
+
}): Promise<EnableResult>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Turning Auto Handoff on in one step (spec §3.17 "页面与一键开启", ADR-0013): the Panel's button does what the CLI's
|
|
2
|
+
// `handoff enable`, `quota setup` and `handoff setup` do, shown first as one plan and applied together after one
|
|
3
|
+
// confirmation. Every step is an existing, reversible setup; nothing new is written.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { lstat, mkdir, readFile, stat } from 'node:fs/promises';
|
|
6
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
7
|
+
import { realpathOrNull, writeFileAtomic } from './fs-safe.js';
|
|
8
|
+
import { configureHandoff, handoffStatus } from './handoff-status.js';
|
|
9
|
+
const keyOf = (p) => createHash('sha256')
|
|
10
|
+
.update(JSON.stringify([p.blocked ?? null, p.sameFamily, p.steps]))
|
|
11
|
+
.digest('hex')
|
|
12
|
+
.slice(0, 16);
|
|
13
|
+
/** Whether `rel` below `dir` goes through a link: a file seen through one is already there, or is not ours to change. */
|
|
14
|
+
async function throughLink(dir, rel) {
|
|
15
|
+
let at = dir;
|
|
16
|
+
for (const part of rel.split('/')) {
|
|
17
|
+
at = join(at, part);
|
|
18
|
+
const st = await lstat(at).catch(() => null);
|
|
19
|
+
if (!st)
|
|
20
|
+
return false;
|
|
21
|
+
if (st.isSymbolicLink())
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The other Accounts of the Family that lack the hook file `file` (a path in the Main Account) at the same place in
|
|
28
|
+
* their own directory. Only that one file is ever written, and one that is already there is left as it is: an
|
|
29
|
+
* Account's other files are never touched.
|
|
30
|
+
*/
|
|
31
|
+
async function hookCopies(rt, family, file) {
|
|
32
|
+
const accounts = (await rt.accounts()).filter((a) => a.family === family);
|
|
33
|
+
const main = accounts.find((a) => a.isMain);
|
|
34
|
+
if (!main)
|
|
35
|
+
return [];
|
|
36
|
+
const rel = relative(main.dir, file);
|
|
37
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
38
|
+
return [];
|
|
39
|
+
const mainReal = await realpathOrNull(main.dir);
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const a of accounts) {
|
|
42
|
+
if (a.isMain || (await realpathOrNull(a.dir)) === mainReal || (await throughLink(a.dir, rel)))
|
|
43
|
+
continue;
|
|
44
|
+
const target = join(a.dir, rel);
|
|
45
|
+
// Whether the file is there, not whether it can be read: one that cannot be read is still not ours to replace.
|
|
46
|
+
const have = await lstat(target).then(() => true, () => false);
|
|
47
|
+
// A file that is already there is not ours to replace, whatever it says.
|
|
48
|
+
if (!have)
|
|
49
|
+
out.push({ ref: a.ref, target });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** `sameFamily` left out keeps the current setting; Turn on from the Panel says it either way. */
|
|
54
|
+
const changeOf = (opts) => ({
|
|
55
|
+
auto: true,
|
|
56
|
+
...(opts.sameFamily === undefined ? {} : { sameFamily: opts.sameFamily }),
|
|
57
|
+
});
|
|
58
|
+
export async function planHandoffEnable(rt, opts = {}) {
|
|
59
|
+
const st = await handoffStatus(rt);
|
|
60
|
+
if (st.sideby.problem) {
|
|
61
|
+
const plan = {
|
|
62
|
+
alreadyOn: false,
|
|
63
|
+
blocked: st.sideby.problem,
|
|
64
|
+
steps: [],
|
|
65
|
+
manual: [],
|
|
66
|
+
sameFamily: st.sameFamily,
|
|
67
|
+
};
|
|
68
|
+
return { ...plan, key: keyOf(plan) };
|
|
69
|
+
}
|
|
70
|
+
const steps = [];
|
|
71
|
+
const manual = [];
|
|
72
|
+
const cfg = await configureHandoff(rt, changeOf(opts), { apply: false });
|
|
73
|
+
if (cfg.changed)
|
|
74
|
+
steps.push({ id: 'config', file: cfg.file, diff: cfg.diff });
|
|
75
|
+
for (const f of st.families) {
|
|
76
|
+
if (!f.starts.length)
|
|
77
|
+
continue;
|
|
78
|
+
const def = rt.families.get(f.family);
|
|
79
|
+
if (def?.quotaSetup && f.starts.includes('quota')) {
|
|
80
|
+
const plan = await rt
|
|
81
|
+
.quotaSetup(f.family)
|
|
82
|
+
.plan()
|
|
83
|
+
.catch(() => null);
|
|
84
|
+
if (plan?.status === 'ready')
|
|
85
|
+
steps.push({ id: 'quota', family: f.family, file: plan.file, diff: plan.diff });
|
|
86
|
+
else if (plan?.status === 'blocked')
|
|
87
|
+
manual.push({ id: 'setup-blocked', family: f.family, message: plan.message });
|
|
88
|
+
}
|
|
89
|
+
if (def?.handoff?.hookSetup) {
|
|
90
|
+
const plan = await rt
|
|
91
|
+
.handoffSetup(f.family)
|
|
92
|
+
.plan()
|
|
93
|
+
.catch(() => null);
|
|
94
|
+
if (plan?.status === 'ready')
|
|
95
|
+
steps.push({ id: 'hook', family: f.family, file: plan.file, diff: plan.diff });
|
|
96
|
+
else if (plan?.status === 'blocked')
|
|
97
|
+
manual.push({ id: 'setup-blocked', family: f.family, message: plan.message });
|
|
98
|
+
if (plan?.status === 'ready' || plan?.status === 'enabled') {
|
|
99
|
+
const copies = await hookCopies(rt, f.family, plan.file);
|
|
100
|
+
if (copies.length)
|
|
101
|
+
steps.push({ id: 'copy', family: f.family, file: plan.file, accounts: copies.map((c) => c.ref) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (f.family === 'codex')
|
|
105
|
+
manual.push({ id: 'codex-trust', family: 'codex' });
|
|
106
|
+
}
|
|
107
|
+
const plan = { alreadyOn: steps.length === 0, steps, manual, sameFamily: cfg.settings.sameFamily };
|
|
108
|
+
return { ...plan, key: keyOf(plan) };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Applies the plan in an order where "on" means "working": the setups first, the config key last. Stops at the first
|
|
112
|
+
* failure. With `expect`, writes nothing unless the plan is still the one that was shown.
|
|
113
|
+
*/
|
|
114
|
+
export async function applyHandoffEnable(rt, opts = {}) {
|
|
115
|
+
const plan = await planHandoffEnable(rt, opts);
|
|
116
|
+
if (opts.expect !== undefined && opts.expect !== plan.key)
|
|
117
|
+
return { plan, results: [], changed: true };
|
|
118
|
+
const results = [];
|
|
119
|
+
if (!plan.blocked) {
|
|
120
|
+
const order = ['quota', 'hook', 'copy', 'config'];
|
|
121
|
+
const todo = [...plan.steps].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
122
|
+
for (const step of todo) {
|
|
123
|
+
let ok = true;
|
|
124
|
+
let message = '';
|
|
125
|
+
try {
|
|
126
|
+
if (step.id === 'quota' || step.id === 'hook') {
|
|
127
|
+
const setup = step.id === 'quota' ? rt.quotaSetup(step.family) : rt.handoffSetup(step.family);
|
|
128
|
+
const r = await setup.apply();
|
|
129
|
+
ok = r.status === 'enabled';
|
|
130
|
+
message = r.message;
|
|
131
|
+
}
|
|
132
|
+
else if (step.id === 'copy') {
|
|
133
|
+
const file = step.file;
|
|
134
|
+
const want = await readFile(file, 'utf8');
|
|
135
|
+
const mode = (await stat(file)).mode & 0o777;
|
|
136
|
+
const listed = new Set(step.accounts);
|
|
137
|
+
const copies = (await hookCopies(rt, step.family, file)).filter((c) => listed.has(c.ref));
|
|
138
|
+
for (const c of copies) {
|
|
139
|
+
await mkdir(dirname(c.target), { recursive: true });
|
|
140
|
+
await writeFileAtomic(c.target, want, mode);
|
|
141
|
+
}
|
|
142
|
+
const left = await hookCopies(rt, step.family, file);
|
|
143
|
+
ok = left.length === 0;
|
|
144
|
+
message = left.length
|
|
145
|
+
? `${left.map((c) => c.ref).join(', ')} still lack ${file}; try again, and if it keeps failing, check that you may write to their hooks folder`
|
|
146
|
+
: '';
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const r = await configureHandoff(rt, changeOf(opts), { apply: true });
|
|
150
|
+
ok = r.applied || !r.changed;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
ok = false;
|
|
155
|
+
message = err.message;
|
|
156
|
+
}
|
|
157
|
+
results.push({
|
|
158
|
+
id: step.id,
|
|
159
|
+
...(step.family ? { family: step.family } : {}),
|
|
160
|
+
...(step.accounts ? { accounts: step.accounts } : {}),
|
|
161
|
+
ok,
|
|
162
|
+
message,
|
|
163
|
+
});
|
|
164
|
+
if (!ok)
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { plan, results };
|
|
169
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
import type { HandoffSettings } from './config.ts';
|
|
3
|
+
import { type HandoffTrigger } from './handoff-chain.ts';
|
|
4
|
+
import type { HandoffDecision } from './handoff-select.ts';
|
|
5
|
+
export interface RecentHandoff {
|
|
6
|
+
from: string;
|
|
7
|
+
to: string;
|
|
8
|
+
/** When the next account started. */
|
|
9
|
+
at: string;
|
|
10
|
+
trigger: HandoffTrigger | null;
|
|
11
|
+
briefSource: 'agent' | 'sideby' | 'user' | null;
|
|
12
|
+
}
|
|
13
|
+
export interface HandoffOverview {
|
|
14
|
+
settings: HandoffSettings;
|
|
15
|
+
/** Where each subscription account of a Family that can start a hand-over would send its work; empty while off. */
|
|
16
|
+
takeover: {
|
|
17
|
+
ref: string;
|
|
18
|
+
family: string;
|
|
19
|
+
starts: ('quota' | 'limit')[];
|
|
20
|
+
decision: HandoffDecision;
|
|
21
|
+
}[];
|
|
22
|
+
recent: RecentHandoff[];
|
|
23
|
+
}
|
|
24
|
+
/** The latest hand-overs, newest first: a hop that ended with a trigger and was followed by another hop. */
|
|
25
|
+
export declare function readRecentHandoffs(stateDir: string, limit?: number): Promise<RecentHandoff[]>;
|
|
26
|
+
export declare function handoffOverview(rt: Runtime): Promise<HandoffOverview>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What the Panel's Auto Handoff page shows (spec §3.17 "页面与一键开启"): where each account's work would go next and
|
|
2
|
+
// the last hand-overs. Reads only; from the chain files it takes the accounts, times and triggers, never a Brief.
|
|
3
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { HOOK_FAMILIES } from '../handoff/hook.js';
|
|
6
|
+
import { FILES, readJsonFile } from './handoff-chain.js';
|
|
7
|
+
const SCAN_CHAINS = 40;
|
|
8
|
+
const RECENT = 5;
|
|
9
|
+
/** The latest hand-overs, newest first: a hop that ended with a trigger and was followed by another hop. */
|
|
10
|
+
export async function readRecentHandoffs(stateDir, limit = RECENT) {
|
|
11
|
+
const root = join(stateDir, 'handoffs');
|
|
12
|
+
const names = await readdir(root).catch(() => []);
|
|
13
|
+
const dirs = (await Promise.all(names.map(async (n) => ({ n, t: (await stat(join(root, n)).catch(() => null))?.mtimeMs ?? 0 }))))
|
|
14
|
+
.sort((a, b) => b.t - a.t)
|
|
15
|
+
.slice(0, SCAN_CHAINS);
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const { n } of dirs) {
|
|
18
|
+
const chain = await readJsonFile(join(root, n, FILES.chain));
|
|
19
|
+
const hops = Array.isArray(chain?.hops) ? chain.hops : [];
|
|
20
|
+
for (let i = 0; i + 1 < hops.length; i++) {
|
|
21
|
+
const from = hops[i];
|
|
22
|
+
const to = hops[i + 1];
|
|
23
|
+
if (typeof from.ref !== 'string' ||
|
|
24
|
+
typeof to.ref !== 'string' ||
|
|
25
|
+
!Number.isFinite(Date.parse(to.startedAt)))
|
|
26
|
+
continue;
|
|
27
|
+
out.push({
|
|
28
|
+
from: from.ref,
|
|
29
|
+
to: to.ref,
|
|
30
|
+
at: to.startedAt,
|
|
31
|
+
trigger: from.trigger ?? null,
|
|
32
|
+
briefSource: from.briefSource ?? null,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at)).slice(0, limit);
|
|
37
|
+
}
|
|
38
|
+
export async function handoffOverview(rt) {
|
|
39
|
+
const settings = rt.handoffSettings();
|
|
40
|
+
const recent = await readRecentHandoffs(rt.paths.stateDir);
|
|
41
|
+
if (!settings.auto)
|
|
42
|
+
return { settings, takeover: [], recent };
|
|
43
|
+
const starters = new Set([...rt.families.values()]
|
|
44
|
+
.filter((f) => f.handoff?.starts.length && HOOK_FAMILIES.includes(f.id))
|
|
45
|
+
.map((f) => f.id));
|
|
46
|
+
const subs = (await rt.accounts()).filter((a) => starters.has(a.family) && a.kind === 'subscription');
|
|
47
|
+
const takeover = await Promise.all(subs.map(async (a) => {
|
|
48
|
+
let decision;
|
|
49
|
+
try {
|
|
50
|
+
decision = await rt.handoffDecision(a, [a.ref], { ahead: true });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
decision = { kind: 'none', reason: err.message };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
ref: a.ref,
|
|
57
|
+
family: a.family,
|
|
58
|
+
starts: [...(rt.families.get(a.family)?.handoff?.starts ?? [])],
|
|
59
|
+
decision,
|
|
60
|
+
};
|
|
61
|
+
}));
|
|
62
|
+
return { settings, takeover, recent };
|
|
63
|
+
}
|
|
@@ -213,7 +213,8 @@ opts = {}) {
|
|
|
213
213
|
const writePlan = () => {
|
|
214
214
|
pending = pending.then(async () => {
|
|
215
215
|
try {
|
|
216
|
-
|
|
216
|
+
// Read by the hook once the threshold is crossed, so it answers for that moment (spec §3.17 "选号" 1).
|
|
217
|
+
const d = await rt.handoffDecision(account, used, { ahead: true });
|
|
217
218
|
if (stopped)
|
|
218
219
|
return;
|
|
219
220
|
const plan = {
|
|
@@ -39,4 +39,6 @@ export declare function selectNext(input: {
|
|
|
39
39
|
/** Accounts this chain already used, the origin included. */
|
|
40
40
|
used: readonly string[];
|
|
41
41
|
now: Date;
|
|
42
|
+
/** Decide now what happens when the origin's fullest window reaches the threshold (see `waitUntil`). */
|
|
43
|
+
ahead?: boolean;
|
|
42
44
|
}): HandoffDecision;
|
|
@@ -9,13 +9,19 @@ export function sameOrganization(a, b) {
|
|
|
9
9
|
return domain(a.email) === domain(b.email);
|
|
10
10
|
return true;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The latest reset of the origin's full windows, when they all reset in time to wait. Full means at the threshold;
|
|
14
|
+
* `ahead` also counts the fullest window once it is past `prepareAt`, since it answers what happens when that window
|
|
15
|
+
* reaches the threshold (spec §3.17 "选号" 1: plan.json and the Panel, decided before the moment comes).
|
|
16
|
+
*/
|
|
17
|
+
function waitUntil(origin, s, now, ahead) {
|
|
14
18
|
if (origin.quota.status !== 'ok' || s.waitIfResetWithinMinutes <= 0)
|
|
15
19
|
return undefined;
|
|
20
|
+
const open = origin.quota.windows.filter((w) => !windowReset(w.resetsAt, now));
|
|
21
|
+
const top = Math.max(0, ...open.map((w) => w.usedPercent));
|
|
16
22
|
let last;
|
|
17
|
-
for (const w of
|
|
18
|
-
if (w.usedPercent < s.threshold
|
|
23
|
+
for (const w of open) {
|
|
24
|
+
if (w.usedPercent < s.threshold && !(ahead && w.usedPercent === top && top >= s.prepareAt))
|
|
19
25
|
continue;
|
|
20
26
|
const t = Date.parse(w.resetsAt);
|
|
21
27
|
if (!Number.isFinite(t))
|
|
@@ -46,7 +52,7 @@ function stateOf(c, s, now) {
|
|
|
46
52
|
}
|
|
47
53
|
export function selectNext(input) {
|
|
48
54
|
const { origin, settings: s, now } = input;
|
|
49
|
-
const until = waitUntil(origin, s, now);
|
|
55
|
+
const until = waitUntil(origin, s, now, input.ahead === true);
|
|
50
56
|
if (until)
|
|
51
57
|
return { kind: 'wait', until };
|
|
52
58
|
const refOf = (name) => input.aliases[name] ?? name;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
import type { Account, ModelChoice } from '../types.ts';
|
|
3
|
+
/** Where a shown value comes from: sideby's own argument, the Host's config, or the last session. */
|
|
4
|
+
export type ChoiceSource = 'sideby' | 'host' | 'last';
|
|
5
|
+
export interface AccountModel {
|
|
6
|
+
ref: string;
|
|
7
|
+
family: string;
|
|
8
|
+
/** Whether sideby can set it for this Account; when not, `reason` says why. */
|
|
9
|
+
settable: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
efforts: string[];
|
|
12
|
+
models: string[];
|
|
13
|
+
sideby: ModelChoice;
|
|
14
|
+
host: ModelChoice;
|
|
15
|
+
last: ModelChoice;
|
|
16
|
+
model?: {
|
|
17
|
+
value: string;
|
|
18
|
+
from: ChoiceSource;
|
|
19
|
+
};
|
|
20
|
+
effort?: {
|
|
21
|
+
value: string;
|
|
22
|
+
from: ChoiceSource;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** `last` is the model and effort of the newest session, from the usage reader when the caller already has it. */
|
|
26
|
+
export declare function accountModel(rt: Runtime, account: Account, last?: ModelChoice): Promise<AccountModel>;
|
|
27
|
+
/** A change: `undefined` keeps what sideby sets now, `null` goes back to the Host's default. */
|
|
28
|
+
export interface ModelChange {
|
|
29
|
+
model?: string | null;
|
|
30
|
+
effort?: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** Shows, or with `apply` writes, the change to the Account's model and effort arguments. */
|
|
33
|
+
export declare function setAccountModel(rt: Runtime, account: Account, change: ModelChange, opts: {
|
|
34
|
+
apply: boolean;
|
|
35
|
+
}): Promise<{
|
|
36
|
+
changed: boolean;
|
|
37
|
+
applied: boolean;
|
|
38
|
+
file: string;
|
|
39
|
+
diff: string;
|
|
40
|
+
args: string[];
|
|
41
|
+
}>;
|