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.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +5 -0
  4. package/dist/src/cli/commands.js +67 -0
  5. package/dist/src/cli/json-schemas.js +30 -0
  6. package/dist/src/cli/main.js +5 -0
  7. package/dist/src/core/config.d.ts +14 -0
  8. package/dist/src/core/config.js +9 -0
  9. package/dist/src/core/handoff-enable.d.ts +52 -0
  10. package/dist/src/core/handoff-enable.js +169 -0
  11. package/dist/src/core/handoff-overview.d.ts +26 -0
  12. package/dist/src/core/handoff-overview.js +63 -0
  13. package/dist/src/core/handoff-run.js +2 -1
  14. package/dist/src/core/handoff-select.d.ts +2 -0
  15. package/dist/src/core/handoff-select.js +11 -5
  16. package/dist/src/core/model-choice.d.ts +41 -0
  17. package/dist/src/core/model-choice.js +128 -0
  18. package/dist/src/families/claude/index.js +2 -0
  19. package/dist/src/families/claude/model.d.ts +2 -0
  20. package/dist/src/families/claude/model.js +42 -0
  21. package/dist/src/families/claude/quota-cache.d.ts +7 -0
  22. package/dist/src/families/claude/quota-cache.js +40 -0
  23. package/dist/src/families/claude/usage.d.ts +2 -0
  24. package/dist/src/families/claude/usage.js +13 -1
  25. package/dist/src/families/codex/index.js +2 -0
  26. package/dist/src/families/codex/model.d.ts +2 -0
  27. package/dist/src/families/codex/model.js +59 -0
  28. package/dist/src/families/codex/rollout.d.ts +5 -0
  29. package/dist/src/families/codex/rollout.js +45 -3
  30. package/dist/src/families/grok/handoff.js +1 -1
  31. package/dist/src/families/shared/model-args.d.ts +7 -0
  32. package/dist/src/families/shared/model-args.js +28 -0
  33. package/dist/src/handoff/messages.js +1 -1
  34. package/dist/src/panel/handler.d.ts +3 -0
  35. package/dist/src/panel/handler.js +69 -0
  36. package/dist/src/panel/i18n.d.ts +67 -0
  37. package/dist/src/panel/i18n.js +134 -0
  38. package/dist/src/panel/page-script.js +328 -13
  39. package/dist/src/panel/page-styles.js +14 -1
  40. package/dist/src/panel/page.js +1 -0
  41. package/dist/src/quota/statusline-tap.js +13 -2
  42. package/dist/src/runtime.d.ts +5 -2
  43. package/dist/src/runtime.js +2 -1
  44. package/dist/src/types.d.ts +29 -0
  45. package/package.json +3 -2
  46. package/schemas/model.json +169 -0
  47. package/schemas/quota.json +6 -0
  48. package/skills/sideby/SKILL.md +2 -1
  49. 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,2 @@
1
+ import type { FamilyModelChoice } from '../../types.ts';
2
+ export declare const claudeModelChoice: FamilyModelChoice;
@@ -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: 1 });
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,2 @@
1
+ import type { FamilyModelChoice } from '../../types.ts';
2
+ export declare const codexModelChoice: FamilyModelChoice;
@@ -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
- return { times, events, deltas: fileDeltas(events), failed: read.failed };
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: 1 });
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}; run \`sideby doctor grok --fix\` to remove the copies in other Grok accounts`,
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)}%, so another account may take over soon. Write a handoff brief to ${brief} now (${TEMPLATE}). ${RULES} Keep it current after each step, then go on with the task.`;
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) {