sideby 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +1 -0
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +2 -0
  4. package/dist/src/cli/commands.js +176 -9
  5. package/dist/src/cli/json-schemas.js +90 -2
  6. package/dist/src/cli/main.js +24 -5
  7. package/dist/src/core/config.d.ts +1 -0
  8. package/dist/src/core/config.js +8 -2
  9. package/dist/src/core/skill-check.d.ts +14 -0
  10. package/dist/src/core/skill-check.js +29 -3
  11. package/dist/src/core/update.d.ts +67 -1
  12. package/dist/src/core/update.js +147 -23
  13. package/dist/src/panel/handler.d.ts +7 -1
  14. package/dist/src/panel/handler.js +21 -5
  15. package/dist/src/panel/i18n.d.ts +118 -5
  16. package/dist/src/panel/i18n.js +266 -40
  17. package/dist/src/panel/page-logic.d.ts +41 -0
  18. package/dist/src/panel/page-logic.js +46 -0
  19. package/dist/src/panel/page-script.js +571 -162
  20. package/dist/src/panel/page-styles.js +71 -23
  21. package/dist/src/panel/page.js +3 -0
  22. package/dist/src/runtime.d.ts +2 -0
  23. package/package.json +3 -2
  24. package/schemas/alias.json +1 -1
  25. package/schemas/app-install.json +1 -1
  26. package/schemas/app-uninstall.json +1 -1
  27. package/schemas/browser-open.json +1 -1
  28. package/schemas/browser-switch.json +1 -1
  29. package/schemas/browser.json +1 -1
  30. package/schemas/config.json +15 -1
  31. package/schemas/desktop-remove.json +1 -1
  32. package/schemas/desktop.json +1 -1
  33. package/schemas/doctor.json +1 -1
  34. package/schemas/error.json +1 -1
  35. package/schemas/families.json +1 -1
  36. package/schemas/handoff-disable.json +1 -1
  37. package/schemas/handoff-enable.json +1 -1
  38. package/schemas/handoff-ready.json +1 -1
  39. package/schemas/handoff-setup.json +1 -1
  40. package/schemas/handoff-status.json +282 -1
  41. package/schemas/handoff-teardown.json +1 -1
  42. package/schemas/list.json +6 -1
  43. package/schemas/model.json +1 -1
  44. package/schemas/new.json +1 -1
  45. package/schemas/next.json +1 -1
  46. package/schemas/order.json +148 -0
  47. package/schemas/pin.json +126 -0
  48. package/schemas/plugins.json +1 -1
  49. package/schemas/quota-check.json +88 -0
  50. package/schemas/quota-setup.json +1 -1
  51. package/schemas/quota-teardown.json +1 -1
  52. package/schemas/quota.json +1 -1
  53. package/schemas/remote-control.json +1 -1
  54. package/schemas/tune-change.json +1 -1
  55. package/schemas/tune.json +1 -1
  56. package/schemas/unpin.json +126 -0
  57. package/schemas/update-auto.json +84 -0
  58. package/schemas/update.json +79 -1
  59. package/skills/sideby/SKILL.md +7 -3
  60. package/skills/sideby/references/handoff.md +8 -0
  61. package/skills/sideby/references/panel.md +28 -0
  62. package/skills/sideby/references/quota.md +6 -0
  63. package/skills/sideby/references/update.md +6 -2
package/README.md CHANGED
@@ -78,6 +78,7 @@ cc-switch's docs describe that switch: it writes the live settings file ([README
78
78
  | 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) |
79
79
  | See each account's model and effort, and set them per account | `sideby model <account>` (or click the model in the panel) | [Model and effort](docs/guide/usage.md#model-and-effort) |
80
80
  | Choose whether Claude sessions connect to claude.ai by themselves (Remote Control), for every account at once | `sideby remote-control off` or `on` (or the panel's Settings) | [Remote Control](docs/guide/usage.md#remote-control) |
81
+ | Pin the accounts you use most and put tools and accounts in your own order | `sideby pin claude:work`, `sideby order` (or the panel's Settings) | [Your own order](docs/guide/usage.md#your-own-order) |
81
82
  | Move on when an account hits its limit | `sideby next claude` | [Handoff](docs/guide/usage.md#when-an-account-hits-its-limit) |
82
83
  | 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) |
83
84
  | Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261017T050946-402d73"}
1
+ {"id":"20261017T115526-c6b039"}
@@ -27,6 +27,8 @@ export declare function serializeDoctor(r: DoctorReport): {
27
27
  general: Omit<Finding, "fix">[];
28
28
  fixes: import("../core/doctor.ts").FixOutcome[];
29
29
  };
30
+ export declare function cmdPin(a: ParsedArgs, io: Io, pin: boolean): Promise<number>;
31
+ export declare function cmdOrder(a: ParsedArgs, io: Io): Promise<number>;
30
32
  export declare function cmdList(a: ParsedArgs, io: Io): Promise<number>;
31
33
  export interface NewSuggestion {
32
34
  name: string;
@@ -2,10 +2,11 @@ import { fileURLToPath } from 'node:url';
2
2
  import { aliasesByAccount } from '../core/accounts.js';
3
3
  import { listAppProfiles, openAppProfile, parseProfileRef, removeAppProfile } from '../core/app-profiles.js';
4
4
  import { browserReport, openInBrowser, setBrowserSignIns } from '../core/browser-profiles.js';
5
- import { aliasRefs } from '../core/config.js';
5
+ import { aliasRefs, editConfigUpdate } from '../core/config.js';
6
6
  import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
7
7
  import { UserError } from '../core/errors.js';
8
8
  import { requestHandoff } from '../core/handoff-chain.js';
9
+ import { handoffOverview } from '../core/handoff-overview.js';
9
10
  import { explain, readyDecision, runWithHandoff } from '../core/handoff-run.js';
10
11
  import { configureHandoff, handoffHint, handoffStatus } from '../core/handoff-status.js';
11
12
  import { installHint, runHost } from '../core/launch.js';
@@ -14,7 +15,7 @@ import { resolvePaths, tildify } from '../core/paths.js';
14
15
  import { USAGE_DAYS } from '../core/quota-levels.js';
15
16
  import { groupsToTurnOff, groupsToTurnOn, groupsToUndo, } from '../core/remote-control.js';
16
17
  import { SHELLS } from '../core/shell-init.js';
17
- import { inDisplayOrder, suggestAlias, suggestName } from '../panel/page-logic.js';
18
+ import { inDisplayOrder, pinnedIn, suggestAlias, suggestName, withPinned } from '../panel/page-logic.js';
18
19
  import { createRuntime, UnknownFamilyError } from '../runtime.js';
19
20
  import { UsageError } from './args.js';
20
21
  import { c, clock, compactNumber, quotaText, table, tuningLines, usageText } from './format.js';
@@ -73,18 +74,155 @@ export function serializeDoctor(r) {
73
74
  function tilde(text, home) {
74
75
  return text.split(`${home}/`).join('~/');
75
76
  }
76
- /** Accounts in the Display Order the user gave them in the Panel (spec §3.25): Families first, then each one's. */
77
+ /** Accounts in the Display Order the user gave them in the Panel (spec §3.25): Families first, then each one's, Pinned Accounts leading. */
77
78
  function displayOrdered(accounts, rt) {
78
79
  const d = rt.displayOrder();
79
80
  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
+ return ids.flatMap((id) => withPinned(inDisplayOrder(accounts.filter((x) => x.family === id), (x) => x.name, d.accounts?.[id]), d.pinned?.[id]));
82
+ }
83
+ /** Drop deleted accounts and unloaded Families only when saving a display change. */
84
+ /**
85
+ * Groups the Display Order may name (spec §3.25): every Family, and every desktop app with windows of its own (Grok
86
+ * Bot, `grok-bot`), each with the names it holds.
87
+ */
88
+ async function orderGroups(rt, accounts) {
89
+ const out = new Map();
90
+ for (const id of rt.families.keys())
91
+ out.set(id, accounts.filter((a) => a.family === id).map((a) => a.name));
92
+ for (const p of await listAppProfiles(rt)) {
93
+ const [id, name] = [p.ref.slice(0, p.ref.indexOf(':')), p.ref.slice(p.ref.indexOf(':') + 1)];
94
+ out.set(id, [...(out.get(id) ?? []), name]);
95
+ }
96
+ return out;
97
+ }
98
+ function cleanDisplay(rt, groups) {
99
+ const d = rt.displayOrder();
100
+ const clean = (lists) => Object.fromEntries(Object.entries(lists ?? {})
101
+ .filter(([id]) => groups.has(id))
102
+ .map(([id, names]) => [id, names.filter((name) => groups.get(id)?.includes(name))]));
103
+ return {
104
+ families: d.families?.filter((id) => groups.has(id)),
105
+ accounts: clean(d.accounts),
106
+ pinned: clean(d.pinned),
107
+ };
108
+ }
109
+ export async function cmdPin(a, io, pin) {
110
+ if (a.positionals.length !== 1 || a.rest.length)
111
+ throw new UsageError('usage: sideby pin|unpin <account|short command> [--json]');
112
+ const rt = await createRuntime();
113
+ const account = await rt.resolve(a.positionals[0]);
114
+ const accounts = await rt.accounts();
115
+ const d = cleanDisplay(rt, await orderGroups(rt, accounts));
116
+ const peers = new Set([account.ref, ...(await rt.sameLogin(account))]);
117
+ const names = accounts.filter((x) => peers.has(x.ref)).map((x) => x.name);
118
+ const pins = d.pinned?.[account.family] ?? [];
119
+ const already = names.some((name) => pins.includes(name));
120
+ if (pin && !already) {
121
+ const units = new Set();
122
+ for (const x of accounts.filter((x) => x.family === account.family && pins.includes(x.name))) {
123
+ units.add([x.ref, ...(await rt.sameLogin(x))].sort().join('|'));
124
+ }
125
+ if (units.size >= 3)
126
+ throw new UserError('At most 3 pinned logins per family; unpin one first with sideby unpin <account>.');
127
+ }
128
+ const next = pin
129
+ ? [...pins, ...names.filter((name) => !pins.includes(name))]
130
+ : pins.filter((name) => !names.includes(name));
131
+ d.pinned = { ...d.pinned, [account.family]: next };
132
+ const before = JSON.stringify(rt.displayOrder());
133
+ const display = await rt.setDisplayOrder(d);
134
+ const changed = before !== JSON.stringify(display);
135
+ const message = pin
136
+ ? already
137
+ ? 'Already pinned.'
138
+ : 'Pinned.'
139
+ : already
140
+ ? 'Unpinned.'
141
+ : 'Account is not pinned; nothing to change.';
142
+ if (a.flags.has('json'))
143
+ json(io, { ref: account.ref, pinned: pin, changed, message, display });
144
+ else
145
+ io.out(`${account.ref}: ${message}`);
146
+ return 0;
147
+ }
148
+ export async function cmdOrder(a, io) {
149
+ const reset = a.flags.has('reset');
150
+ const families = a.flags.has('families');
151
+ if (a.rest.length ||
152
+ (reset && (families || a.positionals.length)) ||
153
+ (families && !a.positionals.length) ||
154
+ (!reset && !families && a.positionals.length === 1))
155
+ throw new UsageError('usage: sideby order [--json] | <family> <name>... | --families <id>... | --reset');
156
+ const rt = await createRuntime();
157
+ const accounts = await rt.accounts();
158
+ if (reset || families || a.positionals.length) {
159
+ const groups = await orderGroups(rt, accounts);
160
+ let d = cleanDisplay(rt, groups);
161
+ if (reset)
162
+ d = { pinned: d.pinned };
163
+ else {
164
+ const [id, ...names] = a.positionals;
165
+ const requested = families ? a.positionals : names;
166
+ const unknown = families
167
+ ? requested.filter((x) => !groups.has(x))
168
+ : !groups.has(id)
169
+ ? [id]
170
+ : names.filter((name) => !groups.get(id)?.includes(name));
171
+ if (unknown.length)
172
+ throw new UserError(`Unknown ${families || !groups.has(id) ? 'families' : 'account names'}: ${unknown.join(', ')}; run sideby families or sideby list to choose existing names.`);
173
+ if (new Set(requested).size !== requested.length)
174
+ throw new UsageError('Names must not repeat; list each name once.');
175
+ if (families)
176
+ d.families = requested;
177
+ else
178
+ d.accounts = { ...d.accounts, [id]: names };
179
+ }
180
+ await rt.setDisplayOrder(d);
181
+ }
182
+ const display = rt.displayOrder();
183
+ const ordered = displayOrdered(accounts, rt);
184
+ const groups = await orderGroups(rt, accounts);
185
+ const rows = inDisplayOrder([...groups.keys()], (id) => id, display.families).map((family) => ({
186
+ family,
187
+ accounts: rt.families.has(family)
188
+ ? ordered
189
+ .filter((x) => x.family === family)
190
+ .map((x) => ({
191
+ name: x.name,
192
+ ref: x.ref,
193
+ pinned: display.pinned?.[family]?.includes(x.name) ?? false,
194
+ }))
195
+ : inDisplayOrder(groups.get(family) ?? [], (n) => n, display.accounts?.[family]).map((name) => ({
196
+ name,
197
+ ref: `${family}:${name}`,
198
+ pinned: false,
199
+ })),
200
+ }));
201
+ if (a.flags.has('json'))
202
+ json(io, { display, families: rows });
203
+ else
204
+ for (const row of rows)
205
+ io.out(`${row.family}: ${row.accounts.map((x) => `${x.name}${x.pinned ? ' (pinned)' : ''}`).join(', ') || '(no accounts)'}`);
206
+ return 0;
207
+ }
208
+ function onOff(value, usage) {
209
+ if (value !== 'on' && value !== 'off')
210
+ throw new UsageError(usage);
211
+ return value === 'on';
81
212
  }
82
213
  export async function cmdList(a, io) {
83
214
  const rt = await createRuntime();
84
215
  const accounts = displayOrdered(await rt.accounts(), rt);
85
216
  const [statuses, appProfiles] = await Promise.all([
86
- withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))),
87
- listAppProfiles(rt),
217
+ withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x)))).then((list) => list.map((s) => pinnedIn(s, rt.displayOrder().pinned?.[s.family] ?? []) ? { ...s, pinned: true } : s)),
218
+ // Desktop app windows in the Display Order too (spec §3.25), the main window first by default.
219
+ listAppProfiles(rt).then((list) => {
220
+ const d = rt.displayOrder();
221
+ const appOf = (ref) => ref.slice(0, ref.indexOf(':'));
222
+ const nameOf = (ref) => ref.slice(ref.indexOf(':') + 1);
223
+ const apps = inDisplayOrder([...new Set(list.map((p) => appOf(p.ref)))], (id) => id, d.families);
224
+ return apps.flatMap((app) => inDisplayOrder(list.filter((p) => appOf(p.ref) === app), (p) => nameOf(p.ref), d.accounts?.[app]));
225
+ }),
88
226
  ]);
89
227
  const hasDesktop = statuses.some((s) => s.desktopApp);
90
228
  const families = inDisplayOrder(await rt.familyInfo(), (f) => f.id, rt.displayOrder().families);
@@ -682,6 +820,22 @@ export async function cmdTune(a, io) {
682
820
  }
683
821
  export async function cmdQuota(a, io) {
684
822
  const sub = a.positionals[0];
823
+ if (sub === 'check') {
824
+ const usage = 'usage: sideby quota check <family> on|off [--json]';
825
+ if (a.positionals.length !== 3 || a.rest.length || a.flags.has('refresh') || a.flags.has('yes'))
826
+ throw new UsageError(usage);
827
+ const on = onOff(a.positionals[2], usage);
828
+ const family = a.positionals[1];
829
+ const rt = await createRuntime();
830
+ if (!rt.quotaChecks().some((x) => x.family === family))
831
+ throw new UserError(`${family} has no quota fetcher; run sideby families to choose a family that fetches quota.`);
832
+ const r = await rt.setQuotaCheck(family, on);
833
+ if (a.flags.has('json'))
834
+ json(io, { family, on, ...r });
835
+ else
836
+ io.out(`${family}: quota checks ${on ? 'on' : 'off'}.`);
837
+ return 0;
838
+ }
685
839
  if (sub === 'setup' || sub === 'teardown')
686
840
  return cmdQuotaSetup(a, io, sub);
687
841
  const rt = await createRuntime();
@@ -774,9 +928,10 @@ async function runSetup(a, io, rt, command, sub, familyId, setup) {
774
928
  }
775
929
  /** `sideby handoff status` (spec §3.17): what Auto Handoff still needs, with the commands to run. */
776
930
  async function cmdHandoffStatus(a, io) {
777
- const st = await handoffStatus(await createRuntime());
931
+ const rt = await createRuntime();
932
+ const st = await handoffStatus(rt);
778
933
  if (a.flags.has('json')) {
779
- json(io, st);
934
+ json(io, { ...st, overview: await handoffOverview(rt) });
780
935
  return 0;
781
936
  }
782
937
  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}%)`);
@@ -1065,6 +1220,18 @@ export async function cmdUi(a, io) {
1065
1220
  export async function cmdUpdate(a, io) {
1066
1221
  if (a.positionals.length)
1067
1222
  throw new UsageError('usage: sideby update [--yes] [--json]');
1223
+ if (a.flags.has('auto')) {
1224
+ if (a.rest.length)
1225
+ throw new UsageError('usage: sideby update --auto on|off [--json]');
1226
+ const auto = onOff(a.flags.get('auto'), 'usage: sideby update --auto on|off [--json]');
1227
+ const paths = resolvePaths(process.env);
1228
+ const r = await editConfigUpdate(paths.configFile, (cur) => ({ ...cur, auto }));
1229
+ if (a.flags.has('json'))
1230
+ json(io, { auto, changed: r.changed });
1231
+ else
1232
+ io.out(`Auto Update ${auto ? 'on' : 'off'}${r.changed ? '.' : ' (unchanged).'}`);
1233
+ return 0;
1234
+ }
1068
1235
  const u = await import('../core/update.js');
1069
1236
  const { SKILL_INSTALL } = await import('../core/skill-check.js');
1070
1237
  const { backgroundPanelPort } = await import('../panel/background.js');
@@ -1091,7 +1258,7 @@ export async function cmdUpdate(a, io) {
1091
1258
  }
1092
1259
  if (!asJson)
1093
1260
  io.out(c.dim(`$ ${sk.command}`));
1094
- const r = await u.applySkillUpdate(sk, process.env, asJson ? undefined : (t) => process.stderr.write(t));
1261
+ const r = await u.applySkillOnly(paths, sk, process.env, asJson ? undefined : (t) => process.stderr.write(t));
1095
1262
  if (asJson)
1096
1263
  json(io, { ...shown, updated: false, skillUpdated: r?.ok ?? false, message: r?.message ?? '' });
1097
1264
  else if (r?.ok)
@@ -3,6 +3,8 @@
3
3
  import { Type } from 'typebox';
4
4
  import { ConfigSchema } from '../core/config.js';
5
5
  const Str = Type.String();
6
+ /** Why sideby cannot do something itself (spec §3.23); `reason` keeps the English text. */
7
+ const ReasonCode = Type.Union(['local-build', 'npx', 'volta', 'not-writable', 'project', 'source', 'skill-in-git'].map((k) => Type.Literal(k)), { description: 'Why, as a code a page can say in its own language.' });
6
8
  const Level = Type.Union([Type.Literal('ok'), Type.Literal('warn'), Type.Literal('fail')]);
7
9
  const Kind = Type.Union([Type.Literal('subscription'), Type.Literal('api')]);
8
10
  const Version = Type.Literal(1);
@@ -39,6 +41,9 @@ const AccountStatus = Type.Intersect([
39
41
  sameLogin: Type.Optional(Type.Array(Str, {
40
42
  description: 'The other accounts signed in as the same login (same family, email and organization), which share one quota (spec §3.21). Absent when there are none.',
41
43
  })),
44
+ pinned: Type.Optional(Type.Literal(true, {
45
+ description: 'Pinned to the top of its family in the Panel and `sideby list` (spec §3.25). Display only; absent when not pinned.',
46
+ })),
42
47
  problems: Type.Optional(Type.Array(Str)),
43
48
  desktopApp: Type.Optional(Type.Object({ app: Str, installed: Type.Boolean(), running: Type.Boolean() }, {
44
49
  description: "The family's desktop app for this account (macOS, subscription accounts of claude and codex): whether it is installed and whether this account's own window runs now. Open it with `sideby desktop <account>`.",
@@ -244,12 +249,90 @@ const Notice = Type.Object({
244
249
  }),
245
250
  current: Str,
246
251
  command: Type.String({
247
- description: 'Updates the installed sideby skill (run it after the user agrees).',
252
+ description: '`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes.',
248
253
  }),
249
254
  message: Str,
250
255
  })),
251
256
  }, { description: 'Not the answer to the task: mention it briefly afterwards and offer to update.' });
257
+ const Display = Type.Object({
258
+ families: Type.Optional(Type.Array(Str)),
259
+ accounts: Type.Optional(Type.Record(Str, Type.Array(Str))),
260
+ pinned: Type.Optional(Type.Record(Str, Type.Array(Str))),
261
+ });
262
+ const PinResult = Type.Object({
263
+ schemaVersion: Version,
264
+ ref: Str,
265
+ pinned: Type.Boolean(),
266
+ changed: Type.Boolean(),
267
+ message: Str,
268
+ display: Display,
269
+ });
270
+ const Skipped = Type.Optional(Type.Array(Type.Object({ ref: Str, reason: Str })));
271
+ const Overview = Type.Object({
272
+ settings: Type.Object({
273
+ auto: Type.Boolean(),
274
+ sameFamily: Type.Boolean(),
275
+ prepareAt: Type.Number(),
276
+ threshold: Type.Number(),
277
+ waitIfResetWithinMinutes: Type.Number(),
278
+ countdownSeconds: Type.Number(),
279
+ crossOrganization: Type.Array(Str),
280
+ families: Type.Record(Str, Type.Object({
281
+ policy: Type.Union([Type.Literal('pressure'), Type.Literal('order')]),
282
+ order: Type.Array(Str),
283
+ })),
284
+ }),
285
+ takeover: Type.Array(Type.Object({
286
+ ref: Str,
287
+ family: Str,
288
+ starts: Type.Array(Type.Union([Type.Literal('quota'), Type.Literal('limit')])),
289
+ decision: Type.Union([
290
+ Type.Object({ kind: Type.Literal('pick'), ref: Str, skipped: Skipped }),
291
+ Type.Object({ kind: Type.Literal('wait'), until: Str }),
292
+ Type.Object({
293
+ kind: Type.Literal('none'),
294
+ reason: Str,
295
+ earliestReset: Type.Optional(Type.Object({ ref: Str, at: Str })),
296
+ skipped: Skipped,
297
+ }),
298
+ ]),
299
+ })),
300
+ recent: Type.Array(Type.Object({
301
+ from: Str,
302
+ to: Str,
303
+ at: Str,
304
+ trigger: Type.Union([
305
+ Type.Literal('threshold'),
306
+ Type.Literal('limit'),
307
+ Type.Literal('manual'),
308
+ Type.Null(),
309
+ ]),
310
+ briefSource: Type.Union([
311
+ Type.Literal('agent'),
312
+ Type.Literal('sideby'),
313
+ Type.Literal('user'),
314
+ Type.Null(),
315
+ ]),
316
+ })),
317
+ });
252
318
  export const OUTPUT_SCHEMAS = {
319
+ pin: PinResult,
320
+ unpin: PinResult,
321
+ order: Type.Object({
322
+ schemaVersion: Version,
323
+ display: Display,
324
+ families: Type.Array(Type.Object({
325
+ family: Str,
326
+ accounts: Type.Array(Type.Object({ name: Str, ref: Str, pinned: Type.Boolean() })),
327
+ })),
328
+ }),
329
+ 'quota-check': Type.Object({
330
+ schemaVersion: Version,
331
+ family: Str,
332
+ on: Type.Boolean(),
333
+ changed: Type.Boolean(),
334
+ }),
335
+ 'update-auto': Type.Object({ schemaVersion: Version, auto: Type.Boolean(), changed: Type.Boolean() }),
253
336
  list: Type.Object({
254
337
  schemaVersion: Version,
255
338
  families: Type.Array(FamilyInfo),
@@ -396,6 +479,7 @@ export const OUTPUT_SCHEMAS = {
396
479
  'quota-setup': Type.Object({ schemaVersion: Version, family: Str, applied: Type.Boolean(), plan: QuotaSetupPlan }, { title: 'sideby quota setup --json' }),
397
480
  'handoff-status': Type.Object({
398
481
  schemaVersion: Version,
482
+ overview: Type.Optional(Overview),
399
483
  auto: Type.Boolean(),
400
484
  sameFamily: Type.Boolean(),
401
485
  prepareAt: Type.Number(),
@@ -582,9 +666,11 @@ export const OUTPUT_SCHEMAS = {
582
666
  latest: Type.String({ description: 'The `latest` version on the npm registry.' }),
583
667
  available: Type.Boolean({ description: '`latest` is newer than `current`.' }),
584
668
  method: Type.Object({
585
- kind: Type.Union(['npm', 'pnpm', 'bun', 'volta', 'npx', 'project', 'source'].map((k) => Type.Literal(k)), { description: 'How this copy was installed.' }),
669
+ kind: Type.Union(['npm', 'pnpm', 'bun', 'volta', 'npx', 'project', 'source', 'local'].map((k) => Type.Literal(k)), { description: 'How this copy was installed.' }),
586
670
  canUpdate: Type.Boolean({ description: 'sideby can run the Update itself.' }),
587
671
  reason: Type.Optional(Type.String({ description: 'Why it cannot.' })),
672
+ reasonCode: Type.Optional(ReasonCode),
673
+ reasonAt: Type.Optional(Type.String({ description: 'The folders that are not writable (`not-writable`).' })),
588
674
  }),
589
675
  command: Type.String({
590
676
  description: 'What an Update runs, or what to run by hand when `canRun` is false.',
@@ -614,6 +700,8 @@ export const OUTPUT_SCHEMAS = {
614
700
  description: 'False when sideby leaves the skill alone (see reason): give the user command.',
615
701
  }),
616
702
  reason: Type.Optional(Str),
703
+ reasonCode: Type.Optional(ReasonCode),
704
+ reasonAt: Type.Optional(Type.String({ description: 'The git repository that owns the skill (`skill-in-git`).' })),
617
705
  }, {
618
706
  description: 'The installed sideby skill, which the Update brings along; absent when none is installed.',
619
707
  })),
@@ -13,6 +13,14 @@ Usage:
13
13
  create an account (families: claude, codex, grok, pi);
14
14
  --next picks the next name and short command (cc008);
15
15
  --alias also adds a short command such as cc008
16
+ sideby pin|unpin <account|short command> [--json]
17
+ pin or unpin a Same Login (at most 3 per family)
18
+ sideby order [--json] show display order, with pinned accounts marked
19
+ sideby order <family> <name>... set account order within a family
20
+ sideby order --families <id>... set family order
21
+ sideby order --reset restore default order, keeping pins
22
+ sideby quota check <family> on|off turn quota fetching on or off
23
+ sideby update --auto on|off set Auto Update without running an update
16
24
  sideby families [family] what each host's accounts share with the main account,
17
25
  where they live and how they sign in
18
26
  sideby alias add <short> <account> [-- args]
@@ -28,7 +36,7 @@ Usage:
28
36
  sideby doctor [target] [--fix] [--force]
29
37
  check shared items and permissions; --fix repairs safe issues
30
38
  sideby quota [account|family] [--refresh]
31
- quota and 7-day token usage; --refresh asks Grok again now
39
+ quota and 7-day token usage; --refresh asks every fetching family again now
32
40
  sideby quota setup claude [--yes] turn on Claude quota (shows the change first)
33
41
  sideby quota teardown claude undo it, restoring the original file
34
42
  sideby tune [account|family] estimate the cheapest auto-compact threshold per model
@@ -215,6 +223,9 @@ export function nodeTooOld(version = process.versions.node) {
215
223
  }
216
224
  const COMMANDS = {
217
225
  list: { flags: ['json'] },
226
+ pin: { flags: ['json'] },
227
+ unpin: { flags: ['json'] },
228
+ order: { flags: ['json', 'families', 'reset'] },
218
229
  run: { flags: [] },
219
230
  resume: { flags: [] },
220
231
  login: { flags: [] },
@@ -237,7 +248,7 @@ const COMMANDS = {
237
248
  'shell-init': { flags: ['write'] },
238
249
  plugins: { flags: ['json'] },
239
250
  families: { flags: ['json'] },
240
- update: { flags: ['json', 'yes'] },
251
+ update: { flags: ['json', 'yes'], valued: ['auto'] },
241
252
  'update-check': { flags: [] },
242
253
  };
243
254
  /** Commands that never print an Update Notice: their output is read by a shell or an agent, or they are internal. */
@@ -261,12 +272,13 @@ async function jsonNoticeFor() {
261
272
  const { resolvePaths } = await import('../core/paths.js');
262
273
  const { loadConfig } = await import('../core/config.js');
263
274
  const { packageVersion } = await import('../core/version.js');
264
- const { installedSkillVersions, SKILL_INSTALL } = await import('../core/skill-check.js');
275
+ const { installedSkillVersions } = await import('../core/skill-check.js');
265
276
  const paths = resolvePaths(process.env);
266
277
  const config = await loadConfig(paths.configFile).catch(() => ({}));
267
278
  const cache = await u.readUpdateCache(paths);
268
279
  const skills = await installedSkillVersions(paths.home);
269
- const n = u.jsonNotice(cache, packageVersion(), config, process.env, skills, SKILL_INSTALL);
280
+ // `sideby update` brings the skill along, so it is the one command for both (spec §3.23).
281
+ const n = u.jsonNotice(cache, packageVersion(), config, process.env, skills, 'sideby update');
270
282
  if (!u.checksOff(process.env, config) && u.checkDue(cache, new Date())) {
271
283
  const { spawn } = await import('node:child_process');
272
284
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), 'update-check'], {
@@ -297,7 +309,9 @@ async function afterCommand() {
297
309
  const paths = resolvePaths(process.env);
298
310
  const config = await loadConfig(paths.configFile).catch(() => ({}));
299
311
  const cache = await u.readUpdateCache(paths);
300
- const plan = u.afterCommandPlan(cache, packageVersion(), config, process.env, new Date());
312
+ const current = packageVersion();
313
+ const skill = u.skillBehind(await u.skillUpdatePlan(paths.home, current).catch(() => null), current);
314
+ const plan = u.afterCommandPlan(cache, current, config, process.env, new Date(), skill);
301
315
  const dim = process.env.NO_COLOR ? (t) => t : (t) => `\x1b[2m${t}\x1b[22m`;
302
316
  for (const l of plan.lines)
303
317
  console.error(dim(l));
@@ -414,6 +428,11 @@ export async function main(argv) {
414
428
  }
415
429
  async function dispatch(cmd, parsed, io, cmds) {
416
430
  switch (cmd) {
431
+ case 'pin':
432
+ case 'unpin':
433
+ return await cmds.cmdPin(parsed, io, cmd === 'pin');
434
+ case 'order':
435
+ return await cmds.cmdOrder(parsed, io);
417
436
  case 'list':
418
437
  return await cmds.cmdList(parsed, io);
419
438
  case 'run':
@@ -72,6 +72,7 @@ export declare const ConfigSchema: Type.TObject<{
72
72
  display: Type.TOptional<Type.TObject<{
73
73
  families: Type.TOptional<Type.TArray<Type.TString>>;
74
74
  accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TArray<Type.TString>>>;
75
+ pinned: Type.TOptional<Type.TRecord<"^.*$", Type.TArray<Type.TString>>>;
75
76
  }>>;
76
77
  quota: Type.TOptional<Type.TObject<{
77
78
  check: Type.TOptional<Type.TRecord<"^.*$", Type.TBoolean>>;
@@ -114,9 +114,12 @@ export const ConfigSchema = Type.Object({
114
114
  accounts: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), {
115
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
116
  })),
117
+ pinned: Type.Optional(Type.Record(Type.String(), Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }), {
118
+ description: 'Per family, the names of the accounts pinned to the top of their group, in the order they are shown; they lead the group in every sort. Resetting the order keeps them.',
119
+ })),
117
120
  }, {
118
121
  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.',
122
+ description: 'Display Order (spec §3.25): the order you gave families and accounts in the Panel, and the accounts you pinned. Display only: it never changes which account a Handoff picks.',
120
123
  })),
121
124
  quota: Type.Optional(Type.Object({
122
125
  check: Type.Optional(Type.Record(Type.String(), Type.Boolean(), {
@@ -275,10 +278,13 @@ export function editConfigBrowser(file, edit, opts = {}) {
275
278
  * or empty (see `editConfigKey` for how the file is written).
276
279
  */
277
280
  export function editConfigDisplay(file, display) {
278
- const accounts = Object.fromEntries(Object.entries(display?.accounts ?? {}).filter(([, names]) => names.length));
281
+ const lists = (r) => Object.fromEntries(Object.entries(r ?? {}).filter(([, names]) => names.length));
282
+ const accounts = lists(display?.accounts);
283
+ const pinned = lists(display?.pinned);
279
284
  const next = {
280
285
  ...(display?.families?.length ? { families: [...display.families] } : {}),
281
286
  ...(Object.keys(accounts).length ? { accounts } : {}),
287
+ ...(Object.keys(pinned).length ? { pinned } : {}),
282
288
  };
283
289
  return editConfigKey(file, 'display', 'move it again', () => (Object.keys(next).length ? next : undefined));
284
290
  }
@@ -1,7 +1,21 @@
1
1
  import type { FamilyDef, Finding } from '../types.ts';
2
+ export declare const SKILL_SOURCE = "Adonis0123/sideby";
2
3
  export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
4
+ /** The command a person runs to update the skill where sideby leaves it alone. */
5
+ export declare const SKILL_UPDATE_SHOWN = "npx skills update sideby -g";
3
6
  /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
4
7
  export declare function gitRootOf(dir: string): Promise<string | null>;
8
+ /**
9
+ * Whether the skills tool's install record (`.skill-lock.json` beside the `skills` folder holding `dir`) names this
10
+ * repository as the source of `sideby`: then `npx skills update` owns the skill, even inside a git repository (a
11
+ * dotfiles clone of `~/.agents`, say). Reads only that one entry's `source`.
12
+ */
13
+ export declare function installedBySkillsTool(dir: string): Promise<boolean>;
14
+ /**
15
+ * The git repository that owns the sideby skill in `dir`, or null when sideby may update it: not in a repository, or
16
+ * the skills tool installed it there.
17
+ */
18
+ export declare function skillOwnerRepo(dir: string): Promise<string | null>;
5
19
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
6
20
  export declare function skillVersionOf(text: string): string | null;
7
21
  /**
@@ -5,7 +5,10 @@ import { readFile, realpath, stat } from 'node:fs/promises';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { mainDirOf } from './accounts.js';
7
7
  import { tildify } from './paths.js';
8
- export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
8
+ export const SKILL_SOURCE = 'Adonis0123/sideby';
9
+ export const SKILL_INSTALL = `npx skills add ${SKILL_SOURCE} -g`;
10
+ /** The command a person runs to update the skill where sideby leaves it alone. */
11
+ export const SKILL_UPDATE_SHOWN = 'npx skills update sideby -g';
9
12
  /** The folder holding `dir` that is a git work tree, if any: a skill there is managed by that repository. */
10
13
  export async function gitRootOf(dir) {
11
14
  for (let d = dir;; d = dirname(d)) {
@@ -20,6 +23,29 @@ export async function gitRootOf(dir) {
20
23
  return null;
21
24
  }
22
25
  }
26
+ /**
27
+ * Whether the skills tool's install record (`.skill-lock.json` beside the `skills` folder holding `dir`) names this
28
+ * repository as the source of `sideby`: then `npx skills update` owns the skill, even inside a git repository (a
29
+ * dotfiles clone of `~/.agents`, say). Reads only that one entry's `source`.
30
+ */
31
+ export async function installedBySkillsTool(dir) {
32
+ try {
33
+ const lock = JSON.parse(await readFile(join(dirname(dirname(dir)), '.skill-lock.json'), 'utf8'));
34
+ const source = lock.skills?.sideby?.source;
35
+ return typeof source === 'string' && source.toLowerCase() === SKILL_SOURCE.toLowerCase();
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * The git repository that owns the sideby skill in `dir`, or null when sideby may update it: not in a repository, or
43
+ * the skills tool installed it there.
44
+ */
45
+ export async function skillOwnerRepo(dir) {
46
+ const repo = await gitRootOf(dir);
47
+ return repo && !(await installedBySkillsTool(dir)) ? repo : null;
48
+ }
23
49
  /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
24
50
  export function skillVersionOf(text) {
25
51
  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
@@ -85,7 +111,7 @@ export async function checkInstalledSkills(home, families, version) {
85
111
  continue;
86
112
  const where = tildify(path, home);
87
113
  // `sideby update` brings the skill along, except one a git repository owns (a dotfiles clone, say).
88
- const repo = await gitRootOf(dirname(real));
114
+ const repo = await skillOwnerRepo(dirname(real));
89
115
  out.push({
90
116
  level: 'warn',
91
117
  account: `${family.id}:main`,
@@ -93,7 +119,7 @@ export async function checkInstalledSkills(home, families, version) {
93
119
  code: 'skill.outdated',
94
120
  message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
95
121
  hint: repo
96
- ? `it lives in the git repository ${tildify(repo, home)}: update it where that repository comes from (\`npx skills update sideby -g\` there), so agents learn this version's commands`
122
+ ? `it lives in the git repository ${tildify(repo, home)}: update it where that repository comes from (\`${SKILL_UPDATE_SHOWN}\` there), so agents learn this version's commands`
97
123
  : `run \`sideby update\`, which updates the skill too (or \`${SKILL_INSTALL}\` by hand), so agents learn this version's commands`,
98
124
  source: 'core',
99
125
  fixable: false,