sideby 0.13.0 → 0.14.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 +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +3 -2
- package/dist/src/cli/commands.js +69 -8
- package/dist/src/cli/format.js +7 -3
- package/dist/src/cli/json-schemas.js +62 -2
- package/dist/src/cli/main.js +42 -4
- package/dist/src/core/config.d.ts +27 -0
- package/dist/src/core/config.js +48 -1
- package/dist/src/core/handoff-enable.js +10 -5
- package/dist/src/core/launch.d.ts +5 -0
- package/dist/src/core/launch.js +14 -3
- package/dist/src/core/quota-fetch.d.ts +22 -0
- package/dist/src/core/quota-fetch.js +98 -0
- package/dist/src/core/quota-levels.d.ts +3 -2
- package/dist/src/core/quota-levels.js +8 -4
- package/dist/src/core/quota-store.d.ts +19 -0
- package/dist/src/core/quota-store.js +62 -0
- package/dist/src/core/skill-check.d.ts +15 -0
- package/dist/src/core/skill-check.js +49 -3
- package/dist/src/core/update.d.ts +45 -0
- package/dist/src/core/update.js +72 -1
- package/dist/src/families/grok/handoff-hook.d.ts +6 -0
- package/dist/src/families/grok/handoff-hook.js +45 -2
- package/dist/src/families/grok/handoff.d.ts +6 -1
- package/dist/src/families/grok/handoff.js +30 -13
- package/dist/src/families/grok/index.js +5 -0
- package/dist/src/families/grok/quota-log.d.ts +30 -0
- package/dist/src/families/grok/quota-log.js +140 -0
- package/dist/src/families/grok/quota.d.ts +5 -0
- package/dist/src/families/grok/quota.js +112 -0
- package/dist/src/panel/handler.d.ts +9 -3
- package/dist/src/panel/handler.js +65 -3
- package/dist/src/panel/i18n.d.ts +25 -5
- package/dist/src/panel/i18n.js +52 -12
- package/dist/src/panel/page-logic.d.ts +18 -2
- package/dist/src/panel/page-logic.js +53 -6
- package/dist/src/panel/page-script.js +286 -92
- package/dist/src/panel/page-styles.js +30 -10
- package/dist/src/runtime.d.ts +30 -2
- package/dist/src/runtime.js +64 -21
- package/dist/src/types.d.ts +49 -1
- package/package.json +2 -2
- package/schemas/alias.json +62 -0
- package/schemas/app-install.json +62 -0
- package/schemas/app-uninstall.json +62 -0
- package/schemas/browser-open.json +62 -0
- package/schemas/browser-switch.json +62 -0
- package/schemas/browser.json +62 -0
- package/schemas/config.json +46 -0
- package/schemas/desktop-remove.json +62 -0
- package/schemas/desktop.json +62 -0
- package/schemas/doctor.json +62 -0
- package/schemas/error.json +62 -0
- package/schemas/families.json +62 -0
- package/schemas/handoff-disable.json +62 -0
- package/schemas/handoff-enable.json +62 -0
- package/schemas/handoff-ready.json +62 -0
- package/schemas/handoff-setup.json +62 -0
- package/schemas/handoff-status.json +62 -0
- package/schemas/handoff-teardown.json +62 -0
- package/schemas/list.json +62 -0
- package/schemas/model.json +62 -0
- package/schemas/new.json +62 -0
- package/schemas/next.json +62 -0
- package/schemas/plugins.json +62 -0
- package/schemas/quota-setup.json +62 -0
- package/schemas/quota-teardown.json +62 -0
- package/schemas/quota.json +76 -1
- package/schemas/remote-control.json +62 -0
- package/schemas/tune-change.json +62 -0
- package/schemas/tune.json +62 -0
- package/schemas/update.json +107 -1
- package/skills/sideby/SKILL.md +4 -2
- package/skills/sideby/references/diagnose.md +3 -2
- package/skills/sideby/references/handoff.md +1 -1
- package/skills/sideby/references/quota.md +5 -1
- package/skills/sideby/references/update.md +16 -0
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ cc-switch's docs describe that switch: it writes the live settings file ([README
|
|
|
94
94
|
|---|---|---|---|---|
|
|
95
95
|
| Claude Code | `~/.claude`, `~/.claude-<name>` | `claude auth login` | ✓ after `quota setup claude` | ✓ |
|
|
96
96
|
| Codex CLI | `~/.codex`, `~/.codex-<name>` | `codex login` | ✓ | ✓ |
|
|
97
|
-
| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` |
|
|
97
|
+
| Grok Build | `~/.grok`, `~/.grok-<name>` | `grok login` | weekly, asked from the `grok` CLI | no public source |
|
|
98
98
|
| pi | `~/.pi/agent`, `~/.pi-<name>/agent` | `/login` inside pi | no public source | no public source |
|
|
99
99
|
|
|
100
100
|
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).
|
package/dist/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"
|
|
1
|
+
{"id":"20261017T050946-402d73"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DoctorReport } from '../core/doctor.ts';
|
|
2
2
|
import { type HandoffIo } from '../core/handoff-run.ts';
|
|
3
|
-
import type { Finding } from '../types.ts';
|
|
3
|
+
import type { Account, Finding } from '../types.ts';
|
|
4
4
|
import { type ParsedArgs } from './args.ts';
|
|
5
5
|
export interface Io {
|
|
6
6
|
out(s: string): void;
|
|
@@ -8,13 +8,14 @@ export interface Io {
|
|
|
8
8
|
/** The terminal Auto Handoff counts down on (spec §3.17); `process.stdin` when left out. */
|
|
9
9
|
stdin?: HandoffIo['stdin'];
|
|
10
10
|
}
|
|
11
|
+
export declare function setJsonNotice(n: object | null): void;
|
|
11
12
|
export declare function serializeDoctor(r: DoctorReport): {
|
|
12
13
|
status: "issues" | "ok";
|
|
13
14
|
accounts: {
|
|
14
15
|
ref: string;
|
|
15
16
|
family: string;
|
|
16
17
|
name: string;
|
|
17
|
-
kind:
|
|
18
|
+
kind: Account['kind'];
|
|
18
19
|
dir: string;
|
|
19
20
|
shared: {
|
|
20
21
|
ok: number;
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -14,7 +14,7 @@ import { resolvePaths, tildify } from '../core/paths.js';
|
|
|
14
14
|
import { USAGE_DAYS } from '../core/quota-levels.js';
|
|
15
15
|
import { groupsToTurnOff, groupsToTurnOn, groupsToUndo, } from '../core/remote-control.js';
|
|
16
16
|
import { SHELLS } from '../core/shell-init.js';
|
|
17
|
-
import { suggestAlias, suggestName } from '../panel/page-logic.js';
|
|
17
|
+
import { inDisplayOrder, suggestAlias, suggestName } from '../panel/page-logic.js';
|
|
18
18
|
import { createRuntime, UnknownFamilyError } from '../runtime.js';
|
|
19
19
|
import { UsageError } from './args.js';
|
|
20
20
|
import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
|
|
@@ -51,7 +51,12 @@ async function runAccountNow(rt, ref, args, io) {
|
|
|
51
51
|
}
|
|
52
52
|
return code;
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
// The `_notice` main.ts works out before a `--json` command (spec §3.23); every JSON output of this run carries it.
|
|
55
|
+
let notice = null;
|
|
56
|
+
export function setJsonNotice(n) {
|
|
57
|
+
notice = n;
|
|
58
|
+
}
|
|
59
|
+
const json = (io, data) => io.out(JSON.stringify({ schemaVersion: 1, ...data, ...(notice ? { _notice: notice } : {}) }, null, 2));
|
|
55
60
|
function stripFix(f) {
|
|
56
61
|
const { fix: _fix, ...rest } = f;
|
|
57
62
|
return rest;
|
|
@@ -68,15 +73,21 @@ export function serializeDoctor(r) {
|
|
|
68
73
|
function tilde(text, home) {
|
|
69
74
|
return text.split(`${home}/`).join('~/');
|
|
70
75
|
}
|
|
76
|
+
/** Accounts in the Display Order the user gave them in the Panel (spec §3.25): Families first, then each one's. */
|
|
77
|
+
function displayOrdered(accounts, rt) {
|
|
78
|
+
const d = rt.displayOrder();
|
|
79
|
+
const ids = inDisplayOrder([...new Set(accounts.map((x) => x.family))], (id) => id, d.families);
|
|
80
|
+
return ids.flatMap((id) => inDisplayOrder(accounts.filter((x) => x.family === id), (x) => x.name, d.accounts?.[id]));
|
|
81
|
+
}
|
|
71
82
|
export async function cmdList(a, io) {
|
|
72
83
|
const rt = await createRuntime();
|
|
73
|
-
const accounts = await rt.accounts();
|
|
84
|
+
const accounts = displayOrdered(await rt.accounts(), rt);
|
|
74
85
|
const [statuses, appProfiles] = await Promise.all([
|
|
75
86
|
withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
|
|
76
87
|
listAppProfiles(rt),
|
|
77
88
|
]);
|
|
78
89
|
const hasDesktop = statuses.some((s) => s.desktopApp);
|
|
79
|
-
const families = await rt.familyInfo();
|
|
90
|
+
const families = inDisplayOrder(await rt.familyInfo(), (f) => f.id, rt.displayOrder().families);
|
|
80
91
|
if (a.flags.has('json')) {
|
|
81
92
|
json(io, { families, accounts: statuses, appProfiles, pluginErrors: [...rt.pluginErrors] });
|
|
82
93
|
return 0;
|
|
@@ -674,7 +685,14 @@ export async function cmdQuota(a, io) {
|
|
|
674
685
|
if (sub === 'setup' || sub === 'teardown')
|
|
675
686
|
return cmdQuotaSetup(a, io, sub);
|
|
676
687
|
const rt = await createRuntime();
|
|
677
|
-
const accounts = sub
|
|
688
|
+
const accounts = !sub
|
|
689
|
+
? displayOrdered(await rt.accounts(), rt)
|
|
690
|
+
: rt.families.has(sub)
|
|
691
|
+
? displayOrdered(await rt.accounts(), rt).filter((x) => x.family === sub)
|
|
692
|
+
: [await rt.resolve(sub)];
|
|
693
|
+
// Asks the Families that fetch Quota again now (spec §3.26); a check that is turned off stays off.
|
|
694
|
+
if (a.flags.has('refresh'))
|
|
695
|
+
await rt.refreshQuota(accounts);
|
|
678
696
|
const rows = await Promise.all(accounts.map(async (x) => ({
|
|
679
697
|
ref: x.ref,
|
|
680
698
|
kind: x.kind,
|
|
@@ -695,9 +713,12 @@ export async function cmdQuota(a, io) {
|
|
|
695
713
|
`${quotaText(r.quota, now)}${r.quota.status === 'ok' && r.quota.from ? c.dim(` read from ${r.quota.from} (same login)`) : ''}`,
|
|
696
714
|
usageText(r.usage),
|
|
697
715
|
]), ['ACCOUNT', 'QUOTA', `USAGE (${USAGE_DAYS}d)`]));
|
|
698
|
-
for (const r of rows)
|
|
716
|
+
for (const r of rows) {
|
|
699
717
|
if (r.quota.status === 'unavailable' && r.quota.detail)
|
|
700
718
|
io.out(c.dim(`${r.ref}: ${r.quota.detail}`));
|
|
719
|
+
if (r.quota.status === 'ok' && r.quota.fetchError)
|
|
720
|
+
io.out(c.dim(`${r.ref}: the latest check failed: ${r.quota.fetchError}`));
|
|
721
|
+
}
|
|
701
722
|
// Suggest a setup only where it would help: the Family has one and its plan can be applied.
|
|
702
723
|
const off = new Set(rows
|
|
703
724
|
.filter((r) => r.quota.status === 'unavailable' && r.quota.reason === 'not-enabled')
|
|
@@ -1051,11 +1072,41 @@ export async function cmdUpdate(a, io) {
|
|
|
1051
1072
|
const asJson = a.flags.has('json');
|
|
1052
1073
|
const plan = await u.planUpdate(paths, process.env);
|
|
1053
1074
|
const shown = u.shownPlan(plan);
|
|
1075
|
+
const sk = plan.skill;
|
|
1076
|
+
const skillLine = (s) => s.canRun
|
|
1077
|
+
? `the sideby skill (for ${s.installed.map((v) => v ?? 'no version').join(', ')}) is updated too:\n ${s.command}`
|
|
1078
|
+
: `the sideby skill is not updated by sideby: ${s.reason}.\n ${s.command}`;
|
|
1054
1079
|
if (!plan.available) {
|
|
1080
|
+
// The CLI is current; an outdated skill is the one thing left to update.
|
|
1081
|
+
if (sk?.outdated && sk.canRun) {
|
|
1082
|
+
if (!a.flags.has('yes')) {
|
|
1083
|
+
if (asJson)
|
|
1084
|
+
json(io, { ...shown, updated: false });
|
|
1085
|
+
else {
|
|
1086
|
+
io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
|
|
1087
|
+
io.out(`\nUpdating ${skillLine(sk)}`);
|
|
1088
|
+
io.out(c.cyan('\nRun it with `sideby update --yes`.'));
|
|
1089
|
+
}
|
|
1090
|
+
return 10;
|
|
1091
|
+
}
|
|
1092
|
+
if (!asJson)
|
|
1093
|
+
io.out(c.dim(`$ ${sk.command}`));
|
|
1094
|
+
const r = await u.applySkillUpdate(sk, process.env, asJson ? undefined : (t) => process.stderr.write(t));
|
|
1095
|
+
if (asJson)
|
|
1096
|
+
json(io, { ...shown, updated: false, skillUpdated: r?.ok ?? false, message: r?.message ?? '' });
|
|
1097
|
+
else if (r?.ok)
|
|
1098
|
+
io.out(`${c.green('✓')} ${r.message}; agents load it in their next session.`);
|
|
1099
|
+
else
|
|
1100
|
+
io.err(c.red(`✗ ${r?.message}`));
|
|
1101
|
+
return r?.ok ? 0 : 1;
|
|
1102
|
+
}
|
|
1055
1103
|
if (asJson)
|
|
1056
1104
|
json(io, { ...shown, updated: false });
|
|
1057
|
-
else
|
|
1105
|
+
else {
|
|
1058
1106
|
io.out(`${c.green('✓')} sideby ${plan.current} is the latest version.`);
|
|
1107
|
+
if (sk?.outdated)
|
|
1108
|
+
io.out(c.yellow(skillLine(sk)));
|
|
1109
|
+
}
|
|
1059
1110
|
return 0;
|
|
1060
1111
|
}
|
|
1061
1112
|
if (!plan.canRun) {
|
|
@@ -1073,6 +1124,8 @@ export async function cmdUpdate(a, io) {
|
|
|
1073
1124
|
else {
|
|
1074
1125
|
io.out(`sideby ${c.bold(plan.latest)} is available (you have ${plan.current}). What changed: ${plan.releaseNotes}`);
|
|
1075
1126
|
io.out(`\nUpdating runs:\n ${plan.command}`);
|
|
1127
|
+
if (sk?.outdated)
|
|
1128
|
+
io.out(`Then ${skillLine(sk)}`);
|
|
1076
1129
|
io.out(c.cyan('\nRun it with `sideby update --yes`.'));
|
|
1077
1130
|
}
|
|
1078
1131
|
return 10;
|
|
@@ -1091,6 +1144,7 @@ export async function cmdUpdate(a, io) {
|
|
|
1091
1144
|
restartedPanel: r.restartedPanel,
|
|
1092
1145
|
refreshedApp: r.refreshedApp,
|
|
1093
1146
|
skill: SKILL_INSTALL,
|
|
1147
|
+
...(r.skillUpdated === undefined ? {} : { skillUpdated: r.skillUpdated }),
|
|
1094
1148
|
...(r.ok ? {} : { output: r.output }),
|
|
1095
1149
|
});
|
|
1096
1150
|
return r.ok ? 0 : 1;
|
|
@@ -1105,7 +1159,14 @@ export async function cmdUpdate(a, io) {
|
|
|
1105
1159
|
if (r.refreshedApp)
|
|
1106
1160
|
io.out(' The desktop app now starts the new version and shows its icon.');
|
|
1107
1161
|
io.out(' A `sideby ui` running in a terminal and sessions already running keep the old version until you restart them.');
|
|
1108
|
-
|
|
1162
|
+
if (r.skillUpdated)
|
|
1163
|
+
io.out(' The sideby skill was updated too; agents load it in their next session.');
|
|
1164
|
+
else if (r.skillUpdated === false)
|
|
1165
|
+
io.err(c.yellow(` ${r.skillMessage}`));
|
|
1166
|
+
else if (sk?.outdated)
|
|
1167
|
+
io.out(c.yellow(` ${skillLine(sk)}`));
|
|
1168
|
+
else if (!sk)
|
|
1169
|
+
io.out(` If you use the sideby skill, install it with: ${SKILL_INSTALL}`);
|
|
1109
1170
|
return 0;
|
|
1110
1171
|
}
|
|
1111
1172
|
/** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
|
package/dist/src/cli/format.js
CHANGED
|
@@ -44,12 +44,14 @@ export function clock(iso, now) {
|
|
|
44
44
|
return sameDay ? hm : `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${hm}`;
|
|
45
45
|
}
|
|
46
46
|
function windowText(w, now) {
|
|
47
|
+
// A window that covers only some models goes by their name (spec §3.26).
|
|
48
|
+
const label = w.name ?? w.label;
|
|
47
49
|
if (Date.parse(w.resetsAt) <= now.getTime())
|
|
48
|
-
return `${
|
|
50
|
+
return `${label} reset`;
|
|
49
51
|
const pct = Math.round(w.usedPercent);
|
|
50
52
|
const level = quotaLevel(pct);
|
|
51
53
|
const color = level === 'fail' ? c.red : level === 'warn' ? c.yellow : c.green;
|
|
52
|
-
return `${
|
|
54
|
+
return `${label} ${color(`${pct}%`)} ${c.dim(`→ ${clock(w.resetsAt, now)}`)}`;
|
|
53
55
|
}
|
|
54
56
|
export const QUOTA_REASON = {
|
|
55
57
|
'not-enabled': 'off',
|
|
@@ -57,11 +59,13 @@ export const QUOTA_REASON = {
|
|
|
57
59
|
unrecognized: 'unrecognized format',
|
|
58
60
|
'no-source': 'no public source',
|
|
59
61
|
'api-account': 'API account',
|
|
62
|
+
'fetch-failed': 'could not get it',
|
|
60
63
|
};
|
|
61
64
|
export function quotaText(q, now) {
|
|
62
65
|
if (q.status === 'unavailable')
|
|
63
66
|
return c.dim(QUOTA_REASON[q.reason] ?? q.reason);
|
|
64
|
-
|
|
67
|
+
const failed = q.fetchError ? c.yellow(' (latest check failed)') : '';
|
|
68
|
+
return `${q.windows.map((w) => windowText(w, now)).join(' ')} ${c.dim(ago(q.observedAt, now))}${failed}`;
|
|
65
69
|
}
|
|
66
70
|
export function compactNumber(n) {
|
|
67
71
|
if (n < 1000)
|
|
@@ -100,6 +100,9 @@ const Finding = Type.Object({
|
|
|
100
100
|
});
|
|
101
101
|
const QuotaWindow = Type.Object({
|
|
102
102
|
label: Str,
|
|
103
|
+
name: Type.Optional(Type.String({
|
|
104
|
+
description: 'The group of models this limit covers, when it covers only some (spec §3.26); it does not count toward Quota Pressure.',
|
|
105
|
+
})),
|
|
103
106
|
windowMinutes: Type.Number(),
|
|
104
107
|
usedPercent: Type.Number(),
|
|
105
108
|
resetsAt: Str,
|
|
@@ -114,8 +117,18 @@ const Quota = Type.Union([
|
|
|
114
117
|
from: Type.Optional(Type.String({
|
|
115
118
|
description: 'The account this reading was taken from, when it is another account of the same login (spec §3.21); observedAt is its time.',
|
|
116
119
|
})),
|
|
120
|
+
fetched: Type.Optional(Type.Boolean({
|
|
121
|
+
description: 'This reading is the kept answer of a quota check that costs a request (spec §3.26).',
|
|
122
|
+
})),
|
|
123
|
+
fetchError: Type.Optional(Type.String({ description: 'Why the latest quota check failed, while this older reading is shown.' })),
|
|
124
|
+
}),
|
|
125
|
+
Type.Object({
|
|
126
|
+
status: Type.Literal('unavailable'),
|
|
127
|
+
reason: Type.String({
|
|
128
|
+
description: 'not-enabled, no-session, unrecognized, no-source, api-account or fetch-failed.',
|
|
129
|
+
}),
|
|
130
|
+
detail: Type.Optional(Str),
|
|
117
131
|
}),
|
|
118
|
-
Type.Object({ status: Type.Literal('unavailable'), reason: Str, detail: Type.Optional(Str) }),
|
|
119
132
|
]);
|
|
120
133
|
const TuningModel = Type.Object({
|
|
121
134
|
model: Str,
|
|
@@ -212,6 +225,30 @@ const AppProfile = Type.Object({
|
|
|
212
225
|
}, {
|
|
213
226
|
description: 'A window of a desktop app that signs in apart from every account (Grok Bot, ADR-0019). Open or create one with `sideby desktop grok-bot:<name>`.',
|
|
214
227
|
});
|
|
228
|
+
/**
|
|
229
|
+
* On any `--json` output when there is something to mention once the task is done (spec §3.23): a newer sideby in the
|
|
230
|
+
* cached Update Check, or an installed sideby skill for another version. Off with SIDEBY_NO_UPDATE_NOTIFIER.
|
|
231
|
+
*/
|
|
232
|
+
const Notice = Type.Object({
|
|
233
|
+
update: Type.Optional(Type.Object({
|
|
234
|
+
current: Str,
|
|
235
|
+
latest: Str,
|
|
236
|
+
command: Type.String({
|
|
237
|
+
description: 'Run it after the user agrees: it shows the plan and exits 10; then --yes.',
|
|
238
|
+
}),
|
|
239
|
+
message: Str,
|
|
240
|
+
})),
|
|
241
|
+
skill: Type.Optional(Type.Object({
|
|
242
|
+
installed: Type.Union([Str, Type.Null()], {
|
|
243
|
+
description: "The skill's version; null when it has none.",
|
|
244
|
+
}),
|
|
245
|
+
current: Str,
|
|
246
|
+
command: Type.String({
|
|
247
|
+
description: 'Updates the installed sideby skill (run it after the user agrees).',
|
|
248
|
+
}),
|
|
249
|
+
message: Str,
|
|
250
|
+
})),
|
|
251
|
+
}, { description: 'Not the answer to the task: mention it briefly afterwards and offer to update.' });
|
|
215
252
|
export const OUTPUT_SCHEMAS = {
|
|
216
253
|
list: Type.Object({
|
|
217
254
|
schemaVersion: Version,
|
|
@@ -564,7 +601,25 @@ export const OUTPUT_SCHEMAS = {
|
|
|
564
601
|
refreshedApp: Type.Optional(Type.Boolean({
|
|
565
602
|
description: 'The desktop app (`sideby app install`) was installed and was written again by the new version.',
|
|
566
603
|
})),
|
|
567
|
-
skill: Type.Optional(Type.String({ description: 'The command that
|
|
604
|
+
skill: Type.Optional(Type.String({ description: 'The command that installs the sideby skill by hand.' })),
|
|
605
|
+
skillUpdate: Type.Optional(Type.Object({
|
|
606
|
+
installed: Type.Array(Type.Union([Str, Type.Null()]), {
|
|
607
|
+
description: 'Versions of the installed sideby skills; null for one without a version.',
|
|
608
|
+
}),
|
|
609
|
+
outdated: Type.Boolean({
|
|
610
|
+
description: 'Some installed skill is for another version than sideby will run.',
|
|
611
|
+
}),
|
|
612
|
+
command: Str,
|
|
613
|
+
canRun: Type.Boolean({
|
|
614
|
+
description: 'False when sideby leaves the skill alone (see reason): give the user command.',
|
|
615
|
+
}),
|
|
616
|
+
reason: Type.Optional(Str),
|
|
617
|
+
}, {
|
|
618
|
+
description: 'The installed sideby skill, which the Update brings along; absent when none is installed.',
|
|
619
|
+
})),
|
|
620
|
+
skillUpdated: Type.Optional(Type.Boolean({
|
|
621
|
+
description: 'The skill was updated too (true) or that failed (false); absent when not tried.',
|
|
622
|
+
})),
|
|
568
623
|
output: Type.Optional(Type.String({ description: 'What the failed install command printed.' })),
|
|
569
624
|
}, { title: 'sideby update --json' }),
|
|
570
625
|
error: Type.Object({
|
|
@@ -576,3 +631,8 @@ export const OUTPUT_SCHEMAS = {
|
|
|
576
631
|
}, { title: 'sideby error --json' }),
|
|
577
632
|
config: ConfigSchema,
|
|
578
633
|
};
|
|
634
|
+
for (const [name, schema] of Object.entries(OUTPUT_SCHEMAS)) {
|
|
635
|
+
const props = schema.properties;
|
|
636
|
+
if (name !== 'config' && props)
|
|
637
|
+
props._notice = Type.Optional(Notice);
|
|
638
|
+
}
|
package/dist/src/cli/main.js
CHANGED
|
@@ -23,11 +23,12 @@ Usage:
|
|
|
23
23
|
sideby resume <family> [-- args] run the host; a session resumed by id (claude --resume <id>,
|
|
24
24
|
codex resume <id>) starts in the account that holds it
|
|
25
25
|
sideby next <family> [--dry-run] [--include-api] [-- args]
|
|
26
|
-
recommend the account with the most quota left (claude, codex)
|
|
26
|
+
recommend the account with the most quota left (claude, codex, grok)
|
|
27
27
|
and start it; --dry-run only recommends
|
|
28
28
|
sideby doctor [target] [--fix] [--force]
|
|
29
29
|
check shared items and permissions; --fix repairs safe issues
|
|
30
|
-
sideby quota [account]
|
|
30
|
+
sideby quota [account|family] [--refresh]
|
|
31
|
+
quota and 7-day token usage; --refresh asks Grok again now
|
|
31
32
|
sideby quota setup claude [--yes] turn on Claude quota (shows the change first)
|
|
32
33
|
sideby quota teardown claude undo it, restoring the original file
|
|
33
34
|
sideby tune [account|family] estimate the cheapest auto-compact threshold per model
|
|
@@ -221,7 +222,7 @@ const COMMANDS = {
|
|
|
221
222
|
new: { flags: ['api', 'json', 'next'], valued: ['alias'] },
|
|
222
223
|
alias: { flags: ['json'] },
|
|
223
224
|
doctor: { flags: ['fix', 'force', 'json'] },
|
|
224
|
-
quota: { flags: ['json', 'yes'] },
|
|
225
|
+
quota: { flags: ['json', 'yes', 'refresh'] },
|
|
225
226
|
tune: { flags: ['json', 'yes'] },
|
|
226
227
|
model: { flags: ['json', 'yes'], valued: ['effort'] },
|
|
227
228
|
'remote-control': { flags: ['json', 'yes'] },
|
|
@@ -249,6 +250,38 @@ function noticeAllowed(cmd, parsed) {
|
|
|
249
250
|
return false;
|
|
250
251
|
return true;
|
|
251
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Before a `--json` command (spec §3.23): the `_notice` its output carries, from the cached Update Check and the
|
|
255
|
+
* installed sideby skills, so an agent that reads JSON learns of a newer version too. Starts the next check detached
|
|
256
|
+
* when the last one is a day old, as afterCommand does on a terminal. Never fails the command.
|
|
257
|
+
*/
|
|
258
|
+
async function jsonNoticeFor() {
|
|
259
|
+
try {
|
|
260
|
+
const u = await import('../core/update.js');
|
|
261
|
+
const { resolvePaths } = await import('../core/paths.js');
|
|
262
|
+
const { loadConfig } = await import('../core/config.js');
|
|
263
|
+
const { packageVersion } = await import('../core/version.js');
|
|
264
|
+
const { installedSkillVersions, SKILL_INSTALL } = await import('../core/skill-check.js');
|
|
265
|
+
const paths = resolvePaths(process.env);
|
|
266
|
+
const config = await loadConfig(paths.configFile).catch(() => ({}));
|
|
267
|
+
const cache = await u.readUpdateCache(paths);
|
|
268
|
+
const skills = await installedSkillVersions(paths.home);
|
|
269
|
+
const n = u.jsonNotice(cache, packageVersion(), config, process.env, skills, SKILL_INSTALL);
|
|
270
|
+
if (!u.checksOff(process.env, config) && u.checkDue(cache, new Date())) {
|
|
271
|
+
const { spawn } = await import('node:child_process');
|
|
272
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
|
|
273
|
+
detached: true,
|
|
274
|
+
stdio: 'ignore',
|
|
275
|
+
});
|
|
276
|
+
child.on('error', () => { });
|
|
277
|
+
child.unref();
|
|
278
|
+
}
|
|
279
|
+
return n;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
252
285
|
/**
|
|
253
286
|
* After a command (spec §3.23): print the Update Notice and the result of an Auto Update, each at most once, and
|
|
254
287
|
* start the next Update Check detached when the last one is a day old. Only on a terminal; never fails the command.
|
|
@@ -319,6 +352,7 @@ export async function main(argv) {
|
|
|
319
352
|
// Only sideby's own options count: arguments after `--` belong to the host.
|
|
320
353
|
const own = args.includes('--') ? args.slice(0, args.indexOf('--')) : args;
|
|
321
354
|
const wantsJson = Boolean(spec?.flags.includes('json')) && own.includes('--json');
|
|
355
|
+
let notice = null;
|
|
322
356
|
try {
|
|
323
357
|
if (!spec)
|
|
324
358
|
throw new UsageError(`unknown command "${cmd}"; run \`sideby --help\``);
|
|
@@ -354,6 +388,10 @@ export async function main(argv) {
|
|
|
354
388
|
return 0;
|
|
355
389
|
}
|
|
356
390
|
const cmds = await import('./commands.js');
|
|
391
|
+
if (wantsJson && noticeAllowed(cmd, parsed)) {
|
|
392
|
+
notice = await jsonNoticeFor();
|
|
393
|
+
cmds.setJsonNotice(notice);
|
|
394
|
+
}
|
|
357
395
|
const code = await dispatch(cmd, parsed, io, cmds);
|
|
358
396
|
if (!wantsJson && noticeAllowed(cmd, parsed))
|
|
359
397
|
await afterCommand();
|
|
@@ -365,7 +403,7 @@ export async function main(argv) {
|
|
|
365
403
|
if (wantsJson) {
|
|
366
404
|
const { UserError } = await import('../core/errors.js');
|
|
367
405
|
const code = usage ? 'usage' : err instanceof UserError && err.code ? err.code : 'error';
|
|
368
|
-
console.log(JSON.stringify({ schemaVersion: 1, error: message, code }, null, 2));
|
|
406
|
+
console.log(JSON.stringify({ schemaVersion: 1, error: message, code, ...(notice ? { _notice: notice } : {}) }, null, 2));
|
|
369
407
|
}
|
|
370
408
|
else
|
|
371
409
|
console.error(`sideby: ${message}`);
|
|
@@ -69,6 +69,13 @@ export declare const ConfigSchema: Type.TObject<{
|
|
|
69
69
|
browser: Type.TOptional<Type.TObject<{
|
|
70
70
|
signIns: Type.TOptional<Type.TBoolean>;
|
|
71
71
|
}>>;
|
|
72
|
+
display: Type.TOptional<Type.TObject<{
|
|
73
|
+
families: Type.TOptional<Type.TArray<Type.TString>>;
|
|
74
|
+
accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TArray<Type.TString>>>;
|
|
75
|
+
}>>;
|
|
76
|
+
quota: Type.TOptional<Type.TObject<{
|
|
77
|
+
check: Type.TOptional<Type.TRecord<"^.*$", Type.TBoolean>>;
|
|
78
|
+
}>>;
|
|
72
79
|
shellInitFile: Type.TOptional<Type.TObject<{
|
|
73
80
|
zsh: Type.TOptional<Type.TString>;
|
|
74
81
|
bash: Type.TOptional<Type.TString>;
|
|
@@ -116,6 +123,26 @@ export declare function editConfigBrowser(file: string, edit: (current: BrowserC
|
|
|
116
123
|
before: BrowserConfig | undefined;
|
|
117
124
|
after: BrowserConfig | undefined;
|
|
118
125
|
}>;
|
|
126
|
+
export type DisplayConfig = NonNullable<Config['display']>;
|
|
127
|
+
/**
|
|
128
|
+
* The Panel's drag and drop (spec §3.25): replaces the config `display` key, or removes it when `display` is undefined
|
|
129
|
+
* or empty (see `editConfigKey` for how the file is written).
|
|
130
|
+
*/
|
|
131
|
+
export declare function editConfigDisplay(file: string, display: DisplayConfig | undefined): Promise<{
|
|
132
|
+
changed: boolean;
|
|
133
|
+
before: DisplayConfig | undefined;
|
|
134
|
+
after: DisplayConfig | undefined;
|
|
135
|
+
}>;
|
|
136
|
+
export type QuotaConfig = NonNullable<Config['quota']>;
|
|
137
|
+
/**
|
|
138
|
+
* The Panel's Quota check switch (spec §3.26): `false` writes `quota.check.<family>: false`, `true` removes that key
|
|
139
|
+
* (on is the default), and an empty `quota` goes away with it (see `editConfigKey` for how the file is written).
|
|
140
|
+
*/
|
|
141
|
+
export declare function editConfigQuotaCheck(file: string, family: string, on: boolean): Promise<{
|
|
142
|
+
changed: boolean;
|
|
143
|
+
before: QuotaConfig | undefined;
|
|
144
|
+
after: QuotaConfig | undefined;
|
|
145
|
+
}>;
|
|
119
146
|
export type AccountsConfig = NonNullable<Config['accounts']>;
|
|
120
147
|
/**
|
|
121
148
|
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
package/dist/src/core/config.js
CHANGED
|
@@ -106,6 +106,23 @@ export const ConfigSchema = Type.Object({
|
|
|
106
106
|
description: "Look in Google Chrome's local data for which browser user signed in to each Account's website (key names only, never cookies or passwords; nothing leaves this computer). Off by default.",
|
|
107
107
|
})),
|
|
108
108
|
}, { additionalProperties: false, description: 'Browser Sign-ins (spec §3.24, ADR-0023).' })),
|
|
109
|
+
display: Type.Optional(Type.Object({
|
|
110
|
+
families: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
|
|
111
|
+
uniqueItems: true,
|
|
112
|
+
description: 'Family ids in the order the Panel and `sideby list` show them; families not listed follow in their usual order.',
|
|
113
|
+
})),
|
|
114
|
+
accounts: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), {
|
|
115
|
+
description: 'Per family, account names (not refs or short commands) in the order they are shown; accounts not listed follow, main first, then by name.',
|
|
116
|
+
})),
|
|
117
|
+
}, {
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
description: 'Display Order (spec §3.25): the order you dragged families and accounts into in the Panel. Display only: it never changes which account a Handoff picks.',
|
|
120
|
+
})),
|
|
121
|
+
quota: Type.Optional(Type.Object({
|
|
122
|
+
check: Type.Optional(Type.Record(Type.String(), Type.Boolean(), {
|
|
123
|
+
description: 'Per family, whether sideby may ask for Quota in a way that costs a request (Grok: its own CLI asks xAI, at most every 15 minutes per account). On by default; `false` turns it off. Off in CI.',
|
|
124
|
+
})),
|
|
125
|
+
}, { additionalProperties: false, description: 'Quota that sideby asks for (spec §3.26, ADR-0025).' })),
|
|
109
126
|
shellInitFile: Type.Optional(Type.Object({
|
|
110
127
|
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
111
128
|
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
@@ -172,7 +189,7 @@ async function configTarget(file) {
|
|
|
172
189
|
}
|
|
173
190
|
/**
|
|
174
191
|
* Re-reads the config file, lets `edit` change one top-level `key` (sideby writes only `aliases`, `handoff`,
|
|
175
|
-
* `accounts`, `update` and `
|
|
192
|
+
* `accounts`, `update`, `browser`, `display` and `quota`, spec §3.10), and an `edit` that returns undefined removes it, and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
|
|
176
193
|
* with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
|
|
177
194
|
* file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
|
|
178
195
|
* while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
|
|
@@ -211,6 +228,8 @@ async function editConfigKey(file, key, retry, edit, opts = {}) {
|
|
|
211
228
|
const next = raw === null
|
|
212
229
|
? { $schema: 'https://unpkg.com/sideby/schemas/config.json', [key]: after }
|
|
213
230
|
: { ...data, [key]: after };
|
|
231
|
+
if (after === undefined)
|
|
232
|
+
delete next[key];
|
|
214
233
|
if (!Value.Check(ConfigSchema, next))
|
|
215
234
|
throw new ConfigError(`the new ${key} would not match schemas/config.json`);
|
|
216
235
|
const h = handoffSettings(next);
|
|
@@ -251,6 +270,34 @@ export function editConfigUpdate(file, edit, opts = {}) {
|
|
|
251
270
|
export function editConfigBrowser(file, edit, opts = {}) {
|
|
252
271
|
return editConfigKey(file, 'browser', 'change the setting', (current) => edit(current ?? {}), opts);
|
|
253
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* The Panel's drag and drop (spec §3.25): replaces the config `display` key, or removes it when `display` is undefined
|
|
275
|
+
* or empty (see `editConfigKey` for how the file is written).
|
|
276
|
+
*/
|
|
277
|
+
export function editConfigDisplay(file, display) {
|
|
278
|
+
const accounts = Object.fromEntries(Object.entries(display?.accounts ?? {}).filter(([, names]) => names.length));
|
|
279
|
+
const next = {
|
|
280
|
+
...(display?.families?.length ? { families: [...display.families] } : {}),
|
|
281
|
+
...(Object.keys(accounts).length ? { accounts } : {}),
|
|
282
|
+
};
|
|
283
|
+
return editConfigKey(file, 'display', 'move it again', () => (Object.keys(next).length ? next : undefined));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The Panel's Quota check switch (spec §3.26): `false` writes `quota.check.<family>: false`, `true` removes that key
|
|
287
|
+
* (on is the default), and an empty `quota` goes away with it (see `editConfigKey` for how the file is written).
|
|
288
|
+
*/
|
|
289
|
+
export function editConfigQuotaCheck(file, family, on) {
|
|
290
|
+
return editConfigKey(file, 'quota', 'change the setting', (current) => {
|
|
291
|
+
const check = { ...(current?.check ?? {}) };
|
|
292
|
+
if (on)
|
|
293
|
+
delete check[family];
|
|
294
|
+
else
|
|
295
|
+
check[family] = false;
|
|
296
|
+
const { check: _old, ...rest } = current ?? {};
|
|
297
|
+
const next = { ...rest, ...(Object.keys(check).length ? { check } : {}) };
|
|
298
|
+
return Object.keys(next).length ? next : undefined;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
254
301
|
/**
|
|
255
302
|
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
|
256
303
|
* written). With `dryRun`, shows the change without writing.
|
|
@@ -25,11 +25,12 @@ async function throughLink(dir, rel) {
|
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* The other Accounts of the Family that lack the hook file `file` (a path in the Main Account) at the same place in
|
|
28
|
-
* their own directory
|
|
29
|
-
* Account's other files are never touched.
|
|
28
|
+
* their own directory, or hold an earlier version of it that an older sideby wrote (`earlierTexts`). Only that one
|
|
29
|
+
* file is ever written; one with other content is left as it is, and an Account's other files are never touched.
|
|
30
30
|
*/
|
|
31
31
|
async function hookCopies(rt, family, file) {
|
|
32
32
|
const accounts = (await rt.accounts()).filter((a) => a.family === family);
|
|
33
|
+
const earlier = rt.families.get(family)?.handoff?.hookSetup?.earlierTexts ?? [];
|
|
33
34
|
const main = accounts.find((a) => a.isMain);
|
|
34
35
|
if (!main)
|
|
35
36
|
return [];
|
|
@@ -43,9 +44,13 @@ async function hookCopies(rt, family, file) {
|
|
|
43
44
|
continue;
|
|
44
45
|
const target = join(a.dir, rel);
|
|
45
46
|
// Whether the file is there, not whether it can be read: one that cannot be read is still not ours to replace.
|
|
46
|
-
const
|
|
47
|
-
// A file that is already there is not ours to replace,
|
|
48
|
-
if (!
|
|
47
|
+
const st = await lstat(target).catch(() => null);
|
|
48
|
+
// A file that is already there is not ours to replace, unless it is, byte for byte, one an older sideby wrote.
|
|
49
|
+
if (!st)
|
|
50
|
+
out.push({ ref: a.ref, target });
|
|
51
|
+
else if (st.isFile() &&
|
|
52
|
+
earlier.length &&
|
|
53
|
+
earlier.includes(await readFile(target, 'utf8').catch(() => '')))
|
|
49
54
|
out.push({ ref: a.ref, target });
|
|
50
55
|
}
|
|
51
56
|
return out;
|
|
@@ -34,6 +34,11 @@ export declare function sidebyBinProblem(env: Env, why: string): Promise<string
|
|
|
34
34
|
* copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
|
|
35
35
|
* then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
|
|
36
36
|
*/
|
|
37
|
+
/**
|
|
38
|
+
* The Account's environment before its Secret File and Plugin hooks: `baseEnv` with the Hijack Variables cleared and
|
|
39
|
+
* the Family's `selectVar` set for every Account but the Main Account.
|
|
40
|
+
*/
|
|
41
|
+
export declare function accountEnv(family: FamilyDef, account: Account, baseEnv: Env): Env;
|
|
37
42
|
export declare function prepareLaunch(opts: {
|
|
38
43
|
account: Account;
|
|
39
44
|
family: FamilyDef;
|
package/dist/src/core/launch.js
CHANGED
|
@@ -59,14 +59,25 @@ export async function sidebyBinProblem(env, why) {
|
|
|
59
59
|
* copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
|
|
60
60
|
* then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
|
|
61
61
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The Account's environment before its Secret File and Plugin hooks: `baseEnv` with the Hijack Variables cleared and
|
|
64
|
+
* the Family's `selectVar` set for every Account but the Main Account.
|
|
65
|
+
*/
|
|
66
|
+
export function accountEnv(family, account, baseEnv) {
|
|
67
|
+
const env = { ...baseEnv };
|
|
65
68
|
for (const v of family.hijackVars)
|
|
66
69
|
delete env[v];
|
|
67
70
|
delete env[family.selectVar];
|
|
71
|
+
if (!account.isMain)
|
|
72
|
+
env[family.selectVar] = account.dir;
|
|
73
|
+
return env;
|
|
74
|
+
}
|
|
75
|
+
export async function prepareLaunch(opts) {
|
|
76
|
+
const { account, family } = opts;
|
|
77
|
+
const env = accountEnv(family, account, opts.baseEnv);
|
|
68
78
|
if (account.secretFile)
|
|
69
79
|
Object.assign(env, await readSecretFile(account.secretFile, opts.baseEnv));
|
|
80
|
+
// The Secret File may not move the Account: its directory always wins.
|
|
70
81
|
if (!account.isMain)
|
|
71
82
|
env[family.selectVar] = account.dir;
|
|
72
83
|
// Which Account this is, for a status line, prompt or hook to show (spec §3.5); set for every Launch, so a value
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Account, Env, QuotaFetch, QuotaResult, ReadContext } from '../types.ts';
|
|
2
|
+
import { type QuotaStoreEntry } from './quota-store.ts';
|
|
3
|
+
/**
|
|
4
|
+
* `wait`: ask when the kept answer is old, and wait for it (the CLI). `background`: ask when old, but show the kept
|
|
5
|
+
* answer now (the Panel; with nothing kept it waits a few seconds). `force`: ask now, and wait (a refresh). `never`:
|
|
6
|
+
* the kept answer only.
|
|
7
|
+
*/
|
|
8
|
+
export type QuotaFetchMode = 'wait' | 'background' | 'force' | 'never';
|
|
9
|
+
export declare const FETCH_TIMEOUT_MS = 20000;
|
|
10
|
+
/** Why fetching is off for `family`, or null when it is on (spec §3.26). */
|
|
11
|
+
export declare function quotaCheckOff(family: string, config: {
|
|
12
|
+
quota?: {
|
|
13
|
+
check?: Record<string, boolean>;
|
|
14
|
+
};
|
|
15
|
+
}, env: Env): string | null;
|
|
16
|
+
/** The kept answer for `account`, after asking again when `mode` and its age say so. */
|
|
17
|
+
export declare function fetchedQuota(fq: QuotaFetch, account: Account, ctx: ReadContext, hostEnv: Env, mode: QuotaFetchMode): Promise<QuotaStoreEntry | null>;
|
|
18
|
+
/**
|
|
19
|
+
* What sideby shows from a Family that fetches Quota: the newer of the kept answer and the local reading; else why
|
|
20
|
+
* there is none (`off` is why fetching is off, or null).
|
|
21
|
+
*/
|
|
22
|
+
export declare function shownQuota(local: QuotaResult | null, kept: QuotaStoreEntry | null, off: string | null): QuotaResult;
|