runlist 0.95.1 → 0.95.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,13 +46,16 @@ Stage filters, grouping and editing are included. Optional grepmax semantic
46
46
  controls are experimental; live retrieval depends on a compatible external
47
47
  provider. Local chat and push are deferred.
48
48
 
49
- The Apple Silicon GUI is **[0.94.1](https://github.com/reowens/runlist/releases/tag/desktop-v0.94.1)**,
50
- signed, accepted by Apple and stapled. It replaces the installers withdrawn
51
- during the privacy cleanup. The desktop app is separate from CLI 0.94.1,
52
- which includes optional plan-filing checks and the owner-written product yardstick,
53
- corrected hub-category inheritance, updates for every saved Claude plugin scope,
54
- and `runlist desktop install`.
55
- Updating the CLI does not replace the GUI's bundled engine; this GUI reports 0.94.1.
49
+ <!-- runlist:release:start -->
50
+ CLI and desktop release version: **0.95.3**.
51
+ [Mac installer and release notes](https://github.com/reowens/runlist/releases/tag/desktop-v0.95.3).
52
+ Normal product releases publish both components; an installed Mac app is updated with rollback after it has quit.
53
+ <!-- runlist:release:end -->
54
+
55
+ The desktop app bundles the matching engine and runtime. Publishing a product
56
+ release now includes building, qualifying, signing/notarizing and publishing the
57
+ Mac installer. `runlist update` also updates an existing Mac app; installing the
58
+ npm package alone does not download an app onto a machine without one.
56
59
  Earlier development packages and their qualification results are historical;
57
60
  see the [desktop validation record](desktop/VALIDATION.md).
58
61
 
@@ -84,8 +87,8 @@ Deferred work awaiting selection:
84
87
  older distributions, real-version upgrades, fresh/minimum-version and Intel
85
88
  Macs, plus native rendering, focus, accessibility and whole-window memory.
86
89
 
87
- Mac GUI 0.94.1 is available from the [desktop release](https://github.com/reowens/runlist/releases/tag/desktop-v0.94.1).
88
- CLI 0.94.1 includes the optional product yardstick.
90
+ The current Mac installer is linked in the release-version section above.
91
+ CLI and desktop include the optional product yardstick.
89
92
  The supported desktop
90
93
  release is Apple Silicon macOS 13.5 or newer. Broader platform work and
91
94
  experimental semantic search remain separate follow-up work.
@@ -109,10 +112,10 @@ without opening it. The app bundles its runtime and reads local files through
109
112
  private pipes, with no listening server. Choose your checkout folder in the app.
110
113
  No checkout configuration is loaded by these CLI commands.
111
114
 
112
- The GUI is versioned separately from the CLI. `runlist desktop install` selects
113
- the public Mac 0.94.1 installer.
115
+ `runlist desktop install` selects the newest published Mac installer and
116
+ updates an older installed app after you quit it.
114
117
  Windows, Linux and Intel Mac GUI downloads are not yet available through this
115
- command. An existing app is kept as-is. To replace it using a downloaded signed
118
+ command. Current or newer installed apps are preserved. To use a downloaded signed
116
119
  DMG, quit Runlist and run:
117
120
 
118
121
  ```bash
@@ -216,7 +219,14 @@ and managed scopes are reported rather than silently updating another scope.
216
219
  The release workflow uses the same scope selection and verifies every entry
217
220
  against the released version.
218
221
 
219
- Restart Claude Code, or run `/reload-plugins`, after a plugin update.
222
+ Updates also refresh already installed OpenCode and Codex integrations. After
223
+ upgrading the CLI, host installers run from the updated package so their version
224
+ and assets match. Codex verification checks the host's installed copy separately
225
+ from its marketplace source. Releases use these same checks and remain resumable
226
+ if an installed host fails to update. Uninstalled hosts are not added.
227
+
228
+ Restart the affected host sessions to load updated integrations; Claude Code also
229
+ supports `/reload-plugins`.
220
230
 
221
231
  ## Quick Start
222
232
 
@@ -280,13 +290,13 @@ runlist desktop install --from /path/to/Runlist.dmg # install a local signed DM
280
290
 
281
291
  The installer currently supports Apple Silicon Macs. It verifies the published
282
292
  download checksum, Runlist developer signature and Apple approval, and installs
283
- without opening the app. An existing app is kept unless `--from` supplies a
284
- replacement; replacement keeps a rollback copy. `--dry-run` uses no network and
285
- changes no files. The public Mac 0.94.1 download replaces the withdrawn
293
+ without opening the app. An older installed app is updated after it has quit;
294
+ replacement keeps a rollback copy. `--dry-run` uses no network and
295
+ changes no files. The current Mac download replaces the withdrawn
286
296
  installers. These commands ship
287
297
  in [CLI 0.92.0](https://github.com/reowens/runlist/releases/tag/v0.92.0) and can also
288
- be used from this checkout as `node bin/runlist.mjs desktop …`. The GUI remains
289
- separately versioned at **0.94.1**; npm installation does not download it.
298
+ be used from this checkout as `node bin/runlist.mjs desktop …`. Normal product releases keep GUI and CLI versions together; an npm install
299
+ does not add a GUI to a machine without one.
290
300
 
291
301
  The optional browser document app is available from this source checkout (unreleased):
292
302
 
@@ -828,13 +838,13 @@ local model or server. GUI controls are implemented in the source checkout:
828
838
  Product goal shows the saved statement, delivery and assessment, with reviewed
829
839
  Serves/Clear/Close/Fold actions and retained recovery. Structured plans display
830
840
  the comparison with an explanation when assessment changes are unavailable.
831
- These controls are included in the public desktop package 0.94.1;
832
- updating the CLI alone does not replace an existing app.
841
+ These controls are included in the public desktop package.
842
+ `runlist update` updates an existing Mac app alongside the CLI and integrations.
833
843
 
834
844
  ### Plan Homes And Filing Checks
835
845
 
836
846
  This policy is available in CLI 0.93.0. From a source checkout, invoke the
837
- commands below as `node bin/runlist.mjs …`. The separately versioned GUI
847
+ commands below as `node bin/runlist.mjs …`. The bundled GUI
838
848
  The desktop app also includes a read-only Filing view. The correction
839
849
  for category inheritance from status-less hub indexes is included in CLI
840
850
  0.93.1.
@@ -880,7 +890,7 @@ is labeled and suppresses absence/uniqueness findings. Path/type-scoped output
880
890
  keeps the full hub/ancestor evidence while reporting only selected plans.
881
891
  The source checkout also includes a read-only Filing view: open it to scan,
882
892
  filter 50-plan pages, and inspect exact hub rows in 20-row evidence pages. Saved
883
- edits invalidate the report. This view is included in public desktop package 0.94.1.
893
+ edits invalidate the report. This view is included in the public desktop package.
884
894
  The policy is off when `filing` is unset, false, or `{ enabled: false }`.
885
895
  It can replace a repository's older “any link counts” coverage script once
886
896
  that repository enables it and retires its custom check.
package/bin/runlist.mjs CHANGED
@@ -1509,8 +1509,8 @@ runlist desktop install [--from /path/to/installer.dmg] — install the optional
1509
1509
 
1510
1510
  Install downloads the latest Mac Apple Silicon GUI from Runlist's GitHub
1511
1511
  releases, or uses a local signed DMG with --from. It verifies the Runlist
1512
- developer signature and Apple approval before installing. An existing app is
1513
- kept as-is unless --from is given; replacements retain a rollback copy.
1512
+ developer signature and Apple approval before installing. Older installed apps
1513
+ are updated after you quit Runlist; replacements retain a rollback copy.
1514
1514
  Installation does not open the app. Windows/Linux installers are not yet
1515
1515
  available through this command. --dry-run uses no network and changes no files.
1516
1516
 
@@ -2206,7 +2206,7 @@ async function main() {
2206
2206
  }
2207
2207
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
2208
2208
  if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
2209
- if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
2209
+ if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); await runUpdate(restArgs, config, { dryRun }); return; }
2210
2210
  if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
2211
2211
  if (command === 'errors') { const { runErrors } = await import('../src/errors-read.mjs'); runErrors(restArgs); return; }
2212
2212
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist",
3
- "version": "0.95.1",
3
+ "version": "0.95.3",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runlist-codex",
3
- "version": "0.95.1",
3
+ "version": "0.95.3",
4
4
  "description": "Runlist workflow skill, session orientation, and tool guard for Codex",
5
5
  "author": {
6
6
  "name": "Reo Owens"
@@ -4,6 +4,8 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, r
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { executableName } from './util.mjs';
7
9
 
8
10
  const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'plugins', 'runlist-codex');
9
11
  const NAME = 'runlist-codex';
@@ -33,6 +35,26 @@ export function codexStatus({ version, homedir = os.homedir() } = {}) {
33
35
  return { ...paths, exists, foreign: exists && !installed, version: installed, stale: Boolean(installed && version && installed !== version) };
34
36
  }
35
37
 
38
+ // The marketplace source is not the plugin Codex loads. Ask the host about its
39
+ // installed copy rather than treating our generated source marker as proof.
40
+ export function installedCodexStatus({ homedir = os.homedir(), run = spawnSync } = {}) {
41
+ try {
42
+ const marketplace = marketplaceAt(codexPaths(homedir).marketplace).name;
43
+ const pluginId = `${NAME}@${marketplace}`;
44
+ const result = run(executableName('codex'), ['plugin', 'list', '--marketplace', marketplace, '--json'], {
45
+ encoding: 'utf8', shell: process.platform === 'win32', timeout: 30_000,
46
+ });
47
+ if (result.status !== 0) return { ok: false, reason: result.error?.message ?? `codex plugin list exited ${result.status ?? '?'}` };
48
+ const installed = JSON.parse(result.stdout).installed;
49
+ if (!Array.isArray(installed)) return { ok: false, reason: 'codex plugin list returned no installed plugin records' };
50
+ const plugin = installed.find(entry => entry.pluginId === pluginId && entry.installed === true);
51
+ return plugin?.version ? { ok: true, version: plugin.version, pluginId }
52
+ : { ok: false, reason: `${pluginId} is not installed in Codex` };
53
+ } catch (err) {
54
+ return { ok: false, reason: `cannot verify installed Codex plugin: ${err.message}` };
55
+ }
56
+ }
57
+
36
58
  export function installCodexPlugin({ version, homedir = os.homedir(), dryRun = false } = {}) {
37
59
  const sourceVersion = JSON.parse(readFileSync(path.join(SOURCE, '.codex-plugin', 'plugin.json'), 'utf8')).version;
38
60
  if (sourceVersion !== version) throw new Error(`Codex plugin source is ${sourceVersion}, CLI is ${version}`);
@@ -1,7 +1,7 @@
1
1
  import {createHash,randomUUID} from 'node:crypto';
2
2
  import {execFileSync} from 'node:child_process';
3
3
  import {createWriteStream} from 'node:fs';
4
- import {lstat,mkdir,mkdtemp,rename,rm} from 'node:fs/promises';
4
+ import {lstat,mkdir,mkdtemp,readFile,rename,rm} from 'node:fs/promises';
5
5
  import {homedir,tmpdir} from 'node:os';
6
6
  import path from 'node:path';
7
7
  import {Readable,Transform} from 'node:stream';
@@ -12,6 +12,35 @@ const RELEASES='https://api.github.com/repos/reowens/runlist/releases';
12
12
  const DOWNLOADS='https://github.com/reowens/runlist/releases/download/';
13
13
  const TEAM='7GSPYYN5X8';
14
14
  const MAX_DOWNLOAD=512*1024*1024;
15
+
16
+ export function compareDesktopVersions(a,b) {
17
+ const parse=value=>/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\w.-]+))?$/.exec(value??'');
18
+ const left=parse(a),right=parse(b);
19
+ if(!left||!right)throw new Error('Cannot compare the installed and published desktop versions. Nothing was replaced.');
20
+ for(let i=1;i<=3;i++)if(Number(left[i])!==Number(right[i]))return Number(left[i])<Number(right[i])?-1:1;
21
+ if(left[4]===right[4])return 0;
22
+ if(!left[4]||!right[4])return left[4]?-1:1;
23
+ const x=left[4].split('.'),y=right[4].split('.');
24
+ for(let i=0;i<Math.max(x.length,y.length);i++) {
25
+ if(x[i]===y[i])continue;
26
+ if(x[i]===undefined||y[i]===undefined)return x[i]===undefined?-1:1;
27
+ const xn=/^\d+$/.test(x[i]),yn=/^\d+$/.test(y[i]);
28
+ if(xn!==yn)return xn?-1:1;
29
+ return xn?(Number(x[i])<Number(y[i])?-1:1):(x[i]<y[i]?-1:1);
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ export async function installedDesktopVersion(app) {
35
+ try {
36
+ const engine=path.join(app,'Contents/Resources/engine');
37
+ const manifest=JSON.parse(await readFile(path.join(engine,'bundle-manifest.json'),'utf8'));
38
+ const pkg=JSON.parse(await readFile(path.join(engine,'package.json'),'utf8'));
39
+ if(manifest.version!==pkg.version||manifest.protocol!==1)throw new Error('Bundle version mismatch.');
40
+ compareDesktopVersions(pkg.version,pkg.version);
41
+ return pkg.version;
42
+ } catch(error) {throw new Error('Cannot verify the installed desktop version. Use a reviewed local installer with --from to repair it.',{cause:error});}
43
+ }
15
44
  const nativeExec=(exe,args)=>{
16
45
  try {return {stdout:execFileSync(exe,args,{encoding:'utf8',timeout:60000,stdio:['ignore','pipe','pipe']})};}
17
46
  catch(error) {throw new Error(`${path.basename(exe)} failed: ${(error.stderr?.toString()||error.message).trim()}`,{cause:error});}
@@ -19,6 +48,7 @@ const nativeExec=(exe,args)=>{
19
48
 
20
49
  export function selectDesktopRelease(releases) {
21
50
  if(!Array.isArray(releases))throw new Error('Runlist release information is unavailable.');
51
+ let selected=null;
22
52
  for(const release of releases) {
23
53
  if(release.draft)continue;
24
54
  for(const asset of release.assets??[]) {
@@ -29,10 +59,11 @@ export function selectDesktopRelease(releases) {
29
59
  if(!Number.isSafeInteger(asset.size)||asset.size<=0||asset.size>MAX_DOWNLOAD)throw new Error('The Runlist installer size is invalid.');
30
60
  const checksum=/^sha256:([a-f0-9]{64})$/i.exec(asset.digest??'')?.[1].toLowerCase();
31
61
  if(!checksum)throw new Error('The published Runlist installer is missing its SHA-256 digest.');
32
- return {url:url.href,version:match[1],size:asset.size,sha256:checksum};
62
+ const candidate={url:url.href,version:match[1],size:asset.size,sha256:checksum};
63
+ if(!selected||compareDesktopVersions(candidate.version,selected.version)>0)selected=candidate;
33
64
  }
34
65
  }
35
- return null;
66
+ return selected;
36
67
  }
37
68
 
38
69
  async function latestInstaller(fetcher) {
@@ -93,7 +124,16 @@ export async function runDesktopInstall(argv,{dryRun=false,platform=process.plat
93
124
  if(fromAt>=0&&(!from||from.startsWith('--')))throw new Error('--from requires a local Runlist installer.dmg path.');
94
125
  if(dryRun){write(`Would install the optional Runlist GUI from ${from?path.resolve(from):'Runlist GitHub releases'} without opening it. No download or files changed.\n`);return {dryRun:true};}
95
126
  const installed=desktopLaunch([],{platform,home}).app;
96
- if(installed&&!from){write(`Runlist is already installed at ${installed}. Open it with runlist desktop.\n`);return {installed,alreadyInstalled:true};}
127
+ let installer;
128
+ if(installed&&!from) {
129
+ const version=await installedDesktopVersion(installed);
130
+ installer=await latestInstaller(fetcher);
131
+ if(compareDesktopVersions(version,installer.version)>=0) {
132
+ write(`Runlist desktop ${version} is current at ${installed}.\n`);
133
+ return {installed,version,alreadyInstalled:true};
134
+ }
135
+ write(`Updating Runlist desktop ${version} → ${installer.version}…\n`);
136
+ }
97
137
  if(!exec) {
98
138
  // Imported only for this explicit installer; no subprocess or download at import time.
99
139
  const {spawnSync}=await import('node:child_process');
@@ -119,7 +159,7 @@ export async function runDesktopInstall(argv,{dryRun=false,platform=process.plat
119
159
  image=path.resolve(from);const info=await entry(image);
120
160
  if(!image.toLowerCase().endsWith('.dmg')||!info?.isFile()||info.isSymbolicLink()||info.size>MAX_DOWNLOAD)throw new Error('--from must name a regular signed Runlist .dmg installer.');
121
161
  } else {
122
- const installer=await latestInstaller(fetcher);write(`Downloading Runlist ${installer.version} for Mac Apple Silicon…\n`);
162
+ installer??=await latestInstaller(fetcher);write(`Downloading Runlist ${installer.version} for Mac Apple Silicon…\n`);
123
163
  image=path.join(work,'Runlist.dmg');await download(installer,image,fetcher);
124
164
  }
125
165
  verifySigned(image,exec);
@@ -128,6 +168,8 @@ export async function runDesktopInstall(argv,{dryRun=false,platform=process.plat
128
168
  const source=path.join(mount,'Runlist.app');const info=await entry(source);
129
169
  if(!info?.isDirectory()||info.isSymbolicLink())throw new Error('The installer does not contain a regular Runlist.app.');
130
170
  verifySigned(source,exec,'dev.reowens.runlist');
171
+ const version=await installedDesktopVersion(source);
172
+ if(installer&&version!==installer.version)throw new Error('The downloaded app version differs from the published installer. Nothing was replaced.');
131
173
  stage=path.join(parent,`.Runlist-install-${randomUUID()}.app`);
132
174
  exec('/usr/bin/ditto',[source,stage]);
133
175
  // Keep normal Mac download identification instead of stripping quarantine.
@@ -145,7 +187,7 @@ export async function runDesktopInstall(argv,{dryRun=false,platform=process.plat
145
187
  catch(error){if(backup)await rename(backup,target);throw error;}
146
188
  write(`Installed Runlist at ${target}. Open it with runlist desktop.\n`);
147
189
  if(backup)write(`Previous app kept at ${backup}.\n`);
148
- return {installed:target,backup:backup??null};
190
+ return {installed:target,version,backup:backup??null};
149
191
  } finally {
150
192
  if(stage)await rm(stage,{recursive:true,force:true});
151
193
  if(attached) {
package/src/install.mjs CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from './host-integration.mjs';
10
10
  import { LEGACY_PLUGIN_ID, readInstalledPlugin, readInstalledPluginRecords } from './update.mjs';
11
11
  import { executableName, which } from './util.mjs';
12
- import { codexStatus, installCodexPlugin } from './codex-integration.mjs';
12
+ import { codexStatus, installCodexPlugin, installedCodexStatus } from './codex-integration.mjs';
13
13
 
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -111,9 +111,12 @@ function installCodex(argv, dryRun, json) {
111
111
  stdio: json ? 'pipe' : 'inherit', encoding: json ? 'utf8' : undefined,
112
112
  shell: process.platform === 'win32',
113
113
  });
114
- if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: added.status === 0 ? 'installed' : 'failed', pluginAddExit: added.status }, null, 2) + '\n');
115
- if (added.status !== 0) {
116
- process.stderr.write(`Codex plugin source is ready; finish installation with: codex plugin add ${selector}\n`);
114
+ const installed = added.status === 0 ? installedCodexStatus() : { ok: false, reason: added.error?.message ?? `codex plugin add exited ${added.status ?? '?'}` };
115
+ const verified = installed.ok && installed.version === pkg.version;
116
+ if (json) process.stdout.write(JSON.stringify({ ...result, pluginAdd: verified ? 'installed' : 'failed', pluginAddExit: added.status,
117
+ installedVersion: installed.version ?? null, ...(!verified ? { reason: installed.reason ?? `Codex has ${installed.version}, expected ${pkg.version}` } : {}) }, null, 2) + '\n');
118
+ if (!verified) {
119
+ process.stderr.write(`Codex plugin installation is unverified: ${installed.reason ?? `has ${installed.version}, expected ${pkg.version}`}. Retry: codex plugin add ${selector}\n`);
117
120
  process.exitCode = 1;
118
121
  } else if (!json) {
119
122
  process.stdout.write('Start a new Codex thread and review/trust the plugin hooks to enable the primer and guard.\n');
package/src/update.mjs CHANGED
@@ -5,8 +5,10 @@ import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { green, dim, yellow } from './color.mjs';
7
7
  import { executableName, which } from './util.mjs';
8
- import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
9
- import { codexStatus } from './codex-integration.mjs';
8
+ import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, opencodeStatus } from './host-integration.mjs';
9
+ import { codexStatus, installedCodexStatus } from './codex-integration.mjs';
10
+ import { desktopLaunch } from './desktop.mjs';
11
+ import { compareDesktopVersions, installedDesktopVersion } from './desktop-install.mjs';
10
12
 
11
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
14
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -201,10 +203,10 @@ export function planUpdate(opts, ctx) {
201
203
  // the moment the CLI moves on. Refresh it here — but only if it is already
202
204
  // installed. `update` keeps hosts in lockstep; it never adopts a new one,
203
205
  // which stays the job of the explicit `runlist install`.
204
- if (ctx.opencode?.exists && ctx.opencode.stale) {
205
- steps.push({ kind: 'opencode', path: ctx.opencode.path });
206
- } else if (ctx.opencode?.foreign) {
206
+ if (ctx.opencode?.foreign) {
207
207
  steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by runlist — leaving it alone` });
208
+ } else if (ctx.opencode?.exists && (ctx.opencode.stale || (!opts.pluginOnly && ctx.hasNpm))) {
209
+ steps.push({ kind: 'opencode', cmd: ['runlist', 'install', 'opencode'] });
208
210
  }
209
211
  if (ctx.codex?.foreign) {
210
212
  steps.push({ kind: 'skip', reason: `${ctx.codex.plugin} was not written by runlist — leaving it alone` });
@@ -212,15 +214,41 @@ export function planUpdate(opts, ctx) {
212
214
  steps.push({ kind: 'codex', cmd: ['runlist', 'install', 'codex'] });
213
215
  }
214
216
  }
217
+ if (!opts.cliOnly && !opts.pluginOnly && ctx.desktop) {
218
+ steps.push({ kind: 'desktop', cmd: ['runlist', 'desktop', 'install'] });
219
+ }
215
220
  return steps;
216
221
  }
217
222
 
218
- export function runUpdate(argv, _config, opts = {}) {
223
+ export function verifyManagedHostUpdates(before, after, expectedVersion) {
224
+ const failures = [];
225
+ for (const host of ['opencode', 'codex']) {
226
+ if (!before[host]?.exists || before[host].foreign) continue;
227
+ const current = after[host];
228
+ if (!current?.exists || current.foreign || current.version !== expectedVersion || current.stale) {
229
+ failures.push(`${host} integration has ${current?.version ?? 'no managed installation'}, expected ${expectedVersion}`);
230
+ }
231
+ if (host === 'codex' && (!current?.installed?.ok || current.installed.version !== expectedVersion)) {
232
+ failures.push(`Codex installed plugin ${current?.installed?.reason ?? `has ${current?.installed?.version ?? 'unknown'}, expected ${expectedVersion}`}`);
233
+ }
234
+ }
235
+ return failures;
236
+ }
237
+
238
+ function currentCodexStatus(version) {
239
+ const source = codexStatus({ version });
240
+ if (!source.exists || source.foreign) return source;
241
+ const installed = which('codex') ? installedCodexStatus() : { ok: false, reason: 'codex CLI not found; installed plugin is unverified' };
242
+ return { ...source, installed, stale: source.stale || !installed.ok || installed.version !== version };
243
+ }
244
+
245
+ export async function runUpdate(argv, _config, opts = {}) {
219
246
  const check = argv.includes('--check');
220
247
  const cliOnly = argv.includes('--cli-only');
221
248
  const pluginOnly = argv.includes('--plugin-only');
222
249
  const plugin = readInstalledPlugin();
223
250
  const legacyPlugin = readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID });
251
+ const desktop = process.platform === 'darwin' && process.arch === 'arm64' ? desktopLaunch([]).app : null;
224
252
 
225
253
  if (check) {
226
254
  process.stdout.write(`runlist CLI: ${pkg.version}\n`);
@@ -246,21 +274,24 @@ export function runUpdate(argv, _config, opts = {}) {
246
274
  if (!oc.exists) process.stdout.write(dim('runlist opencode: not installed\n'));
247
275
  else if (oc.foreign) process.stdout.write(`runlist opencode: ${yellow('unmanaged file — not written by runlist')}\n`);
248
276
  else process.stdout.write(`runlist opencode: ${oc.version} ${oc.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
249
- const cx = codexStatus({ version: pkg.version });
277
+ const cx = currentCodexStatus(pkg.version);
250
278
  if (!cx.exists) process.stdout.write(dim('runlist codex: not installed\n'));
251
279
  else if (cx.foreign) process.stdout.write(`runlist codex: ${yellow('unmanaged plugin directory')}\n`);
252
- else process.stdout.write(`runlist codex: ${cx.version} ${cx.stale ? yellow('behind — run `runlist update`') : green('in sync')}\n`);
280
+ else process.stdout.write(`runlist codex: source ${cx.version}; installed ${cx.installed?.version ?? 'unverified'} ${cx.stale ? yellow('out of sync — run `runlist update`') : green('in sync')}\n`);
281
+ if (desktop) {
282
+ const version = await installedDesktopVersion(desktop);
283
+ process.stdout.write(`runlist desktop: ${version} ${compareDesktopVersions(version, pkg.version) < 0 ? yellow('behind — quit Runlist and run `runlist update`') : green('in sync')}\n`);
284
+ }
253
285
  return;
254
286
  }
255
287
 
256
288
  const opencode = opencodeStatus({ version: pkg.version });
257
- const codex = codexStatus({ version: pkg.version });
258
- const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, legacyPlugin, opencode, codex, hasClaude: which('claude'), hasNpm: which('npm') });
289
+ const codex = currentCodexStatus(pkg.version);
290
+ const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, legacyPlugin, opencode, codex, desktop, hasClaude: which('claude'), hasNpm: which('npm') });
259
291
  if (opts.dryRun) {
260
292
  for (const step of steps) {
261
293
  if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
262
294
  else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
263
- else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
264
295
  else if (step.kind === 'refusal') process.stdout.write(yellow(`[dry-run] Refused: ${step.reason}\n`));
265
296
  else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}${step.cwd ? ` (in ${step.cwd})` : ''}\n`));
266
297
  }
@@ -294,18 +325,17 @@ export function runUpdate(argv, _config, opts = {}) {
294
325
  process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
295
326
  continue;
296
327
  }
297
- if (s.kind === 'opencode') {
298
- const result = installOpencodePlugin({ version: pkg.version });
299
- process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
300
- ran = true;
301
- continue;
302
- }
303
328
  if (s.reason) process.stdout.write(dim(`${s.reason}\n`));
304
329
  process.stdout.write(dim(`$ ${s.cmd.join(' ')}${s.cwd ? ` (in ${s.cwd})` : ''}\n`));
305
- const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
330
+ // npm can replace this package during the CLI step. A new Node process
331
+ // reads the new installers/assets; this process still holds the old modules.
332
+ // Use this installation's entry point, not another runlist earlier on PATH.
333
+ const ownCli = s.cmd[0] === 'runlist';
334
+ const r = spawnSync(ownCli ? process.execPath : executableName(s.cmd[0]),
335
+ ownCli ? [path.join(__dirname, '..', 'bin', 'runlist.mjs'), ...s.cmd.slice(1)] : s.cmd.slice(1), {
306
336
  stdio: 'inherit',
307
337
  ...(s.cwd ? { cwd: s.cwd } : {}),
308
- shell: process.platform === 'win32',
338
+ shell: !ownCli && process.platform === 'win32',
309
339
  });
310
340
  ran = true;
311
341
  if (r.status !== 0) {
@@ -315,24 +345,40 @@ export function runUpdate(argv, _config, opts = {}) {
315
345
  if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(plugin?.marketplace)) process.stdout.write(yellow(`${line}\n`));
316
346
  }
317
347
  }
318
- if (plugin?.id === DEFAULT_PLUGIN_ID && steps.some(step => step.kind === 'plugin') && !failedKinds.has('plugin') && !failedKinds.has('marketplace')) {
348
+ const currentVersion = opts.expectedVersion ?? JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
349
+ if (!cliOnly) {
350
+ const after = { opencode: opencodeStatus({ version: currentVersion }), codex: currentCodexStatus(currentVersion) };
351
+ for (const reason of verifyManagedHostUpdates({ opencode, codex }, after, currentVersion)) {
352
+ failures.push({ reason });
353
+ process.stdout.write(yellow(`${reason}\n`));
354
+ }
355
+ }
356
+ if (!cliOnly && plugin?.id === DEFAULT_PLUGIN_ID && !failedKinds.has('plugin') && !failedKinds.has('marketplace')) {
319
357
  // npm may have replaced this package while this process was running.
320
- const currentVersion = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
321
- const verified = verifyPluginScopeUpdates(plugin, readInstalledPluginRecords({ id: plugin.id }), { minimumVersion: currentVersion });
358
+ const verified = verifyPluginScopeUpdates(plugin, readInstalledPluginRecords({ id: plugin.id }),
359
+ opts.expectedVersion ? { expectedVersion: currentVersion } : { minimumVersion: currentVersion });
322
360
  if (!verified.ok) {
323
361
  failures.push({ reason: verified.reason });
324
362
  process.stdout.write(yellow(`${verified.reason}\n`));
325
363
  }
326
364
  }
365
+ if (!cliOnly && legacyPlugin && readInstalledPluginRecords({ id: LEGACY_PLUGIN_ID })) {
366
+ failures.push({ reason: `legacy Claude plugin ${LEGACY_PLUGIN_ID} remains installed; migration is incomplete` });
367
+ }
368
+ if (desktop && steps.some(step => step.kind === 'desktop') && !failedKinds.has('desktop')) {
369
+ if (compareDesktopVersions(await installedDesktopVersion(desktop), currentVersion) < 0) {
370
+ failures.push({ reason: 'The matching desktop installer is not published yet; retry `runlist update` after the product release completes.' });
371
+ }
372
+ }
327
373
  if (failures.length) {
328
374
  process.stdout.write(yellow(`\n${failures.length === 1 ? '1 step' : `${failures.length} steps`} failed: ${failures.map(s => s.cmd ? `${s.cmd.join(' ')}${s.cwd ? ` (in ${s.cwd})` : ''}` : s.reason).join('; ')}\n`));
329
375
  if (ran && failures.length < steps.filter(s => s.kind !== 'skip' && s.kind !== 'manual').length) {
330
- process.stdout.write(dim('the other steps completed; restart your Claude Code session (or /reload-plugins) to apply them.\n'));
376
+ process.stdout.write(dim('the other steps completed; restart the affected host sessions to load the updated integrations.\n'));
331
377
  }
332
378
  process.exitCode = 1;
333
379
  return;
334
380
  }
335
381
  if (ran) {
336
- process.stdout.write(green('\n✓ restart your Claude Code session (or /reload-plugins) to apply.\n'));
382
+ process.stdout.write(green('\n✓ restart the affected host sessions to load the updated integrations.\n'));
337
383
  }
338
384
  }