@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32

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 (67) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +525 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +24 -3
  10. package/scripts/test-boot-hook-registration.mjs +136 -0
  11. package/scripts/test-boot-payload-trim.mjs +200 -0
  12. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  13. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  14. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  15. package/scripts/test-crc-pair-login-flow.mjs +115 -0
  16. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  17. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  18. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  19. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  20. package/scripts/test-install-prompt-policy.mjs +84 -0
  21. package/scripts/test-installer-skill-directory.mjs +55 -0
  22. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  23. package/scripts/test-installer-target-self-update.mjs +131 -0
  24. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  25. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  26. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  27. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  28. package/scripts/test-ldm-status-timeout.mjs +96 -0
  29. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  30. package/scripts/test-readme-install-prompt.mjs +66 -0
  31. package/shared/boot/boot-config.json +18 -8
  32. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  33. package/shared/rules/security.md +4 -0
  34. package/shared/templates/install-prompt.md +20 -2
  35. package/src/boot/README.md +24 -1
  36. package/src/boot/boot-config.json +18 -8
  37. package/src/boot/boot-hook.mjs +118 -28
  38. package/src/boot/installer.mjs +33 -10
  39. package/src/hosted-mcp/.env.example +4 -0
  40. package/src/hosted-mcp/README.md +37 -0
  41. package/src/hosted-mcp/app/footer.js +74 -0
  42. package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
  43. package/src/hosted-mcp/app/pair.html +165 -57
  44. package/src/hosted-mcp/app/sprites.png +0 -0
  45. package/src/hosted-mcp/app/wip-logo.png +0 -0
  46. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  47. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  48. package/src/hosted-mcp/demo/footer.js +2 -2
  49. package/src/hosted-mcp/demo/index.html +169 -51
  50. package/src/hosted-mcp/demo/login.html +390 -28
  51. package/src/hosted-mcp/demo/privacy.html +4 -214
  52. package/src/hosted-mcp/demo/tos.html +4 -189
  53. package/src/hosted-mcp/deploy.sh +308 -56
  54. package/src/hosted-mcp/docs/self-host.md +268 -0
  55. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  56. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  57. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  58. package/src/hosted-mcp/legal/legal.css +166 -0
  59. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  60. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  61. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  62. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  63. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  64. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  65. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  66. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  67. package/src/hosted-mcp/server.mjs +1681 -166
package/bin/ldm.js CHANGED
@@ -20,9 +20,9 @@
20
20
  * ldm --version Show version
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync, statSync, lstatSync, symlinkSync, copyFileSync } from 'node:fs';
24
24
  import { join, basename, resolve, dirname } from 'node:path';
25
- import { execSync } from 'node:child_process';
25
+ import { execSync, spawnSync } from 'node:child_process';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
28
  const __filename = fileURLToPath(import.meta.url);
@@ -211,6 +211,11 @@ function writeJSON(path, data) {
211
211
  writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
212
212
  }
213
213
 
214
+ function parsePositiveInt(value, fallback) {
215
+ const parsed = Number.parseInt(value || '', 10);
216
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
217
+ }
218
+
214
219
  // ── CLI version check (#29) ──
215
220
 
216
221
  function checkCliVersion() {
@@ -229,6 +234,62 @@ function checkCliVersion() {
229
234
  }
230
235
  }
231
236
 
237
+ function selectedLdmNpmTrack() {
238
+ return ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
239
+ }
240
+
241
+ function selectedLdmTrackLabel(npmTag) {
242
+ return npmTag === 'latest' ? '' : ` (${npmTag} track)`;
243
+ }
244
+
245
+ function latestLdmCliForSelectedTrack() {
246
+ const npmTag = selectedLdmNpmTrack();
247
+ const npmViewCmd = npmTag === 'latest'
248
+ ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
249
+ : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
250
+ const latest = execSync(npmViewCmd, {
251
+ encoding: 'utf8',
252
+ timeout: 15000,
253
+ }).trim();
254
+ return { latest, npmTag };
255
+ }
256
+
257
+ function maybeSelfUpdateLdmCliBeforeInstall() {
258
+ // This shared preflight covers both bare `ldm install` and targeted
259
+ // `ldm install <app>`. Dry runs never update, but still disclose skew.
260
+ if (process.env.LDM_SELF_UPDATED) return;
261
+
262
+ try {
263
+ const { latest, npmTag } = latestLdmCliForSelectedTrack();
264
+ if (!latest || !semverNewer(latest, PKG_VERSION)) return;
265
+
266
+ const trackLabel = selectedLdmTrackLabel(npmTag);
267
+
268
+ if (DRY_RUN) {
269
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel} is available.`);
270
+ console.log(` Dry run only: continuing with v${PKG_VERSION}.`);
271
+ console.log('');
272
+ return;
273
+ }
274
+
275
+ console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
276
+ try {
277
+ execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
278
+ console.log(` CLI updated to v${latest}. Re-running with new code...`);
279
+ console.log('');
280
+ const reArgs = process.argv.slice(2);
281
+ const child = spawnSync('ldm', reArgs.length > 0 ? reArgs : ['install'], {
282
+ stdio: 'inherit',
283
+ env: { ...process.env, LDM_SELF_UPDATED: '1' },
284
+ });
285
+ if (child.error) throw child.error;
286
+ process.exit(child.status ?? 1);
287
+ } catch (e) {
288
+ console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
289
+ }
290
+ } catch {}
291
+ }
292
+
232
293
  // ── Dead backup trigger cleanup (#207) ──
233
294
  // Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
234
295
  // This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
@@ -1440,23 +1501,6 @@ async function showCatalogPicker() {
1440
1501
  // ── ldm install ──
1441
1502
 
1442
1503
  async function cmdInstall() {
1443
- if (!DRY_RUN && !acquireInstallLock()) return;
1444
-
1445
- // Ensure LDM is initialized
1446
- if (!existsSync(VERSION_PATH)) {
1447
- console.log(' LDM OS not initialized. Running init first...');
1448
- console.log('');
1449
- cmdInit();
1450
- }
1451
-
1452
- const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
1453
- const { detectInterfacesJSON } = await import('../lib/detect.mjs');
1454
-
1455
- // Refresh harness detection (catches newly installed harnesses)
1456
- detectHarnesses();
1457
-
1458
- setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1459
-
1460
1504
  // --help flag (#81)
1461
1505
  if (args.includes('--help') || args.includes('-h')) {
1462
1506
  console.log(`
@@ -1476,6 +1520,25 @@ async function cmdInstall() {
1476
1520
  process.exit(0);
1477
1521
  }
1478
1522
 
1523
+ maybeSelfUpdateLdmCliBeforeInstall();
1524
+
1525
+ if (!DRY_RUN && !acquireInstallLock()) return;
1526
+
1527
+ // Ensure LDM is initialized
1528
+ if (!existsSync(VERSION_PATH)) {
1529
+ console.log(' LDM OS not initialized. Running init first...');
1530
+ console.log('');
1531
+ cmdInit();
1532
+ }
1533
+
1534
+ const { setFlags, installFromPath, installSingleTool, installToolbox, detectHarnesses } = await import('../lib/deploy.mjs');
1535
+ const { detectInterfacesJSON } = await import('../lib/detect.mjs');
1536
+
1537
+ // Refresh harness detection (catches newly installed harnesses)
1538
+ detectHarnesses();
1539
+
1540
+ setFlags({ dryRun: DRY_RUN, jsonOutput: JSON_OUTPUT, origin: 'manual' });
1541
+
1479
1542
  // Find the target (skip flags)
1480
1543
  const target = args.slice(1).find(a => !a.startsWith('--'));
1481
1544
 
@@ -1736,6 +1799,135 @@ function migrateRegistry() {
1736
1799
  return migrated;
1737
1800
  }
1738
1801
 
1802
+ // ── Legacy source.npm honest cleanup (Phase 1 of source-types refactor) ──
1803
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
1804
+
1805
+ async function migrateLegacyNpmSources({ dryRun = false } = {}) {
1806
+ const {
1807
+ planLegacyNpmSourcesMigration,
1808
+ summaryHasChanges,
1809
+ npmPackageExists,
1810
+ executeDirectoryMoves,
1811
+ } = await import('../lib/registry-migrations.mjs');
1812
+
1813
+ const registry = readJSON(REGISTRY_PATH);
1814
+ if (!registry?.extensions) return null;
1815
+
1816
+ const { newRegistry, summary } = await planLegacyNpmSourcesMigration({
1817
+ registry,
1818
+ probeNpm: (name) => npmPackageExists(name, { timeoutMs: 2000 }),
1819
+ // Resolve the entry's on-disk location before declaring it a phantom.
1820
+ // Entries autoregistered with `ldmPath` (bin/ldm.js:1822 path) or with
1821
+ // a `paths.ldm` field (lib/deploy.mjs path) can live outside the
1822
+ // default ~/.ldm/extensions/<name> location. A naive check would
1823
+ // silently delete a working entry as "phantom."
1824
+ extensionExists: (name, entry) => {
1825
+ const customPath = entry?.paths?.ldm || entry?.ldmPath;
1826
+ if (customPath && existsSync(customPath)) return true;
1827
+ return existsSync(join(LDM_EXTENSIONS, name));
1828
+ },
1829
+ now: () => new Date(),
1830
+ });
1831
+
1832
+ if (!summaryHasChanges(summary)) return summary;
1833
+
1834
+ if (!dryRun) {
1835
+ const stamp = summary.timestamp.replace(/[:.]/g, '-');
1836
+ const backupPath = `${REGISTRY_PATH}.bak-${stamp}`;
1837
+ copyFileSync(REGISTRY_PATH, backupPath);
1838
+ summary.backupPath = backupPath;
1839
+ writeJSON(REGISTRY_PATH, newRegistry);
1840
+
1841
+ // Execute directory moves AFTER the registry write. Each move sends a
1842
+ // deduplicated extension's on-disk directory to ~/.ldm/_trash/<name>-
1843
+ // deduplicated-<timestamp> so autoDetectExtensions (which only scans
1844
+ // ~/.ldm/extensions/) cannot find it on the next install pass.
1845
+ // Without this, the registry dedup reverts within the same install run.
1846
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-dedup-reverts-between-installs.md
1847
+ const { performed, skipped } = executeDirectoryMoves({
1848
+ directoryMoves: summary.directoryMoves,
1849
+ extensionsRoot: LDM_EXTENSIONS,
1850
+ trashRoot: join(LDM_ROOT, '_trash'),
1851
+ });
1852
+ summary.directoryMovesPerformed.push(...performed);
1853
+ summary.directoryMovesSkipped.push(...skipped);
1854
+ }
1855
+
1856
+ return summary;
1857
+ }
1858
+
1859
+ function printLegacyNpmSourcesSummary(summary, { dryRun = false } = {}) {
1860
+ if (!summary) return;
1861
+ const writeableChanges =
1862
+ summary.migrated.length +
1863
+ summary.phantomsRemoved.length +
1864
+ summary.duplicatesRemoved.length;
1865
+ const probeFailureCount = summary.probeFailures?.length || 0;
1866
+ // Always surface probe failures even when nothing was writeable. If every
1867
+ // probe times out, the install would otherwise look like a no-op and the
1868
+ // [unavailable] rows in `ldm status` would survive silently.
1869
+ if (writeableChanges === 0 && probeFailureCount === 0) return;
1870
+
1871
+ console.log('');
1872
+ console.log(dryRun
1873
+ ? ' Registry source.npm cleanup (dry run, no changes written):'
1874
+ : ' Registry source.npm cleanup:');
1875
+
1876
+ if (summary.migrated.length > 0) {
1877
+ console.log(` + ${dryRun ? 'Would migrate' : 'Migrated'} ${summary.migrated.length} entr${summary.migrated.length === 1 ? 'y' : 'ies'} to updateSource.type=untracked`);
1878
+ for (const m of summary.migrated) {
1879
+ const detail = m.legacyNpmName
1880
+ ? ` (legacy source.npm "${m.legacyNpmName}" preserved in provenance)`
1881
+ : ' (no source info; classified as untracked)';
1882
+ console.log(` ${m.name}${detail}`);
1883
+ }
1884
+ }
1885
+ if (summary.phantomsRemoved.length > 0) {
1886
+ console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.phantomsRemoved.length} phantom entr${summary.phantomsRemoved.length === 1 ? 'y' : 'ies'}:`);
1887
+ for (const p of summary.phantomsRemoved) {
1888
+ console.log(` ${p.name} (${p.reason})`);
1889
+ }
1890
+ }
1891
+ if (summary.duplicatesRemoved.length > 0) {
1892
+ console.log(` + ${dryRun ? 'Would remove' : 'Removed'} ${summary.duplicatesRemoved.length} duplicate entr${summary.duplicatesRemoved.length === 1 ? 'y' : 'ies'}:`);
1893
+ for (const d of summary.duplicatesRemoved) {
1894
+ console.log(` ${d.removed} (canonical: ${d.keep})`);
1895
+ }
1896
+ }
1897
+ if (summary.directoryMoves && summary.directoryMoves.length > 0) {
1898
+ if (dryRun) {
1899
+ console.log(` + Would move ${summary.directoryMoves.length} duplicate director${summary.directoryMoves.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (so autoDetectExtensions cannot re-register):`);
1900
+ for (const m of summary.directoryMoves) {
1901
+ console.log(` ~/.ldm/extensions/${m.name} -> ~/.ldm/_trash/${m.trashName}`);
1902
+ }
1903
+ } else {
1904
+ const performed = summary.directoryMovesPerformed || [];
1905
+ const skipped = summary.directoryMovesSkipped || [];
1906
+ if (performed.length > 0) {
1907
+ console.log(` + Moved ${performed.length} duplicate director${performed.length === 1 ? 'y' : 'ies'} to ~/.ldm/_trash/ (autoDetectExtensions cannot re-register):`);
1908
+ for (const m of performed) {
1909
+ console.log(` ${m.name} -> ${m.destPath.replace(HOME, '~')}`);
1910
+ }
1911
+ }
1912
+ if (skipped.length > 0) {
1913
+ console.log(` ! ${skipped.length} duplicate director${skipped.length === 1 ? 'y' : 'ies'} could not be moved (registry entries are removed; autoDetect may re-add on next install):`);
1914
+ for (const m of skipped) {
1915
+ console.log(` ${m.name} (${m.reason})`);
1916
+ }
1917
+ }
1918
+ }
1919
+ }
1920
+ if (summary.probeFailures && summary.probeFailures.length > 0) {
1921
+ console.log(` ! ${summary.probeFailures.length} npm probe${summary.probeFailures.length === 1 ? '' : 's'} could not complete (will retry on next install):`);
1922
+ for (const f of summary.probeFailures) {
1923
+ console.log(` ${f.name} (source.npm "${f.npmName}")`);
1924
+ }
1925
+ }
1926
+ if (!dryRun && summary.backupPath) {
1927
+ console.log(` + Registry backup: ${summary.backupPath.replace(HOME, '~')}`);
1928
+ }
1929
+ }
1930
+
1739
1931
  // ── Auto-detect unregistered extensions ──
1740
1932
 
1741
1933
  function autoDetectExtensions() {
@@ -1921,38 +2113,6 @@ async function cmdInstallCatalog() {
1921
2113
  // No lock here. cmdInstall() already holds it when calling this.
1922
2114
  installLog(`ldm install started (v${PKG_VERSION}, DRY_RUN=${DRY_RUN})`);
1923
2115
 
1924
- // Self-update: check if CLI itself is outdated. Update first, then re-exec.
1925
- // This breaks the chicken-and-egg: new features in ldm install are always
1926
- // available because the installer upgrades itself before doing anything else.
1927
- // --alpha and --beta flags check the corresponding npm dist-tag instead of @latest.
1928
- if (!DRY_RUN && !process.env.LDM_SELF_UPDATED) {
1929
- try {
1930
- const npmTag = ALPHA_FLAG ? 'alpha' : BETA_FLAG ? 'beta' : 'latest';
1931
- const trackLabel = npmTag === 'latest' ? '' : ` (${npmTag} track)`;
1932
- const npmViewCmd = npmTag === 'latest'
1933
- ? 'npm view @wipcomputer/wip-ldm-os version 2>/dev/null'
1934
- : `npm view @wipcomputer/wip-ldm-os dist-tags.${npmTag} 2>/dev/null`;
1935
- const latest = execSync(npmViewCmd, {
1936
- encoding: 'utf8', timeout: 15000,
1937
- }).trim();
1938
- if (latest && semverNewer(latest, PKG_VERSION)) {
1939
- console.log(` LDM OS CLI v${PKG_VERSION} -> v${latest}${trackLabel}. Updating first...`);
1940
- try {
1941
- execSync(`npm install -g @wipcomputer/wip-ldm-os@${latest}`, { stdio: 'inherit', timeout: 60000 });
1942
- console.log(` CLI updated to v${latest}. Re-running with new code...`);
1943
- console.log('');
1944
- // Re-exec with the new binary. LDM_SELF_UPDATED prevents infinite loop.
1945
- // process.argv.slice(2) skips 'node' and the script path, keeps just 'install' + flags
1946
- const reArgs = process.argv.slice(2).join(' ') || 'install';
1947
- execSync(`LDM_SELF_UPDATED=1 ldm ${reArgs}`, { stdio: 'inherit' });
1948
- process.exit(0);
1949
- } catch (e) {
1950
- console.log(` ! Self-update failed: ${e.message}. Continuing with v${PKG_VERSION}.`);
1951
- }
1952
- }
1953
- } catch {}
1954
- }
1955
-
1956
2116
  autoDetectExtensions();
1957
2117
 
1958
2118
  // Migrate old registry entries to v2 format (#262)
@@ -1961,6 +2121,12 @@ async function cmdInstallCatalog() {
1961
2121
  console.log(` + Migrated ${migrated} registry entries to v2 format (source info added)`);
1962
2122
  }
1963
2123
 
2124
+ // Phase 1 of source-types refactor: clear bad source.npm entries, dedupe,
2125
+ // and classify mystery rows as untracked so `ldm status` stops lying.
2126
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
2127
+ const npmCleanupSummary = await migrateLegacyNpmSources({ dryRun: DRY_RUN });
2128
+ printLegacyNpmSourcesSummary(npmCleanupSummary, { dryRun: DRY_RUN });
2129
+
1964
2130
  // Aggregate the bin ownership manifest BEFORE seedLocalCatalog,
1965
2131
  // deployBridge, deployScripts, and the heal walk run. If two
1966
2132
  // declarers claim the same file in ~/.ldm/bin/ we cannot safely
@@ -2891,6 +3057,27 @@ async function cmdDoctor() {
2891
3057
  }
2892
3058
  }
2893
3059
 
3060
+ // Warn on registry entries whose legacy source.npm value 404s on npm.
3061
+ // `ldm install` migrates these to updateSource.type=untracked; this catches
3062
+ // future drift and any entries the install-time probe couldn't reach.
3063
+ // See ai/product/bugs/installer/2026-05-13--cc-mini--installer-source-npm-honest-cleanup.md
3064
+ try {
3065
+ const { findLegacyNpm404Entries, npmPackageExists } = await import('../lib/registry-migrations.mjs');
3066
+ const docRegistry = readJSON(REGISTRY_PATH);
3067
+ const legacy404 = await findLegacyNpm404Entries({
3068
+ registry: docRegistry,
3069
+ probeNpm: (name) => npmPackageExists(name, { timeoutMs: 1500 }),
3070
+ });
3071
+ if (legacy404.length > 0) {
3072
+ console.log('');
3073
+ console.log(` ! ${legacy404.length} extension(s) declare an npm source whose package does not exist on npm:`);
3074
+ for (const { name, npmName } of legacy404) {
3075
+ console.log(` ${name} (source.npm "${npmName}")`);
3076
+ }
3077
+ console.log(' Run `ldm install` to migrate these to updateSource.type=untracked.');
3078
+ }
3079
+ } catch {}
3080
+
2894
3081
  // --fix: clean up registered-missing entries
2895
3082
  if (FIX_FLAG && registeredMissing.length > 0) {
2896
3083
  const registry = readJSON(REGISTRY_PATH);
@@ -2912,6 +3099,109 @@ async function cmdDoctor() {
2912
3099
  }
2913
3100
  }
2914
3101
 
3102
+ // Duplicate hook entries + invalid model value in ~/.claude/settings.json.
3103
+ // Duplicates: the same hook (event + matcher + commands) registered more
3104
+ // than once runs once per copy every session. 10 duplicate SessionStart
3105
+ // boot-hook entries observed 2026-07-04 (~450KB of boot context per
3106
+ // session start). Invalid model: a paste carrying ANSI escape codes can
3107
+ // land in /model and get persisted (e.g. "opus" + ESC + "[1m"); every new
3108
+ // session then fails its first API call. The corruption signal is a
3109
+ // CONTROL character (ESC 0x1B etc.), never the visible charset: bracketed
3110
+ // IDs like "claude-fable-5[1m]" are legitimate 1M-context variants and
3111
+ // must be left untouched.
3112
+ // Reported always; collapsed/removed under --fix with a timestamped
3113
+ // backup written before the first mutation.
3114
+ // See ai/product/bugs/installer/open-tickets/2026-07-04--cc-mini--installer-sessionstart-hook-duplicate-registration.md
3115
+ // and ai/product/bugs/guard/2026-07-04--cc-mini--no-blessed-recipe-for-live-settings-remediation.md
3116
+ {
3117
+ const settingsPath = join(HOME, '.claude', 'settings.json');
3118
+ const settings = readJSON(settingsPath);
3119
+ if (!settings && existsSync(settingsPath)) {
3120
+ console.log(' ! ~/.claude/settings.json exists but is not valid JSON; skipping hook/model checks');
3121
+ issues++;
3122
+ }
3123
+ let settingsDirty = false;
3124
+ let backupDone = false;
3125
+ const backupSettingsOnce = () => {
3126
+ if (backupDone) return;
3127
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3128
+ const bak = `${settingsPath}.bak-${stamp}`;
3129
+ copyFileSync(settingsPath, bak);
3130
+ console.log(` + Backup written: ${bak}`);
3131
+ backupDone = true;
3132
+ };
3133
+
3134
+ // Duplicate hook entries (same event + matcher + hook command list).
3135
+ // Deliberately ignores type/timeout: entries differing only in timeout
3136
+ // are treated as duplicates and collapse to the first.
3137
+ if (settings?.hooks) {
3138
+ const dupes = [];
3139
+ for (const [event, groups] of Object.entries(settings.hooks)) {
3140
+ if (!Array.isArray(groups)) continue;
3141
+ const seen = new Map();
3142
+ groups.forEach((g, i) => {
3143
+ const key = JSON.stringify({
3144
+ matcher: g?.matcher,
3145
+ hooks: (g?.hooks || []).map((h) => h?.command),
3146
+ });
3147
+ if (!seen.has(key)) seen.set(key, []);
3148
+ seen.get(key).push(i);
3149
+ });
3150
+ for (const idxs of seen.values()) {
3151
+ if (idxs.length > 1) dupes.push({ event, idxs });
3152
+ }
3153
+ }
3154
+ const extra = dupes.reduce((n, d) => n + d.idxs.length - 1, 0);
3155
+ if (extra > 0) {
3156
+ for (const d of dupes) {
3157
+ const cmd = settings.hooks[d.event][d.idxs[0]]?.hooks?.[0]?.command || '(no command)';
3158
+ console.log(` ! ${d.event}: ${d.idxs.length} identical hook entries for: ${cmd}`);
3159
+ }
3160
+ if (FIX_FLAG) {
3161
+ backupSettingsOnce();
3162
+ for (const d of dupes) {
3163
+ // Keep the first, drop the rest. Right-to-left so indices stay valid.
3164
+ for (let j = d.idxs.length - 1; j >= 1; j--) {
3165
+ settings.hooks[d.event].splice(d.idxs[j], 1);
3166
+ }
3167
+ }
3168
+ settingsDirty = true;
3169
+ console.log(` + Collapsed ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
3170
+ } else {
3171
+ console.log(` Run: ldm doctor --fix to collapse ${extra} duplicate hook entr${extra === 1 ? 'y' : 'ies'}`);
3172
+ issues += extra;
3173
+ }
3174
+ }
3175
+ }
3176
+
3177
+ // Invalid model value. Corruption means CONTROL characters (ESC 0x1B
3178
+ // from ANSI fragments, NUL, DEL...) or an impossible length, never a
3179
+ // visible-charset judgment: "claude-fable-5[1m]" is a legitimate
3180
+ // 1M-context model ID and must pass.
3181
+ if (settings && typeof settings.model === 'string') {
3182
+ const valid =
3183
+ settings.model.length > 0 &&
3184
+ settings.model.length <= 256 &&
3185
+ !/[\x00-\x1f\x7f]/.test(settings.model);
3186
+ if (!valid) {
3187
+ console.log(` ! settings.json model value is invalid: ${JSON.stringify(settings.model)}`);
3188
+ if (FIX_FLAG) {
3189
+ backupSettingsOnce();
3190
+ delete settings.model;
3191
+ settingsDirty = true;
3192
+ console.log(' + Removed invalid model value (re-pick with /model in Claude Code)');
3193
+ } else {
3194
+ console.log(' Run: ldm doctor --fix to remove it, then re-pick with /model in Claude Code');
3195
+ issues++;
3196
+ }
3197
+ }
3198
+ }
3199
+
3200
+ if (settingsDirty) {
3201
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
3202
+ }
3203
+ }
3204
+
2915
3205
  // --fix: clean registry entries with /tmp/ sources or ldm-install- names (#54)
2916
3206
  if (FIX_FLAG) {
2917
3207
  const registry = readJSON(REGISTRY_PATH);
@@ -3251,7 +3541,91 @@ async function cmdDoctor() {
3251
3541
 
3252
3542
  // ── ldm status ──
3253
3543
 
3254
- function cmdStatus() {
3544
+ const STATUS_NPM_TIMEOUT_MS = parsePositiveInt(process.env.LDM_STATUS_NPM_TIMEOUT_MS, 5000);
3545
+ const STATUS_TOTAL_BUDGET_MS = parsePositiveInt(process.env.LDM_STATUS_TOTAL_BUDGET_MS, 60000);
3546
+ const STATUS_NPM_CONCURRENCY = parsePositiveInt(process.env.LDM_STATUS_NPM_CONCURRENCY, 8);
3547
+ const STATUS_NPM_REGISTRY_URL = process.env.LDM_STATUS_NPM_REGISTRY_URL || 'https://registry.npmjs.org';
3548
+
3549
+ async function npmViewVersionForStatus(pkg, timeoutMs) {
3550
+ const controller = new AbortController();
3551
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3552
+ try {
3553
+ const registry = STATUS_NPM_REGISTRY_URL.replace(/\/+$/, '');
3554
+ const response = await fetch(`${registry}/${encodeURIComponent(pkg)}`, {
3555
+ signal: controller.signal,
3556
+ headers: { accept: 'application/vnd.npm.install-v1+json, application/json' },
3557
+ });
3558
+ if (!response.ok) {
3559
+ const error = new Error(`npm registry returned ${response.status}`);
3560
+ error.statusCode = response.status;
3561
+ throw error;
3562
+ }
3563
+ const metadata = await response.json();
3564
+ return metadata?.['dist-tags']?.latest || '';
3565
+ } finally {
3566
+ clearTimeout(timeout);
3567
+ }
3568
+ }
3569
+
3570
+ function remainingStatusBudgetMs(startedAt) {
3571
+ return Math.max(0, STATUS_TOTAL_BUDGET_MS - (Date.now() - startedAt));
3572
+ }
3573
+
3574
+ function formatStatusElapsed(ms) {
3575
+ if (!Number.isFinite(ms) || ms <= 0) return '0ms';
3576
+ if (ms < 1000) return `${Math.round(ms)}ms`;
3577
+ return `${(ms / 1000).toFixed(1)}s`;
3578
+ }
3579
+
3580
+ function classifyStatusCheckError(error) {
3581
+ if (error?.name === 'AbortError' || error?.signal === 'SIGTERM' || error?.code === 'ETIMEDOUT' || String(error?.message || '').includes('ETIMEDOUT')) {
3582
+ return 'timeout';
3583
+ }
3584
+ return 'unavailable';
3585
+ }
3586
+
3587
+ async function runStatusProbesWithConcurrency(items, concurrency, statusStartedAt) {
3588
+ if (items.length === 0) return [];
3589
+
3590
+ const results = new Array(items.length);
3591
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
3592
+ let nextIndex = 0;
3593
+
3594
+ async function worker() {
3595
+ while (nextIndex < items.length) {
3596
+ const index = nextIndex;
3597
+ nextIndex += 1;
3598
+ const item = items[index];
3599
+ const remaining = remainingStatusBudgetMs(statusStartedAt);
3600
+
3601
+ if (remaining <= 0) {
3602
+ results[index] = { ...item, status: 'skipped', reason: 'budget', elapsedMs: 0 };
3603
+ continue;
3604
+ }
3605
+
3606
+ const timeout = Math.min(STATUS_NPM_TIMEOUT_MS, remaining);
3607
+ const probeStartedAt = Date.now();
3608
+ console.log(` ${item.name}: checking npm`);
3609
+
3610
+ try {
3611
+ const latest = await npmViewVersionForStatus(item.npm, timeout);
3612
+ results[index] = { ...item, status: 'ok', latest, elapsedMs: Date.now() - probeStartedAt };
3613
+ } catch (error) {
3614
+ results[index] = {
3615
+ ...item,
3616
+ status: 'skipped',
3617
+ reason: classifyStatusCheckError(error),
3618
+ elapsedMs: Date.now() - probeStartedAt,
3619
+ };
3620
+ }
3621
+ }
3622
+ }
3623
+
3624
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
3625
+ return results;
3626
+ }
3627
+
3628
+ async function cmdStatus() {
3255
3629
  const version = readJSON(VERSION_PATH);
3256
3630
  const registry = readJSON(REGISTRY_PATH);
3257
3631
  const extCount = Object.keys(registry?.extensions || {}).length;
@@ -3272,18 +3646,46 @@ function cmdStatus() {
3272
3646
  return;
3273
3647
  }
3274
3648
 
3275
- // Check CLI version against npm
3276
- let cliUpdate = null;
3277
- try {
3278
- const latest = execSync('npm view @wipcomputer/wip-ldm-os version 2>/dev/null', {
3279
- encoding: 'utf8', timeout: 10000,
3280
- }).trim();
3281
- if (latest && semverNewer(latest, PKG_VERSION)) cliUpdate = latest;
3282
- } catch {}
3649
+ console.log('');
3650
+ console.log(` LDM OS v${version.version}`);
3651
+ console.log(` Installed: ${version.installed?.split('T')[0] || 'unknown'}`);
3652
+ console.log(` Updated: ${version.updated?.split('T')[0] || 'unknown'}`);
3653
+ console.log(` Extensions: ${extCount}`);
3654
+ console.log(` Root: ${LDM_ROOT}`);
3655
+
3656
+ const statusStartedAt = Date.now();
3657
+ const skipped = [];
3658
+
3659
+ console.log('');
3660
+ console.log(' Checking updates:');
3283
3661
 
3284
3662
  // Check extensions against npm using registry source info (#262)
3663
+ const probeItems = [{
3664
+ kind: 'cli',
3665
+ name: 'ldm cli',
3666
+ npm: '@wipcomputer/wip-ldm-os',
3667
+ current: PKG_VERSION,
3668
+ }];
3669
+
3285
3670
  const updates = [];
3671
+ const untrackedEntries = [];
3286
3672
  for (const [name, info] of Object.entries(registry?.extensions || {})) {
3673
+ // Phase 1 of source-types refactor: entries explicitly marked as untracked
3674
+ // are listed in a separate section and never probed. Honest reporting:
3675
+ // we don't know how to check their source yet.
3676
+ if (info?.updateSource?.type === 'untracked') {
3677
+ const currentVersion = info?.installed?.version || info.version || 'unknown';
3678
+ untrackedEntries.push({ name, version: currentVersion });
3679
+ continue;
3680
+ }
3681
+ // Defensive skip for any other Phase 2 updateSource types (git, bundled,
3682
+ // private, etc.) that may appear in the registry before their probe
3683
+ // logic ships. Falling through would use the legacy info.source.npm
3684
+ // path, which is wrong for these types and would print them as
3685
+ // [unavailable]. Phase 2 replaces this skip with proper dispatch.
3686
+ if (info?.updateSource && info.updateSource.type !== 'npm') {
3687
+ continue;
3688
+ }
3287
3689
  // Use registry source.npm (v2) or fall back to extension's package.json
3288
3690
  let npmPkg = info?.source?.npm || null;
3289
3691
  if (!npmPkg) {
@@ -3294,22 +3696,52 @@ function cmdStatus() {
3294
3696
  if (!npmPkg) continue;
3295
3697
  const currentVersion = info?.installed?.version || info.version;
3296
3698
  if (!currentVersion) continue;
3297
- try {
3298
- const latest = execSync(`npm view ${npmPkg} version 2>/dev/null`, {
3299
- encoding: 'utf8', timeout: 10000,
3300
- }).trim();
3301
- if (latest && semverNewer(latest, currentVersion)) {
3302
- updates.push({ name, current: currentVersion, latest, npm: npmPkg });
3303
- }
3304
- } catch {}
3699
+ probeItems.push({
3700
+ kind: 'extension',
3701
+ name,
3702
+ npm: npmPkg,
3703
+ current: currentVersion,
3704
+ });
3705
+ }
3706
+ untrackedEntries.sort((a, b) => a.name.localeCompare(b.name));
3707
+
3708
+ let cliUpdate = null;
3709
+ const probeResults = await runStatusProbesWithConcurrency(probeItems, STATUS_NPM_CONCURRENCY, statusStartedAt);
3710
+ for (const result of probeResults) {
3711
+ if (!result) continue;
3712
+ if (result.status === 'skipped') {
3713
+ skipped.push({
3714
+ name: result.name,
3715
+ npm: result.npm,
3716
+ reason: result.reason,
3717
+ elapsedMs: result.elapsedMs,
3718
+ });
3719
+ continue;
3720
+ }
3721
+
3722
+ if (result.kind === 'cli') {
3723
+ if (result.latest && semverNewer(result.latest, PKG_VERSION)) cliUpdate = result.latest;
3724
+ } else if (result.latest && semverNewer(result.latest, result.current)) {
3725
+ updates.push({
3726
+ name: result.name,
3727
+ current: result.current,
3728
+ latest: result.latest,
3729
+ npm: result.npm,
3730
+ });
3731
+ }
3305
3732
  }
3306
3733
 
3307
3734
  console.log('');
3308
- console.log(` LDM OS v${version.version}${cliUpdate ? ` (v${cliUpdate} available)` : ' (latest)'}`);
3309
- console.log(` Installed: ${version.installed?.split('T')[0]}`);
3310
- console.log(` Updated: ${version.updated?.split('T')[0]}`);
3311
- console.log(` Extensions: ${extCount}${updates.length > 0 ? `, ${updates.length} update(s) available` : ', all up to date'}`);
3312
- console.log(` Root: ${LDM_ROOT}`);
3735
+ if (updates.length === 0 && !cliUpdate && skipped.length === 0 && untrackedEntries.length === 0) {
3736
+ console.log(' Update summary: all up to date');
3737
+ } else {
3738
+ const summaryParts = [];
3739
+ if (updates.length > 0) summaryParts.push(`${updates.length} extension update(s) available`);
3740
+ if (cliUpdate) summaryParts.push('CLI update available');
3741
+ if (untrackedEntries.length > 0) summaryParts.push(`${untrackedEntries.length} untracked`);
3742
+ if (skipped.length > 0) summaryParts.push(`${skipped.length} update check(s) skipped`);
3743
+ console.log(` Update summary: ${summaryParts.join(', ')}`);
3744
+ }
3313
3745
 
3314
3746
  if (updates.length > 0) {
3315
3747
  console.log('');
@@ -3325,6 +3757,24 @@ function cmdStatus() {
3325
3757
  console.log(` CLI update: npm install -g @wipcomputer/wip-ldm-os@${cliUpdate}`);
3326
3758
  }
3327
3759
 
3760
+ if (untrackedEntries.length > 0) {
3761
+ console.log('');
3762
+ console.log(' Untracked extensions (pending reclassification):');
3763
+ const maxNameLen = Math.max(...untrackedEntries.map(e => e.name.length));
3764
+ for (const e of untrackedEntries) {
3765
+ console.log(` ${e.name.padEnd(maxNameLen)} v${e.version}`);
3766
+ }
3767
+ console.log(' (run `ldm doctor --reclassify-sources` to classify these)');
3768
+ }
3769
+
3770
+ if (skipped.length > 0) {
3771
+ console.log('');
3772
+ console.log(' Update checks skipped:');
3773
+ for (const item of skipped) {
3774
+ console.log(` ${item.name}: [${item.reason} ${formatStatusElapsed(item.elapsedMs)}] ${item.npm}`);
3775
+ }
3776
+ }
3777
+
3328
3778
  console.log('');
3329
3779
  }
3330
3780
 
@@ -4005,7 +4455,7 @@ async function main() {
4005
4455
  console.log(' Module ... ESM main/exports -> importable');
4006
4456
  console.log(' MCP Server ... mcp-server.mjs -> claude mcp add --scope user');
4007
4457
  console.log(' OpenClaw ... openclaw.plugin.json -> ~/.ldm/extensions/ + ~/.openclaw/extensions/');
4008
- console.log(' Skill ... SKILL.md -> ~/.openclaw/skills/<tool>/');
4458
+ console.log(' Skill ... SKILL.md or skills/<name>/SKILL.md -> agent skill paths');
4009
4459
  console.log(' CC Hook ... guard.mjs or claudeCode.hook -> ~/.claude/settings.json');
4010
4460
  console.log('');
4011
4461
  console.log(` v${PKG_VERSION}`);
@@ -4780,7 +5230,7 @@ async function main() {
4780
5230
  await cmdDoctor();
4781
5231
  break;
4782
5232
  case 'status':
4783
- cmdStatus();
5233
+ await cmdStatus();
4784
5234
  break;
4785
5235
  case 'sessions':
4786
5236
  await cmdSessions();