sideby 0.2.2 → 0.4.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 (116) 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 +330 -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 +113 -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 +41 -0
  11. package/dist/src/core/config.js +86 -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 +22 -1
  27. package/dist/src/core/launch.js +54 -4
  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 +51 -0
  33. package/dist/src/core/tuning-apply.js +237 -0
  34. package/dist/src/core/tuning.d.ts +98 -0
  35. package/dist/src/core/tuning.js +176 -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 +187 -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 +6 -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 +96 -0
  87. package/dist/src/panel/i18n.js +192 -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 +454 -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 +226 -43
  96. package/dist/src/types.d.ts +101 -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 +77 -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 +108 -0
  110. package/schemas/tune.json +243 -0
  111. package/skills/sideby/SKILL.md +7 -4
  112. package/skills/sideby/references/badge.md +31 -0
  113. package/skills/sideby/references/diagnose.md +4 -0
  114. package/skills/sideby/references/handoff.md +24 -2
  115. package/skills/sideby/references/setup.md +3 -2
  116. package/README.zh-CN.md +0 -112
@@ -4,7 +4,7 @@
4
4
  import { mkdir, readFile, rm, stat } from 'node:fs/promises';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { lstatOrNull, realpathOrNull, sha256, writeFileAtomic, writeIfUnchanged } from '../core/fs-safe.js';
7
- import { which } from '../core/launch.js';
7
+ import { sidebyBinProblem } from '../core/launch.js';
8
8
  import { tildify } from '../core/paths.js';
9
9
  import { CLAUDE_MAIN_DIR } from '../families/claude/layout.js';
10
10
  import { ORIG_FLAG, TAP_COMMAND } from './statusline-tap.js';
@@ -73,12 +73,9 @@ async function analyze(ctx) {
73
73
  return {
74
74
  plan: { status: 'enabled', file: target, diff: '', message: `already enabled in ${targetName}${note}` },
75
75
  };
76
- const bin = await which('sideby', ctx.env);
77
- if (!bin)
78
- return blocked(`sideby is not on PATH, and the status line runs \`${TAP_COMMAND}\` on every refresh; install it with \`npm i -g sideby\` and retry`, target);
79
- const real = (await realpathOrNull(bin)) ?? bin;
80
- if (bin.includes('/_npx/') || real.includes('/_npx/'))
81
- return blocked(`sideby on PATH comes from the npx cache (${bin}), which can disappear; install it with \`npm i -g sideby\` and retry`, target);
76
+ const problem = await sidebyBinProblem(ctx.env, `the status line runs \`${TAP_COMMAND}\` on every refresh`);
77
+ if (problem)
78
+ return blocked(problem, target);
82
79
  const statusLine = current
83
80
  ? { type: 'command', ...current, command: wrapCommand(command) }
84
81
  : { type: 'command', command: TAP_COMMAND };
@@ -1,23 +1,31 @@
1
1
  import { type AccountRefError } from './core/accounts.ts';
2
- import { type Config } from './core/config.ts';
2
+ import { type Config, type HandoffSettings } from './core/config.ts';
3
3
  import { type CreateResult } from './core/create.ts';
4
4
  import { type DoctorReport } from './core/doctor.ts';
5
5
  import { UserError } from './core/errors.ts';
6
6
  import { type HandoffPlan } from './core/handoff.ts';
7
+ import { type HandoffDecision } from './core/handoff-select.ts';
7
8
  import { type DoctorHistory } from './core/last-doctor.ts';
8
9
  import { type HostLaunch, type PreparedLaunch } from './core/launch.ts';
9
10
  import { type Paths } from './core/paths.ts';
10
11
  import { type Shell, type ShellInitWrite } from './core/shell-init.ts';
12
+ import { type TuneEntry, type TuneGroup } from './core/tuning.ts';
13
+ import { type TuneApplyPlan, type TuneChange, type TuneUndoRecord } from './core/tuning-apply.ts';
11
14
  import { type BuiltinPlugin, type LoadedPlugin, type PluginLoadError } from './plugins/loader.ts';
12
- import type { Account, AccountIdentity, Env, FamilyDef, LoginState, QuotaResult, QuotaSetupPlan, ReadContext, ShareMode, UsageResult } from './types.ts';
15
+ import type { Account, AccountIdentity, Env, FamilyDef, LoginState, QuotaResult, QuotaSetupPlan, ReadContext, ShareMode, TuningResult, UsageResult } from './types.ts';
13
16
  export type { AccountIdentity, AccountRefError, DoctorHistory, Shell, ShellInitWrite };
14
17
  /** How `sideby resume` starts the Host (spec §3.15). */
15
18
  export interface ResumeLaunch {
16
19
  launch: HostLaunch;
17
20
  /** The session the Host arguments resume by id, when they name one. */
18
21
  sessionId?: string;
19
- /** Set when the session lives in a non-main Account and `launch` starts that Account as `run` would. */
22
+ /**
23
+ * Set when the session lives in a non-main Account and `launch` starts that Account as `run` would, or with
24
+ * `newSession` the Account (main included) that started a session the Host never saved.
25
+ */
20
26
  account?: Account;
27
+ /** Set when the session was never saved: `launch` starts a new session in `account` without the resume (ADR-0009). */
28
+ newSession?: true;
21
29
  }
22
30
  /** What happened to the alias asked for with a new Account. */
23
31
  export interface AliasResult {
@@ -52,6 +60,8 @@ export interface FamilyInfo {
52
60
  installed: boolean;
53
61
  /** Plugin that provides the Family. */
54
62
  plugin: string;
63
+ /** The Family can estimate a Compaction Threshold (spec §3.18). */
64
+ tuning: boolean;
55
65
  }
56
66
  /** One Shared Item as `sideby families` reports it (spec §3.16). */
57
67
  export interface SharedItemInfo {
@@ -147,6 +157,8 @@ export interface Runtime {
147
157
  }[]>;
148
158
  /** One Family's quota setup; throws UnknownFamilyError or a UserError when the Family has none. */
149
159
  quotaSetup(familyId: string): BoundQuotaSetup;
160
+ /** One Family's handoff hook setup (spec §3.17, Grok); throws a UserError when the Family needs none. */
161
+ handoffSetup(familyId: string): BoundQuotaSetup;
150
162
  /**
151
163
  * Creates the Account, then (when `alias` is given) adds it to the config `aliases`, then rewrites every
152
164
  * `shellInitFile`. An alias that is invalid or taken is refused before anything is created; a failure to write
@@ -177,6 +189,24 @@ export interface Runtime {
177
189
  resumeLaunch(familyId: string, userArgs: readonly string[]): Promise<ResumeLaunch>;
178
190
  quota(account: Account): Promise<QuotaResult>;
179
191
  usage(account: Account): Promise<UsageResult>;
192
+ /** Estimated Compaction Threshold per model for one Account (spec §3.18); read-only. */
193
+ tuning(account: Account): Promise<TuningResult>;
194
+ /** Suggestions grouped by settings file (Accounts sharing a file share one), with every listed Account's details. */
195
+ tuneGroups(only?: Account): Promise<{
196
+ accounts: TuneEntry[];
197
+ groups: TuneGroup[];
198
+ }>;
199
+ /** What using a suggested size would change in the Account's settings; writes nothing (spec §3.18, ADR-0012). */
200
+ tuneApplyPlan(account: Account, value: number | null): Promise<TuneApplyPlan>;
201
+ /** Writes the size into the Account's settings (one key; null goes back to the default), and remembers how to undo it. */
202
+ tuneApply(account: Account, value: number | null): Promise<TuneChange>;
203
+ /** Puts the setting back as it was, only while it still holds what sideby wrote. */
204
+ tuneUndo(account: Account): Promise<{
205
+ ok: boolean;
206
+ message: string;
207
+ }>;
208
+ /** What could be undone for the Account, or null. */
209
+ tuneUndoInfo(account: Account): Promise<TuneUndoRecord | null>;
180
210
  /**
181
211
  * Which Account of a Family to move on to (spec §3.13). Throws a UserError with code `no-quota-source`
182
212
  * when the Family cannot tell, `no-accounts` when it has none.
@@ -184,6 +214,13 @@ export interface Runtime {
184
214
  handoff(familyId: string, opts?: {
185
215
  includeApi?: boolean;
186
216
  }): Promise<HandoffPlan>;
217
+ /** The config `handoff` settings with every default filled in (spec §3.17). */
218
+ handoffSettings(): HandoffSettings;
219
+ /**
220
+ * Where an Auto Handoff from `origin` would go now (spec §3.17 "选号"): every Account of every Family is a
221
+ * candidate; `used` lists the chain's Accounts so far.
222
+ */
223
+ handoffDecision(origin: Account, used: readonly string[]): Promise<HandoffDecision>;
187
224
  readContext(): ReadContext;
188
225
  }
189
226
  export declare class UnknownFamilyError extends UserError {
@@ -1,17 +1,22 @@
1
1
  // The one service layer the CLI and the Panel share. Frozen contract for v0.1 (plan: 审核修订).
2
- import { discoverAccounts, resolveRef } from './core/accounts.js';
3
- import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, loadConfig, removeConfigAlias, } from './core/config.js';
2
+ import { aliasesByAccount, discoverAccounts, resolveRef } from './core/accounts.js';
3
+ import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, handoffSettings, loadConfig, removeConfigAlias, } from './core/config.js';
4
4
  import { createAccount } from './core/create.js';
5
5
  import { applyFixes, runDoctor } from './core/doctor.js';
6
6
  import { UserError } from './core/errors.js';
7
7
  import { planHandoff } from './core/handoff.js';
8
+ import { handoffFindings } from './core/handoff-doctor.js';
9
+ import { selectNext } from './core/handoff-select.js';
8
10
  import { identityOf } from './core/identity.js';
9
11
  import { mergeDoctorHistory, readDoctorHistory, writeDoctorHistory, } from './core/last-doctor.js';
10
12
  import { prepareLaunch, which } from './core/launch.js';
13
+ import { memoStore } from './core/memo-store.js';
11
14
  import { resolvePaths } from './core/paths.js';
12
15
  import { findResumeRoute } from './core/resume.js';
13
16
  import { shareMeaning } from './core/share-meaning.js';
14
17
  import { SHELLS, shellInitScript, writeShellInitFile, } from './core/shell-init.js';
18
+ import { groupSuggestion } from './core/tuning.js';
19
+ import { applyTune, currentTuneValue, planTuneApply, settingsFileOf, tuneUndoInfo, undoTune, } from './core/tuning-apply.js';
15
20
  import { packageVersion } from './core/version.js';
16
21
  import { BUILTIN_PLUGINS } from './families/index.js';
17
22
  import { HookBus } from './plugins/bus.js';
@@ -36,6 +41,7 @@ async function readIdentity(f, account) {
36
41
  export async function createRuntime(opts = {}) {
37
42
  const env = opts.env ?? process.env;
38
43
  const paths = resolvePaths(env);
44
+ const memos = memoStore(paths.stateDir);
39
45
  const config = await loadConfig(paths.configFile);
40
46
  const bus = new HookBus();
41
47
  const loaded = await loadPlugins({
@@ -60,12 +66,62 @@ export async function createRuntime(opts = {}) {
60
66
  if (f)
61
67
  families.set(id, { ...f, sharedItems: [...f.sharedItems, ...extra] });
62
68
  }
69
+ /**
70
+ * `SIDEBY_LABEL` for a Launch: the alias it was started through; else the Account's aliases, those without Host
71
+ * arguments first, then by name; else the ref.
72
+ */
73
+ const launchLabel = (ref, account, all) => {
74
+ if (!ref.includes(':') && ownAlias(ref) !== undefined)
75
+ return ref;
76
+ const names = aliasesByAccount(aliasRefs(config.aliases), all).get(account.ref) ?? [];
77
+ const plain = names.filter((n) => aliasArgs(ownAlias(n)).length === 0);
78
+ return plain[0] ?? names[0] ?? account.ref;
79
+ };
63
80
  const accounts = async () => {
64
81
  const out = [];
65
82
  for (const f of families.values())
66
83
  out.push(...(await discoverAccounts(f, paths.home, ignore)));
67
84
  return out;
68
85
  };
86
+ const loginOf = async (a) => {
87
+ if (a.kind === 'api')
88
+ return 'not-needed';
89
+ try {
90
+ return (await familyOf(a.family).loginState?.(a)) ?? 'unknown';
91
+ }
92
+ catch {
93
+ return 'unknown';
94
+ }
95
+ };
96
+ /** A Family's one-time setup bound to this Runtime; a setup that throws is reported, never crashes the caller. */
97
+ const bindSetup = (f, setup) => {
98
+ // The message is the error's own text; setup implementations must not put file contents into it.
99
+ const blocked = (err) => ({
100
+ status: 'blocked',
101
+ file: '',
102
+ diff: '',
103
+ message: err?.message ?? String(err),
104
+ });
105
+ return {
106
+ family: f.id,
107
+ summary: setup.summary,
108
+ // Promise.resolve().then also catches a setup that throws synchronously.
109
+ plan: () => Promise.resolve()
110
+ .then(() => setup.plan(readContext()))
111
+ .catch(blocked),
112
+ apply: () => Promise.resolve()
113
+ .then(() => setup.apply(readContext()))
114
+ .catch(blocked),
115
+ teardown: () => Promise.resolve()
116
+ .then(() => setup.teardown(readContext()))
117
+ .catch((err) => ({ ok: false, message: err?.message ?? String(err) })),
118
+ };
119
+ };
120
+ // Only Claude Code has a suggestion to use; Codex only observes (spec §3.18).
121
+ const onlyClaude = (account) => {
122
+ if (account.family !== 'claude')
123
+ throw new UserError(`${account.ref} has no suggested size to use yet: only Claude Code accounts do. Run \`sideby tune ${account.ref}\` to see what is observed`, { code: 'no-suggestion' });
124
+ };
69
125
  const readContext = () => ({
70
126
  home: paths.home,
71
127
  now: now(),
@@ -103,6 +159,7 @@ export async function createRuntime(opts = {}) {
103
159
  installUrl: f.installUrl,
104
160
  installed: (await which(f.bin, env)) !== null,
105
161
  plugin: loaded.familyOwner.get(f.id) ?? 'unknown',
162
+ tuning: Boolean(f.readTuning),
106
163
  })));
107
164
  },
108
165
  async familyDetails() {
@@ -198,10 +255,26 @@ export async function createRuntime(opts = {}) {
198
255
  // of that Family keeps its skill warning.
199
256
  skillVersion: packageVersion(),
200
257
  };
201
- let report = await runDoctor(input);
258
+ // Auto Handoff warnings (spec §3.17) join the general Findings; they never fail Doctor.
259
+ const all = await accounts();
260
+ const withHandoff = async (r) => {
261
+ const claude = fams.get('claude');
262
+ const extra = await handoffFindings({
263
+ settings: handoffSettings(config),
264
+ families: [...fams.keys()],
265
+ accounts: all,
266
+ aliases: aliasRefs(config.aliases),
267
+ ...(claude?.quotaSetup
268
+ ? { claudeTap: (await bindSetup(claude, claude.quotaSetup).plan()).status === 'enabled' }
269
+ : {}),
270
+ home: paths.home,
271
+ });
272
+ return extra.length ? { ...r, general: [...r.general, ...extra] } : r;
273
+ };
274
+ let report = await withHandoff(await runDoctor(input));
202
275
  if (o.fix) {
203
276
  const fixes = await applyFixes(report);
204
- report = { ...(await runDoctor(input)), fixes };
277
+ report = { ...(await withHandoff(await runDoctor(input))), fixes };
205
278
  }
206
279
  const history = mergeDoctorHistory(await readDoctorHistory(paths.stateDir), report, scope, now());
207
280
  if (o.persist !== false)
@@ -221,34 +294,15 @@ export async function createRuntime(opts = {}) {
221
294
  },
222
295
  quotaSetup(familyId) {
223
296
  const f = familyOf(familyId);
224
- const setup = f.quotaSetup;
225
- if (!setup)
297
+ if (!f.quotaSetup)
226
298
  throw new UserError(`${f.title} needs no quota setup`);
227
- // One rule for every adapter: a setup that throws is reported, never crashes the caller. The message
228
- // is the error's own text; setup implementations must not put file contents into it.
229
- const blocked = (err) => ({
230
- status: 'blocked',
231
- file: '',
232
- diff: '',
233
- message: err?.message ?? String(err),
234
- });
235
- return {
236
- family: f.id,
237
- summary: setup.summary,
238
- // Promise.resolve().then also catches a setup that throws synchronously.
239
- plan: () => Promise.resolve()
240
- .then(() => setup.plan(readContext()))
241
- .catch(blocked),
242
- apply: () => Promise.resolve()
243
- .then(() => setup.apply(readContext()))
244
- .catch(blocked),
245
- teardown: () => Promise.resolve()
246
- .then(() => setup.teardown(readContext()))
247
- .catch((err) => ({
248
- ok: false,
249
- message: err?.message ?? String(err),
250
- })),
251
- };
299
+ return bindSetup(f, f.quotaSetup);
300
+ },
301
+ handoffSetup(familyId) {
302
+ const f = familyOf(familyId);
303
+ if (!f.handoff?.hookSetup)
304
+ throw new UserError(`${f.title} needs no handoff setup: ${f.handoff?.starts.length ? 'sideby adds its hooks when it starts the account' : 'it cannot start a handoff, only take one over'}`);
305
+ return bindSetup(f, f.handoff.hookSetup);
252
306
  },
253
307
  async createAccount(familyId, name, o = {}) {
254
308
  const family = familyOf(familyId);
@@ -338,8 +392,11 @@ export async function createRuntime(opts = {}) {
338
392
  return out;
339
393
  },
340
394
  async prepareLaunch(ref, userArgs, command) {
341
- const account = resolveRef(ref, await accounts(), aliasRefs(config.aliases));
395
+ const all = await accounts();
396
+ const account = resolveRef(ref, all, aliasRefs(config.aliases));
342
397
  return prepareLaunch({
398
+ label: launchLabel(ref, account, all),
399
+ title: config.accountTitle === true,
343
400
  account,
344
401
  family: familyOf(account.family),
345
402
  baseEnv: env,
@@ -360,7 +417,14 @@ export async function createRuntime(opts = {}) {
360
417
  return { launch: host };
361
418
  const route = await findResumeRoute(family, await accounts(), userArgs);
362
419
  const id = route.sessionId ? { sessionId: route.sessionId } : {};
363
- if (!route.account || route.account.isMain)
420
+ if (route.neverSaved && route.account && family.withoutResume) {
421
+ const args = family.withoutResume(userArgs);
422
+ const launch = route.account.isMain
423
+ ? { ...host, args }
424
+ : await rt.prepareLaunch(route.account.ref, args, 'run');
425
+ return { launch, ...id, account: route.account, newSession: true };
426
+ }
427
+ if (!route.account || route.account.isMain || route.neverSaved)
364
428
  return { launch: host, ...id };
365
429
  return {
366
430
  launch: await rt.prepareLaunch(route.account.ref, userArgs, 'run'),
@@ -374,23 +438,109 @@ export async function createRuntime(opts = {}) {
374
438
  const f = familyOf(account.family);
375
439
  if (!f.readQuota)
376
440
  return { status: 'unavailable', reason: 'no-source' };
441
+ await memos.ready();
377
442
  try {
378
443
  return await f.readQuota(account, readContext());
379
444
  }
380
445
  catch (err) {
381
446
  return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
382
447
  }
448
+ finally {
449
+ memos.saveSoon();
450
+ }
383
451
  },
384
452
  async usage(account) {
385
453
  const f = familyOf(account.family);
386
454
  if (!f.readUsage)
387
455
  return { status: 'unavailable', reason: 'no-source' };
456
+ await memos.ready();
388
457
  try {
389
458
  return await f.readUsage(account, readContext());
390
459
  }
391
460
  catch (err) {
392
461
  return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
393
462
  }
463
+ finally {
464
+ memos.saveSoon();
465
+ }
466
+ },
467
+ async tuneGroups(only) {
468
+ const tunable = new Set((await rt.familyInfo()).filter((f) => f.tuning).map((f) => f.id));
469
+ const all = (await accounts()).filter((a) => tunable.has(a.family));
470
+ const files = new Map();
471
+ for (const a of all) {
472
+ if (a.family !== 'claude')
473
+ continue;
474
+ const file = await settingsFileOf(a);
475
+ files.set(file, [...(files.get(file) ?? []), a]);
476
+ }
477
+ const wanted = only
478
+ ? [...files.entries()].filter(([, m]) => m.some((a) => a.ref === only.ref))
479
+ : [...files.entries()];
480
+ const entries = new Map();
481
+ const entryOf = async (a) => {
482
+ const have = entries.get(a.ref);
483
+ if (have)
484
+ return have;
485
+ const made = { ref: a.ref, family: a.family, tuning: await rt.tuning(a) };
486
+ entries.set(a.ref, made);
487
+ return made;
488
+ };
489
+ const groups = [];
490
+ for (const [file, members] of wanted) {
491
+ const list = [];
492
+ for (const m of members)
493
+ list.push(await entryOf(m));
494
+ const pick = groupSuggestion(list);
495
+ const current = await currentTuneValue(members[0]);
496
+ const target = members.find((m) => m.ref === pick?.ref) ?? members[0];
497
+ const undo = await tuneUndoInfo(target, readContext());
498
+ groups.push({
499
+ file,
500
+ members: members.map((m) => m.ref),
501
+ target: target.ref,
502
+ current,
503
+ pick,
504
+ undo: undo ? { value: undo.applied, previous: undo.previous } : null,
505
+ });
506
+ }
507
+ const shown = [];
508
+ // Asking about one Account also lists the others that share its file: the suggestion is for the whole group.
509
+ const refs = only ? new Set([only.ref, ...wanted.flatMap(([, m]) => m.map((a) => a.ref))]) : null;
510
+ for (const a of all)
511
+ if (!refs || refs.has(a.ref))
512
+ shown.push(await entryOf(a));
513
+ return { accounts: shown, groups };
514
+ },
515
+ async tuneApplyPlan(account, value) {
516
+ onlyClaude(account);
517
+ return planTuneApply(account, value, await accounts());
518
+ },
519
+ async tuneApply(account, value) {
520
+ onlyClaude(account);
521
+ return applyTune(account, value, await accounts(), readContext());
522
+ },
523
+ async tuneUndo(account) {
524
+ onlyClaude(account);
525
+ return undoTune(account, readContext());
526
+ },
527
+ async tuneUndoInfo(account) {
528
+ return account.family === 'claude' ? tuneUndoInfo(account, readContext()) : null;
529
+ },
530
+ async tuning(account) {
531
+ const f = familyOf(account.family);
532
+ if (!f.readTuning)
533
+ return { status: 'unavailable', reason: 'no-source' };
534
+ await memos.ready();
535
+ try {
536
+ return await f.readTuning(account, readContext());
537
+ }
538
+ catch (err) {
539
+ return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
540
+ }
541
+ finally {
542
+ memos.saveSoon();
543
+ }
394
544
  },
395
545
  async handoff(familyId, o = {}) {
396
546
  const f = familyOf(familyId);
@@ -402,22 +552,55 @@ export async function createRuntime(opts = {}) {
402
552
  code: 'no-accounts',
403
553
  });
404
554
  // Only the login state and Quota: no identity, model or Host lookups are needed to rank.
405
- const loginOf = async (a) => {
406
- if (a.kind === 'api')
407
- return 'not-needed';
408
- try {
409
- return (await f.loginState?.(a)) ?? 'unknown';
410
- }
411
- catch {
412
- return 'unknown';
413
- }
414
- };
415
555
  const candidates = await Promise.all(list.map(async (a) => {
416
556
  const [login, quota] = await Promise.all([loginOf(a), rt.quota(a)]);
417
557
  return { ref: a.ref, name: a.name, isMain: a.isMain, kind: a.kind, login, quota };
418
558
  }));
419
559
  return planHandoff(f.id, candidates, readContext().now, o);
420
560
  },
561
+ handoffSettings: () => handoffSettings(config),
562
+ async handoffDecision(origin, used) {
563
+ const candidates = await Promise.all((await accounts()).map(async (a) => {
564
+ const f = familyOf(a.family);
565
+ const [installed, login, quota, identity] = await Promise.all([
566
+ which(f.bin, env),
567
+ loginOf(a),
568
+ rt.quota(a),
569
+ readIdentity(f, a),
570
+ ]);
571
+ let receive = false;
572
+ try {
573
+ receive = Boolean(f.handoff &&
574
+ (await f.handoff.eligibility(config.accounts?.[a.ref]?.args ?? [], a, env)).receive);
575
+ }
576
+ catch {
577
+ // a Family that cannot tell does not take over
578
+ }
579
+ return {
580
+ ref: a.ref,
581
+ family: a.family,
582
+ name: a.name,
583
+ isMain: a.isMain,
584
+ kind: a.kind,
585
+ login,
586
+ quota,
587
+ ...(identity ? { identity } : {}),
588
+ installed: installed !== null,
589
+ receive,
590
+ };
591
+ }));
592
+ const self = candidates.find((c) => c.ref === origin.ref);
593
+ if (!self)
594
+ throw new UserError(`${origin.ref} is not an account sideby can find; run \`sideby list\``);
595
+ return selectNext({
596
+ origin: self,
597
+ candidates,
598
+ settings: handoffSettings(config),
599
+ aliases: aliasRefs(config.aliases),
600
+ used,
601
+ now: now(),
602
+ });
603
+ },
421
604
  readContext,
422
605
  };
423
606
  return rt;
@@ -67,6 +67,14 @@ export interface FamilyDef {
67
67
  };
68
68
  /** Arguments sideby adds before the Account's and the user's arguments. */
69
69
  defaultArgs?(account: Account, userArgs: readonly string[]): string[];
70
+ /**
71
+ * Host arguments that stop the Host from setting the terminal title for this run, so the title sideby sets with
72
+ * config `accountTitle` (`[<SIDEBY_LABEL>]`) stays. Added after `defaultArgs`, unless a user argument contains `key`.
73
+ */
74
+ keepTitle?: {
75
+ args: string[];
76
+ key: string;
77
+ };
70
78
  loginState?(account: Account): Promise<LoginState>;
71
79
  model?(account: Account): Promise<string | undefined>;
72
80
  /**
@@ -77,6 +85,11 @@ export interface FamilyDef {
77
85
  identity?(account: Account): Promise<AccountIdentity | undefined>;
78
86
  readQuota?(account: Account, ctx: ReadContext): Promise<QuotaResult>;
79
87
  readUsage?(account: Account, ctx: ReadContext): Promise<UsageResult>;
88
+ /**
89
+ * Estimates the best Compaction Threshold per model from the Account's recent local session records
90
+ * (spec §3.18). Read-only: it never writes a Host directory, and reads one named key of the Host's config at most.
91
+ */
92
+ readTuning?(account: Account, ctx: ReadContext): Promise<TuningResult>;
80
93
  /** One-time, reversible change that lets the Host report Quota (Claude: status line wrapper). */
81
94
  quotaSetup?: QuotaSetup;
82
95
  /**
@@ -86,6 +99,55 @@ export interface FamilyDef {
86
99
  resumedSession?(args: readonly string[]): string | undefined;
87
100
  /** When the Account holds the session, the last write time of its file in ms; otherwise undefined. */
88
101
  sessionWrittenAt?(account: Account, sessionId: string): Promise<number | undefined>;
102
+ /**
103
+ * When the Account started the session, even one the Host never saved, the time in ms; otherwise undefined.
104
+ * With `withoutResume` it lets `sideby resume` start a new session there instead of a resume that fails (ADR-0009).
105
+ */
106
+ sessionStartedAt?(account: Account, sessionId: string): Promise<number | undefined>;
107
+ /** The Host arguments without the by-id resume `resumedSession` reads; the others keep their order. */
108
+ withoutResume?(args: readonly string[]): string[];
109
+ /** How this Family takes part in a Handoff (spec §3.17); without it the Family neither starts nor receives one. */
110
+ handoff?: FamilyHandoff;
111
+ }
112
+ /** A Host hook event sideby's handoff hook listens to. */
113
+ export type HandoffHookEvent = 'PostToolUse' | 'Stop' | 'StopFailure';
114
+ /** Whether one run may start a Handoff, or start as the next Account of one, and why not. */
115
+ export interface HandoffEligibility {
116
+ start: boolean;
117
+ receive: boolean;
118
+ /** Shown before the Host starts when `start` or `receive` is false. */
119
+ reason?: string;
120
+ /** Host arguments this run needs to take part, such as Grok's per-Account leader socket. */
121
+ args?: string[];
122
+ /** False when the session cannot write its Brief (Codex read-only sandbox): sideby puts one together instead. */
123
+ agentBrief?: boolean;
124
+ }
125
+ export interface FamilyHandoff {
126
+ /**
127
+ * What this Family can start: `quota` before the limit (prepare and threshold, from its Quota), `limit` after a
128
+ * turn failed at the limit. Empty: it can only receive. Plugin Families can only receive for now: the hook entry
129
+ * loads built-in readers only.
130
+ */
131
+ starts: readonly ('quota' | 'limit')[];
132
+ /** Host arguments that load sideby's hooks for one run; omitted when the hooks come from `hookSetup`. */
133
+ hookArgs?(command: (event: HandoffHookEvent) => string, stateDir: string): Promise<string[]>;
134
+ /** Host arguments that let the session read and write the chain directory. */
135
+ dirArgs?(dir: string): string[];
136
+ /** Host arguments that start a new interactive session with this first prompt. */
137
+ promptArgs(prompt: string): string[];
138
+ /** The user's Host arguments for the next session of this Family: no resume, continue or old first prompt. */
139
+ continueArgs(args: readonly string[]): string[];
140
+ /** Host arguments that resume this session in the same Account. */
141
+ resumeArgs(sessionId: string): string[];
142
+ /** Whether this run may start or receive a Handoff (headless runs, Codex `--remote`, Grok sandbox profiles). */
143
+ eligibility(args: readonly string[], account: Account, env: Env): Promise<HandoffEligibility>;
144
+ /** A Brief from the session's local records, without a model; undefined when nothing could be read. */
145
+ briefFromSession?(account: Account, session: {
146
+ id?: string;
147
+ transcriptPath?: string;
148
+ }): Promise<string | undefined>;
149
+ /** One-time hook installation for a Host without a launch option (Grok); the same contract as `quotaSetup`. */
150
+ hookSetup?: QuotaSetup;
89
151
  }
90
152
  /** Identity fields of a signed-in Account. */
91
153
  export interface AccountIdentity {
@@ -182,6 +244,45 @@ export type UsageResult = ({
182
244
  reason: 'no-source' | 'no-session' | 'unrecognized';
183
245
  detail?: string;
184
246
  };
247
+ /** Why a model has no Tuning Suggestion, or `suggested` / `already-best` when it has an answer (spec §3.18). */
248
+ export type TuningReason = 'suggested' | 'already-best' | 'insufficient-calls' | 'no-compactions' | 'no-optimum' | 'unknown-model' | 'observe-only';
249
+ /** What one model's Compaction Threshold looks like in an Account's recent sessions. */
250
+ export interface TuningModel {
251
+ model: string;
252
+ /** Assistant turns of this model in the window. */
253
+ calls: number;
254
+ /** Context window in tokens, inferred from the records (spec §3.18). */
255
+ contextWindow: number;
256
+ /** The Account's configured Compaction Threshold in tokens, or null for the Host default. */
257
+ configured: number | null;
258
+ /** The threshold in effect (configured, capped by the window), or null when unknown. */
259
+ effective: number | null;
260
+ compactions: {
261
+ total: number;
262
+ auto: number;
263
+ manual: number;
264
+ preTokensP50: number | null;
265
+ preTokensMax: number | null;
266
+ };
267
+ /** The estimated cheapest threshold; savings are null when there is no baseline to compare with. */
268
+ suggestion: {
269
+ threshold: number;
270
+ savedPercent: number | null;
271
+ savedWeighted: number | null;
272
+ } | null;
273
+ reason: TuningReason;
274
+ }
275
+ export type TuningResult = {
276
+ status: 'ok';
277
+ days: number;
278
+ /** Always true: the whole result is an estimate from replaying local records, not a measurement. */
279
+ estimate: true;
280
+ models: TuningModel[];
281
+ } | {
282
+ status: 'unavailable';
283
+ reason: 'no-source' | 'no-session' | 'unrecognized';
284
+ detail?: string;
285
+ };
185
286
  export type FindingLevel = 'ok' | 'warn' | 'fail';
186
287
  export interface FixResult {
187
288
  ok: boolean;
@@ -17,6 +17,11 @@ export interface FakeHostOptions {
17
17
  selfSignal?: string;
18
18
  /** Seconds to sleep before exiting, so tests can signal it. */
19
19
  sleep?: number;
20
+ /**
21
+ * JavaScript run right after the start record, with `fs`, `path` and `process` in scope; for example to write
22
+ * the file a Host hook would write. It may call `process.exit`.
23
+ */
24
+ onStart?: string;
20
25
  }
21
26
  /**
22
27
  * Installs a fake Host executable named `name` that appends its argv, selected env and received signals
@@ -58,6 +58,7 @@ const rec = (o) => fs.appendFileSync(log, JSON.stringify(o) + '\\n')
58
58
  // Handlers first: tests signal the Host as soon as the start record appears.
59
59
  for (const s of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']) process.on(s, () => { rec({ signal: s }); process.exit(${opts.sleep ? 100 : 0}) })
60
60
  rec({ argv: process.argv.slice(2), env: process.env, pid: process.pid })
61
+ ${opts.onStart ? `{ const path = require('node:path'); ${opts.onStart} }` : ''}
61
62
  const finish = () => {
62
63
  ${opts.selfSignal ? `process.removeAllListeners(${JSON.stringify(opts.selfSignal)}); process.kill(process.pid, ${JSON.stringify(opts.selfSignal)})` : `process.exit(${opts.exitCode ?? 0})`}
63
64
  }