sideby 0.3.0 → 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 (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 +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 +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 +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 +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 +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 +210 -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 +108 -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
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <p align="center">
2
- <img alt="sideby: one shared config, every account in its own terminal" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg" width="880">
2
+ <img alt="sideby: work, personal and API-key accounts, one terminal each, one shared config" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/banner.jpg" width="880">
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <b>Run every AI coding account side by side.</b><br>
7
- Claude Code, Codex, Grok Build and pi: one directory per account, one shared set of skills, hooks and rules, one panel for every quota.
6
+ <b>Run work, personal and API-key accounts at the same time, without their configs drifting apart.</b><br>
7
+ Each account is its own directory and its own terminal. Skills, hooks and settings stay linked to your main account. Claude Code, Codex, Grok Build and pi.
8
8
  </p>
9
9
 
10
10
  <p align="center">
@@ -16,13 +16,17 @@
16
16
 
17
17
  <p align="center">English | <a href="README.zh-CN.md">中文</a></p>
18
18
 
19
- ## Why
19
+ ## Why not just an alias?
20
20
 
21
- You have a work subscription, a personal one and maybe an API key. Switching tools rewrite the Host's global config, so only one account is live at a time, and your skills and hooks slowly drift apart between accounts.
21
+ `CLAUDE_CONFIG_DIR` plus a shell alias already starts a second account. It does not keep skills and settings in one place, and it does not show quota for every account. [Does this break a provider's terms?](docs/guide/faq.md#terms)
22
22
 
23
- - **Side by side, not switching.** Each account lives in its own directory, and sideby starts the official CLI with that directory selected. Run three accounts in three terminals; your shell is never changed.
24
- - **One copy of skills, hooks and rules.** Your Main Account (for example `~/.claude`) is the source. Other accounts link or copy its Shared Items ([what is shared](docs/guide/usage.md#what-each-account-shares)), and `sideby doctor --fix` repairs drift without touching real files or credentials.
25
- - **Every quota on one screen.** 5-hour and 7-day quota, reset times and 7-day token usage for every account. Out of room? `sideby next` starts the account with the most left. All from local files: no network calls, no token reads.
23
+ | | At the same time | Skills, hooks, settings | Quota and usage | Setup |
24
+ |---|---|---|---|---|
25
+ | `CLAUDE_CONFIG_DIR` and an alias | Yes. Each terminal exports the variable. | Each directory is its own copy. They drift unless you symlink by hand. | That session's status line | One alias per account |
26
+ | [cc-switch](https://github.com/farion1231/cc-switch) | Enabling a provider writes the host's live config, so open sessions share that switch. | Plugin settings are copied across providers as a separate step. | The active provider, from its quota endpoint | Install the desktop app |
27
+ | sideby | Yes. `sideby run` selects the directory for that process and leaves your shell alone. | Linked to the main account. `sideby doctor --fix` repairs copies. Sign-in and sessions stay separate. | Every account, from local files. Claude needs `quota setup`. | `npm i -g sideby`, then `sideby new` and sign in |
28
+
29
+ cc-switch's docs describe that switch: it writes the live settings file ([README](https://github.com/farion1231/cc-switch#readme)). [#1105](https://github.com/farion1231/cc-switch/issues/1105) and [#2908](https://github.com/farion1231/cc-switch/issues/2908) ask for several sessions at once. [#1106](https://github.com/farion1231/cc-switch/issues/1106) is closed; the maintainer says Claude Code's "open terminal" can start one provider without changing the global config.
26
30
 
27
31
  ## Quick start
28
32
 
@@ -51,7 +55,7 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
51
55
 
52
56
  ## What it looks like
53
57
 
54
- <img alt="sideby in a terminal: list accounts, check quota, pick the next account, find and fix drift" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1320">
58
+ <img alt="Two terminals: the personal account and the work account running at the same time, then the local quota panel" src="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/demo.gif" width="1280">
55
59
 
56
60
  <picture>
57
61
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Adonis0123/sideby/main/docs/assets/panel-dark.png">
@@ -66,7 +70,9 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
66
70
  | Add an API key account (DeepSeek, a gateway, …) | `sideby new claude deepseek --api` | [API accounts](docs/guide/usage.md#api-accounts) |
67
71
  | Find and fix config drift between accounts | `sideby doctor --fix` | [Doctor](docs/guide/usage.md#doctor) |
68
72
  | See Claude quota (Codex needs no setup) | `sideby quota setup claude --yes` | [Claude quota](docs/guide/usage.md#claude-quota) |
73
+ | Find the cheapest auto-compact size from your own sessions, and use it in one click | `sideby tune` (or the panel's Context tuning tab) | [Context tuning](docs/guide/usage.md#context-tuning) |
69
74
  | Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
75
+ | Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |
70
76
  | Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
71
77
  | Reopen a session from Orca and the like in the account that holds it | `"resumeRouting": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |
72
78
  | Open the panel from the Dock or app menu | `sideby app install` | [Desktop app](docs/guide/panel.md#desktop-app) |
@@ -84,14 +90,24 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
84
90
 
85
91
  sideby starts the official binary on your `PATH` and never patches it. Tested versions and per-Host notes are in the [guide](docs/guide/usage.md#host-notes).
86
92
 
93
+ ## Platforms
94
+
95
+ macOS and Linux. CI runs the suite on `ubuntu-latest` and `macos-latest`, Node 22 and 24. **Windows is untested.** `sideby app` only installs a launcher on macOS and Linux. Linked items are symlinks, `sideby shell-init` prints bash and zsh functions, and the Claude quota wrapper runs `/bin/sh`.
96
+
97
+ ## Roadmap
98
+
99
+ **Gemini CLI.** Gemini can point at another config directory with `GEMINI_CONFIG_DIR`. Older docs use `GEMINI_CLI_HOME`; current builds treat that name as deprecated, and exit on startup if it is set together with an exact directory override. A sideby family still needs a tested Gemini version, a login check that does not read tokens, and a list of which files to link or copy. That is more than a small patch, so it is not in this version.
100
+
87
101
  ## Safe by design
88
102
 
89
- - It never edits a Host's global config to switch accounts, and never rotates accounts by itself.
103
+ - It never edits a Host's global config to switch accounts. It moves a task to another account only when you turn on Auto Handoff, and never proxies requests or pools logins.
90
104
  - It never reads, prints or sends a credential value. Nothing leaves your machine: sideby calls no remote service.
91
105
  - `doctor --fix` never replaces a real file and never changes where a link points.
92
106
 
93
107
  Details and the terms-of-service question are in the [FAQ](docs/guide/faq.md).
94
108
 
109
+ Usage questions and ideas go in [Discussions](https://github.com/Adonis0123/sideby/discussions).
110
+
95
111
  ## Docs
96
112
 
97
113
  - [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes
@@ -100,6 +116,7 @@ Details and the terms-of-service question are in the [FAQ](docs/guide/faq.md).
100
116
  - [Panel](docs/guide/panel.md): the panel, desktop app and embedding
101
117
  - [Plugins](docs/guide/plugins.md): write your own Family or launch hook
102
118
  - [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall
119
+ - [Changelog](https://github.com/Adonis0123/sideby/releases)
103
120
 
104
121
  ## For AI agents
105
122
 
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261008T112709-0a27b0"}
1
+ {"id":"20261013T091101-030f9b"}
@@ -1,9 +1,12 @@
1
1
  import type { DoctorReport } from '../core/doctor.ts';
2
+ import { type HandoffIo } from '../core/handoff-run.ts';
2
3
  import type { Finding } from '../types.ts';
3
4
  import { type ParsedArgs } from './args.ts';
4
5
  export interface Io {
5
6
  out(s: string): void;
6
7
  err(s: string): void;
8
+ /** The terminal Auto Handoff counts down on (spec §3.17); `process.stdin` when left out. */
9
+ stdin?: HandoffIo['stdin'];
7
10
  }
8
11
  export declare function serializeDoctor(r: DoctorReport): {
9
12
  status: "issues" | "ok";
@@ -34,13 +37,17 @@ export declare function cmdNew(a: ParsedArgs, io: Io): Promise<number>;
34
37
  export declare function cmdRun(a: ParsedArgs, io: Io, command: 'run' | 'login'): Promise<number>;
35
38
  /**
36
39
  * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
37
- * session the arguments resume by id, or runs the Host unchanged.
40
+ * session the arguments resume by id, a new session where a never-saved one started (ADR-0009), or the Host unchanged.
38
41
  */
39
42
  export declare function cmdResume(a: ParsedArgs, io: Io): Promise<number>;
40
43
  /** `sideby next <family>`: recommend the Account with the most room, then start it (spec §3.13). */
41
44
  export declare function cmdNext(a: ParsedArgs, io: Io): Promise<number>;
42
45
  export declare function cmdDoctor(a: ParsedArgs, io: Io): Promise<number>;
46
+ /** `sideby tune [account|family]`: estimated Compaction Threshold per model (spec §3.18). */
47
+ export declare function cmdTune(a: ParsedArgs, io: Io): Promise<number>;
43
48
  export declare function cmdQuota(a: ParsedArgs, io: Io): Promise<number>;
49
+ /** `sideby handoff ready|setup|teardown` (spec §3.17). */
50
+ export declare function cmdHandoff(a: ParsedArgs, io: Io): Promise<number>;
44
51
  export declare function cmdPlugins(a: ParsedArgs, io: Io): Promise<number>;
45
52
  export declare function cmdShellInit(a: ParsedArgs, io: Io): Promise<number>;
46
53
  export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
@@ -2,6 +2,9 @@ import { fileURLToPath } from 'node:url';
2
2
  import { aliasesByAccount } from '../core/accounts.js';
3
3
  import { aliasRefs } from '../core/config.js';
4
4
  import { UserError } from '../core/errors.js';
5
+ import { requestHandoff } from '../core/handoff-chain.js';
6
+ import { explain, readyDecision, runWithHandoff } from '../core/handoff-run.js';
7
+ import { configureHandoff, handoffHint, handoffStatus } from '../core/handoff-status.js';
5
8
  import { runHost } from '../core/launch.js';
6
9
  import { resolvePaths, tildify } from '../core/paths.js';
7
10
  import { USAGE_DAYS } from '../core/quota-levels.js';
@@ -9,7 +12,26 @@ import { SHELLS } from '../core/shell-init.js';
9
12
  import { suggestAlias, suggestName } from '../panel/page-logic.js';
10
13
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
11
14
  import { UsageError } from './args.js';
12
- import { c, clock, quotaText, table, usageText } from './format.js';
15
+ import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
16
+ /** Runs the Account with Auto Handoff when it applies (spec §3.17), else the Host as usual. */
17
+ async function runAccount(rt, ref, args, io) {
18
+ const handed = await runWithHandoff(rt, ref, args, {
19
+ err: (l) => io.err(c.dim(l)),
20
+ stdin: io.stdin ?? process.stdin,
21
+ });
22
+ if (handed !== null)
23
+ return handed;
24
+ const prepared = await rt.prepareLaunch(ref, args, 'run');
25
+ if (prepared.notice)
26
+ io.err(c.cyan(prepared.notice));
27
+ const code = await runHost(prepared);
28
+ if ((io.stdin ?? process.stdin).isTTY) {
29
+ const hint = await handoffHint(rt, ref);
30
+ if (hint)
31
+ io.err(c.dim(`sideby: ${hint}`));
32
+ }
33
+ return code;
34
+ }
13
35
  const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data }, null, 2));
14
36
  function stripFix(f) {
15
37
  const { fix: _fix, ...rest } = f;
@@ -149,6 +171,8 @@ export async function cmdRun(a, io, command) {
149
171
  if (command === 'login' && (extra.length || a.rest.length))
150
172
  throw new UsageError('login takes no extra arguments');
151
173
  const rt = await createRuntime();
174
+ if (command === 'run')
175
+ return runAccount(rt, ref, [...extra, ...a.rest], io);
152
176
  const prepared = await rt.prepareLaunch(ref, [...extra, ...a.rest], command);
153
177
  if (prepared.notice)
154
178
  io.err(c.cyan(prepared.notice));
@@ -156,7 +180,7 @@ export async function cmdRun(a, io, command) {
156
180
  }
157
181
  /**
158
182
  * `sideby resume <family>`: what the Host-named shell function calls (spec §3.15). Starts the Account that holds the
159
- * session the arguments resume by id, or runs the Host unchanged.
183
+ * session the arguments resume by id, a new session where a never-saved one started (ADR-0009), or the Host unchanged.
160
184
  */
161
185
  export async function cmdResume(a, io) {
162
186
  const [family, ...extra] = a.positionals;
@@ -164,7 +188,9 @@ export async function cmdResume(a, io) {
164
188
  throw new UsageError('usage: sideby resume <family> [-- host args]');
165
189
  const rt = await createRuntime();
166
190
  const route = await rt.resumeLaunch(family, [...extra, ...a.rest]);
167
- if (route.account)
191
+ if (route.newSession)
192
+ io.err(c.dim(`sideby: session ${route.sessionId} was never saved; starting a new session in ${route.account.ref}`));
193
+ else if (route.account)
168
194
  io.err(c.dim(`sideby: session ${route.sessionId} is in ${route.account.ref}; starting it there`));
169
195
  return runHost(route.launch);
170
196
  }
@@ -236,10 +262,9 @@ export async function cmdNext(a, io) {
236
262
  }
237
263
  // The old session stays in the old Account (spec §3.13); a short note carries the context over.
238
264
  io.out(c.dim('Tip: ask your old session for a short handoff note and paste it into the new one.'));
239
- const prepared = await rt.prepareLaunch(plan.pick, a.rest, 'run');
240
- if (prepared.notice)
241
- io.err(c.cyan(prepared.notice));
242
- return runHost(prepared);
265
+ if (!rt.handoffSettings().auto)
266
+ io.out(c.dim('Sessions can also hand over by themselves when quota runs low: see `sideby handoff status`.'));
267
+ return runAccount(rt, plan.pick, a.rest, io);
243
268
  }
244
269
  export async function cmdDoctor(a, io) {
245
270
  const rt = await createRuntime();
@@ -278,6 +303,140 @@ export async function cmdDoctor(a, io) {
278
303
  io.out(c.cyan(`\n${fixable} issue(s) can be fixed with \`sideby doctor --fix\`.`));
279
304
  return report.status === 'ok' ? 0 : 1;
280
305
  }
306
+ /** The words a person reads before agreeing to a change: where, from what to what, and who else it reaches. */
307
+ function tuneChangeLines(plan, home) {
308
+ const from = plan.current === null ? 'the default' : compactNumber(plan.current);
309
+ const lines = [
310
+ ` File: ${tilde(plan.file, home)}${plan.creates ? ' (will be created)' : ''}`,
311
+ ` Change: auto-compact size ${from} -> ${plan.next === null ? 'the default' : compactNumber(plan.next)}`,
312
+ ];
313
+ if (plan.affected.length > 1)
314
+ lines.push(` Careful: this file is shared, so it also changes ${plan.affected.slice(1).join(', ')}`);
315
+ lines.push(' Sessions already running keep the old value; new ones use the new one. You can undo it.');
316
+ return lines;
317
+ }
318
+ /** `160000`, `160k` or `1m` as a number of tokens, `default` as null (go back to the default); NaN when it is none of those. */
319
+ function parseTokens(text) {
320
+ if (text.trim().toLowerCase() === 'default')
321
+ return null;
322
+ const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(text.trim());
323
+ if (!m)
324
+ return Number.NaN;
325
+ return Math.round(Number(m[1]) * (m[2] === '' ? 1 : m[2]?.toLowerCase() === 'k' ? 1_000 : 1_000_000));
326
+ }
327
+ /** `sideby tune apply <account> [tokens]` and `sideby tune undo <account>` (spec §3.18, ADR-0012). */
328
+ async function cmdTuneChange(a, io, action) {
329
+ const [, ref, tokens] = a.positionals;
330
+ if (!ref || a.positionals.length > (action === 'apply' ? 3 : 2))
331
+ throw new UsageError(`usage: sideby tune ${action} <account>${action === 'apply' ? ' [tokens]' : ''} [--yes] [--json]`);
332
+ const wantJson = a.flags.has('json');
333
+ const yes = a.flags.has('yes');
334
+ const rt = await createRuntime();
335
+ const account = await rt.resolve(ref);
336
+ const home = rt.paths.home;
337
+ const out = (data) => json(io, { account: account.ref, action, ...data });
338
+ if (action === 'undo') {
339
+ const info = await rt.tuneUndoInfo(account);
340
+ if (!info) {
341
+ if (wantJson)
342
+ out({ applied: false, message: 'There is nothing from sideby to undo for this account.' });
343
+ else
344
+ io.out('There is nothing from sideby to undo for this account.');
345
+ return 1;
346
+ }
347
+ if (!yes) {
348
+ if (wantJson)
349
+ out({ applied: false, message: 'Run again with --yes to undo.' });
350
+ else
351
+ io.out(`Undo: auto-compact size ${info.applied === null ? 'the default' : compactNumber(info.applied)} -> ${info.previous === null ? 'the default' : compactNumber(info.previous)} in ${tilde(info.file, home)}\nRun again with --yes to undo.`);
352
+ return 10;
353
+ }
354
+ const r = await rt.tuneUndo(account);
355
+ if (wantJson)
356
+ out({ applied: r.ok, message: r.message });
357
+ else
358
+ io.out(r.message);
359
+ return r.ok ? 0 : 1;
360
+ }
361
+ let value = tokens === undefined ? undefined : parseTokens(tokens);
362
+ if (value === undefined) {
363
+ const pick = (await rt.tuneGroups(account)).groups[0]?.pick ?? null;
364
+ if (!pick)
365
+ throw new UserError(`no suggested size for ${account.ref} to use: give one, for example \`sideby tune apply ${account.ref} 160000\`, or run \`sideby tune ${account.ref}\` to see why`, { code: 'no-suggestion' });
366
+ value = pick.threshold;
367
+ }
368
+ const plan = await rt.tuneApplyPlan(account, value);
369
+ const planJson = { ...plan, file: tilde(plan.file, home) };
370
+ if (plan.status === 'blocked') {
371
+ if (wantJson)
372
+ out({ applied: false, plan: planJson, message: plan.reason });
373
+ else
374
+ io.out(plan.reason ?? 'blocked');
375
+ return 1;
376
+ }
377
+ if (plan.status === 'unchanged') {
378
+ if (wantJson)
379
+ out({ applied: false, plan: planJson, message: 'It is already set to that value; nothing changed.' });
380
+ else
381
+ io.out('It is already set to that value; nothing changed.');
382
+ return 0;
383
+ }
384
+ if (!yes) {
385
+ if (wantJson)
386
+ out({ applied: false, plan: planJson, message: 'Run again with --yes to apply.' });
387
+ else
388
+ io.out([
389
+ `${plan.next === null ? 'Go back to the default' : `Use ${compactNumber(plan.next)}`} for ${account.ref}?`,
390
+ ...tuneChangeLines(plan, home),
391
+ 'Run again with --yes to apply.',
392
+ ].join('\n'));
393
+ return 10;
394
+ }
395
+ const r = await rt.tuneApply(account, value);
396
+ if (wantJson)
397
+ out({ applied: r.ok, plan: { ...r.plan, file: tilde(r.plan.file, home) }, message: r.message });
398
+ else
399
+ io.out(r.message);
400
+ return r.ok ? 0 : 1;
401
+ }
402
+ /** `sideby tune [account|family]`: estimated Compaction Threshold per model (spec §3.18). */
403
+ export async function cmdTune(a, io) {
404
+ if (a.positionals[0] === 'apply' || a.positionals[0] === 'undo')
405
+ return cmdTuneChange(a, io, a.positionals[0]);
406
+ if (a.positionals.length > 1 || a.flags.has('yes'))
407
+ throw new UsageError('usage: sideby tune [account|family] [--json] (--yes belongs to `tune apply` and `tune undo`)');
408
+ const rt = await createRuntime();
409
+ const target = a.positionals[0];
410
+ const all = await rt.accounts();
411
+ let accounts = all;
412
+ if (target) {
413
+ const ofFamily = all.filter((x) => x.family === target);
414
+ if (ofFamily.length)
415
+ accounts = ofFamily;
416
+ else if ((await rt.familyInfo()).some((f) => f.id === target))
417
+ accounts = [];
418
+ else
419
+ accounts = [await rt.resolve(target)];
420
+ }
421
+ const rows = await Promise.all(accounts.map(async (x) => ({ ref: x.ref, family: x.family, tuning: await rt.tuning(x) })));
422
+ if (a.flags.has('json')) {
423
+ json(io, { accounts: rows });
424
+ return 0;
425
+ }
426
+ if (rows.length === 0) {
427
+ io.out(target
428
+ ? `No ${target} accounts yet. Run \`sideby new ${target} <name>\` to add one.`
429
+ : 'No accounts found yet. Run `sideby` for how to start.');
430
+ return 0;
431
+ }
432
+ for (const r of rows) {
433
+ io.out(c.bold(r.ref));
434
+ for (const line of tuningLines(r.tuning))
435
+ io.out(` ${line}`);
436
+ }
437
+ io.out(c.dim(`\nEstimates from your last ${USAGE_DAYS} days of local sessions; sideby does not change any setting.`));
438
+ return 0;
439
+ }
281
440
  export async function cmdQuota(a, io) {
282
441
  const sub = a.positionals[0];
283
442
  if (sub === 'setup' || sub === 'teardown')
@@ -318,7 +477,10 @@ async function cmdQuotaSetup(a, io, sub) {
318
477
  if (!familyId)
319
478
  throw new UsageError(`usage: sideby quota ${sub} <family>${sub === 'setup' ? ' [--yes]' : ''}`);
320
479
  const rt = await createRuntime();
321
- const setup = rt.quotaSetup(familyId);
480
+ return runSetup(a, io, rt, 'quota', sub, familyId, rt.quotaSetup(familyId));
481
+ }
482
+ /** `sideby quota|handoff setup|teardown <family>`: show the change, apply it only with --yes (exit 10 before). */
483
+ async function runSetup(a, io, rt, command, sub, familyId, setup) {
322
484
  if (sub === 'teardown') {
323
485
  const r = await setup.teardown();
324
486
  if (a.flags.has('json'))
@@ -340,7 +502,7 @@ async function cmdQuotaSetup(a, io, sub) {
340
502
  if (plan.status === 'ready') {
341
503
  io.out(`\n${setup.summary}\nChange to ${tildify(plan.file, rt.paths.home)}:\n`);
342
504
  io.out(plan.diff);
343
- io.out(c.cyan(`\nApply it with \`sideby quota setup ${familyId} --yes\`; undo any time with \`sideby quota teardown ${familyId}\`.`));
505
+ io.out(c.cyan(`\nApply it with \`sideby ${command} setup ${familyId} --yes\`; undo any time with \`sideby ${command} teardown ${familyId}\`.`));
344
506
  }
345
507
  }
346
508
  // 10: the change is shown and waits for the user's yes (spec §3.9); agents ask before adding --yes.
@@ -353,6 +515,165 @@ async function cmdQuotaSetup(a, io, sub) {
353
515
  io.out(`${after.status === 'enabled' ? c.green('✓') : c.red('✗')} ${after.message}`);
354
516
  return after.status === 'enabled' ? 0 : 1;
355
517
  }
518
+ /** `sideby handoff status` (spec §3.17): what Auto Handoff still needs, with the commands to run. */
519
+ async function cmdHandoffStatus(a, io) {
520
+ const st = await handoffStatus(await createRuntime());
521
+ if (a.flags.has('json')) {
522
+ json(io, st);
523
+ return 0;
524
+ }
525
+ io.out(`Auto Handoff: ${st.auto ? c.green('on') : c.yellow('off')} (same family ${st.sameFamily ? 'on' : 'off'}; brief at ${st.prepareAt}%, hand over at ${st.threshold}%)`);
526
+ if (st.sideby.problem)
527
+ io.out(c.red(`sideby on PATH: ${st.sideby.problem}`));
528
+ if (st.families.length)
529
+ io.out(`\n${table(st.families.map((f) => [
530
+ c.bold(f.family),
531
+ f.starts.length ? f.starts.join(', ') : c.dim('takes over only'),
532
+ f.ready ? c.green('ready') : f.starts.length ? c.yellow('not yet') : c.dim('—'),
533
+ f.order.policy === 'order' ? f.order.order.join(' → ') : c.dim('by quota'),
534
+ ]), ['FAMILY', 'STARTS', 'STATE', 'ORDER'])}`);
535
+ for (const f of st.families) {
536
+ for (const p of f.problems)
537
+ io.out(c.yellow(`${f.family}: ${p}`));
538
+ for (const n of f.notes)
539
+ io.out(c.dim(`${f.family}: ${n}`));
540
+ }
541
+ if (st.nextSteps.length) {
542
+ io.out('\nNext:');
543
+ for (const [i, step] of st.nextSteps.entries())
544
+ io.out(` ${i + 1}. ${step}`);
545
+ }
546
+ return 0;
547
+ }
548
+ function intFlag(a, name) {
549
+ const v = a.flags.get(name);
550
+ if (v === undefined)
551
+ return undefined;
552
+ const n = Number(v);
553
+ if (typeof v !== 'string' || !Number.isInteger(n) || n < 0)
554
+ throw new UsageError(`--${name} takes a whole number, for example --${name} 95`);
555
+ return n;
556
+ }
557
+ /** The `enable`/`disable` command line for `change`, to repeat with --yes. */
558
+ function switchCommand(sub, change) {
559
+ const parts = [`sideby handoff ${sub}`];
560
+ if (change.sameFamily === true)
561
+ parts.push('--same-family');
562
+ if (change.sameFamily === false)
563
+ parts.push('--no-same-family');
564
+ for (const o of change.orders ?? [])
565
+ parts.push(`--order ${o.family}=${o.order.join(',')}`);
566
+ if (change.prepareAt !== undefined)
567
+ parts.push(`--prepare-at ${change.prepareAt}`);
568
+ if (change.threshold !== undefined)
569
+ parts.push(`--threshold ${change.threshold}`);
570
+ if (change.waitIfResetWithinMinutes !== undefined)
571
+ parts.push(`--wait-minutes ${change.waitIfResetWithinMinutes}`);
572
+ if (change.countdownSeconds !== undefined)
573
+ parts.push(`--countdown ${change.countdownSeconds}`);
574
+ return parts.join(' ');
575
+ }
576
+ /** `sideby handoff enable|disable` (spec §3.17): show the config change (exit 10), write it with --yes. */
577
+ async function cmdHandoffSwitch(a, io, sub) {
578
+ const change = { auto: sub === 'enable' };
579
+ if (sub === 'enable') {
580
+ if (a.flags.has('same-family'))
581
+ change.sameFamily = true;
582
+ if (a.flags.has('no-same-family'))
583
+ change.sameFamily = false;
584
+ const order = a.flags.get('order');
585
+ if (typeof order === 'string') {
586
+ const eq = order.indexOf('=');
587
+ if (eq <= 0)
588
+ throw new UsageError('--order takes <family>=<account,account>, for example --order claude=codex001,cc002');
589
+ change.orders = [
590
+ {
591
+ family: order.slice(0, eq),
592
+ order: order
593
+ .slice(eq + 1)
594
+ .split(',')
595
+ .map((x) => x.trim())
596
+ .filter(Boolean),
597
+ },
598
+ ];
599
+ }
600
+ const nums = {
601
+ prepareAt: 'prepare-at',
602
+ threshold: 'threshold',
603
+ waitIfResetWithinMinutes: 'wait-minutes',
604
+ countdownSeconds: 'countdown',
605
+ };
606
+ for (const [key, flag] of Object.entries(nums)) {
607
+ const n = intFlag(a, flag);
608
+ if (n !== undefined)
609
+ change[key] = n;
610
+ }
611
+ }
612
+ const rt = await createRuntime();
613
+ const r = await configureHandoff(rt, change, { apply: a.flags.has('yes') });
614
+ const nextSteps = r.applied && sub === 'enable' ? (await handoffStatus(await createRuntime())).nextSteps : [];
615
+ if (a.flags.has('json')) {
616
+ json(io, { applied: r.applied, file: r.file, diff: r.diff, settings: r.settings, nextSteps });
617
+ }
618
+ else if (!r.changed)
619
+ io.out(`Auto Handoff is already ${sub === 'enable' ? 'set that way' : 'off'}; nothing to change.`);
620
+ else if (!r.applied) {
621
+ io.out(`Change to ${tildify(r.file, rt.paths.home)}:\n\n${r.diff}`);
622
+ if (sub === 'enable' && r.settings.sameFamily)
623
+ io.out(c.yellow('Same-family hand-over moves work between subscriptions of one vendor; vendors may act on that.'));
624
+ io.out(c.cyan(`\nApply it with \`${switchCommand(sub, change)} --yes\`.`));
625
+ }
626
+ else {
627
+ io.out(`${c.green('✓')} Auto Handoff is ${sub === 'enable' ? 'on' : 'off'}.`);
628
+ if (nextSteps.length) {
629
+ io.out('Still to do:');
630
+ for (const [i, step] of nextSteps.entries())
631
+ io.out(` ${i + 1}. ${step}`);
632
+ }
633
+ }
634
+ // 10: the change is shown and waits for the user's yes, as for `quota setup`.
635
+ return r.changed && !r.applied ? 10 : 0;
636
+ }
637
+ /** `sideby handoff ready|setup|teardown` (spec §3.17). */
638
+ export async function cmdHandoff(a, io) {
639
+ const sub = a.positionals[0];
640
+ if (sub === 'setup' || sub === 'teardown') {
641
+ const familyId = a.positionals[1];
642
+ if (!familyId)
643
+ throw new UsageError(`usage: sideby handoff ${sub} <family>${sub === 'setup' ? ' [--yes]' : ''}`);
644
+ const rt = await createRuntime();
645
+ return runSetup(a, io, rt, 'handoff', sub, familyId, rt.handoffSetup(familyId));
646
+ }
647
+ if (sub === 'status')
648
+ return cmdHandoffStatus(a, io);
649
+ if (sub === 'enable' || sub === 'disable')
650
+ return cmdHandoffSwitch(a, io, sub);
651
+ if (sub !== 'ready')
652
+ throw new UsageError('usage: sideby handoff status | enable [--same-family] [--order <family>=<acct,…>] [--threshold n] [--prepare-at n] [--yes] | disable [--yes] | ready [--brief <file>] | setup|teardown <family> [--yes]');
653
+ const flag = a.flags.get('brief');
654
+ const brief = typeof flag === 'string' ? flag : undefined;
655
+ const decision = await readyDecision(await createRuntime(), process.env);
656
+ if (decision && decision.kind !== 'pick')
657
+ throw new UserError(`nothing can take over now: ${explain(decision)}; keep working in this account`, {
658
+ code: 'no-pick',
659
+ });
660
+ let request;
661
+ try {
662
+ request = await requestHandoff(process.env, brief);
663
+ }
664
+ catch (err) {
665
+ throw new UserError(`cannot read the brief ${brief}: ${err.message}; check the path and retry`, {
666
+ code: 'brief-unreadable',
667
+ });
668
+ }
669
+ if (!request)
670
+ throw new UserError('not inside a session sideby started with Auto Handoff; set `handoff.auto` in the config and start the account with sideby (`sideby run`, a short command), or use `sideby next <family>`', { code: 'not-in-handoff-run' });
671
+ if (a.flags.has('json'))
672
+ json(io, { requested: true, ...(request.brief ? { brief: request.brief } : {}) });
673
+ else
674
+ io.out('The next account starts when this turn ends; finish the turn now.');
675
+ return 0;
676
+ }
356
677
  export async function cmdPlugins(a, io) {
357
678
  const rt = await createRuntime();
358
679
  if (a.flags.has('json'))
@@ -1,4 +1,4 @@
1
- import type { QuotaResult, UsageResult } from '../types.ts';
1
+ import type { QuotaResult, TuningResult, UsageResult } from '../types.ts';
2
2
  export declare const c: {
3
3
  dim: (s: string) => string;
4
4
  bold: (s: string) => string;
@@ -14,3 +14,5 @@ export declare const QUOTA_REASON: Record<string, string>;
14
14
  export declare function quotaText(q: QuotaResult, now: Date): string;
15
15
  export declare function compactNumber(n: number): string;
16
16
  export declare function usageText(u: UsageResult): string;
17
+ /** One line per model of a tuning result, or the reason there is none. */
18
+ export declare function tuningLines(t: TuningResult): string[];
@@ -1,4 +1,5 @@
1
1
  import { quotaLevel } from '../core/quota-levels.js';
2
+ import { MIN_CALLS } from '../core/tuning.js';
2
3
  const useColor = () => !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
3
4
  const paint = (code) => (s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
4
5
  export const c = {
@@ -78,3 +79,27 @@ export function usageText(u) {
78
79
  return c.dim('none');
79
80
  return `${compactNumber(u.totalTokens)} tok / ${u.days}d`;
80
81
  }
82
+ const TUNING_REASON = {
83
+ 'already-best': 'already the cheapest setting we can estimate',
84
+ 'insufficient-calls': `too few calls to estimate (needs ${MIN_CALLS})`,
85
+ 'no-compactions': 'no compaction recorded to estimate from',
86
+ 'no-optimum': 'the cheapest point is at the bottom of the range tried, so there is no clear best value',
87
+ 'unknown-model': 'not a Claude model, so the cost weights do not apply',
88
+ 'observe-only': 'observation only (no public default for this host)',
89
+ };
90
+ /** One line per model of a tuning result, or the reason there is none. */
91
+ export function tuningLines(t) {
92
+ if (t.status === 'unavailable')
93
+ return [t.reason === 'no-source' ? 'no public source for this host' : (t.detail ?? t.reason)];
94
+ return t.models.map((m) => {
95
+ const now = m.configured === null ? 'Host default' : `${compactNumber(m.configured)}`;
96
+ const head = `${m.model} ${m.calls} calls now ${now} ${m.compactions.total} compactions`;
97
+ if (m.suggestion) {
98
+ const saved = m.suggestion.savedPercent === null
99
+ ? ''
100
+ : ` (about ${m.suggestion.savedPercent}% fewer weighted tokens)`;
101
+ return `${head} suggest ${compactNumber(m.suggestion.threshold)}${saved}`;
102
+ }
103
+ return `${head} ${m.reason === 'suggested' ? '' : TUNING_REASON[m.reason]}`;
104
+ });
105
+ }