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.
- package/README.md +27 -10
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +8 -1
- package/dist/src/cli/commands.js +332 -9
- package/dist/src/cli/format.d.ts +3 -1
- package/dist/src/cli/format.js +25 -0
- package/dist/src/cli/json-schemas.js +114 -0
- package/dist/src/cli/main.d.ts +5 -0
- package/dist/src/cli/main.js +40 -8
- package/dist/src/core/config.d.ts +40 -0
- package/dist/src/core/config.js +83 -11
- package/dist/src/core/file-memo.d.ts +23 -0
- package/dist/src/core/file-memo.js +37 -0
- package/dist/src/core/handoff-brief.d.ts +17 -0
- package/dist/src/core/handoff-brief.js +75 -0
- package/dist/src/core/handoff-chain.d.ts +74 -0
- package/dist/src/core/handoff-chain.js +74 -0
- package/dist/src/core/handoff-doctor.d.ts +13 -0
- package/dist/src/core/handoff-doctor.js +35 -0
- package/dist/src/core/handoff-run.d.ts +31 -0
- package/dist/src/core/handoff-run.js +346 -0
- package/dist/src/core/handoff-select.d.ts +42 -0
- package/dist/src/core/handoff-select.js +88 -0
- package/dist/src/core/handoff-status.d.ts +66 -0
- package/dist/src/core/handoff-status.js +198 -0
- package/dist/src/core/launch.d.ts +15 -0
- package/dist/src/core/launch.js +25 -2
- package/dist/src/core/memo-store.d.ts +20 -0
- package/dist/src/core/memo-store.js +92 -0
- package/dist/src/core/resume.d.ts +7 -2
- package/dist/src/core/resume.js +22 -9
- package/dist/src/core/tuning-apply.d.ts +63 -0
- package/dist/src/core/tuning-apply.js +264 -0
- package/dist/src/core/tuning.d.ts +113 -0
- package/dist/src/core/tuning.js +192 -0
- package/dist/src/families/claude/brief.d.ts +5 -0
- package/dist/src/families/claude/brief.js +63 -0
- package/dist/src/families/claude/handoff-hook.d.ts +2 -0
- package/dist/src/families/claude/handoff-hook.js +31 -0
- package/dist/src/families/claude/handoff.d.ts +5 -0
- package/dist/src/families/claude/handoff.js +115 -0
- package/dist/src/families/claude/index.js +8 -1
- package/dist/src/families/claude/tuning.d.ts +22 -0
- package/dist/src/families/claude/tuning.js +203 -0
- package/dist/src/families/claude/usage.d.ts +1 -0
- package/dist/src/families/claude/usage.js +2 -2
- package/dist/src/families/codex/brief.d.ts +5 -0
- package/dist/src/families/codex/brief.js +49 -0
- package/dist/src/families/codex/handoff-hook.d.ts +2 -0
- package/dist/src/families/codex/handoff-hook.js +16 -0
- package/dist/src/families/codex/handoff.d.ts +5 -0
- package/dist/src/families/codex/handoff.js +121 -0
- package/dist/src/families/codex/index.js +4 -0
- package/dist/src/families/codex/rate-limits.d.ts +16 -0
- package/dist/src/families/codex/rate-limits.js +92 -0
- package/dist/src/families/codex/rollout.d.ts +1 -2
- package/dist/src/families/codex/rollout.js +4 -48
- package/dist/src/families/codex/tuning.d.ts +16 -0
- package/dist/src/families/codex/tuning.js +145 -0
- package/dist/src/families/grok/brief.d.ts +5 -0
- package/dist/src/families/grok/brief.js +74 -0
- package/dist/src/families/grok/handoff-hook.d.ts +4 -0
- package/dist/src/families/grok/handoff-hook.js +26 -0
- package/dist/src/families/grok/handoff.d.ts +47 -0
- package/dist/src/families/grok/handoff.js +219 -0
- package/dist/src/families/grok/index.js +2 -0
- package/dist/src/families/pi/handoff.d.ts +2 -0
- package/dist/src/families/pi/handoff.js +13 -0
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/families/shared/brief.d.ts +24 -0
- package/dist/src/families/shared/brief.js +42 -0
- package/dist/src/families/shared/handoff-args.d.ts +18 -0
- package/dist/src/families/shared/handoff-args.js +57 -0
- package/dist/src/families/shared/sessions.d.ts +4 -0
- package/dist/src/families/shared/sessions.js +19 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +30 -0
- package/dist/src/handoff/hook.d.ts +16 -0
- package/dist/src/handoff/hook.js +166 -0
- package/dist/src/handoff/messages.d.ts +7 -0
- package/dist/src/handoff/messages.js +27 -0
- package/dist/src/handoff/reader.d.ts +11 -0
- package/dist/src/handoff/reader.js +9 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +110 -0
- package/dist/src/panel/i18n.d.ts +98 -0
- package/dist/src/panel/i18n.js +196 -0
- package/dist/src/panel/page-logic.d.ts +49 -0
- package/dist/src/panel/page-logic.js +67 -0
- package/dist/src/panel/page-script.js +461 -4
- package/dist/src/panel/page-styles.js +28 -2
- package/dist/src/panel/page.js +4 -1
- package/dist/src/quota/claude-statusline.js +4 -7
- package/dist/src/runtime.d.ts +40 -3
- package/dist/src/runtime.js +212 -41
- package/dist/src/types.d.ts +93 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +1 -0
- package/package.json +16 -4
- package/schemas/config.json +73 -0
- package/schemas/families.json +5 -1
- package/schemas/handoff-disable.json +106 -0
- package/schemas/handoff-enable.json +106 -0
- package/schemas/handoff-ready.json +23 -0
- package/schemas/handoff-setup.json +60 -0
- package/schemas/handoff-status.json +137 -0
- package/schemas/handoff-teardown.json +30 -0
- package/schemas/list.json +5 -1
- package/schemas/tune-change.json +126 -0
- package/schemas/tune.json +243 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/badge.md +1 -1
- package/skills/sideby/references/diagnose.md +4 -0
- package/skills/sideby/references/handoff.md +24 -2
- package/README.zh-CN.md +0 -112
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img alt="sideby:
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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="
|
|
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
|
|
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":"
|
|
1
|
+
{"id":"20261013T093231-6fb759"}
|
|
@@ -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
|
|
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>;
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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,142 @@ 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
|
+
if (plan.overrides.length)
|
|
316
|
+
lines.push(` Careful: these models save a size of their own, which wins over this one: ${plan.overrides.map((o) => `${o.model} (${compactNumber(o.value)})`).join(', ')}`);
|
|
317
|
+
lines.push(' Sessions already running keep the old value; new ones use the new one. You can undo it.');
|
|
318
|
+
return lines;
|
|
319
|
+
}
|
|
320
|
+
/** `160000`, `160k` or `1m` as a number of tokens, `default` as null (go back to the default); NaN when it is none of those. */
|
|
321
|
+
function parseTokens(text) {
|
|
322
|
+
if (text.trim().toLowerCase() === 'default')
|
|
323
|
+
return null;
|
|
324
|
+
const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(text.trim());
|
|
325
|
+
if (!m)
|
|
326
|
+
return Number.NaN;
|
|
327
|
+
return Math.round(Number(m[1]) * (m[2] === '' ? 1 : m[2]?.toLowerCase() === 'k' ? 1_000 : 1_000_000));
|
|
328
|
+
}
|
|
329
|
+
/** `sideby tune apply <account> [tokens]` and `sideby tune undo <account>` (spec §3.18, ADR-0012). */
|
|
330
|
+
async function cmdTuneChange(a, io, action) {
|
|
331
|
+
const [, ref, tokens] = a.positionals;
|
|
332
|
+
if (!ref || a.positionals.length > (action === 'apply' ? 3 : 2))
|
|
333
|
+
throw new UsageError(`usage: sideby tune ${action} <account>${action === 'apply' ? ' [tokens]' : ''} [--yes] [--json]`);
|
|
334
|
+
const wantJson = a.flags.has('json');
|
|
335
|
+
const yes = a.flags.has('yes');
|
|
336
|
+
const rt = await createRuntime();
|
|
337
|
+
const account = await rt.resolve(ref);
|
|
338
|
+
const home = rt.paths.home;
|
|
339
|
+
const out = (data) => json(io, { account: account.ref, action, ...data });
|
|
340
|
+
if (action === 'undo') {
|
|
341
|
+
const info = await rt.tuneUndoInfo(account);
|
|
342
|
+
if (!info) {
|
|
343
|
+
if (wantJson)
|
|
344
|
+
out({ applied: false, message: 'There is nothing from sideby to undo for this account.' });
|
|
345
|
+
else
|
|
346
|
+
io.out('There is nothing from sideby to undo for this account.');
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
if (!yes) {
|
|
350
|
+
if (wantJson)
|
|
351
|
+
out({ applied: false, message: 'Run again with --yes to undo.' });
|
|
352
|
+
else
|
|
353
|
+
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.`);
|
|
354
|
+
return 10;
|
|
355
|
+
}
|
|
356
|
+
const r = await rt.tuneUndo(account);
|
|
357
|
+
if (wantJson)
|
|
358
|
+
out({ applied: r.ok, message: r.message });
|
|
359
|
+
else
|
|
360
|
+
io.out(r.message);
|
|
361
|
+
return r.ok ? 0 : 1;
|
|
362
|
+
}
|
|
363
|
+
let value = tokens === undefined ? undefined : parseTokens(tokens);
|
|
364
|
+
if (value === undefined) {
|
|
365
|
+
const pick = (await rt.tuneGroups(account)).groups[0]?.pick ?? null;
|
|
366
|
+
if (!pick)
|
|
367
|
+
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' });
|
|
368
|
+
value = pick.threshold;
|
|
369
|
+
}
|
|
370
|
+
const plan = await rt.tuneApplyPlan(account, value);
|
|
371
|
+
const planJson = { ...plan, file: tilde(plan.file, home) };
|
|
372
|
+
if (plan.status === 'blocked') {
|
|
373
|
+
if (wantJson)
|
|
374
|
+
out({ applied: false, plan: planJson, message: plan.reason });
|
|
375
|
+
else
|
|
376
|
+
io.out(plan.reason ?? 'blocked');
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
if (plan.status === 'unchanged') {
|
|
380
|
+
if (wantJson)
|
|
381
|
+
out({ applied: false, plan: planJson, message: 'It is already set to that value; nothing changed.' });
|
|
382
|
+
else
|
|
383
|
+
io.out('It is already set to that value; nothing changed.');
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
if (!yes) {
|
|
387
|
+
if (wantJson)
|
|
388
|
+
out({ applied: false, plan: planJson, message: 'Run again with --yes to apply.' });
|
|
389
|
+
else
|
|
390
|
+
io.out([
|
|
391
|
+
`${plan.next === null ? 'Go back to the default' : `Use ${compactNumber(plan.next)}`} for ${account.ref}?`,
|
|
392
|
+
...tuneChangeLines(plan, home),
|
|
393
|
+
'Run again with --yes to apply.',
|
|
394
|
+
].join('\n'));
|
|
395
|
+
return 10;
|
|
396
|
+
}
|
|
397
|
+
const r = await rt.tuneApply(account, value);
|
|
398
|
+
if (wantJson)
|
|
399
|
+
out({ applied: r.ok, plan: { ...r.plan, file: tilde(r.plan.file, home) }, message: r.message });
|
|
400
|
+
else
|
|
401
|
+
io.out(r.message);
|
|
402
|
+
return r.ok ? 0 : 1;
|
|
403
|
+
}
|
|
404
|
+
/** `sideby tune [account|family]`: estimated Compaction Threshold per model (spec §3.18). */
|
|
405
|
+
export async function cmdTune(a, io) {
|
|
406
|
+
if (a.positionals[0] === 'apply' || a.positionals[0] === 'undo')
|
|
407
|
+
return cmdTuneChange(a, io, a.positionals[0]);
|
|
408
|
+
if (a.positionals.length > 1 || a.flags.has('yes'))
|
|
409
|
+
throw new UsageError('usage: sideby tune [account|family] [--json] (--yes belongs to `tune apply` and `tune undo`)');
|
|
410
|
+
const rt = await createRuntime();
|
|
411
|
+
const target = a.positionals[0];
|
|
412
|
+
const all = await rt.accounts();
|
|
413
|
+
let accounts = all;
|
|
414
|
+
if (target) {
|
|
415
|
+
const ofFamily = all.filter((x) => x.family === target);
|
|
416
|
+
if (ofFamily.length)
|
|
417
|
+
accounts = ofFamily;
|
|
418
|
+
else if ((await rt.familyInfo()).some((f) => f.id === target))
|
|
419
|
+
accounts = [];
|
|
420
|
+
else
|
|
421
|
+
accounts = [await rt.resolve(target)];
|
|
422
|
+
}
|
|
423
|
+
const rows = await Promise.all(accounts.map(async (x) => ({ ref: x.ref, family: x.family, tuning: await rt.tuning(x) })));
|
|
424
|
+
if (a.flags.has('json')) {
|
|
425
|
+
json(io, { accounts: rows });
|
|
426
|
+
return 0;
|
|
427
|
+
}
|
|
428
|
+
if (rows.length === 0) {
|
|
429
|
+
io.out(target
|
|
430
|
+
? `No ${target} accounts yet. Run \`sideby new ${target} <name>\` to add one.`
|
|
431
|
+
: 'No accounts found yet. Run `sideby` for how to start.');
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
for (const r of rows) {
|
|
435
|
+
io.out(c.bold(r.ref));
|
|
436
|
+
for (const line of tuningLines(r.tuning))
|
|
437
|
+
io.out(` ${line}`);
|
|
438
|
+
}
|
|
439
|
+
io.out(c.dim(`\nEstimates from your last ${USAGE_DAYS} days of local sessions; sideby does not change any setting.`));
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
281
442
|
export async function cmdQuota(a, io) {
|
|
282
443
|
const sub = a.positionals[0];
|
|
283
444
|
if (sub === 'setup' || sub === 'teardown')
|
|
@@ -318,7 +479,10 @@ async function cmdQuotaSetup(a, io, sub) {
|
|
|
318
479
|
if (!familyId)
|
|
319
480
|
throw new UsageError(`usage: sideby quota ${sub} <family>${sub === 'setup' ? ' [--yes]' : ''}`);
|
|
320
481
|
const rt = await createRuntime();
|
|
321
|
-
|
|
482
|
+
return runSetup(a, io, rt, 'quota', sub, familyId, rt.quotaSetup(familyId));
|
|
483
|
+
}
|
|
484
|
+
/** `sideby quota|handoff setup|teardown <family>`: show the change, apply it only with --yes (exit 10 before). */
|
|
485
|
+
async function runSetup(a, io, rt, command, sub, familyId, setup) {
|
|
322
486
|
if (sub === 'teardown') {
|
|
323
487
|
const r = await setup.teardown();
|
|
324
488
|
if (a.flags.has('json'))
|
|
@@ -340,7 +504,7 @@ async function cmdQuotaSetup(a, io, sub) {
|
|
|
340
504
|
if (plan.status === 'ready') {
|
|
341
505
|
io.out(`\n${setup.summary}\nChange to ${tildify(plan.file, rt.paths.home)}:\n`);
|
|
342
506
|
io.out(plan.diff);
|
|
343
|
-
io.out(c.cyan(`\nApply it with \`sideby
|
|
507
|
+
io.out(c.cyan(`\nApply it with \`sideby ${command} setup ${familyId} --yes\`; undo any time with \`sideby ${command} teardown ${familyId}\`.`));
|
|
344
508
|
}
|
|
345
509
|
}
|
|
346
510
|
// 10: the change is shown and waits for the user's yes (spec §3.9); agents ask before adding --yes.
|
|
@@ -353,6 +517,165 @@ async function cmdQuotaSetup(a, io, sub) {
|
|
|
353
517
|
io.out(`${after.status === 'enabled' ? c.green('✓') : c.red('✗')} ${after.message}`);
|
|
354
518
|
return after.status === 'enabled' ? 0 : 1;
|
|
355
519
|
}
|
|
520
|
+
/** `sideby handoff status` (spec §3.17): what Auto Handoff still needs, with the commands to run. */
|
|
521
|
+
async function cmdHandoffStatus(a, io) {
|
|
522
|
+
const st = await handoffStatus(await createRuntime());
|
|
523
|
+
if (a.flags.has('json')) {
|
|
524
|
+
json(io, st);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
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}%)`);
|
|
528
|
+
if (st.sideby.problem)
|
|
529
|
+
io.out(c.red(`sideby on PATH: ${st.sideby.problem}`));
|
|
530
|
+
if (st.families.length)
|
|
531
|
+
io.out(`\n${table(st.families.map((f) => [
|
|
532
|
+
c.bold(f.family),
|
|
533
|
+
f.starts.length ? f.starts.join(', ') : c.dim('takes over only'),
|
|
534
|
+
f.ready ? c.green('ready') : f.starts.length ? c.yellow('not yet') : c.dim('—'),
|
|
535
|
+
f.order.policy === 'order' ? f.order.order.join(' → ') : c.dim('by quota'),
|
|
536
|
+
]), ['FAMILY', 'STARTS', 'STATE', 'ORDER'])}`);
|
|
537
|
+
for (const f of st.families) {
|
|
538
|
+
for (const p of f.problems)
|
|
539
|
+
io.out(c.yellow(`${f.family}: ${p}`));
|
|
540
|
+
for (const n of f.notes)
|
|
541
|
+
io.out(c.dim(`${f.family}: ${n}`));
|
|
542
|
+
}
|
|
543
|
+
if (st.nextSteps.length) {
|
|
544
|
+
io.out('\nNext:');
|
|
545
|
+
for (const [i, step] of st.nextSteps.entries())
|
|
546
|
+
io.out(` ${i + 1}. ${step}`);
|
|
547
|
+
}
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
function intFlag(a, name) {
|
|
551
|
+
const v = a.flags.get(name);
|
|
552
|
+
if (v === undefined)
|
|
553
|
+
return undefined;
|
|
554
|
+
const n = Number(v);
|
|
555
|
+
if (typeof v !== 'string' || !Number.isInteger(n) || n < 0)
|
|
556
|
+
throw new UsageError(`--${name} takes a whole number, for example --${name} 95`);
|
|
557
|
+
return n;
|
|
558
|
+
}
|
|
559
|
+
/** The `enable`/`disable` command line for `change`, to repeat with --yes. */
|
|
560
|
+
function switchCommand(sub, change) {
|
|
561
|
+
const parts = [`sideby handoff ${sub}`];
|
|
562
|
+
if (change.sameFamily === true)
|
|
563
|
+
parts.push('--same-family');
|
|
564
|
+
if (change.sameFamily === false)
|
|
565
|
+
parts.push('--no-same-family');
|
|
566
|
+
for (const o of change.orders ?? [])
|
|
567
|
+
parts.push(`--order ${o.family}=${o.order.join(',')}`);
|
|
568
|
+
if (change.prepareAt !== undefined)
|
|
569
|
+
parts.push(`--prepare-at ${change.prepareAt}`);
|
|
570
|
+
if (change.threshold !== undefined)
|
|
571
|
+
parts.push(`--threshold ${change.threshold}`);
|
|
572
|
+
if (change.waitIfResetWithinMinutes !== undefined)
|
|
573
|
+
parts.push(`--wait-minutes ${change.waitIfResetWithinMinutes}`);
|
|
574
|
+
if (change.countdownSeconds !== undefined)
|
|
575
|
+
parts.push(`--countdown ${change.countdownSeconds}`);
|
|
576
|
+
return parts.join(' ');
|
|
577
|
+
}
|
|
578
|
+
/** `sideby handoff enable|disable` (spec §3.17): show the config change (exit 10), write it with --yes. */
|
|
579
|
+
async function cmdHandoffSwitch(a, io, sub) {
|
|
580
|
+
const change = { auto: sub === 'enable' };
|
|
581
|
+
if (sub === 'enable') {
|
|
582
|
+
if (a.flags.has('same-family'))
|
|
583
|
+
change.sameFamily = true;
|
|
584
|
+
if (a.flags.has('no-same-family'))
|
|
585
|
+
change.sameFamily = false;
|
|
586
|
+
const order = a.flags.get('order');
|
|
587
|
+
if (typeof order === 'string') {
|
|
588
|
+
const eq = order.indexOf('=');
|
|
589
|
+
if (eq <= 0)
|
|
590
|
+
throw new UsageError('--order takes <family>=<account,account>, for example --order claude=codex001,cc002');
|
|
591
|
+
change.orders = [
|
|
592
|
+
{
|
|
593
|
+
family: order.slice(0, eq),
|
|
594
|
+
order: order
|
|
595
|
+
.slice(eq + 1)
|
|
596
|
+
.split(',')
|
|
597
|
+
.map((x) => x.trim())
|
|
598
|
+
.filter(Boolean),
|
|
599
|
+
},
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
const nums = {
|
|
603
|
+
prepareAt: 'prepare-at',
|
|
604
|
+
threshold: 'threshold',
|
|
605
|
+
waitIfResetWithinMinutes: 'wait-minutes',
|
|
606
|
+
countdownSeconds: 'countdown',
|
|
607
|
+
};
|
|
608
|
+
for (const [key, flag] of Object.entries(nums)) {
|
|
609
|
+
const n = intFlag(a, flag);
|
|
610
|
+
if (n !== undefined)
|
|
611
|
+
change[key] = n;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const rt = await createRuntime();
|
|
615
|
+
const r = await configureHandoff(rt, change, { apply: a.flags.has('yes') });
|
|
616
|
+
const nextSteps = r.applied && sub === 'enable' ? (await handoffStatus(await createRuntime())).nextSteps : [];
|
|
617
|
+
if (a.flags.has('json')) {
|
|
618
|
+
json(io, { applied: r.applied, file: r.file, diff: r.diff, settings: r.settings, nextSteps });
|
|
619
|
+
}
|
|
620
|
+
else if (!r.changed)
|
|
621
|
+
io.out(`Auto Handoff is already ${sub === 'enable' ? 'set that way' : 'off'}; nothing to change.`);
|
|
622
|
+
else if (!r.applied) {
|
|
623
|
+
io.out(`Change to ${tildify(r.file, rt.paths.home)}:\n\n${r.diff}`);
|
|
624
|
+
if (sub === 'enable' && r.settings.sameFamily)
|
|
625
|
+
io.out(c.yellow('Same-family hand-over moves work between subscriptions of one vendor; vendors may act on that.'));
|
|
626
|
+
io.out(c.cyan(`\nApply it with \`${switchCommand(sub, change)} --yes\`.`));
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
io.out(`${c.green('✓')} Auto Handoff is ${sub === 'enable' ? 'on' : 'off'}.`);
|
|
630
|
+
if (nextSteps.length) {
|
|
631
|
+
io.out('Still to do:');
|
|
632
|
+
for (const [i, step] of nextSteps.entries())
|
|
633
|
+
io.out(` ${i + 1}. ${step}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// 10: the change is shown and waits for the user's yes, as for `quota setup`.
|
|
637
|
+
return r.changed && !r.applied ? 10 : 0;
|
|
638
|
+
}
|
|
639
|
+
/** `sideby handoff ready|setup|teardown` (spec §3.17). */
|
|
640
|
+
export async function cmdHandoff(a, io) {
|
|
641
|
+
const sub = a.positionals[0];
|
|
642
|
+
if (sub === 'setup' || sub === 'teardown') {
|
|
643
|
+
const familyId = a.positionals[1];
|
|
644
|
+
if (!familyId)
|
|
645
|
+
throw new UsageError(`usage: sideby handoff ${sub} <family>${sub === 'setup' ? ' [--yes]' : ''}`);
|
|
646
|
+
const rt = await createRuntime();
|
|
647
|
+
return runSetup(a, io, rt, 'handoff', sub, familyId, rt.handoffSetup(familyId));
|
|
648
|
+
}
|
|
649
|
+
if (sub === 'status')
|
|
650
|
+
return cmdHandoffStatus(a, io);
|
|
651
|
+
if (sub === 'enable' || sub === 'disable')
|
|
652
|
+
return cmdHandoffSwitch(a, io, sub);
|
|
653
|
+
if (sub !== 'ready')
|
|
654
|
+
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]');
|
|
655
|
+
const flag = a.flags.get('brief');
|
|
656
|
+
const brief = typeof flag === 'string' ? flag : undefined;
|
|
657
|
+
const decision = await readyDecision(await createRuntime(), process.env);
|
|
658
|
+
if (decision && decision.kind !== 'pick')
|
|
659
|
+
throw new UserError(`nothing can take over now: ${explain(decision)}; keep working in this account`, {
|
|
660
|
+
code: 'no-pick',
|
|
661
|
+
});
|
|
662
|
+
let request;
|
|
663
|
+
try {
|
|
664
|
+
request = await requestHandoff(process.env, brief);
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
throw new UserError(`cannot read the brief ${brief}: ${err.message}; check the path and retry`, {
|
|
668
|
+
code: 'brief-unreadable',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (!request)
|
|
672
|
+
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' });
|
|
673
|
+
if (a.flags.has('json'))
|
|
674
|
+
json(io, { requested: true, ...(request.brief ? { brief: request.brief } : {}) });
|
|
675
|
+
else
|
|
676
|
+
io.out('The next account starts when this turn ends; finish the turn now.');
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
356
679
|
export async function cmdPlugins(a, io) {
|
|
357
680
|
const rt = await createRuntime();
|
|
358
681
|
if (a.flags.has('json'))
|
package/dist/src/cli/format.d.ts
CHANGED
|
@@ -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[];
|
package/dist/src/cli/format.js
CHANGED
|
@@ -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 model window is below the lowest size Claude Code accepts (100K), so there is nothing to set',
|
|
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
|
+
}
|