sideby 0.14.0 → 0.16.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 (66) hide show
  1. package/README.md +1 -0
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +7 -0
  4. package/dist/src/cli/commands.js +259 -9
  5. package/dist/src/cli/json-schemas.js +109 -2
  6. package/dist/src/cli/main.js +30 -6
  7. package/dist/src/core/config.d.ts +1 -0
  8. package/dist/src/core/config.js +8 -2
  9. package/dist/src/core/local-build.d.ts +63 -0
  10. package/dist/src/core/local-build.js +189 -0
  11. package/dist/src/core/skill-check.d.ts +14 -0
  12. package/dist/src/core/skill-check.js +29 -3
  13. package/dist/src/core/update.d.ts +73 -1
  14. package/dist/src/core/update.js +155 -23
  15. package/dist/src/panel/handler.d.ts +12 -1
  16. package/dist/src/panel/handler.js +84 -5
  17. package/dist/src/panel/i18n.d.ts +125 -5
  18. package/dist/src/panel/i18n.js +280 -40
  19. package/dist/src/panel/page-logic.d.ts +41 -0
  20. package/dist/src/panel/page-logic.js +46 -0
  21. package/dist/src/panel/page-script.js +616 -162
  22. package/dist/src/panel/page-styles.js +74 -23
  23. package/dist/src/panel/page.js +3 -0
  24. package/dist/src/runtime.d.ts +2 -0
  25. package/package.json +3 -2
  26. package/schemas/alias.json +1 -1
  27. package/schemas/app-install.json +1 -1
  28. package/schemas/app-uninstall.json +1 -1
  29. package/schemas/browser-open.json +1 -1
  30. package/schemas/browser-switch.json +1 -1
  31. package/schemas/browser.json +1 -1
  32. package/schemas/config.json +15 -1
  33. package/schemas/desktop-remove.json +1 -1
  34. package/schemas/desktop.json +1 -1
  35. package/schemas/doctor.json +1 -1
  36. package/schemas/error.json +1 -1
  37. package/schemas/families.json +1 -1
  38. package/schemas/handoff-disable.json +1 -1
  39. package/schemas/handoff-enable.json +1 -1
  40. package/schemas/handoff-ready.json +1 -1
  41. package/schemas/handoff-setup.json +1 -1
  42. package/schemas/handoff-status.json +282 -1
  43. package/schemas/handoff-teardown.json +1 -1
  44. package/schemas/list.json +6 -1
  45. package/schemas/local.json +165 -0
  46. package/schemas/model.json +1 -1
  47. package/schemas/new.json +1 -1
  48. package/schemas/next.json +1 -1
  49. package/schemas/order.json +148 -0
  50. package/schemas/pin.json +126 -0
  51. package/schemas/plugins.json +1 -1
  52. package/schemas/quota-check.json +88 -0
  53. package/schemas/quota-setup.json +1 -1
  54. package/schemas/quota-teardown.json +1 -1
  55. package/schemas/quota.json +1 -1
  56. package/schemas/remote-control.json +1 -1
  57. package/schemas/tune-change.json +1 -1
  58. package/schemas/tune.json +1 -1
  59. package/schemas/unpin.json +126 -0
  60. package/schemas/update-auto.json +84 -0
  61. package/schemas/update.json +79 -1
  62. package/skills/sideby/SKILL.md +8 -3
  63. package/skills/sideby/references/handoff.md +8 -0
  64. package/skills/sideby/references/panel.md +28 -0
  65. package/skills/sideby/references/quota.md +6 -0
  66. package/skills/sideby/references/update.md +15 -2
@@ -6,7 +6,7 @@ import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat }
6
6
  import { dirname, join, sep } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { writeFileAtomic } from './fs-safe.js';
9
- import { gitRootOf, installedSkills, SKILL_INSTALL, SKILL_UPDATE } from './skill-check.js';
9
+ import { installedSkills, SKILL_UPDATE, SKILL_UPDATE_SHOWN, skillOwnerRepo } from './skill-check.js';
10
10
  import { localBuild, packageVersion, parseLocalBuild } from './version.js';
11
11
  export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
12
12
  export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -102,6 +102,14 @@ export function bindUpdateDeps(deps) {
102
102
  };
103
103
  }
104
104
  const deps = () => ({ ...realDeps, ...override });
105
+ /** Runs a program through the swappable machine (tests bind `exec`); a Local Build switch installs with it. */
106
+ export const runProgram = (file, args, opts) => deps().exec(file, args, opts);
107
+ /** Starts the background Panel again from the code `sideby` now names; see `UpdateDeps.restartPanel`. */
108
+ export const restartPanelByName = (env, port) => deps()
109
+ .restartPanel(env, port)
110
+ .catch(() => false);
111
+ /** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
112
+ export const takeInstallLock = (paths) => takeLock(paths, 'update-install.lock').catch(() => null);
105
113
  // Build metadata is limited to what semver allows, so a version never carries a terminal escape into the notice.
106
114
  const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
107
115
  function parseVersion(v) {
@@ -151,21 +159,25 @@ export function sameLine(from, to) {
151
159
  const set = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
152
160
  /** True in CI, where a notice is noise and a check is a request nobody reads. */
153
161
  export const inCi = (env) => set(env.CI) || set(env.CONTINUOUS_INTEGRATION) || set(env.BUILD_NUMBER);
154
- /** Why Update Checks are off, or null when they are on (spec §3.23). */
155
- export function checksOff(env, config, local = localBuild() !== null) {
162
+ /** Why Update Checks are off, with a code for the Panel, or null when they are on (spec §3.23). */
163
+ export function checksOffWhy(env, config, local = localBuild() !== null) {
156
164
  // A Local Build (ADR-0024) follows the checkout: a notice or an Auto Update would only replace it with npm's.
157
165
  if (local)
158
- return 'this is a local build (pnpm local)';
166
+ return { code: 'local-build', text: 'this is a local build (pnpm local)' };
159
167
  if (set(env.SIDEBY_NO_UPDATE_NOTIFIER))
160
- return 'SIDEBY_NO_UPDATE_NOTIFIER is set';
168
+ return { code: 'env', text: 'SIDEBY_NO_UPDATE_NOTIFIER is set' };
161
169
  if (set(env.NO_UPDATE_NOTIFIER))
162
- return 'NO_UPDATE_NOTIFIER is set';
170
+ return { code: 'env', text: 'NO_UPDATE_NOTIFIER is set' };
163
171
  if (config.update?.check === false)
164
- return 'config update.check is false';
172
+ return { code: 'config', text: 'config update.check is false' };
165
173
  if (inCi(env))
166
- return 'running in CI';
174
+ return { code: 'ci', text: 'running in CI' };
167
175
  return null;
168
176
  }
177
+ /** Why Update Checks are off, or null when they are on (spec §3.23). */
178
+ export function checksOff(env, config, local = localBuild() !== null) {
179
+ return checksOffWhy(env, config, local)?.text ?? null;
180
+ }
169
181
  // ---- registry
170
182
  /** The registry checks and installs use: `SIDEBY_UPDATE_REGISTRY` (http or https only), else npm's. */
171
183
  function registryOf(env) {
@@ -217,6 +229,18 @@ export async function readUpdateCache(paths) {
217
229
  message: a.message,
218
230
  ...(a.shown ? { shown: true } : {}),
219
231
  };
232
+ const sn = d.skillNotified;
233
+ if (sn && str(sn.version) && str(sn.at))
234
+ out.skillNotified = { version: sn.version, at: sn.at };
235
+ const sa = d.skillAuto;
236
+ if (sa && str(sa.to) && str(sa.at) && typeof sa.ok === 'boolean' && str(sa.message))
237
+ out.skillAuto = {
238
+ to: sa.to,
239
+ at: sa.at,
240
+ ok: sa.ok,
241
+ message: sa.message,
242
+ ...(sa.shown ? { shown: true } : {}),
243
+ };
220
244
  return out;
221
245
  }
222
246
  catch {
@@ -352,6 +376,44 @@ export function autoUpdateLine(cache) {
352
376
  ? `sideby updated itself from ${a.from} to ${a.to} (${RELEASE_NOTES(a.to)})`
353
377
  : `sideby could not update itself to ${a.to}: ${a.message}`;
354
378
  }
379
+ /** What the CLI says once about the skill Auto Update brought up to date alone, or null. */
380
+ export function skillAutoLine(cache) {
381
+ const a = cache.skillAuto;
382
+ if (!a || a.shown)
383
+ return null;
384
+ return a.ok
385
+ ? `sideby updated the sideby skill agents read to ${a.to}`
386
+ : `sideby could not update the sideby skill to ${a.to}: ${a.message}`;
387
+ }
388
+ /** The skill part of a `SkillUpdatePlan` toward the running version, as `afterCommandPlan` takes it. */
389
+ export const skillBehind = (plan, current) => plan?.outdated
390
+ ? { installed: plan.installed.find((v) => v !== current) ?? null, canRun: plan.canRun }
391
+ : null;
392
+ /**
393
+ * The line about a skill behind this version the CLI may print now (spec §3.23), or null: once a day per CLI version,
394
+ * and never while Auto Update is on (the background check brings the skill along) and sideby may update it.
395
+ */
396
+ export function skillNoticeLine(cache, current, config, skill, now) {
397
+ if (!skill || (config.update?.auto === true && skill.canRun))
398
+ return null;
399
+ const n = cache.skillNotified;
400
+ if (n && n.version === current && age(n.at, now) < NOTICE_INTERVAL_MS)
401
+ return null;
402
+ return `the sideby skill agents read is for ${skill.installed ?? 'an older sideby'} (this sideby is ${current}) · run \`sideby update\``;
403
+ }
404
+ /**
405
+ * Auto Update is on, the CLI is the newest version the cache knows, and the skill is behind it where sideby may
406
+ * update it, not tried for this version yet (or a failed try a day ago). A newer CLI waiting is left to the Update
407
+ * of the CLI: the skills tool fetches the default branch, which would put the skill ahead of this CLI.
408
+ */
409
+ export function skillAutoPending(cache, current, config, skill, now) {
410
+ if (config.update?.auto !== true || !skill?.canRun || isNewer(cache.latest, current))
411
+ return false;
412
+ const a = cache.skillAuto;
413
+ if (!a || a.to !== current)
414
+ return true;
415
+ return !a.ok && !(age(a.at, now) < AUTO_RETRY_MS);
416
+ }
355
417
  /**
356
418
  * The `_notice` for a `--json` output, from the cached Update Check and the versions of the installed sideby skills
357
419
  * (`null` for one written before skills had a version); null when there is nothing to say or checks are off. It
@@ -383,27 +445,40 @@ export function jsonNotice(cache, current, config, env, skills, skillCommand) {
383
445
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
384
446
  * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
385
447
  */
386
- export function afterCommandPlan(cache, current, config, env, now) {
448
+ export function afterCommandPlan(cache, current, config, env, now, skill = null) {
387
449
  if (checksOff(env, config))
388
450
  return { lines: [], record: null, startCheck: false };
389
451
  const lines = [];
390
452
  const auto = autoUpdateLine(cache);
453
+ const skillAuto = skillAutoLine(cache);
391
454
  const notice = noticeLine(cache, current, now);
392
- if (auto)
393
- lines.push(auto);
394
- if (notice)
395
- lines.push(notice);
455
+ // A newer CLI's notice already leads to `sideby update`, which brings the skill along.
456
+ const skillNotice = notice ? null : skillNoticeLine(cache, current, config, skill, now);
457
+ for (const l of [auto, skillAuto, notice, skillNotice])
458
+ if (l)
459
+ lines.push(l);
396
460
  const shownAuto = cache.auto;
461
+ const shownSkillAuto = cache.skillAuto;
397
462
  const record = lines.length
398
463
  ? (cur) => ({
399
464
  // Mark only the Auto Update that was printed; a newer one written meanwhile stays to be said.
400
465
  ...(auto && cur.auto && cur.auto.at === shownAuto?.at && cur.auto.to === shownAuto.to
401
466
  ? { auto: { ...cur.auto, shown: true } }
402
467
  : {}),
468
+ ...(skillAuto && cur.skillAuto && cur.skillAuto.at === shownSkillAuto?.at
469
+ ? { skillAuto: { ...cur.skillAuto, shown: true } }
470
+ : {}),
403
471
  ...(notice ? { notified: { version: cache.latest, at: now.toISOString() } } : {}),
472
+ ...(skillNotice ? { skillNotified: { version: current, at: now.toISOString() } } : {}),
404
473
  })
405
474
  : null;
406
- return { lines, record, startCheck: checkDue(cache, now) || autoPending(cache, current, config, now) };
475
+ return {
476
+ lines,
477
+ record,
478
+ startCheck: checkDue(cache, now) ||
479
+ autoPending(cache, current, config, now) ||
480
+ skillAutoPending(cache, current, config, skill, now),
481
+ };
407
482
  }
408
483
  // ---- Install Method
409
484
  /** Where the running copy comes from (spec §3.23 table); reads only paths. */
@@ -421,16 +496,20 @@ export async function installMethod(env) {
421
496
  if (parseLocalBuild(JSON.parse(await readFile(join(packageDir, 'dist', 'build.json'), 'utf8'))))
422
497
  return at('local', {
423
498
  reason: 'it is a local build; run `pnpm local --off` in the sideby checkout to go back to npm',
499
+ reasonCode: 'local-build',
424
500
  });
425
501
  }
426
502
  catch {
427
503
  // no build file: not a Local Build
428
504
  }
429
505
  if (p.includes('/_npx/'))
430
- return at('npx', { reason: 'it runs from the npx cache, which fetches a version per call' });
506
+ return at('npx', {
507
+ reason: 'it runs from the npx cache, which fetches a version per call',
508
+ reasonCode: 'npx',
509
+ });
431
510
  // Volta keeps a global package under its own npm prefix; installing there behind its back breaks its records.
432
511
  if (p.includes('/.volta/'))
433
- return at('volta', { reason: 'Volta manages this install' });
512
+ return at('volta', { reason: 'Volta manages this install', reasonCode: 'volta' });
434
513
  const npm = /^(.*)\/lib\/node_modules\/sideby$/.exec(p);
435
514
  if (npm) {
436
515
  const prefix = npm[1];
@@ -442,7 +521,11 @@ export async function installMethod(env) {
442
521
  prefix,
443
522
  canUpdate: !blocked.length,
444
523
  ...(blocked.length
445
- ? { reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you` }
524
+ ? {
525
+ reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you`,
526
+ reasonCode: 'not-writable',
527
+ reasonAt: blocked.join(', '),
528
+ }
446
529
  : {}),
447
530
  });
448
531
  }
@@ -452,8 +535,8 @@ export async function installMethod(env) {
452
535
  if (p.includes('/.bun/install/global/'))
453
536
  return at('bun', { canUpdate: true });
454
537
  if (p.includes('/node_modules/'))
455
- return at('project', { reason: 'it is a dependency of a project' });
456
- return at('source', { reason: 'it runs from a source checkout' });
538
+ return at('project', { reason: 'it is a dependency of a project', reasonCode: 'project' });
539
+ return at('source', { reason: 'it runs from a source checkout', reasonCode: 'source' });
457
540
  }
458
541
  /**
459
542
  * The command an Update runs (or that the user runs) for `version`; `run` is set only when sideby may run it. With
@@ -548,6 +631,8 @@ export const shownMethod = (m) => ({
548
631
  kind: m.kind,
549
632
  canUpdate: m.canUpdate,
550
633
  ...(m.reason ? { reason: m.reason } : {}),
634
+ ...(m.reasonCode ? { reasonCode: m.reasonCode } : {}),
635
+ ...(m.reasonAt ? { reasonAt: m.reasonAt } : {}),
551
636
  });
552
637
  /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
553
638
  export function shownPlan(plan) {
@@ -575,14 +660,17 @@ export async function skillUpdatePlan(home, target) {
575
660
  canRun: true,
576
661
  };
577
662
  for (const s of skills) {
578
- const root = await gitRootOf(s.dir);
663
+ // A git repository owns the skill unless the skills tool records installing it there (spec §3.23).
664
+ const root = await skillOwnerRepo(s.dir);
579
665
  if (root) {
580
666
  const at = root.startsWith(home) ? `~${root.slice(home.length)}` : root;
581
667
  return {
582
668
  ...plan,
583
- command: SKILL_INSTALL,
669
+ command: SKILL_UPDATE_SHOWN,
584
670
  canRun: false,
585
671
  reason: `the skill is in the git repository ${at}, so sideby leaves it alone; update it where that repository comes from`,
672
+ reasonCode: 'skill-in-git',
673
+ reasonAt: at,
586
674
  };
587
675
  }
588
676
  }
@@ -609,6 +697,27 @@ export async function applySkillUpdate(plan, env, onOutput) {
609
697
  ? { ok: true, message: 'updated the sideby skill' }
610
698
  : { ok: false, message: `\`${plan.command}\` failed (exit ${r.code}); run it yourself to see why` };
611
699
  }
700
+ /**
701
+ * Updates only the sideby skill, for a CLI that is already current (`sideby update`, the Panel, Auto Update). Holds
702
+ * the same lock as an Update, so a skill update never runs beside one; null when there was nothing to do.
703
+ */
704
+ export async function applySkillOnly(paths, plan, env, onOutput) {
705
+ if (!plan?.outdated || !plan.canRun)
706
+ return null;
707
+ const release = await takeLock(paths, 'update-install.lock').catch(() => null);
708
+ if (!release)
709
+ return {
710
+ ok: false,
711
+ busy: true,
712
+ message: 'another sideby update is running; wait for it to finish, then run `sideby update` again',
713
+ };
714
+ try {
715
+ return await applySkillUpdate(plan, env, onOutput);
716
+ }
717
+ finally {
718
+ await release().catch(() => { });
719
+ }
720
+ }
612
721
  /**
613
722
  * Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
614
723
  * desktop app is installed, runs `sideby app install` again from the new code. The caller has
@@ -689,6 +798,22 @@ export async function autoUpdateBlocker(paths, plan) {
689
798
  return `a session is running (pid ${running.join(', ')})`;
690
799
  return null;
691
800
  }
801
+ /**
802
+ * The skill half of Auto Update (spec §3.23): with the CLI current, a skill behind it is updated alone and the result
803
+ * recorded once per version, so a failure waits a day.
804
+ */
805
+ async function skillAutoSync(paths, env, config, cache, current, now) {
806
+ const plan = await skillUpdatePlan(paths.home, current).catch(() => null);
807
+ if (!skillAutoPending(cache, current, config, skillBehind(plan, current), now))
808
+ return;
809
+ const r = await applySkillOnly(paths, plan ?? undefined, env);
810
+ // A manual Update holding the lock is not a failure; the next command looks again.
811
+ if (!r || r.busy)
812
+ return;
813
+ await updateUpdateCache(paths, {
814
+ skillAuto: { to: current, at: deps().now().toISOString(), ok: r.ok, message: r.message },
815
+ });
816
+ }
692
817
  /**
693
818
  * Body of the detached `sideby update-check`: an Update Check when one is due, then an Auto Update when config
694
819
  * `update.auto` is on and nothing blocks it. Only one runs at a time; it never throws.
@@ -705,9 +830,16 @@ export async function backgroundCheck(paths, env, config, panelPort) {
705
830
  if (checkDue(cache, now))
706
831
  cache = await refreshUpdateCheck(paths, env);
707
832
  const current = packageVersion();
708
- if (!autoPending(cache, current, config, now))
833
+ if (!autoPending(cache, current, config, now)) {
834
+ await skillAutoSync(paths, env, config, cache, current, now);
709
835
  return;
710
- const plan = planFrom(await installMethod(env), current, cache.latest, env);
836
+ }
837
+ // The skill teaches agents the new version's commands, so an Auto Update carries it as `sideby update` does.
838
+ const skill = await skillUpdatePlan(paths.home, cache.latest).catch(() => null);
839
+ const plan = {
840
+ ...planFrom(await installMethod(env), current, cache.latest, env),
841
+ ...(skill ? { skill } : {}),
842
+ };
711
843
  await updateUpdateCache(paths, { autoTriedAt: now.toISOString() });
712
844
  // A blocker only the user can lift is said once and looked at again a day later, like a failed install; a
713
845
  // running session only delays it (the next look is within the hour).
@@ -81,12 +81,15 @@ export interface PanelUpdate {
81
81
  checkedAt?: string;
82
82
  /** Why Update Checks are off; absent when they are on. */
83
83
  checksOff?: string;
84
+ checksOffCode?: update.ChecksOffCode;
84
85
  /** Config `update.auto`. */
85
86
  auto: boolean;
86
87
  method: {
87
88
  kind: update.InstallKind;
88
89
  canUpdate: boolean;
89
90
  reason?: string;
91
+ reasonCode?: update.ReasonCode;
92
+ reasonAt?: string;
90
93
  };
91
94
  /** The command an Update runs, or the one to run by hand when `canRun` is false. */
92
95
  command?: string;
@@ -94,8 +97,16 @@ export interface PanelUpdate {
94
97
  releaseNotes?: string;
95
98
  /** The command that installs the sideby skill by hand. */
96
99
  skill: string;
97
- /** The installed sideby skill, which an Update brings along (spec §3.23); absent when none is installed. */
100
+ /**
101
+ * The installed sideby skill, which an Update brings along (spec §3.23); absent when none is installed, and for a
102
+ * Local Build, which `pnpm local --off` brings back to npm together with the skill.
103
+ */
98
104
  skillUpdate?: import('../core/update.ts').SkillUpdatePlan;
105
+ /**
106
+ * The switch between a Local Build and the npm package (ADR-0024): present only while a Local Build runs or one is
107
+ * kept next to an npm install, so a plain install never sees it.
108
+ */
109
+ localSwitch?: import('../core/local-build.ts').LocalStatus;
99
110
  }
100
111
  export interface PanelState {
101
112
  schemaVersion: 1;
@@ -53,7 +53,7 @@ let checking = null;
53
53
  */
54
54
  async function updateState(rt, readOnly) {
55
55
  const current = packageVersion();
56
- const off = update.checksOff(rt.env, rt.config);
56
+ const off = update.checksOffWhy(rt.env, rt.config);
57
57
  const cache = await update.readUpdateCache(rt.paths);
58
58
  if (!off && !readOnly && !checking && update.checkDue(cache, new Date()))
59
59
  checking = update.refreshUpdateCheck(rt.paths, rt.env).finally(() => {
@@ -61,23 +61,55 @@ async function updateState(rt, readOnly) {
61
61
  });
62
62
  const method = await update.installMethod(rt.env);
63
63
  const plan = cache.latest ? update.shownPlan(update.planFrom(method, current, cache.latest, rt.env)) : null;
64
- const skillUpdate = await update
65
- .skillUpdatePlan(rt.paths.home, plan?.available ? plan.latest : current)
66
- .catch(() => null);
64
+ const skillUpdate = method.kind === 'local'
65
+ ? null
66
+ : await update.skillUpdatePlan(rt.paths.home, plan?.available ? plan.latest : current).catch(() => null);
67
67
  return {
68
68
  current,
69
69
  ...(cache.latest ? { latest: cache.latest } : {}),
70
70
  available: Boolean(plan?.available),
71
71
  ...(cache.checkedAt ? { checkedAt: cache.checkedAt } : {}),
72
- ...(off ? { checksOff: off } : {}),
72
+ ...(off ? { checksOff: off.text, checksOffCode: off.code } : {}),
73
73
  auto: rt.config.update?.auto === true,
74
74
  method: update.shownMethod(method),
75
75
  ...(plan ? { command: plan.command, releaseNotes: plan.releaseNotes } : {}),
76
76
  canRun: Boolean(plan?.canRun),
77
77
  skill: SKILL_INSTALL,
78
78
  ...(skillUpdate ? { skillUpdate } : {}),
79
+ ...(await localSwitchState(rt, method)),
79
80
  };
80
81
  }
82
+ // npm's global prefix, asked once per process: the page reads the state every 20 s and `npm prefix -g` takes a while.
83
+ let localDirsMemo = null;
84
+ async function localDirsFor(rt) {
85
+ const lb = await import('../core/local-build.js');
86
+ localDirsMemo ??= lb.localDirs(rt.env).then((d) => {
87
+ if (!d)
88
+ localDirsMemo = null; // ask again next time
89
+ return d;
90
+ });
91
+ return localDirsMemo;
92
+ }
93
+ // An npm install knows its prefix; only a running Local Build needs npm asked where the global package is.
94
+ async function switchDirs(rt, method) {
95
+ const lb = await import('../core/local-build.js');
96
+ return method.prefix ? { dev: lb.devDir(rt.env), prefix: method.prefix } : localDirsFor(rt);
97
+ }
98
+ async function localSwitchState(rt, method) {
99
+ if (method.kind !== 'local' && method.kind !== 'npm')
100
+ return {};
101
+ try {
102
+ const lb = await import('../core/local-build.js');
103
+ const d = await switchDirs(rt, method);
104
+ if (!d)
105
+ return {};
106
+ const s = await lb.localStatus(d);
107
+ return s.running === 'local' || (s.running === 'npm' && s.local) ? { localSwitch: s } : {};
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }
81
113
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
82
114
  export function handoffByFamily(rt, accounts, now) {
83
115
  const out = {};
@@ -329,6 +361,11 @@ function displayBody(body) {
329
361
  throw new HttpError(400, '"accounts" must be an object');
330
362
  out.accounts = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"accounts.${f}"`)]));
331
363
  }
364
+ else if (k === 'pinned') {
365
+ if (!v || typeof v !== 'object' || Array.isArray(v))
366
+ throw new HttpError(400, '"pinned" must be an object');
367
+ out.pinned = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"pinned.${f}"`)]));
368
+ }
332
369
  else
333
370
  throw new HttpError(400, `unknown key "${k}"`);
334
371
  }
@@ -661,6 +698,17 @@ export function createPanelHandler(opts) {
661
698
  const shown = update.shownPlan(plan);
662
699
  if (!optBool(body, 'confirm'))
663
700
  return { ...shown, updated: false };
701
+ // The CLI is current and the skill is behind: `skill: true` updates it alone, as `sideby update --yes` does.
702
+ if (optBool(body, 'skill')) {
703
+ if (plan.available || plan.method.kind === 'local')
704
+ return { ...shown, updated: false, changed: true };
705
+ const r = await update.applySkillOnly(rt.paths, plan.skill, rt.env);
706
+ if (!r)
707
+ return { ...shown, updated: false, changed: true };
708
+ if (!r.ok)
709
+ throw new UserError(r.message);
710
+ return { ...shown, updated: false, skillUpdated: true, message: r.message };
711
+ }
664
712
  // A confirmation that runs nothing always says why: the registry names another version (or none newer) now.
665
713
  if (body.version !== plan.latest || !plan.available)
666
714
  return { ...shown, updated: false, changed: true };
@@ -680,6 +728,37 @@ export function createPanelHandler(opts) {
680
728
  };
681
729
  },
682
730
  },
731
+ // The Local Build switch (ADR-0024): without `confirm` the current status; with it, `to` goes behind the global
732
+ // `sideby` and a background Panel restarts from it. The page then waits for the new build and reloads.
733
+ '/api/local': {
734
+ async run(body) {
735
+ if (!updates)
736
+ throw new HttpError(403, 'updates are managed by the page that shows this panel');
737
+ const to = body.to;
738
+ if (to !== 'local' && to !== 'npm')
739
+ throw new HttpError(400, '"to" must be "local" or "npm"');
740
+ const rt = await getRuntime();
741
+ const lb = await import('../core/local-build.js');
742
+ const d = await switchDirs(rt, await update.installMethod(rt.env));
743
+ if (!d)
744
+ throw new UserError('`npm prefix -g` failed; switching needs npm on PATH and an npm global install');
745
+ if (!optBool(body, 'confirm'))
746
+ return { ...(await lb.localStatus(d)), to, switched: false };
747
+ const { backgroundPanelPort } = await import('./background.js');
748
+ const r = await lb.switchLocal(rt.paths, d, to, rt.env, {
749
+ panelPort: () => backgroundPanelPort(rt.paths),
750
+ });
751
+ if (!r.ok)
752
+ throw new UserError(r.message);
753
+ return {
754
+ ...(await lb.localStatus(d)),
755
+ to,
756
+ switched: !r.unchanged,
757
+ message: r.message,
758
+ restartedPanel: r.restartedPanel,
759
+ };
760
+ },
761
+ },
683
762
  // The Auto Update switch: config `update.auto`, shown as a diff first like every config change.
684
763
  '/api/update/settings': {
685
764
  writes: (body) => body.confirm === true,