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
|
@@ -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
|
+
};
|
|
@@ -12,6 +12,13 @@ export declare function cacheStateDir(env: Env): string;
|
|
|
12
12
|
export declare function accountNameFromEnv(env: Env): string | null;
|
|
13
13
|
/** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
|
|
14
14
|
export declare function parseRateLimits(input: string, observedAt: Date): ClaudeQuotaCache | null;
|
|
15
|
+
/**
|
|
16
|
+
* Merges a status line reading into the cached windows. Each session reports the Quota of its own last request, so an
|
|
17
|
+
* idle session of the same Account reports older numbers, sometimes of a window that has already reset: within one
|
|
18
|
+
* window the higher use wins, otherwise the later reset; a window the reading lacks is kept. Null when the reading
|
|
19
|
+
* only repeats older numbers, so nothing is written and `observedAt` keeps the time of the newer reading.
|
|
20
|
+
*/
|
|
21
|
+
export declare function mergeQuotaCache(cached: readonly QuotaWindow[], next: ClaudeQuotaCache): ClaudeQuotaCache | null;
|
|
15
22
|
/** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
|
|
16
23
|
export declare function writeQuotaCache(stateDir: string, name: string, cache: ClaudeQuotaCache): Promise<void>;
|
|
17
24
|
export type CacheRead = {
|
|
@@ -64,6 +64,46 @@ export function parseRateLimits(input, observedAt) {
|
|
|
64
64
|
}
|
|
65
65
|
return windows.length ? { version: 1, observedAt: observedAt.toISOString(), windows } : null;
|
|
66
66
|
}
|
|
67
|
+
/** Two readings belong to one window when their resets are less than a quarter of the window apart. */
|
|
68
|
+
function sameWindow(a, b) {
|
|
69
|
+
return Math.abs(Date.parse(a.resetsAt) - Date.parse(b.resetsAt)) < (a.windowMinutes * 60_000) / 4;
|
|
70
|
+
}
|
|
71
|
+
const sameReading = (a, b) => a.label === b.label &&
|
|
72
|
+
a.windowMinutes === b.windowMinutes &&
|
|
73
|
+
a.usedPercent === b.usedPercent &&
|
|
74
|
+
a.resetsAt === b.resetsAt;
|
|
75
|
+
const order = (label) => {
|
|
76
|
+
const i = WINDOWS.findIndex((w) => w.label === label);
|
|
77
|
+
return i < 0 ? WINDOWS.length : i;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Merges a status line reading into the cached windows. Each session reports the Quota of its own last request, so an
|
|
81
|
+
* idle session of the same Account reports older numbers, sometimes of a window that has already reset: within one
|
|
82
|
+
* window the higher use wins, otherwise the later reset; a window the reading lacks is kept. Null when the reading
|
|
83
|
+
* only repeats older numbers, so nothing is written and `observedAt` keeps the time of the newer reading.
|
|
84
|
+
*/
|
|
85
|
+
export function mergeQuotaCache(cached, next) {
|
|
86
|
+
let older = false;
|
|
87
|
+
const windows = next.windows.map((w) => {
|
|
88
|
+
const c = cached.find((x) => x.label === w.label);
|
|
89
|
+
if (!c)
|
|
90
|
+
return w;
|
|
91
|
+
const keep = sameWindow(c, w)
|
|
92
|
+
? c.usedPercent > w.usedPercent
|
|
93
|
+
: Date.parse(c.resetsAt) > Date.parse(w.resetsAt);
|
|
94
|
+
if (keep)
|
|
95
|
+
older = true;
|
|
96
|
+
return keep ? c : w;
|
|
97
|
+
});
|
|
98
|
+
for (const c of cached)
|
|
99
|
+
if (!windows.some((w) => w.label === c.label))
|
|
100
|
+
windows.push(c);
|
|
101
|
+
windows.sort((a, b) => order(a.label) - order(b.label));
|
|
102
|
+
const unchanged = windows.length === cached.length && windows.every((w) => cached.some((c) => sameReading(c, w)));
|
|
103
|
+
if (older && unchanged)
|
|
104
|
+
return null;
|
|
105
|
+
return { ...next, windows };
|
|
106
|
+
}
|
|
67
107
|
/** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
|
|
68
108
|
export async function writeQuotaCache(stateDir, name, cache) {
|
|
69
109
|
const file = claudeQuotaCacheFile(stateDir, name);
|
|
@@ -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. */
|
|
@@ -174,6 +174,42 @@ export function fileTokens(events) {
|
|
|
174
174
|
return null;
|
|
175
175
|
return fileDeltas(events).reduce(plus, ZERO);
|
|
176
176
|
}
|
|
177
|
+
/** The newest `turn_context` of a rollout: the model and effort its last turn ran with. */
|
|
178
|
+
async function lastContext(file, read) {
|
|
179
|
+
const lines = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
|
|
180
|
+
let best = null;
|
|
181
|
+
try {
|
|
182
|
+
for await (const line of lines) {
|
|
183
|
+
if (!line.includes('turn_context'))
|
|
184
|
+
continue;
|
|
185
|
+
let o;
|
|
186
|
+
try {
|
|
187
|
+
o = JSON.parse(line);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!isObj(o) || o.type !== 'turn_context' || !isObj(o.payload))
|
|
193
|
+
continue;
|
|
194
|
+
const at = typeof o.timestamp === 'string' ? Date.parse(o.timestamp) : Number.NaN;
|
|
195
|
+
if (!Number.isFinite(at) || (best && at < best.at))
|
|
196
|
+
continue;
|
|
197
|
+
const { model, effort } = o.payload;
|
|
198
|
+
best = {
|
|
199
|
+
at,
|
|
200
|
+
...(typeof model === 'string' && model ? { model } : {}),
|
|
201
|
+
...(typeof effort === 'string' && effort ? { effort } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
read.failed = true;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
lines.close();
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
177
213
|
async function fileUsage(file) {
|
|
178
214
|
const read = { failed: false };
|
|
179
215
|
const times = [];
|
|
@@ -184,10 +220,11 @@ async function fileUsage(file) {
|
|
|
184
220
|
if (t)
|
|
185
221
|
events.push({ at, ...t });
|
|
186
222
|
}
|
|
187
|
-
|
|
223
|
+
const context = await lastContext(file, read);
|
|
224
|
+
return { times, events, deltas: fileDeltas(events), context, failed: read.failed };
|
|
188
225
|
}
|
|
189
226
|
/** Parsed token totals per Account directory; a rollout is read again only after it changed. */
|
|
190
|
-
export const codexUsageMemo = new FileMemo({ name: 'codex-usage', version:
|
|
227
|
+
export const codexUsageMemo = new FileMemo({ name: 'codex-usage', version: 2 });
|
|
191
228
|
/**
|
|
192
229
|
* Token totals for events in the last `days` x 24 h, from rollout files modified in that window (mtime only
|
|
193
230
|
* picks which files to read). Each event counts its fileDeltas increment, so a long session resumed today
|
|
@@ -207,13 +244,16 @@ export async function readCodexUsage(dir, now, days = USAGE_DAYS) {
|
|
|
207
244
|
let sawTokenCount = false;
|
|
208
245
|
let sawTotals = false;
|
|
209
246
|
let last = Number.NEGATIVE_INFINITY;
|
|
247
|
+
let lastContextSeen = null;
|
|
210
248
|
const daily = dailyBuckets(now, days);
|
|
211
249
|
const sum = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
212
250
|
const memo = codexUsageMemo.pass(dir);
|
|
213
251
|
for (const f of files) {
|
|
214
|
-
const { times, events, deltas } = await memo.get(f.path, f.stats, () => fileUsage(f.path), (v) => !v.failed);
|
|
252
|
+
const { times, events, deltas, context } = await memo.get(f.path, f.stats, () => fileUsage(f.path), (v) => !v.failed);
|
|
215
253
|
if (times.length)
|
|
216
254
|
sawTokenCount = true;
|
|
255
|
+
if (context?.model && context.at <= end && (!lastContextSeen || context.at >= lastContextSeen.at))
|
|
256
|
+
lastContextSeen = context;
|
|
217
257
|
for (const at of times)
|
|
218
258
|
if (at <= end && at > last)
|
|
219
259
|
last = at;
|
|
@@ -250,5 +290,7 @@ export async function readCodexUsage(dir, now, days = USAGE_DAYS) {
|
|
|
250
290
|
totalTokens: sum.inputTokens + sum.outputTokens + sum.cacheReadTokens + sum.cacheWriteTokens,
|
|
251
291
|
daily: daily.daily,
|
|
252
292
|
...(Number.isFinite(last) ? { lastActivityAt: new Date(last).toISOString() } : {}),
|
|
293
|
+
...(lastContextSeen?.model ? { lastModel: lastContextSeen.model } : {}),
|
|
294
|
+
...(lastContextSeen?.effort ? { lastEffort: lastContextSeen.effort } : {}),
|
|
253
295
|
};
|
|
254
296
|
}
|
|
@@ -191,7 +191,7 @@ export const grokHookSetup = {
|
|
|
191
191
|
await rmdir(join(ctx.home, '.grok', 'hooks')).catch(() => { });
|
|
192
192
|
return {
|
|
193
193
|
ok: true,
|
|
194
|
-
message: `removed ${shown};
|
|
194
|
+
message: `removed ${shown}; the copies in other Grok accounts (hooks/sideby-handoff.json) stay and do nothing outside a sideby hand-over; delete them yourself if you no longer want them`,
|
|
195
195
|
};
|
|
196
196
|
},
|
|
197
197
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Takes every occurrence of the flags in `names` out of `args`; the last value wins. */
|
|
2
|
+
export declare function takeFlag(args: readonly string[], names: readonly string[]): {
|
|
3
|
+
value?: string;
|
|
4
|
+
rest: string[];
|
|
5
|
+
};
|
|
6
|
+
/** A model name sideby passes on: letters, digits and `._:/[]-` (for example `claude-opus-5-5[1m]`). */
|
|
7
|
+
export declare const MODEL_NAME: RegExp;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Reading and writing a model or effort choice as Host arguments (spec §3.19): the value of `--name v`, `--name=v`
|
|
2
|
+
// or `-x v`, wherever it is. Built-in Families use it for `modelChoice`.
|
|
3
|
+
/** Takes every occurrence of the flags in `names` out of `args`; the last value wins. */
|
|
4
|
+
export function takeFlag(args, names) {
|
|
5
|
+
const rest = [];
|
|
6
|
+
let value;
|
|
7
|
+
for (let i = 0; i < args.length; i++) {
|
|
8
|
+
const a = args[i];
|
|
9
|
+
// Past `--` everything is the Host's prompt text, not an option.
|
|
10
|
+
if (a === '--') {
|
|
11
|
+
rest.push(...args.slice(i));
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
const eq = names.find((n) => n.startsWith('--') && a.startsWith(`${n}=`));
|
|
15
|
+
if (eq) {
|
|
16
|
+
value = a.slice(eq.length + 1);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (names.includes(a) && i + 1 < args.length) {
|
|
20
|
+
value = args[++i];
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
rest.push(a);
|
|
24
|
+
}
|
|
25
|
+
return value === undefined ? { rest } : { value, rest };
|
|
26
|
+
}
|
|
27
|
+
/** A model name sideby passes on: letters, digits and `._:/[]-` (for example `claude-opus-5-5[1m]`). */
|
|
28
|
+
export const MODEL_NAME = /^[A-Za-z0-9._:/[\]-]{1,100}$/;
|
|
@@ -9,7 +9,7 @@ const TEMPLATE = [
|
|
|
9
9
|
].join('; ');
|
|
10
10
|
const RULES = 'Reference specs, ADRs, commits and diffs by path instead of repeating them, list the skills the next agent should use, and never include keys, passwords or personal data.';
|
|
11
11
|
export function prepareMessage(brief, pressure) {
|
|
12
|
-
return `sideby: this account's quota is at ${Math.round(pressure)}
|
|
12
|
+
return `sideby: this account's quota is at ${Math.round(pressure)}%. Write a handoff brief to ${brief} now (${TEMPLATE}). ${RULES} Keep it current after each step, then go on with the task. Do not tell the user that the work will move to another account: sideby decides that and tells them itself.`;
|
|
13
13
|
}
|
|
14
14
|
export function thresholdMessage(brief, pressure) {
|
|
15
15
|
return `sideby: this account's quota is at ${Math.round(pressure)}%. Finish the current step, update the handoff brief at ${brief} (${TEMPLATE}; ${RULES}), then end your turn. sideby will start the next account from that brief.`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { type HandoffSettings } from '../core/config.ts';
|
|
3
|
+
import { type AccountModel } from '../core/model-choice.ts';
|
|
3
4
|
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
4
5
|
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
5
6
|
import { type PanelTheme } from './theme.ts';
|
|
@@ -47,6 +48,8 @@ export type PanelAccount = (AccountStatus | (Account & Partial<AccountStatus>))
|
|
|
47
48
|
aliases?: string[];
|
|
48
49
|
/** Host arguments of those aliases that add any (alias → arguments); absent when none do. */
|
|
49
50
|
aliasArgs?: Record<string, string[]>;
|
|
51
|
+
/** The model and effort it starts with and where each comes from (spec §3.19); absent for Families without a choice. */
|
|
52
|
+
modelInfo?: Omit<AccountModel, 'ref' | 'family'>;
|
|
50
53
|
};
|
|
51
54
|
export type { DoctorHistory };
|
|
52
55
|
/** Counts of failing and warning Findings per checked Account, from the Doctor history. */
|
|
@@ -4,7 +4,10 @@ import { aliasesByAccount } from '../core/accounts.js';
|
|
|
4
4
|
import { aliasArgs, aliasRefs } from '../core/config.js';
|
|
5
5
|
import { UserError } from '../core/errors.js';
|
|
6
6
|
import { planHandoff } from '../core/handoff.js';
|
|
7
|
+
import { applyHandoffEnable, planHandoffEnable } from '../core/handoff-enable.js';
|
|
8
|
+
import { handoffOverview } from '../core/handoff-overview.js';
|
|
7
9
|
import { configureHandoff, handoffStatus } from '../core/handoff-status.js';
|
|
10
|
+
import { accountModel, setAccountModel } from '../core/model-choice.js';
|
|
8
11
|
import { tildify } from '../core/paths.js';
|
|
9
12
|
import { MIN_CALLS } from '../core/tuning.js';
|
|
10
13
|
import { buildId, packageVersion } from '../core/version.js';
|
|
@@ -168,8 +171,23 @@ async function accountState(rt, account, families) {
|
|
|
168
171
|
login: 'unknown',
|
|
169
172
|
};
|
|
170
173
|
errors.push(...(status?.problems ?? []));
|
|
174
|
+
let modelInfo;
|
|
175
|
+
if (rt.families.get(account.family)?.modelChoice) {
|
|
176
|
+
const last = usage?.status === 'ok'
|
|
177
|
+
? {
|
|
178
|
+
...(usage.lastModel ? { model: usage.lastModel } : {}),
|
|
179
|
+
...(usage.lastEffort ? { effort: usage.lastEffort } : {}),
|
|
180
|
+
}
|
|
181
|
+
: {};
|
|
182
|
+
const info = await guard(accountModel(rt, account, last));
|
|
183
|
+
if (info) {
|
|
184
|
+
const { ref: _r, family: _f, ...rest } = info;
|
|
185
|
+
modelInfo = rest;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
171
188
|
return {
|
|
172
189
|
...base,
|
|
190
|
+
...(modelInfo ? { modelInfo } : {}),
|
|
173
191
|
...(quota ? { quota } : {}),
|
|
174
192
|
...(usage ? { usage } : {}),
|
|
175
193
|
...(errors.length ? { error: errors.join('; ') } : {}),
|
|
@@ -393,6 +411,57 @@ export function createPanelHandler(opts) {
|
|
|
393
411
|
return { ...r, file: tildify(r.file, rt.paths.home) };
|
|
394
412
|
},
|
|
395
413
|
},
|
|
414
|
+
// Model and effort per Account (spec §3.19, ADR-0014): without `confirm` only the config diff.
|
|
415
|
+
// `model` / `effort`: left out keeps it, null goes back to the Host's default, a string sets it.
|
|
416
|
+
'/api/account/model': {
|
|
417
|
+
writes: (body) => body.confirm === true,
|
|
418
|
+
async run(body) {
|
|
419
|
+
const account = optString(body, 'account');
|
|
420
|
+
if (!account)
|
|
421
|
+
throw new HttpError(400, '"account" is required');
|
|
422
|
+
const change = {};
|
|
423
|
+
for (const k of ['model', 'effort']) {
|
|
424
|
+
const v = body[k];
|
|
425
|
+
if (v === undefined)
|
|
426
|
+
continue;
|
|
427
|
+
if (v !== null && typeof v !== 'string')
|
|
428
|
+
throw new HttpError(400, `"${k}" must be a string, or null for the default`);
|
|
429
|
+
change[k] = v;
|
|
430
|
+
}
|
|
431
|
+
const rt = await getRuntime();
|
|
432
|
+
const found = await rt.resolve(account);
|
|
433
|
+
const r = await setAccountModel(rt, found, change, { apply: optBool(body, 'confirm') });
|
|
434
|
+
return { ref: found.ref, ...r, file: tildify(r.file, rt.paths.home) };
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
// The Auto Handoff page (spec §3.17): where work would go next and the last hand-overs. Reads only.
|
|
438
|
+
'/api/handoff/overview': {
|
|
439
|
+
writes: () => false,
|
|
440
|
+
async run() {
|
|
441
|
+
return handoffOverview(await getRuntime());
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
// Turning it on in one step (ADR-0013): without `confirm` only the plan; with it, every step in order.
|
|
445
|
+
'/api/handoff/enable': {
|
|
446
|
+
writes: (body) => body.confirm === true,
|
|
447
|
+
async run(body) {
|
|
448
|
+
// Left out keeps the current setting; true or false sets it.
|
|
449
|
+
const sameFamily = body.sameFamily === undefined ? undefined : optBool(body, 'sameFamily');
|
|
450
|
+
const rt = await getRuntime();
|
|
451
|
+
const home = rt.paths.home;
|
|
452
|
+
const show = (p) => ({
|
|
453
|
+
...p,
|
|
454
|
+
steps: p.steps.map((s) => (s.file ? { ...s, file: tildify(s.file, home) } : s)),
|
|
455
|
+
});
|
|
456
|
+
const opts = sameFamily === undefined ? {} : { sameFamily };
|
|
457
|
+
if (!optBool(body, 'confirm'))
|
|
458
|
+
return show(await planHandoffEnable(rt, opts));
|
|
459
|
+
// `expect`: the key of the plan the person saw; a different plan now is shown again, not applied.
|
|
460
|
+
const expect = optString(body, 'expect');
|
|
461
|
+
const r = await applyHandoffEnable(rt, expect === undefined ? opts : { ...opts, expect });
|
|
462
|
+
return { ...r, plan: show(r.plan) };
|
|
463
|
+
},
|
|
464
|
+
},
|
|
396
465
|
// Undo for the setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
|
|
397
466
|
'/api/quota/teardown': {
|
|
398
467
|
async run(body) {
|