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
@@ -32,7 +32,25 @@ export interface UpdateCache {
32
32
  message: string;
33
33
  shown?: boolean;
34
34
  };
35
+ /** The last line the CLI printed about a sideby skill behind this version (`version` is the CLI's). */
36
+ skillNotified?: {
37
+ version: string;
38
+ at: string;
39
+ };
40
+ /** The last time Auto Update brought the skill to `to` alone; `shown` once the CLI has said so. */
41
+ skillAuto?: {
42
+ to: string;
43
+ at: string;
44
+ ok: boolean;
45
+ message: string;
46
+ shown?: boolean;
47
+ };
35
48
  }
49
+ /**
50
+ * Why sideby cannot do something itself, for a Panel to say in the viewer's language; `reason` keeps the English
51
+ * text the CLI prints. `reasonAt` carries the path a code needs.
52
+ */
53
+ export type ReasonCode = 'local-build' | 'npx' | 'volta' | 'not-writable' | 'project' | 'source' | 'skill-in-git';
36
54
  export type InstallKind = 'npm' | 'pnpm' | 'bun' | 'volta' | 'npx' | 'project' | 'source' | 'local';
37
55
  export interface InstallMethod {
38
56
  kind: InstallKind;
@@ -42,6 +60,9 @@ export interface InstallMethod {
42
60
  canUpdate: boolean;
43
61
  /** Why it cannot, in a phrase that follows "sideby cannot update itself: ". */
44
62
  reason?: string;
63
+ reasonCode?: ReasonCode;
64
+ /** The folders that are not writable (`not-writable`). */
65
+ reasonAt?: string;
45
66
  /** npm only: the prefix whose `lib/node_modules` holds sideby. */
46
67
  prefix?: string;
47
68
  }
@@ -87,6 +108,12 @@ type UpdateSettings = {
87
108
  auto?: boolean;
88
109
  };
89
110
  };
111
+ export type ChecksOffCode = 'local-build' | 'env' | 'config' | 'ci';
112
+ /** Why Update Checks are off, with a code for the Panel, or null when they are on (spec §3.23). */
113
+ export declare function checksOffWhy(env: Env, config: UpdateSettings, local?: boolean): {
114
+ code: ChecksOffCode;
115
+ text: string;
116
+ } | null;
90
117
  /** Why Update Checks are off, or null when they are on (spec §3.23). */
91
118
  export declare function checksOff(env: Env, config: UpdateSettings, local?: boolean): string | null;
92
119
  export declare function readUpdateCache(paths: Paths): Promise<UpdateCache>;
@@ -114,6 +141,29 @@ export declare function refreshUpdateCheck(paths: Paths, env: Env): Promise<Upda
114
141
  export declare function noticeLine(cache: UpdateCache, current: string, now: Date): string | null;
115
142
  /** What the CLI says once about the last Auto Update, or null. */
116
143
  export declare function autoUpdateLine(cache: UpdateCache): string | null;
144
+ /** What the CLI says once about the skill Auto Update brought up to date alone, or null. */
145
+ export declare function skillAutoLine(cache: UpdateCache): string | null;
146
+ /**
147
+ * The sideby skill an agent reads, as the CLI sees it after a command: the version of the first one behind
148
+ * `current` (null: one without a version), and whether sideby may update it. Absent when none is behind.
149
+ */
150
+ export interface SkillBehind {
151
+ installed: string | null;
152
+ canRun: boolean;
153
+ }
154
+ /** The skill part of a `SkillUpdatePlan` toward the running version, as `afterCommandPlan` takes it. */
155
+ export declare const skillBehind: (plan: SkillUpdatePlan | null, current: string) => SkillBehind | null;
156
+ /**
157
+ * The line about a skill behind this version the CLI may print now (spec §3.23), or null: once a day per CLI version,
158
+ * and never while Auto Update is on (the background check brings the skill along) and sideby may update it.
159
+ */
160
+ export declare function skillNoticeLine(cache: UpdateCache, current: string, config: UpdateSettings, skill: SkillBehind | null, now: Date): string | null;
161
+ /**
162
+ * Auto Update is on, the CLI is the newest version the cache knows, and the skill is behind it where sideby may
163
+ * update it, not tried for this version yet (or a failed try a day ago). A newer CLI waiting is left to the Update
164
+ * of the CLI: the skills tool fetches the default branch, which would put the skill ahead of this CLI.
165
+ */
166
+ export declare function skillAutoPending(cache: UpdateCache, current: string, config: UpdateSettings, skill: SkillBehind | null, now: Date): boolean;
117
167
  /** The `_notice` of a `--json` output (spec §3.23): what an agent should mention once its task is done. */
118
168
  export interface JsonNotice {
119
169
  update?: {
@@ -140,7 +190,7 @@ export declare function jsonNotice(cache: UpdateCache, current: string, config:
140
190
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
141
191
  * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
142
192
  */
143
- export declare function afterCommandPlan(cache: UpdateCache, current: string, config: UpdateSettings, env: Env, now: Date): {
193
+ export declare function afterCommandPlan(cache: UpdateCache, current: string, config: UpdateSettings, env: Env, now: Date, skill?: SkillBehind | null): {
144
194
  lines: string[];
145
195
  record: ((cur: UpdateCache) => Partial<UpdateCache>) | null;
146
196
  startCheck: boolean;
@@ -183,6 +233,8 @@ export declare const shownMethod: (m: InstallMethod) => {
183
233
  kind: InstallKind;
184
234
  canUpdate: boolean;
185
235
  reason?: string | undefined;
236
+ reasonCode?: ReasonCode | undefined;
237
+ reasonAt?: string | undefined;
186
238
  };
187
239
  /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
188
240
  export declare function shownPlan(plan: UpdatePlan): {
@@ -193,6 +245,8 @@ export declare function shownPlan(plan: UpdatePlan): {
193
245
  kind: InstallKind;
194
246
  canUpdate: boolean;
195
247
  reason?: string | undefined;
248
+ reasonCode?: ReasonCode | undefined;
249
+ reasonAt?: string | undefined;
196
250
  };
197
251
  command: string;
198
252
  canRun: boolean;
@@ -209,6 +263,9 @@ export interface SkillUpdatePlan {
209
263
  /** False when sideby must not run it; `reason` says why and the user runs `command` where it belongs. */
210
264
  canRun: boolean;
211
265
  reason?: string;
266
+ reasonCode?: ReasonCode;
267
+ /** The git repository that owns the skill (`skill-in-git`). */
268
+ reasonAt?: string;
212
269
  }
213
270
  /** The skill part of an Update toward `target`; null when no sideby skill is installed. Reads two files. */
214
271
  export declare function skillUpdatePlan(home: string, target: string): Promise<SkillUpdatePlan | null>;
@@ -219,6 +276,15 @@ export declare function applySkillUpdate(plan: SkillUpdatePlan | undefined, env:
219
276
  ok: boolean;
220
277
  message: string;
221
278
  } | null>;
279
+ /**
280
+ * Updates only the sideby skill, for a CLI that is already current (`sideby update`, the Panel, Auto Update). Holds
281
+ * the same lock as an Update, so a skill update never runs beside one; null when there was nothing to do.
282
+ */
283
+ export declare function applySkillOnly(paths: Paths, plan: SkillUpdatePlan | undefined, env: Env, onOutput?: (text: string) => void): Promise<{
284
+ ok: boolean;
285
+ message: string;
286
+ busy?: boolean;
287
+ } | null>;
222
288
  export interface UpdateResult {
223
289
  ok: boolean;
224
290
  /** Exit code of the install command; absent when it did not run. */
@@ -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;
@@ -151,21 +151,25 @@ export function sameLine(from, to) {
151
151
  const set = (v) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false';
152
152
  /** True in CI, where a notice is noise and a check is a request nobody reads. */
153
153
  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) {
154
+ /** Why Update Checks are off, with a code for the Panel, or null when they are on (spec §3.23). */
155
+ export function checksOffWhy(env, config, local = localBuild() !== null) {
156
156
  // A Local Build (ADR-0024) follows the checkout: a notice or an Auto Update would only replace it with npm's.
157
157
  if (local)
158
- return 'this is a local build (pnpm local)';
158
+ return { code: 'local-build', text: 'this is a local build (pnpm local)' };
159
159
  if (set(env.SIDEBY_NO_UPDATE_NOTIFIER))
160
- return 'SIDEBY_NO_UPDATE_NOTIFIER is set';
160
+ return { code: 'env', text: 'SIDEBY_NO_UPDATE_NOTIFIER is set' };
161
161
  if (set(env.NO_UPDATE_NOTIFIER))
162
- return 'NO_UPDATE_NOTIFIER is set';
162
+ return { code: 'env', text: 'NO_UPDATE_NOTIFIER is set' };
163
163
  if (config.update?.check === false)
164
- return 'config update.check is false';
164
+ return { code: 'config', text: 'config update.check is false' };
165
165
  if (inCi(env))
166
- return 'running in CI';
166
+ return { code: 'ci', text: 'running in CI' };
167
167
  return null;
168
168
  }
169
+ /** Why Update Checks are off, or null when they are on (spec §3.23). */
170
+ export function checksOff(env, config, local = localBuild() !== null) {
171
+ return checksOffWhy(env, config, local)?.text ?? null;
172
+ }
169
173
  // ---- registry
170
174
  /** The registry checks and installs use: `SIDEBY_UPDATE_REGISTRY` (http or https only), else npm's. */
171
175
  function registryOf(env) {
@@ -217,6 +221,18 @@ export async function readUpdateCache(paths) {
217
221
  message: a.message,
218
222
  ...(a.shown ? { shown: true } : {}),
219
223
  };
224
+ const sn = d.skillNotified;
225
+ if (sn && str(sn.version) && str(sn.at))
226
+ out.skillNotified = { version: sn.version, at: sn.at };
227
+ const sa = d.skillAuto;
228
+ if (sa && str(sa.to) && str(sa.at) && typeof sa.ok === 'boolean' && str(sa.message))
229
+ out.skillAuto = {
230
+ to: sa.to,
231
+ at: sa.at,
232
+ ok: sa.ok,
233
+ message: sa.message,
234
+ ...(sa.shown ? { shown: true } : {}),
235
+ };
220
236
  return out;
221
237
  }
222
238
  catch {
@@ -352,6 +368,44 @@ export function autoUpdateLine(cache) {
352
368
  ? `sideby updated itself from ${a.from} to ${a.to} (${RELEASE_NOTES(a.to)})`
353
369
  : `sideby could not update itself to ${a.to}: ${a.message}`;
354
370
  }
371
+ /** What the CLI says once about the skill Auto Update brought up to date alone, or null. */
372
+ export function skillAutoLine(cache) {
373
+ const a = cache.skillAuto;
374
+ if (!a || a.shown)
375
+ return null;
376
+ return a.ok
377
+ ? `sideby updated the sideby skill agents read to ${a.to}`
378
+ : `sideby could not update the sideby skill to ${a.to}: ${a.message}`;
379
+ }
380
+ /** The skill part of a `SkillUpdatePlan` toward the running version, as `afterCommandPlan` takes it. */
381
+ export const skillBehind = (plan, current) => plan?.outdated
382
+ ? { installed: plan.installed.find((v) => v !== current) ?? null, canRun: plan.canRun }
383
+ : null;
384
+ /**
385
+ * The line about a skill behind this version the CLI may print now (spec §3.23), or null: once a day per CLI version,
386
+ * and never while Auto Update is on (the background check brings the skill along) and sideby may update it.
387
+ */
388
+ export function skillNoticeLine(cache, current, config, skill, now) {
389
+ if (!skill || (config.update?.auto === true && skill.canRun))
390
+ return null;
391
+ const n = cache.skillNotified;
392
+ if (n && n.version === current && age(n.at, now) < NOTICE_INTERVAL_MS)
393
+ return null;
394
+ return `the sideby skill agents read is for ${skill.installed ?? 'an older sideby'} (this sideby is ${current}) · run \`sideby update\``;
395
+ }
396
+ /**
397
+ * Auto Update is on, the CLI is the newest version the cache knows, and the skill is behind it where sideby may
398
+ * update it, not tried for this version yet (or a failed try a day ago). A newer CLI waiting is left to the Update
399
+ * of the CLI: the skills tool fetches the default branch, which would put the skill ahead of this CLI.
400
+ */
401
+ export function skillAutoPending(cache, current, config, skill, now) {
402
+ if (config.update?.auto !== true || !skill?.canRun || isNewer(cache.latest, current))
403
+ return false;
404
+ const a = cache.skillAuto;
405
+ if (!a || a.to !== current)
406
+ return true;
407
+ return !a.ok && !(age(a.at, now) < AUTO_RETRY_MS);
408
+ }
355
409
  /**
356
410
  * The `_notice` for a `--json` output, from the cached Update Check and the versions of the installed sideby skills
357
411
  * (`null` for one written before skills had a version); null when there is nothing to say or checks are off. It
@@ -383,27 +437,40 @@ export function jsonNotice(cache, current, config, env, skills, skillCommand) {
383
437
  * then the Update Notice), the change that records them, and whether to start a background check. The caller prints,
384
438
  * writes `record` with `updateUpdateCache` and starts the check; nothing here touches the machine.
385
439
  */
386
- export function afterCommandPlan(cache, current, config, env, now) {
440
+ export function afterCommandPlan(cache, current, config, env, now, skill = null) {
387
441
  if (checksOff(env, config))
388
442
  return { lines: [], record: null, startCheck: false };
389
443
  const lines = [];
390
444
  const auto = autoUpdateLine(cache);
445
+ const skillAuto = skillAutoLine(cache);
391
446
  const notice = noticeLine(cache, current, now);
392
- if (auto)
393
- lines.push(auto);
394
- if (notice)
395
- lines.push(notice);
447
+ // A newer CLI's notice already leads to `sideby update`, which brings the skill along.
448
+ const skillNotice = notice ? null : skillNoticeLine(cache, current, config, skill, now);
449
+ for (const l of [auto, skillAuto, notice, skillNotice])
450
+ if (l)
451
+ lines.push(l);
396
452
  const shownAuto = cache.auto;
453
+ const shownSkillAuto = cache.skillAuto;
397
454
  const record = lines.length
398
455
  ? (cur) => ({
399
456
  // Mark only the Auto Update that was printed; a newer one written meanwhile stays to be said.
400
457
  ...(auto && cur.auto && cur.auto.at === shownAuto?.at && cur.auto.to === shownAuto.to
401
458
  ? { auto: { ...cur.auto, shown: true } }
402
459
  : {}),
460
+ ...(skillAuto && cur.skillAuto && cur.skillAuto.at === shownSkillAuto?.at
461
+ ? { skillAuto: { ...cur.skillAuto, shown: true } }
462
+ : {}),
403
463
  ...(notice ? { notified: { version: cache.latest, at: now.toISOString() } } : {}),
464
+ ...(skillNotice ? { skillNotified: { version: current, at: now.toISOString() } } : {}),
404
465
  })
405
466
  : null;
406
- return { lines, record, startCheck: checkDue(cache, now) || autoPending(cache, current, config, now) };
467
+ return {
468
+ lines,
469
+ record,
470
+ startCheck: checkDue(cache, now) ||
471
+ autoPending(cache, current, config, now) ||
472
+ skillAutoPending(cache, current, config, skill, now),
473
+ };
407
474
  }
408
475
  // ---- Install Method
409
476
  /** Where the running copy comes from (spec §3.23 table); reads only paths. */
@@ -421,16 +488,20 @@ export async function installMethod(env) {
421
488
  if (parseLocalBuild(JSON.parse(await readFile(join(packageDir, 'dist', 'build.json'), 'utf8'))))
422
489
  return at('local', {
423
490
  reason: 'it is a local build; run `pnpm local --off` in the sideby checkout to go back to npm',
491
+ reasonCode: 'local-build',
424
492
  });
425
493
  }
426
494
  catch {
427
495
  // no build file: not a Local Build
428
496
  }
429
497
  if (p.includes('/_npx/'))
430
- return at('npx', { reason: 'it runs from the npx cache, which fetches a version per call' });
498
+ return at('npx', {
499
+ reason: 'it runs from the npx cache, which fetches a version per call',
500
+ reasonCode: 'npx',
501
+ });
431
502
  // Volta keeps a global package under its own npm prefix; installing there behind its back breaks its records.
432
503
  if (p.includes('/.volta/'))
433
- return at('volta', { reason: 'Volta manages this install' });
504
+ return at('volta', { reason: 'Volta manages this install', reasonCode: 'volta' });
434
505
  const npm = /^(.*)\/lib\/node_modules\/sideby$/.exec(p);
435
506
  if (npm) {
436
507
  const prefix = npm[1];
@@ -442,7 +513,11 @@ export async function installMethod(env) {
442
513
  prefix,
443
514
  canUpdate: !blocked.length,
444
515
  ...(blocked.length
445
- ? { reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you` }
516
+ ? {
517
+ reason: `${blocked.join(' and ')} ${blocked.length > 1 ? 'are' : 'is'} not writable by you`,
518
+ reasonCode: 'not-writable',
519
+ reasonAt: blocked.join(', '),
520
+ }
446
521
  : {}),
447
522
  });
448
523
  }
@@ -452,8 +527,8 @@ export async function installMethod(env) {
452
527
  if (p.includes('/.bun/install/global/'))
453
528
  return at('bun', { canUpdate: true });
454
529
  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' });
530
+ return at('project', { reason: 'it is a dependency of a project', reasonCode: 'project' });
531
+ return at('source', { reason: 'it runs from a source checkout', reasonCode: 'source' });
457
532
  }
458
533
  /**
459
534
  * The command an Update runs (or that the user runs) for `version`; `run` is set only when sideby may run it. With
@@ -548,6 +623,8 @@ export const shownMethod = (m) => ({
548
623
  kind: m.kind,
549
624
  canUpdate: m.canUpdate,
550
625
  ...(m.reason ? { reason: m.reason } : {}),
626
+ ...(m.reasonCode ? { reasonCode: m.reasonCode } : {}),
627
+ ...(m.reasonAt ? { reasonAt: m.reasonAt } : {}),
551
628
  });
552
629
  /** The plan as `sideby update --json` and the Panel show it (schemas/update.json). */
553
630
  export function shownPlan(plan) {
@@ -575,14 +652,17 @@ export async function skillUpdatePlan(home, target) {
575
652
  canRun: true,
576
653
  };
577
654
  for (const s of skills) {
578
- const root = await gitRootOf(s.dir);
655
+ // A git repository owns the skill unless the skills tool records installing it there (spec §3.23).
656
+ const root = await skillOwnerRepo(s.dir);
579
657
  if (root) {
580
658
  const at = root.startsWith(home) ? `~${root.slice(home.length)}` : root;
581
659
  return {
582
660
  ...plan,
583
- command: SKILL_INSTALL,
661
+ command: SKILL_UPDATE_SHOWN,
584
662
  canRun: false,
585
663
  reason: `the skill is in the git repository ${at}, so sideby leaves it alone; update it where that repository comes from`,
664
+ reasonCode: 'skill-in-git',
665
+ reasonAt: at,
586
666
  };
587
667
  }
588
668
  }
@@ -609,6 +689,27 @@ export async function applySkillUpdate(plan, env, onOutput) {
609
689
  ? { ok: true, message: 'updated the sideby skill' }
610
690
  : { ok: false, message: `\`${plan.command}\` failed (exit ${r.code}); run it yourself to see why` };
611
691
  }
692
+ /**
693
+ * Updates only the sideby skill, for a CLI that is already current (`sideby update`, the Panel, Auto Update). Holds
694
+ * the same lock as an Update, so a skill update never runs beside one; null when there was nothing to do.
695
+ */
696
+ export async function applySkillOnly(paths, plan, env, onOutput) {
697
+ if (!plan?.outdated || !plan.canRun)
698
+ return null;
699
+ const release = await takeLock(paths, 'update-install.lock').catch(() => null);
700
+ if (!release)
701
+ return {
702
+ ok: false,
703
+ busy: true,
704
+ message: 'another sideby update is running; wait for it to finish, then run `sideby update` again',
705
+ };
706
+ try {
707
+ return await applySkillUpdate(plan, env, onOutput);
708
+ }
709
+ finally {
710
+ await release().catch(() => { });
711
+ }
712
+ }
612
713
  /**
613
714
  * Runs the Update the plan names, then starts a running background Panel again from the new code and, when the
614
715
  * desktop app is installed, runs `sideby app install` again from the new code. The caller has
@@ -689,6 +790,22 @@ export async function autoUpdateBlocker(paths, plan) {
689
790
  return `a session is running (pid ${running.join(', ')})`;
690
791
  return null;
691
792
  }
793
+ /**
794
+ * The skill half of Auto Update (spec §3.23): with the CLI current, a skill behind it is updated alone and the result
795
+ * recorded once per version, so a failure waits a day.
796
+ */
797
+ async function skillAutoSync(paths, env, config, cache, current, now) {
798
+ const plan = await skillUpdatePlan(paths.home, current).catch(() => null);
799
+ if (!skillAutoPending(cache, current, config, skillBehind(plan, current), now))
800
+ return;
801
+ const r = await applySkillOnly(paths, plan ?? undefined, env);
802
+ // A manual Update holding the lock is not a failure; the next command looks again.
803
+ if (!r || r.busy)
804
+ return;
805
+ await updateUpdateCache(paths, {
806
+ skillAuto: { to: current, at: deps().now().toISOString(), ok: r.ok, message: r.message },
807
+ });
808
+ }
692
809
  /**
693
810
  * Body of the detached `sideby update-check`: an Update Check when one is due, then an Auto Update when config
694
811
  * `update.auto` is on and nothing blocks it. Only one runs at a time; it never throws.
@@ -705,9 +822,16 @@ export async function backgroundCheck(paths, env, config, panelPort) {
705
822
  if (checkDue(cache, now))
706
823
  cache = await refreshUpdateCheck(paths, env);
707
824
  const current = packageVersion();
708
- if (!autoPending(cache, current, config, now))
825
+ if (!autoPending(cache, current, config, now)) {
826
+ await skillAutoSync(paths, env, config, cache, current, now);
709
827
  return;
710
- const plan = planFrom(await installMethod(env), current, cache.latest, env);
828
+ }
829
+ // The skill teaches agents the new version's commands, so an Auto Update carries it as `sideby update` does.
830
+ const skill = await skillUpdatePlan(paths.home, cache.latest).catch(() => null);
831
+ const plan = {
832
+ ...planFrom(await installMethod(env), current, cache.latest, env),
833
+ ...(skill ? { skill } : {}),
834
+ };
711
835
  await updateUpdateCache(paths, { autoTriedAt: now.toISOString() });
712
836
  // A blocker only the user can lift is said once and looked at again a day later, like a failed install; a
713
837
  // 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,7 +97,10 @@ 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;
99
105
  }
100
106
  export interface PanelState {
@@ -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,15 +61,15 @@ 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 } : {}),
@@ -329,6 +329,11 @@ function displayBody(body) {
329
329
  throw new HttpError(400, '"accounts" must be an object');
330
330
  out.accounts = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"accounts.${f}"`)]));
331
331
  }
332
+ else if (k === 'pinned') {
333
+ if (!v || typeof v !== 'object' || Array.isArray(v))
334
+ throw new HttpError(400, '"pinned" must be an object');
335
+ out.pinned = Object.fromEntries(Object.entries(v).map(([f, list]) => [f, names(list, `"pinned.${f}"`)]));
336
+ }
332
337
  else
333
338
  throw new HttpError(400, `unknown key "${k}"`);
334
339
  }
@@ -661,6 +666,17 @@ export function createPanelHandler(opts) {
661
666
  const shown = update.shownPlan(plan);
662
667
  if (!optBool(body, 'confirm'))
663
668
  return { ...shown, updated: false };
669
+ // The CLI is current and the skill is behind: `skill: true` updates it alone, as `sideby update --yes` does.
670
+ if (optBool(body, 'skill')) {
671
+ if (plan.available || plan.method.kind === 'local')
672
+ return { ...shown, updated: false, changed: true };
673
+ const r = await update.applySkillOnly(rt.paths, plan.skill, rt.env);
674
+ if (!r)
675
+ return { ...shown, updated: false, changed: true };
676
+ if (!r.ok)
677
+ throw new UserError(r.message);
678
+ return { ...shown, updated: false, skillUpdated: true, message: r.message };
679
+ }
664
680
  // A confirmation that runs nothing always says why: the registry names another version (or none newer) now.
665
681
  if (body.version !== plan.latest || !plan.available)
666
682
  return { ...shown, updated: false, changed: true };