sideby 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261017T131419-98017e"}
1
+ {"id":"20261017T135126-a0a3cf"}
@@ -1419,9 +1419,12 @@ export async function cmdLocal(a, io) {
1419
1419
  switched: r.ok,
1420
1420
  message: r.message,
1421
1421
  restartedPanel: r.restartedPanel,
1422
+ ...(r.skillSynced === undefined ? {} : { skillSynced: r.skillSynced }),
1422
1423
  });
1423
1424
  else if (r.ok) {
1424
1425
  io.out(`${c.green('✓')} ${r.message}.`);
1426
+ if (r.skillSynced === false)
1427
+ io.err(c.yellow('The sideby skill was not brought along; run `sideby update` to see why.'));
1425
1428
  if (r.restartedPanel)
1426
1429
  io.out(c.dim('The background panel restarted with it; open panels reload by themselves.'));
1427
1430
  }
@@ -351,6 +351,9 @@ export const OUTPUT_SCHEMAS = {
351
351
  blocked: Type.Optional(Type.String({ description: 'Why the switch cannot run, with what to do.' })),
352
352
  message: Type.Optional(Str),
353
353
  restartedPanel: Type.Optional(Type.Boolean()),
354
+ skillSynced: Type.Optional(Type.Boolean({
355
+ description: 'Back on npm, `sideby update --yes` brought the sideby skill to that version (true) or failed (false).',
356
+ })),
354
357
  }, { title: 'sideby local --json' }),
355
358
  list: Type.Object({
356
359
  schemaVersion: Version,
@@ -49,6 +49,13 @@ export interface SwitchResult {
49
49
  /** True when it already ran `to`, and nothing changed. */
50
50
  unchanged?: boolean;
51
51
  restartedPanel: boolean;
52
+ /**
53
+ * Back on npm, `sideby update --yes` of the npm code brought the skill agents read to its version (true), or that
54
+ * failed (false); absent for a switch to the Local Build, which has no skill of its own to bring.
55
+ */
56
+ skillSynced?: boolean;
57
+ /** With `skillSync: 'background'`: that `sideby update --yes` started on its own and is not waited for. */
58
+ skillSyncStarted?: boolean;
52
59
  /** Another install held the lock; nothing ran. */
53
60
  busy?: boolean;
54
61
  }
@@ -60,4 +67,9 @@ export interface SwitchResult {
60
67
  export declare function switchLocal(paths: Paths, d: LocalDirs, to: LocalTarget, env: Env, opts?: {
61
68
  panelPort?: () => Promise<number | null>;
62
69
  onOutput?: (text: string) => void;
70
+ /**
71
+ * Back on npm, wait for the skill (a terminal shows its progress) or start it on its own: it fetches from the
72
+ * network and can take minutes, longer than a Portal waits for an answer (`background`, the Panel).
73
+ */
74
+ skillSync?: 'wait' | 'background';
63
75
  }): Promise<SwitchResult>;
@@ -5,7 +5,7 @@
5
5
  import { lstat, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
6
6
  import { homedir } from 'node:os';
7
7
  import { dirname, join, relative } from 'node:path';
8
- import { compareVersions, fetchLatest, restartPanelByName, runProgram, takeInstallLock } from './update.js';
8
+ import { compareVersions, fetchLatest, restartPanelByName, runProgram, startDetached, takeInstallLock, } from './update.js';
9
9
  import { parseLocalBuild } from './version.js';
10
10
  const BIN = 'dist/src/cli/main.js';
11
11
  export const pkgDir = (d) => join(d.dev, 'pkg');
@@ -185,5 +185,21 @@ export async function switchLocal(paths, d, to, env, opts = {}) {
185
185
  }
186
186
  const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
187
187
  const restartedPanel = port !== null && (await restartPanelByName(env, port));
188
- return { ok: true, message, running: to, restartedPanel };
188
+ if (to === 'local')
189
+ return { ok: true, message, running: to, restartedPanel };
190
+ // The skill is part of the version (spec §3.23): the npm code updates it as `sideby update --yes` does for anyone,
191
+ // by name so the new code runs, after the lock above is free. A failure keeps the switch.
192
+ if (opts.skillSync === 'background')
193
+ return {
194
+ ok: true,
195
+ message,
196
+ running: to,
197
+ restartedPanel,
198
+ skillSyncStarted: await startDetached('sideby', ['update', '--yes'], env),
199
+ };
200
+ const u = await runProgram('sideby', ['update', '--yes'], {
201
+ env,
202
+ ...(opts.onOutput ? { onOutput: opts.onOutput } : {}),
203
+ }).catch(() => ({ code: 1, output: '' }));
204
+ return { ok: true, message, running: to, restartedPanel, skillSynced: u.code === 0 };
189
205
  }
@@ -66,6 +66,8 @@ export interface InstallMethod {
66
66
  /** npm only: the prefix whose `lib/node_modules` holds sideby. */
67
67
  prefix?: string;
68
68
  }
69
+ /** Whether a Linux `/proc/<pid>/stat` line is of a zombie: the state after the parenthesised command name is `Z`. */
70
+ export declare function isZombieStat(stat: string): boolean;
69
71
  /** Swapped in tests through `bindUpdateDeps`; the real machine otherwise. */
70
72
  export interface UpdateDeps {
71
73
  fetch: typeof fetch;
@@ -84,6 +86,8 @@ export interface UpdateDeps {
84
86
  }>;
85
87
  /** Starts `sideby ui --background --no-open --port <port>` from the newly installed code, detached; false when it could not start. */
86
88
  restartPanel: (env: Env, port: number) => Promise<boolean>;
89
+ /** Starts a program detached, nothing kept of its output; false when it could not start. */
90
+ startDetached: (file: string, args: readonly string[], env: Env) => Promise<boolean>;
87
91
  /** Whether `sideby app install` put a desktop app in this home (its files carry the app marker). */
88
92
  appInstalled: (home: string, env: Env) => Promise<boolean>;
89
93
  /** Runs `sideby app install --json` from the newly installed code and waits; false when it failed. */
@@ -95,6 +99,8 @@ export declare function bindUpdateDeps(deps: Partial<UpdateDeps>): () => void;
95
99
  export declare const runProgram: UpdateDeps['exec'];
96
100
  /** Starts the background Panel again from the code `sideby` now names; see `UpdateDeps.restartPanel`. */
97
101
  export declare const restartPanelByName: (env: Env, port: number) => Promise<boolean>;
102
+ /** Starts a program detached through the swappable machine; see `UpdateDeps.startDetached`. */
103
+ export declare const startDetached: (file: string, args: readonly string[], env: Env) => Promise<boolean>;
98
104
  /** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
99
105
  export declare const takeInstallLock: (paths: Paths) => Promise<(() => Promise<void>) | null>;
100
106
  /** -1, 0 or 1 like a sort comparator; a prerelease sorts before its release. Null when either is not a version. */
@@ -2,6 +2,7 @@
2
2
  // only network request sideby makes on its own: one GET for the `latest` dist-tag, carrying nothing about the user.
3
3
  // Everything it learns goes to one state file shared by the CLI and every Panel.
4
4
  import { spawn } from 'node:child_process';
5
+ import { readFileSync } from 'node:fs';
5
6
  import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat } from 'node:fs/promises';
6
7
  import { dirname, join, sep } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
@@ -17,6 +18,10 @@ export const RETRY_INTERVAL_MS = 60 * 60 * 1000;
17
18
  export const AUTO_RETRY_MS = 24 * 60 * 60 * 1000;
18
19
  const FETCH_TIMEOUT_MS = 5000;
19
20
  export const RELEASE_NOTES = (v) => `https://github.com/Adonis0123/sideby/releases/tag/v${v}`;
21
+ /** Whether a Linux `/proc/<pid>/stat` line is of a zombie: the state after the parenthesised command name is `Z`. */
22
+ export function isZombieStat(stat) {
23
+ return /\)\s+Z\s/.test(stat.slice(stat.lastIndexOf(')')));
24
+ }
20
25
  /** The folder of the sideby package this module belongs to: two levels above src/, three above dist/src/. */
21
26
  async function ownPackageDir() {
22
27
  const here = dirname(fileURLToPath(import.meta.url));
@@ -41,11 +46,18 @@ const realDeps = {
41
46
  alive(pid) {
42
47
  try {
43
48
  process.kill(pid, 0);
44
- return true;
45
49
  }
46
50
  catch (err) {
47
51
  return err.code === 'EPERM';
48
52
  }
53
+ // An exited process nobody waited for (its parent went first) still answers signal 0 on Linux: a zombie holds
54
+ // no lock and runs no session.
55
+ try {
56
+ return !isZombieStat(readFileSync(`/proc/${pid}/stat`, 'utf8'));
57
+ }
58
+ catch {
59
+ return true; // no /proc (macOS): signal 0 is the answer
60
+ }
49
61
  },
50
62
  exec: (file, args, opts) => new Promise((resolve) => {
51
63
  let output = '';
@@ -77,6 +89,14 @@ const realDeps = {
77
89
  });
78
90
  child.once('error', () => resolve(false));
79
91
  }),
92
+ startDetached: (file, args, env) => new Promise((resolve) => {
93
+ const child = spawn(file, args, { env: env, detached: true, stdio: 'ignore' });
94
+ child.once('spawn', () => {
95
+ child.unref();
96
+ resolve(true);
97
+ });
98
+ child.once('error', () => resolve(false));
99
+ }),
80
100
  appInstalled: async (home, env) => {
81
101
  const { appInstalled } = await import('../desktop/app.js');
82
102
  return appInstalled({ home, env, platform: process.platform });
@@ -108,6 +128,10 @@ export const runProgram = (file, args, opts) => deps().exec(file, args, opts);
108
128
  export const restartPanelByName = (env, port) => deps()
109
129
  .restartPanel(env, port)
110
130
  .catch(() => false);
131
+ /** Starts a program detached through the swappable machine; see `UpdateDeps.startDetached`. */
132
+ export const startDetached = (file, args, env) => deps()
133
+ .startDetached(file, args, env)
134
+ .catch(() => false);
111
135
  /** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
112
136
  export const takeInstallLock = (paths) => takeLock(paths, 'update-install.lock').catch(() => null);
113
137
  // Build metadata is limited to what semver allows, so a version never carries a terminal escape into the notice.
@@ -745,8 +745,10 @@ export function createPanelHandler(opts) {
745
745
  if (!optBool(body, 'confirm'))
746
746
  return { ...(await lb.localStatus(d)), to, switched: false };
747
747
  const { backgroundPanelPort } = await import('./background.js');
748
+ // The skill fetch can outlast what a Portal waits for: it runs on its own, and the answer comes at once.
748
749
  const r = await lb.switchLocal(rt.paths, d, to, rt.env, {
749
750
  panelPort: () => backgroundPanelPort(rt.paths),
751
+ skillSync: 'background',
750
752
  });
751
753
  if (!r.ok)
752
754
  throw new UserError(r.message);
@@ -756,6 +758,7 @@ export function createPanelHandler(opts) {
756
758
  switched: !r.unchanged,
757
759
  message: r.message,
758
760
  restartedPanel: r.restartedPanel,
761
+ ...(r.skillSyncStarted === undefined ? {} : { skillSyncStarted: r.skillSyncStarted }),
759
762
  };
760
763
  },
761
764
  },
@@ -31,6 +31,9 @@ declare const en: {
31
31
  'upd.local': string;
32
32
  'upd.localDirty': string;
33
33
  'loc.toNpm': string;
34
+ 'loc.toNpmV': string;
35
+ 'upd.localSwitch': string;
36
+ 'upd.localManual': string;
34
37
  'loc.toNpmHint': string;
35
38
  'loc.toLocal': string;
36
39
  'loc.toLocalHint': string;
@@ -33,6 +33,9 @@ const en = {
33
33
  'upd.local': 'Local build from this checkout, built {at}.',
34
34
  'upd.localDirty': 'Local build from this checkout, with changes not committed yet, built {at}.',
35
35
  'loc.toNpm': 'Switch to the release',
36
+ 'loc.toNpmV': 'Switch to the release {v}',
37
+ 'upd.localSwitch': 'You are running a local build, and sideby {v} is out. Switching to the release puts it in place, and the skill comes along.',
38
+ 'upd.localManual': 'Or run this in the sideby checkout:',
36
39
  'loc.toNpmHint': 'Go back to the version published on npm; switch here again to come back.',
37
40
  'loc.toLocal': 'Switch to the local build',
38
41
  'loc.toLocalHint': 'A local build is kept: {b}, built {at}. It is not rebuilt; pnpm local in the checkout builds a new one.',
@@ -782,6 +785,9 @@ const zh = {
782
785
  'upd.local': '本地构建,来自仓库代码,构建于 {at}。',
783
786
  'upd.localDirty': '本地构建,含还没提交的改动,构建于 {at}。',
784
787
  'loc.toNpm': '切换到正式版',
788
+ 'loc.toNpmV': '切换到正式版 {v}',
789
+ 'upd.localSwitch': '你在用本地构建,sideby {v} 已发布。切换到正式版就会用上它,skill 也会一起同步。',
790
+ 'upd.localManual': '也可以在 sideby 仓库里运行:',
785
791
  'loc.toNpmHint': '回到 npm 上发布的版本,之后可以在这里再切回来。',
786
792
  'loc.toLocal': '切换到本地构建',
787
793
  'loc.toLocalHint': '本机留着一份本地构建:{b},构建于 {at}。切换不会重新构建;要新的就在仓库里运行 pnpm local。',
@@ -177,6 +177,24 @@ export declare function panelHint(hint: string, fixable: boolean, action: string
177
177
  * Whether the Panel fixes Finding `f` with a button of its own: a sideby skill behind this version while the
178
178
  * Update plan says sideby may update it (`skillUpdatable`). Such Findings stay out of the prompt for an AI agent.
179
179
  */
180
+ /**
181
+ * A Local Build the Panel can leave for npm itself (ADR-0024): the global `sideby` still runs it and the Panel may
182
+ * write. Then switching to the release is the update, offered as a button instead of the `pnpm local --off` command.
183
+ */
184
+ export declare function canLeaveLocal(u: {
185
+ localSwitch?: {
186
+ running: string | null;
187
+ };
188
+ } | null, readOnly: boolean): boolean;
189
+ /**
190
+ * What a failed Local Build switch request means (ADR-0024): a lost connection (a background Panel restarting) or a
191
+ * Portal's 5xx (it moved to the new code mid-request) may follow a switch that happened, so the state decides;
192
+ * anything else is the server saying it did not switch.
193
+ */
194
+ export declare function switchFailure(e: {
195
+ network?: boolean;
196
+ status?: number;
197
+ }): 'wait' | 'check' | 'fail';
180
198
  export declare function panelFixes(f: {
181
199
  code?: string;
182
200
  }, skillUpdatable: boolean): boolean;
@@ -361,6 +361,23 @@ export function panelHint(hint, fixable, action) {
361
361
  * Whether the Panel fixes Finding `f` with a button of its own: a sideby skill behind this version while the
362
362
  * Update plan says sideby may update it (`skillUpdatable`). Such Findings stay out of the prompt for an AI agent.
363
363
  */
364
+ /**
365
+ * A Local Build the Panel can leave for npm itself (ADR-0024): the global `sideby` still runs it and the Panel may
366
+ * write. Then switching to the release is the update, offered as a button instead of the `pnpm local --off` command.
367
+ */
368
+ export function canLeaveLocal(u, readOnly) {
369
+ return !readOnly && !!u?.localSwitch && u.localSwitch.running === 'local';
370
+ }
371
+ /**
372
+ * What a failed Local Build switch request means (ADR-0024): a lost connection (a background Panel restarting) or a
373
+ * Portal's 5xx (it moved to the new code mid-request) may follow a switch that happened, so the state decides;
374
+ * anything else is the server saying it did not switch.
375
+ */
376
+ export function switchFailure(e) {
377
+ if (e.network)
378
+ return 'wait';
379
+ return (e.status ?? 0) >= 500 ? 'check' : 'fail';
380
+ }
364
381
  export function panelFixes(f, skillUpdatable) {
365
382
  return skillUpdatable && f.code === 'skill.outdated';
366
383
  }
@@ -1,6 +1,6 @@
1
1
  // The Panel page's inline script. Plain browser JavaScript in a string (no template literals inside, so String.raw
2
2
  // keeps it verbatim); the pure helpers from page-logic.ts are prepended so the tested code is the code that runs.
3
- import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, updatedAge, windowState, withPinned, } from './page-logic.js';
3
+ import { aliasArgsOf, aliasIssue, attentionReasons, attentionWhy, cacheHitRate, canLeaveLocal, fixPrompt, formatDuration, handoffChangeOf, handoffHintAccount, inDisplayOrder, loginClusters, loginKey, maskEmail, matchesQuery, mergeDaily, newAccountDir, nextReset, panelFixes, panelHint, panelLang, passedWindow, pinnedIn, quotaPressure, remoteControlSummary, remoteControlTodo, reorder, sortAccounts, suggestAlias, suggestName, switchFailure, updatedAge, windowState, withPinned, } from './page-logic.js';
4
4
  const LOGIC = [
5
5
  aliasArgsOf,
6
6
  aliasIssue,
@@ -19,6 +19,8 @@ const LOGIC = [
19
19
  mergeDaily,
20
20
  newAccountDir,
21
21
  nextReset,
22
+ canLeaveLocal,
23
+ switchFailure,
22
24
  panelFixes,
23
25
  panelHint,
24
26
  panelLang,
@@ -3571,6 +3573,8 @@ const MAIN = String.raw `
3571
3573
  function updState() { return BOOT.updates && S.state ? S.state.update || null : null }
3572
3574
  // The skill agents read is part of the version (spec §3.23): behind a current CLI it is the one thing to update.
3573
3575
  const isLocal = (u) => u.method.kind === 'local'
3576
+ // A Local Build the Panel can leave for npm itself (ADR-0024): then that switch is the update, not a command.
3577
+ const leavesLocal = (u) => canLeaveLocal(u, BOOT.readOnly)
3574
3578
  const skillOld = (u) => !!(u && !u.available && !isLocal(u) && u.skillUpdate && u.skillUpdate.outdated)
3575
3579
  const skillDue = (u) => skillOld(u) && u.skillUpdate.canRun
3576
3580
  function skillFor(u) {
@@ -3690,7 +3694,8 @@ const MAIN = String.raw `
3690
3694
  if (u && u.available) {
3691
3695
  kids.push(h('p', { class: 'small' }, t('upd.available', { v: u.latest }), ' ',
3692
3696
  h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3693
- kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3697
+ if (!(isLocal(u) && leavesLocal(u)))
3698
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: toDialog }, t('upd.open'))))
3694
3699
  } else if (skillOld(u)) {
3695
3700
  kids.push(h('p', { class: 'small' }, t('upd.skillBehind', { s: skillFor(u) })))
3696
3701
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (skillDue(u) ? ' btn-primary' : ''), type: 'button', onclick: toDialog }, t('upd.open'))))
@@ -3722,30 +3727,42 @@ const MAIN = String.raw `
3722
3727
  kids.push(h('p', { class: 'small' }, t('loc.stuck')))
3723
3728
  kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
3724
3729
  } else {
3725
- kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
3726
- vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
3730
+ const newer = toNpm && u.available && u.latest
3731
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm' + (newer ? ' btn-primary' : ''), type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
3732
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : newer ? t('loc.toNpmV', { v: u.latest }) : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
3727
3733
  }
3728
3734
  if (vs.error) kids.push(errorNote(vs.error, null))
3729
3735
  return h('div', { class: 'popover-foot' }, kids)
3730
3736
  }
3737
+ function renderSwitch() {
3738
+ renderVerPop()
3739
+ if (dlg.kind === 'update' && up) renderUpdate()
3740
+ }
3731
3741
  async function localSwitchTo(to) {
3732
3742
  vs.busy = true
3733
3743
  vs.to = to
3734
3744
  vs.error = null
3735
3745
  vs.stuck = false
3736
- renderVerPop()
3746
+ renderSwitch()
3737
3747
  try {
3738
3748
  await api('/api/local', { to, confirm: true })
3739
3749
  } catch (e) {
3740
- // A background Panel restarting from the new code may drop this answer: wait for the build all the same.
3741
- if (!e.network) { vs.busy = false; vs.error = e; renderVerPop(); return }
3750
+ // A background Panel restarting from the new code may drop this answer, and a Portal moving to it may answer
3751
+ // 5xx (spec §3.23): the state then says whether the switch happened. Only a switch that did not is an error.
3752
+ const kind = switchFailure(e)
3753
+ if (kind === 'fail') { vs.busy = false; vs.error = e; renderSwitch(); return }
3754
+ if (kind === 'check') {
3755
+ await load({ silent: true })
3756
+ const ls = updState() && updState().localSwitch
3757
+ if (!ls || ls.running !== to) { vs.busy = false; vs.error = e; renderSwitch(); return }
3758
+ }
3742
3759
  }
3743
3760
  vs.waitUntil = Date.now() + 20000
3744
3761
  localWait()
3745
3762
  }
3746
3763
  function localWait() {
3747
3764
  if (S.newBuild) { location.reload(); return }
3748
- if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderVerPop(); return }
3765
+ if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderSwitch(); return }
3749
3766
  setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
3750
3767
  }
3751
3768
  async function verPopCheck() {
@@ -3834,6 +3851,14 @@ const MAIN = String.raw `
3834
3851
  else body.push(...updSkill(u))
3835
3852
  } else if (up.skillDone && !skillOld(u)) {
3836
3853
  body.push(h('p', { class: 'note' }, t('upd.skillDoneOnly')))
3854
+ } else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy || vs.stuck)) {
3855
+ // A Local Build the Panel can leave (ADR-0024): switching to the release is the update, and brings the skill.
3856
+ body.push(h('p', null, t('upd.localSwitch', { v: u.latest }), ' ',
3857
+ h('a', { href: u.releaseNotes, target: '_blank', rel: 'noopener noreferrer' }, t('upd.notes'))))
3858
+ if (vs.busy) body.push(h('p', { class: 'muted small' }, spinner(), ' ', t('upd.reloading')))
3859
+ if (vs.stuck) body.push(h('p', { class: 'note' }, t('loc.stuck')))
3860
+ if (vs.error) body.push(errorNote(vs.error, null))
3861
+ body.push(updManual([h('p', { class: 'small' }, t('upd.localManual')), codeCopy(u.command)]))
3837
3862
  } else if (u.available && isLocal(u)) {
3838
3863
  // A Local Build (ADR-0024): one way back to npm, which brings the skill along.
3839
3864
  body.push(h('p', null, t('upd.localAvail', { v: u.latest }), ' ',
@@ -3888,7 +3913,11 @@ const MAIN = String.raw `
3888
3913
  if (up.error) body.push(errorNote(up.error, null))
3889
3914
  const foot = [h('button', { class: 'btn', type: 'button', disabled: up.busy, onclick: () => dlg.el.close() }, t('btn.close'))]
3890
3915
  const primary = (label, run) => h('button', { class: 'btn btn-primary', type: 'button', disabled: up.busy, 'aria-busy': up.busy ? 'true' : null, onclick: run }, up.busy ? spinner() : null, up.busy ? t('upd.updating') : label)
3891
- if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
3916
+ if (u.available && isLocal(u) && vs.stuck) foot.push(h('button', { class: 'btn btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload')))
3917
+ else if (u.available && isLocal(u) && (leavesLocal(u) || vs.busy))
3918
+ foot.push(h('button', { class: 'btn btn-primary', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo('npm') },
3919
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t('loc.toNpmV', { v: u.latest })))
3920
+ else if (u.available && u.canRun && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.now', { v: u.latest }), updRun))
3892
3921
  else if (skillDue(u) && !BOOT.readOnly && !up.done) foot.push(primary(t('upd.skillNow'), updSkillRun))
3893
3922
  setKids(dlg.el, dialogFrame(t('upd.title'), null, body, foot))
3894
3923
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
@@ -98,6 +98,10 @@
98
98
  "restartedPanel": {
99
99
  "type": "boolean"
100
100
  },
101
+ "skillSynced": {
102
+ "type": "boolean",
103
+ "description": "Back on npm, `sideby update --yes` brought the sideby skill to that version (true) or failed (false)."
104
+ },
101
105
  "_notice": {
102
106
  "type": "object",
103
107
  "properties": {
@@ -2,7 +2,7 @@
2
2
  name: sideby
3
3
  description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, moving the current task to another account ("用 cc008 继续", "交回 cc008"), adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command (inside an Auto Handoff session, `sideby handoff ready --to` moves the task itself).
4
4
  metadata:
5
- version: "0.16.0"
5
+ version: "0.16.1"
6
6
  requires:
7
7
  bins: [sideby, node]
8
8
  cliHelp: sideby --help
@@ -25,5 +25,5 @@ For people who work on sideby itself. A Local Build (`pnpm local` in a sideby ch
25
25
 
26
26
  1. Run `sideby local --json`. `running` is what runs now (`local` or `npm`), `local` the kept Local Build (`version`, `commit`, `dirty`, `builtAt`), `kept` the npm version set aside.
27
27
  2. Run `sideby local off --json` (back to npm) or `sideby local on --json` (the kept Local Build). It exits `10` with the change; after their yes, add `--yes`. `on` does not rebuild: `pnpm local` in the checkout makes a new build. `blocked` (exit 1) says why it cannot and what to do.
28
- 3. A background panel restarts by itself and open panels reload; a `sideby ui` in a terminal needs a restart.
28
+ 3. `off` brings the sideby skill to the npm version too (`skillSynced`; false means run `sideby update`). A background panel restarts by itself and open panels reload; a `sideby ui` in a terminal needs a restart.
29
29