sideby 0.5.0 → 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/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/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/shared/model-args.d.ts +7 -0
- package/dist/src/families/shared/model-args.js +28 -0
- package/dist/src/panel/handler.d.ts +3 -0
- package/dist/src/panel/handler.js +39 -0
- package/dist/src/panel/i18n.d.ts +19 -0
- package/dist/src/panel/i18n.js +38 -0
- package/dist/src/panel/page-script.js +114 -0
- package/dist/src/panel/page-styles.js +5 -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/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,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
|
+
}>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Model and effort per Account (spec §3.19, ADR-0014): what an Account starts with, and the one change sideby makes,
|
|
2
|
+
// to the model and effort arguments in the config's `accounts.<ref>.args`. No Host file is written.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { MODEL_NAME } from '../families/shared/model-args.js';
|
|
5
|
+
import { editConfigAccounts } from './config.js';
|
|
6
|
+
import { UserError } from './errors.js';
|
|
7
|
+
import { lineDiff } from './handoff-status.js';
|
|
8
|
+
function pick(key, sideby, host, last) {
|
|
9
|
+
if (sideby[key])
|
|
10
|
+
return { value: sideby[key], from: 'sideby' };
|
|
11
|
+
if (host[key])
|
|
12
|
+
return { value: host[key], from: 'host' };
|
|
13
|
+
if (last[key])
|
|
14
|
+
return { value: last[key], from: 'last' };
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
/** `last` is the model and effort of the newest session, from the usage reader when the caller already has it. */
|
|
18
|
+
export async function accountModel(rt, account, last) {
|
|
19
|
+
const f = rt.families.get(account.family);
|
|
20
|
+
const mc = f?.modelChoice;
|
|
21
|
+
const base = {
|
|
22
|
+
ref: account.ref,
|
|
23
|
+
family: account.family,
|
|
24
|
+
efforts: [],
|
|
25
|
+
models: [],
|
|
26
|
+
sideby: {},
|
|
27
|
+
host: {},
|
|
28
|
+
last: {},
|
|
29
|
+
};
|
|
30
|
+
if (!mc)
|
|
31
|
+
return { ...base, settable: false, reason: `${f?.title ?? account.family} has no model choice in sideby` };
|
|
32
|
+
let seen = last;
|
|
33
|
+
if (!seen) {
|
|
34
|
+
const u = await rt.usage(account).catch(() => null);
|
|
35
|
+
seen =
|
|
36
|
+
u?.status === 'ok'
|
|
37
|
+
? {
|
|
38
|
+
...(u.lastModel ? { model: u.lastModel } : {}),
|
|
39
|
+
...(u.lastEffort ? { effort: u.lastEffort } : {}),
|
|
40
|
+
}
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
const sideby = mc.fromArgs(rt.config.accounts?.[account.ref]?.args ?? []).choice;
|
|
44
|
+
// An API account's model is the one its proxy.env names (the Family's `model` reader); the effort still comes from settings.
|
|
45
|
+
const apiModel = account.kind === 'api' ? await f.model?.(account).catch(() => undefined) : undefined;
|
|
46
|
+
const configured = await mc.configured(account, sideby.model ?? apiModel, seen.model);
|
|
47
|
+
const host = apiModel ? { ...configured, model: apiModel } : configured;
|
|
48
|
+
const models = [...new Set([...mc.models, ...(seen.model ? [seen.model] : [])])];
|
|
49
|
+
const out = {
|
|
50
|
+
...base,
|
|
51
|
+
settable: account.kind !== 'api',
|
|
52
|
+
efforts: [...mc.efforts],
|
|
53
|
+
models,
|
|
54
|
+
sideby,
|
|
55
|
+
host,
|
|
56
|
+
last: seen,
|
|
57
|
+
};
|
|
58
|
+
if (account.kind === 'api')
|
|
59
|
+
out.reason = 'an API account takes its model from proxy.env';
|
|
60
|
+
const m = pick('model', sideby, host, seen);
|
|
61
|
+
const e = pick('effort', sideby, host, seen);
|
|
62
|
+
if (m)
|
|
63
|
+
out.model = m;
|
|
64
|
+
if (e)
|
|
65
|
+
out.effort = e;
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const pretty = (v) => JSON.stringify(v ?? {}, null, 2);
|
|
69
|
+
/** Shows, or with `apply` writes, the change to the Account's model and effort arguments. */
|
|
70
|
+
export async function setAccountModel(rt, account, change, opts) {
|
|
71
|
+
const f = rt.families.get(account.family);
|
|
72
|
+
const mc = f?.modelChoice;
|
|
73
|
+
if (!mc)
|
|
74
|
+
throw new UserError(`${f?.title ?? account.family} has no model choice in sideby`, {
|
|
75
|
+
code: 'unsupported',
|
|
76
|
+
});
|
|
77
|
+
if (account.kind === 'api')
|
|
78
|
+
throw new UserError(`${account.ref} is an API account: its model comes from proxy.env, so set it there`, {
|
|
79
|
+
code: 'api-account',
|
|
80
|
+
});
|
|
81
|
+
if (typeof change.model === 'string' && !MODEL_NAME.test(change.model))
|
|
82
|
+
throw new UserError(`"${change.model}" is not a model name sideby passes on: use letters, digits and . _ : / [ ] - only, for example ${mc.models[0] ?? 'gpt-5'}`, { code: 'bad-value' });
|
|
83
|
+
if (typeof change.effort === 'string' && !mc.efforts.includes(change.effort))
|
|
84
|
+
throw new UserError(`"${change.effort}" is not an effort level for ${f.title}; use one of: ${mc.efforts.join(', ')}, default`, {
|
|
85
|
+
code: 'bad-value',
|
|
86
|
+
});
|
|
87
|
+
let args = [];
|
|
88
|
+
const r = await editConfigAccounts(rt.paths.configFile, (current) => {
|
|
89
|
+
const now = current[account.ref]?.args ?? [];
|
|
90
|
+
const unchanged = (list) => list.length === now.length && list.every((x, i) => x === now[i]);
|
|
91
|
+
const { choice, rest } = mc.fromArgs(now);
|
|
92
|
+
const next = { ...choice };
|
|
93
|
+
for (const k of ['model', 'effort']) {
|
|
94
|
+
if (change[k] === null)
|
|
95
|
+
delete next[k];
|
|
96
|
+
else if (change[k] !== undefined)
|
|
97
|
+
next[k] = change[k];
|
|
98
|
+
}
|
|
99
|
+
// After a `--` everything is prompt text, so the choice goes in front of it.
|
|
100
|
+
const dd = rest.indexOf('--');
|
|
101
|
+
const add = mc.toArgs(next);
|
|
102
|
+
args = dd < 0 ? [...rest, ...add] : [...rest.slice(0, dd), ...add, ...rest.slice(dd)];
|
|
103
|
+
// Nothing to write, not even an empty `accounts` key in a config that has none.
|
|
104
|
+
if (unchanged(args))
|
|
105
|
+
return null;
|
|
106
|
+
const out = { ...current };
|
|
107
|
+
if (args.length)
|
|
108
|
+
out[account.ref] = { ...current[account.ref], args };
|
|
109
|
+
else {
|
|
110
|
+
const { args: _gone, ...others } = current[account.ref] ?? {};
|
|
111
|
+
if (Object.keys(others).length)
|
|
112
|
+
out[account.ref] = others;
|
|
113
|
+
else
|
|
114
|
+
delete out[account.ref];
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}, { dryRun: !opts.apply });
|
|
118
|
+
const file = rt.paths.configFile;
|
|
119
|
+
return {
|
|
120
|
+
changed: r.changed,
|
|
121
|
+
applied: r.changed && opts.apply,
|
|
122
|
+
file,
|
|
123
|
+
diff: r.changed
|
|
124
|
+
? `--- ${join('sideby', 'config.json')} (accounts)\n${lineDiff(pretty(r.before), pretty(r.after))}\n`
|
|
125
|
+
: '',
|
|
126
|
+
args,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -8,6 +8,7 @@ import { BUILTIN_LOGOS } from '../logos.js';
|
|
|
8
8
|
import { dirWrittenAt, groupedEntryWrittenAt, resumeFlagSession, withoutResumeFlag, } from '../shared/sessions.js';
|
|
9
9
|
import { claudeHandoff } from './handoff.js';
|
|
10
10
|
import { CLAUDE_LAYOUT } from './layout.js';
|
|
11
|
+
import { claudeModelChoice } from './model.js';
|
|
11
12
|
import { readClaudeQuota } from './quota.js';
|
|
12
13
|
import { readClaudeTuning } from './tuning.js';
|
|
13
14
|
import { readClaudeUsage } from './usage.js';
|
|
@@ -114,6 +115,7 @@ export const claudeFamily = {
|
|
|
114
115
|
},
|
|
115
116
|
loginState,
|
|
116
117
|
model,
|
|
118
|
+
modelChoice: claudeModelChoice,
|
|
117
119
|
identity: claudeIdentity,
|
|
118
120
|
readQuota: readClaudeQuota,
|
|
119
121
|
readUsage: readClaudeUsage,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The model and effort a Claude Code Account starts with (spec §3.19, ADR-0014). Reads only `model`,
|
|
2
|
+
// `effortLevel` and `modelSettings.<model>.effortLevel` of the Account's `settings.json`.
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { takeFlag } from '../shared/model-args.js';
|
|
6
|
+
const MAX_BYTES = 1_000_000;
|
|
7
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
8
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
9
|
+
async function configured(account, model, lastModel) {
|
|
10
|
+
try {
|
|
11
|
+
const file = join(account.dir, 'settings.json');
|
|
12
|
+
const st = await stat(file);
|
|
13
|
+
if (!st.isFile() || st.size > MAX_BYTES)
|
|
14
|
+
return {};
|
|
15
|
+
const s = JSON.parse(await readFile(file, 'utf8'));
|
|
16
|
+
if (!isObj(s))
|
|
17
|
+
return {};
|
|
18
|
+
const m = str(s.model);
|
|
19
|
+
const ms = isObj(s.modelSettings) ? s.modelSettings : {};
|
|
20
|
+
const per = (name) => name && isObj(ms[name]) ? str(ms[name].effortLevel) : undefined;
|
|
21
|
+
// The level saved for the model the session will run: the one passed in, else the file's own, else the last one used.
|
|
22
|
+
const effort = per(model ?? m ?? lastModel) ?? str(s.effortLevel);
|
|
23
|
+
return { ...(m ? { model: m } : {}), ...(effort ? { effort } : {}) };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const claudeModelChoice = {
|
|
30
|
+
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
31
|
+
models: ['fable', 'opus', 'sonnet', 'haiku'],
|
|
32
|
+
configured,
|
|
33
|
+
toArgs: (c) => [...(c.model ? ['--model', c.model] : []), ...(c.effort ? ['--effort', c.effort] : [])],
|
|
34
|
+
fromArgs(args) {
|
|
35
|
+
const m = takeFlag(args, ['--model']);
|
|
36
|
+
const e = takeFlag(m.rest, ['--effort']);
|
|
37
|
+
return {
|
|
38
|
+
choice: { ...(m.value ? { model: m.value } : {}), ...(e.value ? { effort: e.value } : {}) },
|
|
39
|
+
rest: e.rest,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -7,6 +7,8 @@ interface Entry {
|
|
|
7
7
|
output: number;
|
|
8
8
|
cacheRead: number;
|
|
9
9
|
cacheWrite: number;
|
|
10
|
+
model?: string;
|
|
11
|
+
effort?: string;
|
|
10
12
|
}
|
|
11
13
|
export declare function listJsonl(dir: string, out?: string[]): Promise<string[]>;
|
|
12
14
|
/** What one session log holds: its usage rows in file order, keyed for the "last line wins" dedupe. */
|
|
@@ -64,6 +64,13 @@ async function parseFile(file) {
|
|
|
64
64
|
output: count(usage.output_tokens),
|
|
65
65
|
cacheRead: count(usage.cache_read_input_tokens),
|
|
66
66
|
cacheWrite: count(usage.cache_creation_input_tokens),
|
|
67
|
+
// Claude Code logs a placeholder model for some local rows; it is not a model anyone chose.
|
|
68
|
+
...(typeof row.message?.model === 'string' &&
|
|
69
|
+
row.message.model &&
|
|
70
|
+
row.message.model !== '<synthetic>'
|
|
71
|
+
? { model: row.message.model }
|
|
72
|
+
: {}),
|
|
73
|
+
...(typeof row.effort === 'string' && row.effort ? { effort: row.effort } : {}),
|
|
67
74
|
},
|
|
68
75
|
]);
|
|
69
76
|
}
|
|
@@ -75,7 +82,7 @@ async function parseFile(file) {
|
|
|
75
82
|
return out;
|
|
76
83
|
}
|
|
77
84
|
/** Parsed session logs per Account directory; a file is parsed again only after it changed. */
|
|
78
|
-
export const claudeUsageMemo = new FileMemo({ name: 'claude-usage', version:
|
|
85
|
+
export const claudeUsageMemo = new FileMemo({ name: 'claude-usage', version: 2 });
|
|
79
86
|
export async function readClaudeUsage(account, ctx) {
|
|
80
87
|
const files = await listJsonl(join(account.dir, 'projects'));
|
|
81
88
|
if (!files.length)
|
|
@@ -113,6 +120,7 @@ export async function readClaudeUsage(account, ctx) {
|
|
|
113
120
|
const sessions = new Set();
|
|
114
121
|
const days = dailyBuckets(ctx.now);
|
|
115
122
|
let last = Number.NEGATIVE_INFINITY;
|
|
123
|
+
let lastTurn;
|
|
116
124
|
let inputTokens = 0;
|
|
117
125
|
let outputTokens = 0;
|
|
118
126
|
let cacheReadTokens = 0;
|
|
@@ -122,6 +130,8 @@ export async function readClaudeUsage(account, ctx) {
|
|
|
122
130
|
continue;
|
|
123
131
|
if (e.timestamp > last)
|
|
124
132
|
last = e.timestamp;
|
|
133
|
+
if (e.model && (!lastTurn || e.timestamp >= lastTurn.timestamp))
|
|
134
|
+
lastTurn = e;
|
|
125
135
|
if (e.timestamp < since)
|
|
126
136
|
continue;
|
|
127
137
|
sessions.add(e.session);
|
|
@@ -142,5 +152,7 @@ export async function readClaudeUsage(account, ctx) {
|
|
|
142
152
|
totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
143
153
|
daily: days.daily,
|
|
144
154
|
...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
|
|
155
|
+
...(lastTurn?.model ? { lastModel: lastTurn.model } : {}),
|
|
156
|
+
...(lastTurn?.effort ? { lastEffort: lastTurn.effort } : {}),
|
|
145
157
|
};
|
|
146
158
|
}
|
|
@@ -5,6 +5,7 @@ import { BUILTIN_LOGOS } from '../logos.js';
|
|
|
5
5
|
import { nestedFileWrittenAt, resumeSubcommandSession } from '../shared/sessions.js';
|
|
6
6
|
import { topLevelTomlString } from '../shared/toml.js';
|
|
7
7
|
import { codexHandoff } from './handoff.js';
|
|
8
|
+
import { codexModelChoice } from './model.js';
|
|
8
9
|
import { readCodexQuota, readCodexUsage } from './rollout.js';
|
|
9
10
|
import { readCodexTuning } from './tuning.js';
|
|
10
11
|
const FILE_STORE_KEY = 'cli_auth_credentials_store';
|
|
@@ -60,6 +61,7 @@ export const codexFamily = {
|
|
|
60
61
|
}
|
|
61
62
|
},
|
|
62
63
|
model: readModel,
|
|
64
|
+
modelChoice: codexModelChoice,
|
|
63
65
|
identity: codexIdentity,
|
|
64
66
|
readQuota: (account) => readCodexQuota(account.dir),
|
|
65
67
|
readUsage: (account, ctx) => readCodexUsage(account.dir, ctx.now),
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The model and effort a Codex Account starts with (spec §3.19, ADR-0014). Reads only the top-level `model` and
|
|
2
|
+
// `model_reasoning_effort` of the Account's `config.toml`.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { takeFlag } from '../shared/model-args.js';
|
|
6
|
+
import { topLevelTomlString } from '../shared/toml.js';
|
|
7
|
+
const EFFORT_KEY = 'model_reasoning_effort';
|
|
8
|
+
async function configured(account) {
|
|
9
|
+
try {
|
|
10
|
+
const text = await readFile(join(account.dir, 'config.toml'), 'utf8');
|
|
11
|
+
const model = topLevelTomlString(text, 'model');
|
|
12
|
+
const effort = topLevelTomlString(text, EFFORT_KEY);
|
|
13
|
+
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** `-c model_reasoning_effort=…` is one of possibly many `-c` overrides: only that one is taken out. */
|
|
20
|
+
function takeEffort(args) {
|
|
21
|
+
const rest = [];
|
|
22
|
+
let value;
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const a = args[i];
|
|
25
|
+
if (a === '--') {
|
|
26
|
+
rest.push(...args.slice(i));
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
const inline = a.startsWith('--config=') ? a.slice('--config='.length) : null;
|
|
30
|
+
const next = (a === '-c' || a === '--config') && i + 1 < args.length ? args[i + 1] : null;
|
|
31
|
+
const kv = inline ?? next;
|
|
32
|
+
if (kv?.startsWith(`${EFFORT_KEY}=`)) {
|
|
33
|
+
value = kv.slice(EFFORT_KEY.length + 1).replace(/^(["'])(.*)\1$/, '$2');
|
|
34
|
+
if (next !== null)
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
rest.push(a);
|
|
39
|
+
}
|
|
40
|
+
return value === undefined ? { rest } : { value, rest };
|
|
41
|
+
}
|
|
42
|
+
export const codexModelChoice = {
|
|
43
|
+
// Codex: "Available levels depend on the model and client"; these are the levels its docs name.
|
|
44
|
+
efforts: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
|
|
45
|
+
models: [],
|
|
46
|
+
configured,
|
|
47
|
+
toArgs: (c) => [
|
|
48
|
+
...(c.model ? ['-m', c.model] : []),
|
|
49
|
+
...(c.effort ? ['-c', `${EFFORT_KEY}="${c.effort}"`] : []),
|
|
50
|
+
],
|
|
51
|
+
fromArgs(args) {
|
|
52
|
+
const m = takeFlag(args, ['-m', '--model']);
|
|
53
|
+
const e = takeEffort(m.rest);
|
|
54
|
+
return {
|
|
55
|
+
choice: { ...(m.value ? { model: m.value } : {}), ...(e.value ? { effort: e.value } : {}) },
|
|
56
|
+
rest: e.rest,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -54,6 +54,11 @@ interface FileUsage {
|
|
|
54
54
|
turn: Totals | null;
|
|
55
55
|
}[];
|
|
56
56
|
deltas: Totals[];
|
|
57
|
+
context: {
|
|
58
|
+
at: number;
|
|
59
|
+
model?: string;
|
|
60
|
+
effort?: string;
|
|
61
|
+
} | null;
|
|
57
62
|
failed: boolean;
|
|
58
63
|
}
|
|
59
64
|
/** Parsed token totals per Account directory; a rollout is read again only after it changed. */
|