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.
- 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 +330 -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 +113 -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 +51 -0
- package/dist/src/core/tuning-apply.js +237 -0
- package/dist/src/core/tuning.d.ts +98 -0
- package/dist/src/core/tuning.js +176 -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 +187 -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 +96 -0
- package/dist/src/panel/i18n.js +192 -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 +454 -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 +210 -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 +108 -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
|
@@ -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 {
|
|
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
|
|
77
|
-
if (
|
|
78
|
-
return blocked(
|
|
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 };
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// The one service layer the CLI and the Panel share. Frozen contract for v0.1 (plan: 审核修订).
|
|
2
2
|
import { aliasesByAccount, discoverAccounts, resolveRef } from './core/accounts.js';
|
|
3
|
-
import { addConfigAlias, aliasAccount, aliasArgs, aliasProblem, aliasRefs, loadConfig, removeConfigAlias, } from './core/config.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({
|
|
@@ -77,6 +83,45 @@ export async function createRuntime(opts = {}) {
|
|
|
77
83
|
out.push(...(await discoverAccounts(f, paths.home, ignore)));
|
|
78
84
|
return out;
|
|
79
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
|
+
};
|
|
80
125
|
const readContext = () => ({
|
|
81
126
|
home: paths.home,
|
|
82
127
|
now: now(),
|
|
@@ -114,6 +159,7 @@ export async function createRuntime(opts = {}) {
|
|
|
114
159
|
installUrl: f.installUrl,
|
|
115
160
|
installed: (await which(f.bin, env)) !== null,
|
|
116
161
|
plugin: loaded.familyOwner.get(f.id) ?? 'unknown',
|
|
162
|
+
tuning: Boolean(f.readTuning),
|
|
117
163
|
})));
|
|
118
164
|
},
|
|
119
165
|
async familyDetails() {
|
|
@@ -209,10 +255,26 @@ export async function createRuntime(opts = {}) {
|
|
|
209
255
|
// of that Family keeps its skill warning.
|
|
210
256
|
skillVersion: packageVersion(),
|
|
211
257
|
};
|
|
212
|
-
|
|
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));
|
|
213
275
|
if (o.fix) {
|
|
214
276
|
const fixes = await applyFixes(report);
|
|
215
|
-
report = { ...(await runDoctor(input)), fixes };
|
|
277
|
+
report = { ...(await withHandoff(await runDoctor(input))), fixes };
|
|
216
278
|
}
|
|
217
279
|
const history = mergeDoctorHistory(await readDoctorHistory(paths.stateDir), report, scope, now());
|
|
218
280
|
if (o.persist !== false)
|
|
@@ -232,34 +294,15 @@ export async function createRuntime(opts = {}) {
|
|
|
232
294
|
},
|
|
233
295
|
quotaSetup(familyId) {
|
|
234
296
|
const f = familyOf(familyId);
|
|
235
|
-
|
|
236
|
-
if (!setup)
|
|
297
|
+
if (!f.quotaSetup)
|
|
237
298
|
throw new UserError(`${f.title} needs no quota setup`);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
246
|
-
return {
|
|
247
|
-
family: f.id,
|
|
248
|
-
summary: setup.summary,
|
|
249
|
-
// Promise.resolve().then also catches a setup that throws synchronously.
|
|
250
|
-
plan: () => Promise.resolve()
|
|
251
|
-
.then(() => setup.plan(readContext()))
|
|
252
|
-
.catch(blocked),
|
|
253
|
-
apply: () => Promise.resolve()
|
|
254
|
-
.then(() => setup.apply(readContext()))
|
|
255
|
-
.catch(blocked),
|
|
256
|
-
teardown: () => Promise.resolve()
|
|
257
|
-
.then(() => setup.teardown(readContext()))
|
|
258
|
-
.catch((err) => ({
|
|
259
|
-
ok: false,
|
|
260
|
-
message: err?.message ?? String(err),
|
|
261
|
-
})),
|
|
262
|
-
};
|
|
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);
|
|
263
306
|
},
|
|
264
307
|
async createAccount(familyId, name, o = {}) {
|
|
265
308
|
const family = familyOf(familyId);
|
|
@@ -374,7 +417,14 @@ export async function createRuntime(opts = {}) {
|
|
|
374
417
|
return { launch: host };
|
|
375
418
|
const route = await findResumeRoute(family, await accounts(), userArgs);
|
|
376
419
|
const id = route.sessionId ? { sessionId: route.sessionId } : {};
|
|
377
|
-
if (
|
|
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)
|
|
378
428
|
return { launch: host, ...id };
|
|
379
429
|
return {
|
|
380
430
|
launch: await rt.prepareLaunch(route.account.ref, userArgs, 'run'),
|
|
@@ -388,23 +438,109 @@ export async function createRuntime(opts = {}) {
|
|
|
388
438
|
const f = familyOf(account.family);
|
|
389
439
|
if (!f.readQuota)
|
|
390
440
|
return { status: 'unavailable', reason: 'no-source' };
|
|
441
|
+
await memos.ready();
|
|
391
442
|
try {
|
|
392
443
|
return await f.readQuota(account, readContext());
|
|
393
444
|
}
|
|
394
445
|
catch (err) {
|
|
395
446
|
return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
|
|
396
447
|
}
|
|
448
|
+
finally {
|
|
449
|
+
memos.saveSoon();
|
|
450
|
+
}
|
|
397
451
|
},
|
|
398
452
|
async usage(account) {
|
|
399
453
|
const f = familyOf(account.family);
|
|
400
454
|
if (!f.readUsage)
|
|
401
455
|
return { status: 'unavailable', reason: 'no-source' };
|
|
456
|
+
await memos.ready();
|
|
402
457
|
try {
|
|
403
458
|
return await f.readUsage(account, readContext());
|
|
404
459
|
}
|
|
405
460
|
catch (err) {
|
|
406
461
|
return { status: 'unavailable', reason: 'unrecognized', detail: err.message };
|
|
407
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
|
+
}
|
|
408
544
|
},
|
|
409
545
|
async handoff(familyId, o = {}) {
|
|
410
546
|
const f = familyOf(familyId);
|
|
@@ -416,22 +552,55 @@ export async function createRuntime(opts = {}) {
|
|
|
416
552
|
code: 'no-accounts',
|
|
417
553
|
});
|
|
418
554
|
// Only the login state and Quota: no identity, model or Host lookups are needed to rank.
|
|
419
|
-
const loginOf = async (a) => {
|
|
420
|
-
if (a.kind === 'api')
|
|
421
|
-
return 'not-needed';
|
|
422
|
-
try {
|
|
423
|
-
return (await f.loginState?.(a)) ?? 'unknown';
|
|
424
|
-
}
|
|
425
|
-
catch {
|
|
426
|
-
return 'unknown';
|
|
427
|
-
}
|
|
428
|
-
};
|
|
429
555
|
const candidates = await Promise.all(list.map(async (a) => {
|
|
430
556
|
const [login, quota] = await Promise.all([loginOf(a), rt.quota(a)]);
|
|
431
557
|
return { ref: a.ref, name: a.name, isMain: a.isMain, kind: a.kind, login, quota };
|
|
432
558
|
}));
|
|
433
559
|
return planHandoff(f.id, candidates, readContext().now, o);
|
|
434
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
|
+
},
|
|
435
604
|
readContext,
|
|
436
605
|
};
|
|
437
606
|
return rt;
|
package/dist/src/types.d.ts
CHANGED
|
@@ -85,6 +85,11 @@ export interface FamilyDef {
|
|
|
85
85
|
identity?(account: Account): Promise<AccountIdentity | undefined>;
|
|
86
86
|
readQuota?(account: Account, ctx: ReadContext): Promise<QuotaResult>;
|
|
87
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>;
|
|
88
93
|
/** One-time, reversible change that lets the Host report Quota (Claude: status line wrapper). */
|
|
89
94
|
quotaSetup?: QuotaSetup;
|
|
90
95
|
/**
|
|
@@ -94,6 +99,55 @@ export interface FamilyDef {
|
|
|
94
99
|
resumedSession?(args: readonly string[]): string | undefined;
|
|
95
100
|
/** When the Account holds the session, the last write time of its file in ms; otherwise undefined. */
|
|
96
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;
|
|
97
151
|
}
|
|
98
152
|
/** Identity fields of a signed-in Account. */
|
|
99
153
|
export interface AccountIdentity {
|
|
@@ -190,6 +244,45 @@ export type UsageResult = ({
|
|
|
190
244
|
reason: 'no-source' | 'no-session' | 'unrecognized';
|
|
191
245
|
detail?: string;
|
|
192
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
|
+
};
|
|
193
286
|
export type FindingLevel = 'ok' | 'warn' | 'fail';
|
|
194
287
|
export interface FixResult {
|
|
195
288
|
ok: boolean;
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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
|
package/dist/testing/index.js
CHANGED
|
@@ -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
|
}
|