sideby 0.3.0 → 0.4.1

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 (115) hide show
  1. package/README.md +27 -10
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +8 -1
  4. package/dist/src/cli/commands.js +332 -9
  5. package/dist/src/cli/format.d.ts +3 -1
  6. package/dist/src/cli/format.js +25 -0
  7. package/dist/src/cli/json-schemas.js +114 -0
  8. package/dist/src/cli/main.d.ts +5 -0
  9. package/dist/src/cli/main.js +40 -8
  10. package/dist/src/core/config.d.ts +40 -0
  11. package/dist/src/core/config.js +83 -11
  12. package/dist/src/core/file-memo.d.ts +23 -0
  13. package/dist/src/core/file-memo.js +37 -0
  14. package/dist/src/core/handoff-brief.d.ts +17 -0
  15. package/dist/src/core/handoff-brief.js +75 -0
  16. package/dist/src/core/handoff-chain.d.ts +74 -0
  17. package/dist/src/core/handoff-chain.js +74 -0
  18. package/dist/src/core/handoff-doctor.d.ts +13 -0
  19. package/dist/src/core/handoff-doctor.js +35 -0
  20. package/dist/src/core/handoff-run.d.ts +31 -0
  21. package/dist/src/core/handoff-run.js +346 -0
  22. package/dist/src/core/handoff-select.d.ts +42 -0
  23. package/dist/src/core/handoff-select.js +88 -0
  24. package/dist/src/core/handoff-status.d.ts +66 -0
  25. package/dist/src/core/handoff-status.js +198 -0
  26. package/dist/src/core/launch.d.ts +15 -0
  27. package/dist/src/core/launch.js +25 -2
  28. package/dist/src/core/memo-store.d.ts +20 -0
  29. package/dist/src/core/memo-store.js +92 -0
  30. package/dist/src/core/resume.d.ts +7 -2
  31. package/dist/src/core/resume.js +22 -9
  32. package/dist/src/core/tuning-apply.d.ts +63 -0
  33. package/dist/src/core/tuning-apply.js +264 -0
  34. package/dist/src/core/tuning.d.ts +113 -0
  35. package/dist/src/core/tuning.js +192 -0
  36. package/dist/src/families/claude/brief.d.ts +5 -0
  37. package/dist/src/families/claude/brief.js +63 -0
  38. package/dist/src/families/claude/handoff-hook.d.ts +2 -0
  39. package/dist/src/families/claude/handoff-hook.js +31 -0
  40. package/dist/src/families/claude/handoff.d.ts +5 -0
  41. package/dist/src/families/claude/handoff.js +115 -0
  42. package/dist/src/families/claude/index.js +8 -1
  43. package/dist/src/families/claude/tuning.d.ts +22 -0
  44. package/dist/src/families/claude/tuning.js +203 -0
  45. package/dist/src/families/claude/usage.d.ts +1 -0
  46. package/dist/src/families/claude/usage.js +2 -2
  47. package/dist/src/families/codex/brief.d.ts +5 -0
  48. package/dist/src/families/codex/brief.js +49 -0
  49. package/dist/src/families/codex/handoff-hook.d.ts +2 -0
  50. package/dist/src/families/codex/handoff-hook.js +16 -0
  51. package/dist/src/families/codex/handoff.d.ts +5 -0
  52. package/dist/src/families/codex/handoff.js +121 -0
  53. package/dist/src/families/codex/index.js +4 -0
  54. package/dist/src/families/codex/rate-limits.d.ts +16 -0
  55. package/dist/src/families/codex/rate-limits.js +92 -0
  56. package/dist/src/families/codex/rollout.d.ts +1 -2
  57. package/dist/src/families/codex/rollout.js +4 -48
  58. package/dist/src/families/codex/tuning.d.ts +16 -0
  59. package/dist/src/families/codex/tuning.js +145 -0
  60. package/dist/src/families/grok/brief.d.ts +5 -0
  61. package/dist/src/families/grok/brief.js +74 -0
  62. package/dist/src/families/grok/handoff-hook.d.ts +4 -0
  63. package/dist/src/families/grok/handoff-hook.js +26 -0
  64. package/dist/src/families/grok/handoff.d.ts +47 -0
  65. package/dist/src/families/grok/handoff.js +219 -0
  66. package/dist/src/families/grok/index.js +2 -0
  67. package/dist/src/families/pi/handoff.d.ts +2 -0
  68. package/dist/src/families/pi/handoff.js +13 -0
  69. package/dist/src/families/pi/index.js +2 -0
  70. package/dist/src/families/shared/brief.d.ts +24 -0
  71. package/dist/src/families/shared/brief.js +42 -0
  72. package/dist/src/families/shared/handoff-args.d.ts +18 -0
  73. package/dist/src/families/shared/handoff-args.js +57 -0
  74. package/dist/src/families/shared/sessions.d.ts +4 -0
  75. package/dist/src/families/shared/sessions.js +19 -0
  76. package/dist/src/families/shared/toml.d.ts +5 -0
  77. package/dist/src/families/shared/toml.js +30 -0
  78. package/dist/src/handoff/hook.d.ts +16 -0
  79. package/dist/src/handoff/hook.js +166 -0
  80. package/dist/src/handoff/messages.d.ts +7 -0
  81. package/dist/src/handoff/messages.js +27 -0
  82. package/dist/src/handoff/reader.d.ts +11 -0
  83. package/dist/src/handoff/reader.js +9 -0
  84. package/dist/src/panel/handler.d.ts +6 -0
  85. package/dist/src/panel/handler.js +110 -0
  86. package/dist/src/panel/i18n.d.ts +98 -0
  87. package/dist/src/panel/i18n.js +196 -0
  88. package/dist/src/panel/page-logic.d.ts +49 -0
  89. package/dist/src/panel/page-logic.js +67 -0
  90. package/dist/src/panel/page-script.js +461 -4
  91. package/dist/src/panel/page-styles.js +28 -2
  92. package/dist/src/panel/page.js +4 -1
  93. package/dist/src/quota/claude-statusline.js +4 -7
  94. package/dist/src/runtime.d.ts +40 -3
  95. package/dist/src/runtime.js +212 -41
  96. package/dist/src/types.d.ts +93 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +1 -0
  99. package/package.json +16 -4
  100. package/schemas/config.json +73 -0
  101. package/schemas/families.json +5 -1
  102. package/schemas/handoff-disable.json +106 -0
  103. package/schemas/handoff-enable.json +106 -0
  104. package/schemas/handoff-ready.json +23 -0
  105. package/schemas/handoff-setup.json +60 -0
  106. package/schemas/handoff-status.json +137 -0
  107. package/schemas/handoff-teardown.json +30 -0
  108. package/schemas/list.json +5 -1
  109. package/schemas/tune-change.json +126 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +5 -3
  112. package/skills/sideby/references/badge.md +1 -1
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. package/README.zh-CN.md +0 -112
@@ -0,0 +1,264 @@
1
+ // Applying a Tuning Suggestion: writes one key, `autoCompactWindow`, of an Account's `settings.json` (spec §3.18,
2
+ // ADR-0012). Everything else in the file is kept, nothing but that key is ever read out of it into an output, an
3
+ // error or the undo record, and the write goes through `writeIfUnchanged`. A symlinked `settings.json` (settings
4
+ // shared with the Main Account) is written at the real file it points to, and the plan names every Account that
5
+ // shares it; a link is never created, replaced or retargeted.
6
+ import { mkdir, readFile, rm, stat } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { UserError } from './errors.js';
9
+ import { fileHash, lstatOrNull, realpathOrNull, sha256, writeFileAtomic, writeIfUnchanged, } from './fs-safe.js';
10
+ import { MAX_COMPACT_WINDOW, MIN_COMPACT_WINDOW } from './tuning.js';
11
+ export const TUNE_KEY = 'autoCompactWindow';
12
+ export const MIN_WINDOW = MIN_COMPACT_WINDOW;
13
+ export const MAX_WINDOW = MAX_COMPACT_WINDOW;
14
+ const MAX_BYTES = 5_000_000;
15
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
16
+ function checkValue(value) {
17
+ if (value === null)
18
+ return;
19
+ if (!Number.isInteger(value) || value < MIN_WINDOW || value > MAX_WINDOW)
20
+ throw new UserError(`${value} is not a usable size: give a whole number of tokens from ${MIN_WINDOW} to ${MAX_WINDOW}, for example 160000`, { code: 'bad-value' });
21
+ }
22
+ function detectIndent(text) {
23
+ const m = /^([ \t]+)"/m.exec(text);
24
+ if (!m)
25
+ return 2;
26
+ const lead = m[1];
27
+ return lead.startsWith('\t') ? '\t' : lead.length;
28
+ }
29
+ function render(obj, original) {
30
+ const out = JSON.stringify(obj, null, detectIndent(original));
31
+ return original.endsWith('\n') || original === '' ? `${out}\n` : out;
32
+ }
33
+ async function readObject(file) {
34
+ const st = await stat(file).catch(() => null);
35
+ if (!st || !st.isFile())
36
+ return { error: 'it is not a regular file' };
37
+ if (st.size > MAX_BYTES)
38
+ return { error: 'it is too large to be a settings file' };
39
+ const text = await readFile(file, 'utf8');
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(text);
43
+ }
44
+ catch {
45
+ return { error: 'it is not valid JSON' };
46
+ }
47
+ if (!isObj(parsed))
48
+ return { error: 'it is not a JSON object' };
49
+ return { text, obj: parsed };
50
+ }
51
+ /** The sizes models save for themselves under `modelSettings`: only those `autoCompactWindow` numbers are read. */
52
+ function overridesOf(obj) {
53
+ const ms = obj.modelSettings;
54
+ if (!isObj(ms))
55
+ return [];
56
+ const out = [];
57
+ for (const [model, entry] of Object.entries(ms)) {
58
+ const v = isObj(entry) ? entry[TUNE_KEY] : undefined;
59
+ if (typeof v === 'number' && Number.isFinite(v))
60
+ out.push({ model, value: v });
61
+ }
62
+ return out;
63
+ }
64
+ /** Where the Account's settings really live, and whether `settings.json` is a link to it. */
65
+ async function locate(account) {
66
+ const path = join(account.dir, 'settings.json');
67
+ const lst = await lstatOrNull(path);
68
+ // Every path is canonical (no link anywhere in it), so two Accounts that share a file compare equal and the undo
69
+ // record is found from either of them, even when HOME itself sits behind a link.
70
+ if (!lst) {
71
+ const dir = (await realpathOrNull(account.dir)) ?? account.dir;
72
+ return { file: join(dir, 'settings.json'), link: false, exists: false };
73
+ }
74
+ const real = await realpathOrNull(path);
75
+ if (!lst.isSymbolicLink())
76
+ return { file: real ?? path, link: false, exists: true };
77
+ return real
78
+ ? { file: real, link: true, exists: true }
79
+ : { file: path, link: true, exists: false, broken: true };
80
+ }
81
+ function recordPath(ctx, file) {
82
+ return join(ctx.stateDir, 'tune', `${sha256(file).slice(0, 16)}.json`);
83
+ }
84
+ async function readRecord(ctx, file) {
85
+ try {
86
+ const r = JSON.parse(await readFile(recordPath(ctx, file), 'utf8'));
87
+ if (r.file === file &&
88
+ (r.applied === null || typeof r.applied === 'number') &&
89
+ (r.previous === null || typeof r.previous === 'number'))
90
+ return r;
91
+ }
92
+ catch {
93
+ // no record
94
+ }
95
+ return null;
96
+ }
97
+ /** What applying `value` to the Account would do. Writes nothing. */
98
+ export async function planTuneApply(account, value, accounts) {
99
+ checkValue(value);
100
+ const where = await locate(account);
101
+ const base = {
102
+ file: where.file,
103
+ link: where.link,
104
+ affected: [account.ref],
105
+ current: null,
106
+ next: value,
107
+ creates: false,
108
+ overrides: [],
109
+ };
110
+ if (where.broken)
111
+ return {
112
+ ...base,
113
+ status: 'blocked',
114
+ reason: 'settings.json is a link to a file that does not exist; fix the link first',
115
+ };
116
+ if (!where.exists)
117
+ return { ...base, status: value === null ? 'unchanged' : 'ready', creates: value !== null };
118
+ const read = await readObject(where.file);
119
+ if ('error' in read)
120
+ return {
121
+ ...base,
122
+ status: 'blocked',
123
+ reason: `settings.json cannot be changed safely: ${read.error}. Fix or move the file, then try again`,
124
+ };
125
+ const have = read.obj[TUNE_KEY];
126
+ const current = typeof have === 'number' && Number.isFinite(have) ? have : null;
127
+ const affected = [account.ref];
128
+ for (const other of accounts) {
129
+ if (other.ref === account.ref)
130
+ continue;
131
+ const loc = await locate(other);
132
+ if (loc.exists && loc.file === where.file)
133
+ affected.push(other.ref);
134
+ }
135
+ return {
136
+ ...base,
137
+ affected,
138
+ current,
139
+ overrides: overridesOf(read.obj),
140
+ status: current === value ? 'unchanged' : 'ready',
141
+ };
142
+ }
143
+ /** Plans, and writes only when the plan is `ready`. */
144
+ export async function applyTune(account, value, accounts, ctx) {
145
+ const plan = await planTuneApply(account, value, accounts);
146
+ if (plan.status === 'blocked')
147
+ return { ok: false, plan, message: plan.reason ?? 'blocked' };
148
+ if (plan.status === 'unchanged')
149
+ return { ok: true, plan, message: 'It is already set to that value; nothing changed.' };
150
+ let before = null;
151
+ let obj = {};
152
+ let mode = 0o600;
153
+ if (!plan.creates) {
154
+ const read = await readObject(plan.file);
155
+ if ('error' in read)
156
+ return { ok: false, plan, message: `settings.json changed: ${read.error}` };
157
+ before = read.text;
158
+ obj = read.obj;
159
+ mode = (await stat(plan.file)).mode & 0o777;
160
+ }
161
+ const have = typeof obj[TUNE_KEY] === 'number' ? obj[TUNE_KEY] : null;
162
+ // Applying again over what sideby itself wrote keeps the original value, so one undo goes all the way back.
163
+ const earlier = await readRecord(ctx, plan.file);
164
+ const chained = earlier !== null && (earlier.applied ?? null) === have;
165
+ const previous = chained ? earlier.previous : have;
166
+ const changed = { ...obj };
167
+ if (value === null)
168
+ delete changed[TUNE_KEY];
169
+ else
170
+ changed[TUNE_KEY] = value;
171
+ const next = render(changed, before ?? '');
172
+ const wrote = await writeIfUnchanged(plan.file, before === null ? null : sha256(before), next, mode);
173
+ if (!wrote)
174
+ return {
175
+ ok: false,
176
+ plan,
177
+ message: 'settings.json changed while sideby was writing, so nothing was changed. Try again.',
178
+ };
179
+ const record = {
180
+ file: plan.file,
181
+ previous,
182
+ applied: value,
183
+ created: chained ? earlier.created : plan.creates,
184
+ at: new Date().toISOString(),
185
+ };
186
+ await mkdir(join(ctx.stateDir, 'tune'), { recursive: true });
187
+ await writeFileAtomic(recordPath(ctx, plan.file), `${JSON.stringify(record, null, 2)}\n`, 0o600);
188
+ return {
189
+ ok: true,
190
+ plan,
191
+ message: 'Done. New sessions use it; sessions already running keep the old value.',
192
+ };
193
+ }
194
+ /** What could be undone for the Account: the record, only while the key still holds the value sideby wrote. */
195
+ export async function tuneUndoInfo(account, ctx) {
196
+ const where = await locate(account);
197
+ if (!where.exists)
198
+ return null;
199
+ const record = await readRecord(ctx, where.file);
200
+ if (!record)
201
+ return null;
202
+ const read = await readObject(where.file);
203
+ if ('error' in read || (read.obj[TUNE_KEY] ?? null) !== record.applied)
204
+ return null;
205
+ return record;
206
+ }
207
+ /** Puts the key back as it was, only while it still holds what sideby wrote. */
208
+ export async function undoTune(account, ctx) {
209
+ const where = await locate(account);
210
+ const record = where.exists ? await readRecord(ctx, where.file) : null;
211
+ if (!record)
212
+ return { ok: false, message: 'There is nothing from sideby to undo for this account.' };
213
+ const read = await readObject(where.file);
214
+ if ('error' in read)
215
+ return { ok: false, message: `settings.json cannot be changed safely: ${read.error}.` };
216
+ if ((read.obj[TUNE_KEY] ?? null) !== record.applied)
217
+ return { ok: false, message: 'The setting was changed after sideby set it, so it was left alone.' };
218
+ const obj = { ...read.obj };
219
+ if (record.previous === null)
220
+ delete obj[TUNE_KEY];
221
+ else
222
+ obj[TUNE_KEY] = record.previous;
223
+ if (record.created && Object.keys(obj).length === 0) {
224
+ // Same care as a write: delete only if the file is still what was read.
225
+ if ((await fileHash(where.file)) !== sha256(read.text))
226
+ return {
227
+ ok: false,
228
+ message: 'settings.json changed while sideby was working, so nothing was changed. Try again.',
229
+ };
230
+ await rm(where.file, { force: true });
231
+ }
232
+ else {
233
+ const mode = (await stat(where.file)).mode & 0o777;
234
+ const wrote = await writeIfUnchanged(where.file, sha256(read.text), render(obj, read.text), mode);
235
+ if (!wrote)
236
+ return {
237
+ ok: false,
238
+ message: 'settings.json changed while sideby was writing, so nothing was changed. Try again.',
239
+ };
240
+ }
241
+ await rm(recordPath(ctx, where.file), { force: true });
242
+ return { ok: true, message: 'Undone. New sessions go back to the previous setting.' };
243
+ }
244
+ /** The real file an Account's setting lives in (canonical, so Accounts sharing it get the same answer). */
245
+ export async function settingsFileOf(account) {
246
+ return (await locate(account)).file;
247
+ }
248
+ /**
249
+ * The size in the Account's settings file now (null: the default) and the sizes models save for themselves. Reads
250
+ * only those `autoCompactWindow` numbers.
251
+ */
252
+ export async function readTuneSettings(account) {
253
+ const where = await locate(account);
254
+ if (!where.exists)
255
+ return { current: null, overrides: [] };
256
+ const read = await readObject(where.file);
257
+ if ('error' in read)
258
+ return { current: null, overrides: [] };
259
+ const v = read.obj[TUNE_KEY];
260
+ return {
261
+ current: typeof v === 'number' && Number.isFinite(v) ? v : null,
262
+ overrides: overridesOf(read.obj),
263
+ };
264
+ }
@@ -0,0 +1,113 @@
1
+ import type { TuningModel, TuningResult } from '../types.ts';
2
+ /** Relative cost of a token, input = 1. These are Anthropic's published multipliers, not prices (spec §3.18). */
3
+ export declare const WEIGHT: {
4
+ readonly input: 1;
5
+ readonly cacheRead: 0.1;
6
+ readonly write5m: 1.25;
7
+ readonly write1h: 2;
8
+ readonly output: 5;
9
+ };
10
+ /**
11
+ * The sizes Claude Code accepts for the auto-compact window: 100K to 1M tokens, never above the model's context
12
+ * window (https://code.claude.com/docs/en/model-config#set-the-auto-compact-window).
13
+ */
14
+ export declare const MIN_COMPACT_WINDOW = 100000;
15
+ export declare const MAX_COMPACT_WINDOW = 1000000;
16
+ /** A model with fewer turns than this in the window gets no suggestion. */
17
+ export declare const MIN_CALLS = 200;
18
+ /** Candidate thresholds as a share of the model's context window. */
19
+ export declare const CANDIDATE_PERCENTS: readonly [4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95];
20
+ /** One assistant turn: the whole context it ran with, what it wrote, and what it added to the prompt cache. */
21
+ export interface Turn {
22
+ ts: number;
23
+ /** `input + cache read + cache write` tokens of the turn. */
24
+ ctx: number;
25
+ out: number;
26
+ write5m: number;
27
+ write1h: number;
28
+ }
29
+ /** One compaction the Host recorded. */
30
+ export interface Compaction {
31
+ ts: number;
32
+ trigger: 'auto' | 'manual' | 'unknown';
33
+ pre: number;
34
+ post: number;
35
+ }
36
+ export interface ModelRecords {
37
+ model: string;
38
+ /** Turns of this model per session, each in time order. */
39
+ sessions: Turn[][];
40
+ compactions: Compaction[];
41
+ contextWindow: number;
42
+ /** Where the Host compacts when nothing is set (documented), or null when that is not known. */
43
+ defaultThreshold?: number | null;
44
+ }
45
+ export interface SummarizeOptions {
46
+ /** The Account's configured threshold in tokens; null is the Host default. */
47
+ configured: number | null;
48
+ /** The model's cost weights are known (a Claude model), so a suggestion is possible. */
49
+ known: boolean;
50
+ /** The Family may suggest at all (Codex only observes). */
51
+ mayRecommend: boolean;
52
+ }
53
+ export declare function summarizeModel(rec: ModelRecords, o: SummarizeOptions): TuningModel;
54
+ /** The value stored under `key`, creating and storing `make()` first when there is none. */
55
+ export declare function getOrCreate<K, V>(map: Map<K, V>, key: K, make: () => V): V;
56
+ export interface TuningOptions {
57
+ /** The Account's configured threshold in tokens; null is the Host default. */
58
+ configured: number | null;
59
+ /** Whether a model's cost weights are known (spec §3.18); an unknown model gets no suggestion. */
60
+ known: (model: string) => boolean;
61
+ /** The Family may suggest at all (Codex only observes). */
62
+ mayRecommend: boolean;
63
+ }
64
+ /** The whole result for an Account: every model summarized, the busiest first. */
65
+ export declare function tuningResult(days: number, records: ModelRecords[], o: TuningOptions): TuningResult;
66
+ /**
67
+ * The one suggestion for Accounts that share a settings file: the busiest model across them that has one, with the
68
+ * Account it was seen on. Their other numbers stay as information.
69
+ */
70
+ export declare function groupSuggestion(entries: readonly {
71
+ ref: string;
72
+ tuning: TuningResult;
73
+ }[]): {
74
+ ref: string;
75
+ model: string;
76
+ threshold: number;
77
+ savedPercent: number | null;
78
+ calls: number;
79
+ } | null;
80
+ /** One Account's result, as the Panel and `sideby tune` list it. */
81
+ export interface TuneEntry {
82
+ ref: string;
83
+ family: string;
84
+ tuning: TuningResult;
85
+ }
86
+ /** The Accounts that share one settings file: they get one suggestion and one thing to apply. */
87
+ export interface TuneGroup {
88
+ /** The size in the file is outside what Claude Code accepts, so it cannot be offered again. */
89
+ outOfRange: boolean;
90
+ /** Models that save an auto-compact size of their own, which wins over the file's size for them. */
91
+ overrides: {
92
+ model: string;
93
+ value: number;
94
+ }[];
95
+ /** The real settings file. */
96
+ file: string;
97
+ members: string[];
98
+ /** The member to ask for the change and the undo (the one the suggestion came from). */
99
+ target: string;
100
+ /** The size in the file now, or null for the default. */
101
+ current: number | null;
102
+ pick: {
103
+ ref: string;
104
+ model: string;
105
+ threshold: number;
106
+ savedPercent: number | null;
107
+ calls: number;
108
+ } | null;
109
+ undo: {
110
+ value: number | null;
111
+ previous: number | null;
112
+ } | null;
113
+ }
@@ -0,0 +1,192 @@
1
+ /** Relative cost of a token, input = 1. These are Anthropic's published multipliers, not prices (spec §3.18). */
2
+ export const WEIGHT = { input: 1, cacheRead: 0.1, write5m: 1.25, write1h: 2, output: 5 };
3
+ /**
4
+ * The sizes Claude Code accepts for the auto-compact window: 100K to 1M tokens, never above the model's context
5
+ * window (https://code.claude.com/docs/en/model-config#set-the-auto-compact-window).
6
+ */
7
+ export const MIN_COMPACT_WINDOW = 100_000;
8
+ export const MAX_COMPACT_WINDOW = 1_000_000;
9
+ /** A model with fewer turns than this in the window gets no suggestion. */
10
+ export const MIN_CALLS = 200;
11
+ /** Candidate thresholds as a share of the model's context window. */
12
+ export const CANDIDATE_PERCENTS = [
13
+ 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95,
14
+ ];
15
+ /** A suggestion must save at least this share against the current threshold; otherwise it is already the best. */
16
+ const MIN_SAVING_PERCENT = 1;
17
+ function median(values) {
18
+ if (!values.length)
19
+ return null;
20
+ const v = [...values].sort((a, b) => a - b);
21
+ const mid = v.length >> 1;
22
+ return v.length % 2 ? (v[mid] ?? 0) : ((v[mid - 1] ?? 0) + (v[mid] ?? 0)) / 2;
23
+ }
24
+ function writeWeight(t) {
25
+ const n = t.write5m + t.write1h;
26
+ return n > 0 ? (t.write5m * WEIGHT.write5m + t.write1h * WEIGHT.write1h) / n : WEIGHT.write5m;
27
+ }
28
+ /** Weighted cost of one session's turns if the Host compacted whenever the context reached `threshold`. */
29
+ function replaySession(turns, threshold, post) {
30
+ const first = turns[0];
31
+ if (!first)
32
+ return 0;
33
+ const growth = [];
34
+ for (let i = 1; i < turns.length; i++) {
35
+ const d = (turns[i]?.ctx ?? 0) - (turns[i - 1]?.ctx ?? 0);
36
+ if (d >= 0)
37
+ growth.push(d);
38
+ }
39
+ // A recorded drop means the Host compacted there; what the turn then added is unknown, so use the usual growth.
40
+ const usual = median(growth) ?? 0;
41
+ let ctx = first.ctx;
42
+ let cost = ctx * writeWeight(first) + first.out * WEIGHT.output;
43
+ for (let i = 1; i < turns.length; i++) {
44
+ const t = turns[i];
45
+ const prev = turns[i - 1];
46
+ if (!t || !prev)
47
+ continue;
48
+ const d = t.ctx - prev.ctx;
49
+ const g = d >= 0 ? d : usual;
50
+ if (ctx + g >= threshold) {
51
+ // The whole context goes in as input, a summary of about `post` tokens comes out, and the next turn
52
+ // writes that summary to the cache again.
53
+ cost += ctx * WEIGHT.input + post * WEIGHT.output + post * WEIGHT.write5m;
54
+ ctx = post + g;
55
+ }
56
+ else {
57
+ cost += ctx * WEIGHT.cacheRead;
58
+ ctx += g;
59
+ }
60
+ cost += g * writeWeight(t) + t.out * WEIGHT.output;
61
+ }
62
+ return cost;
63
+ }
64
+ function replay(sessions, threshold, post) {
65
+ let total = 0;
66
+ for (const s of sessions)
67
+ total += replaySession(s, threshold, post);
68
+ return total;
69
+ }
70
+ const roundTokens = (n) => Math.round(n / 1000) * 1000;
71
+ export function summarizeModel(rec, o) {
72
+ const calls = rec.sessions.reduce((n, s) => n + s.length, 0);
73
+ const auto = rec.compactions.filter((c) => c.trigger === 'auto');
74
+ const pre = rec.compactions.map((c) => c.pre).filter((n) => n > 0);
75
+ const posts = rec.compactions.map((c) => c.post).filter((n) => n > 0);
76
+ const autoPre = auto.map((c) => c.pre).filter((n) => n > 0);
77
+ // What is in effect: the configured size, else what the Host did, else where it compacts by default.
78
+ // A size outside 100K-1M is not one Claude Code runs with (the documentation does not say what it does with it),
79
+ // so it is not taken as the baseline.
80
+ const usable = o.configured !== null && o.configured >= MIN_COMPACT_WINDOW && o.configured <= MAX_COMPACT_WINDOW;
81
+ const effective = usable && o.configured !== null
82
+ ? Math.min(o.configured, rec.contextWindow)
83
+ : (median(autoPre) ?? rec.defaultThreshold ?? null);
84
+ const base = {
85
+ model: rec.model,
86
+ calls,
87
+ contextWindow: rec.contextWindow,
88
+ configured: o.configured,
89
+ effective: effective === null ? null : Math.round(effective),
90
+ compactions: {
91
+ total: rec.compactions.length,
92
+ auto: auto.length,
93
+ manual: rec.compactions.filter((c) => c.trigger === 'manual').length,
94
+ preTokensP50: pre.length ? Math.round(median(pre)) : null,
95
+ preTokensMax: pre.length ? Math.max(...pre) : null,
96
+ },
97
+ suggestion: null,
98
+ reason: 'observe-only',
99
+ };
100
+ const stop = (reason) => ({ ...base, reason });
101
+ if (!o.mayRecommend)
102
+ return stop('observe-only');
103
+ if (!o.known)
104
+ return stop('unknown-model');
105
+ if (calls < MIN_CALLS)
106
+ return stop('insufficient-calls');
107
+ // What is left after a compaction can only be read from compactions the Host recorded.
108
+ const post = median(posts);
109
+ if (post === null)
110
+ return stop('no-compactions');
111
+ // Only sizes Claude Code accepts: the lowest allowed one is always a candidate.
112
+ const top = Math.min(rec.contextWindow, MAX_COMPACT_WINDOW);
113
+ const candidates = new Set([MIN_COMPACT_WINDOW]);
114
+ for (const pct of CANDIDATE_PERCENTS)
115
+ candidates.add(roundTokens((rec.contextWindow * pct) / 100));
116
+ const sizes = [...candidates].filter((t) => t >= MIN_COMPACT_WINDOW && t <= top).sort((x, y) => x - y);
117
+ if (!sizes.length)
118
+ return stop('no-optimum');
119
+ let best = null;
120
+ for (const threshold of sizes) {
121
+ const cost = replay(rec.sessions, threshold, post);
122
+ // On a tie the larger threshold wins: fewer compactions for the same cost.
123
+ if (!best || cost < best.cost || (cost === best.cost && threshold > best.threshold))
124
+ best = { threshold, cost };
125
+ }
126
+ if (!best)
127
+ return stop('insufficient-calls');
128
+ // The lowest allowed size can still be the cheapest: that is a real answer, "as low as Claude Code lets you".
129
+ if (effective === null)
130
+ return {
131
+ ...base,
132
+ reason: 'suggested',
133
+ suggestion: { threshold: best.threshold, savedPercent: null, savedWeighted: null },
134
+ };
135
+ const current = replay(rec.sessions, effective, post);
136
+ const saved = current - best.cost;
137
+ const percent = current > 0 ? (saved / current) * 100 : 0;
138
+ if (percent < MIN_SAVING_PERCENT)
139
+ return stop('already-best');
140
+ return {
141
+ ...base,
142
+ reason: 'suggested',
143
+ suggestion: {
144
+ threshold: best.threshold,
145
+ savedPercent: Math.round(percent * 10) / 10,
146
+ savedWeighted: Math.round(saved),
147
+ },
148
+ };
149
+ }
150
+ /** The value stored under `key`, creating and storing `make()` first when there is none. */
151
+ export function getOrCreate(map, key, make) {
152
+ const have = map.get(key);
153
+ if (have !== undefined)
154
+ return have;
155
+ const made = make();
156
+ map.set(key, made);
157
+ return made;
158
+ }
159
+ /** The whole result for an Account: every model summarized, the busiest first. */
160
+ export function tuningResult(days, records, o) {
161
+ const models = records.map((rec) => summarizeModel(rec, {
162
+ configured: o.configured,
163
+ known: o.known(rec.model),
164
+ mayRecommend: o.mayRecommend,
165
+ }));
166
+ models.sort((a, b) => b.calls - a.calls);
167
+ return { status: 'ok', days, estimate: true, models };
168
+ }
169
+ /**
170
+ * The one suggestion for Accounts that share a settings file: the busiest model across them that has one, with the
171
+ * Account it was seen on. Their other numbers stay as information.
172
+ */
173
+ export function groupSuggestion(entries) {
174
+ let best = null;
175
+ for (const e of entries) {
176
+ if (e.tuning.status !== 'ok')
177
+ continue;
178
+ for (const m of e.tuning.models) {
179
+ if (!m.suggestion)
180
+ continue;
181
+ if (!best || m.calls > best.calls)
182
+ best = {
183
+ ref: e.ref,
184
+ model: m.model,
185
+ threshold: m.suggestion.threshold,
186
+ savedPercent: m.suggestion.savedPercent,
187
+ calls: m.calls,
188
+ };
189
+ }
190
+ }
191
+ return best;
192
+ }
@@ -0,0 +1,5 @@
1
+ import type { Account } from '../../types.ts';
2
+ export declare function claudeBrief(account: Account, session: {
3
+ id?: string;
4
+ transcriptPath?: string;
5
+ }): Promise<string | undefined>;
@@ -0,0 +1,63 @@
1
+ // A Brief from a Claude Code session file (`projects/<dir>/<id>.jsonl`), for when the session could not write one.
2
+ import { readdir, readFile, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { DigestBuilder, formatDigest } from '../shared/brief.js';
5
+ const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
6
+ async function findSession(dir, id) {
7
+ try {
8
+ for (const d of await readdir(join(dir, 'projects'), { withFileTypes: true })) {
9
+ if (!d.isDirectory())
10
+ continue;
11
+ const p = join(dir, 'projects', d.name, `${id}.jsonl`);
12
+ try {
13
+ await stat(p);
14
+ return p;
15
+ }
16
+ catch {
17
+ // not in this project
18
+ }
19
+ }
20
+ }
21
+ catch {
22
+ // no projects yet
23
+ }
24
+ return undefined;
25
+ }
26
+ export async function claudeBrief(account, session) {
27
+ const file = session.transcriptPath ?? (session.id ? await findSession(account.dir, session.id) : undefined);
28
+ if (!file)
29
+ return undefined;
30
+ let text;
31
+ try {
32
+ text = await readFile(file, 'utf8');
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ const b = new DigestBuilder();
38
+ for (const line of text.split('\n')) {
39
+ let o;
40
+ try {
41
+ o = JSON.parse(line);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ if ((o.type !== 'user' && o.type !== 'assistant') || o.isMeta === true)
47
+ continue;
48
+ const content = o.message?.content;
49
+ const blocks = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : [];
50
+ for (const blk of blocks) {
51
+ if (blk.type === 'text' && typeof blk.text === 'string') {
52
+ if (o.type === 'user')
53
+ b.user(blk.text);
54
+ else
55
+ b.assistant(blk.text);
56
+ }
57
+ else if (blk.type === 'tool_use' && typeof blk.name === 'string' && EDITS.has(blk.name))
58
+ b.file(blk.input?.file_path ?? blk.input?.notebook_path);
59
+ }
60
+ }
61
+ const d = b.result();
62
+ return d ? formatDigest(d) : undefined;
63
+ }
@@ -0,0 +1,2 @@
1
+ import { type HookReader } from '../../handoff/reader.ts';
2
+ export declare const claudeHookReader: HookReader;
@@ -0,0 +1,31 @@
1
+ // Claude Code for the handoff hook (spec §3.17): Quota Pressure from the status line tap's cache, and a used-up
2
+ // Quota told apart from a transient rate limit. Leaf module: the hook runs on every tool call.
3
+ import { quotaPressure } from '../../core/quota-levels.js';
4
+ import { field } from '../../handoff/reader.js';
5
+ import { accountNameFromEnv, cacheStateDir, readQuotaCache } from './quota-cache.js';
6
+ /** Claude Code's wording when a subscription window is used up (as opposed to an overloaded server). */
7
+ const USED_UP = /usage limit|limit reached|out of (extra )?usage/i;
8
+ export const claudeHookReader = {
9
+ async pressure(_input, env, now) {
10
+ const name = accountNameFromEnv(env);
11
+ if (!name)
12
+ return null;
13
+ try {
14
+ const cache = await readQuotaCache(cacheStateDir(env), name);
15
+ if (cache.status !== 'ok')
16
+ return null;
17
+ return quotaPressure({ ...cache, source: 'statusline' }, now);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ },
23
+ limitHit(input, pressure, threshold) {
24
+ if (field(input, 'error') !== 'rate_limit')
25
+ return false;
26
+ if (pressure !== null && pressure >= threshold)
27
+ return true;
28
+ const text = `${field(input, 'error_details', 'errorDetails') ?? ''} ${field(input, 'last_assistant_message', 'lastAssistantMessage') ?? ''}`;
29
+ return USED_UP.test(text);
30
+ },
31
+ };
@@ -0,0 +1,5 @@
1
+ import type { FamilyHandoff } from '../../types.ts';
2
+ import { type ArgRules } from '../shared/handoff-args.ts';
3
+ /** From `claude --help`: options that take a value, so it is never mistaken for the old prompt. */
4
+ export declare const CLAUDE_ARGS: ArgRules;
5
+ export declare const claudeHandoff: FamilyHandoff;