@wipcomputer/wip-ldm-os 0.4.61 → 0.4.63

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/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.61"
12
+ version: "0.4.63"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -9,6 +9,9 @@
9
9
  * ldm install Install/update all registered components
10
10
  * ldm doctor Check health of all extensions
11
11
  * ldm status Show LDM OS version and extension count
12
+ * ldm backup Run a full backup now
13
+ * ldm backup --dry-run Preview what would be backed up (with sizes)
14
+ * ldm backup --pin "x" Pin the latest backup so rotation skips it
12
15
  * ldm sessions List active sessions
13
16
  * ldm msg send <to> <b> Send a message to a session
14
17
  * ldm msg list List pending messages
@@ -17,7 +20,7 @@
17
20
  * ldm --version Show version
18
21
  */
19
22
 
20
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync } from 'node:fs';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, cpSync, chmodSync, unlinkSync, readlinkSync, renameSync } from 'node:fs';
21
24
  import { join, basename, resolve, dirname } from 'node:path';
22
25
  import { execSync } from 'node:child_process';
23
26
  import { fileURLToPath } from 'node:url';
@@ -112,6 +115,26 @@ function acquireInstallLock() {
112
115
  }
113
116
 
114
117
  const args = process.argv.slice(2);
118
+
119
+ // Normalize dry-run flag variants before parsing (#239)
120
+ // --dryrun -> --dry-run
121
+ // --dry run (two words) -> --dry-run (consume the stray "run" so it doesn't
122
+ // become a package target and install random npm packages)
123
+ for (let i = 0; i < args.length; i++) {
124
+ if (args[i] === '--dryrun') {
125
+ args[i] = '--dry-run';
126
+ } else if (args[i] === '--dry') {
127
+ if (args[i + 1] === 'run') {
128
+ args[i] = '--dry-run';
129
+ args.splice(i + 1, 1);
130
+ } else {
131
+ // Bare --dry with no "run" after it. Treat as --dry-run since there
132
+ // is no other --dry flag and the intent is obvious.
133
+ args[i] = '--dry-run';
134
+ }
135
+ }
136
+ }
137
+
115
138
  const command = args[0];
116
139
  const DRY_RUN = args.includes('--dry-run');
117
140
  const JSON_OUTPUT = args.includes('--json');
@@ -152,6 +175,76 @@ function checkCliVersion() {
152
175
  }
153
176
  }
154
177
 
178
+ // ── Dead backup trigger cleanup (#207) ──
179
+ // Three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
180
+ // This removes: broken cron entry (LDMDevTools.app), old com.wipcomputer.daily-backup.
181
+
182
+ function cleanDeadBackupTriggers() {
183
+ let cleaned = 0;
184
+
185
+ // 1. Remove broken cron entries referencing LDMDevTools.app
186
+ // Matches both "LDMDevTools.app" and "LDM Dev Tools.app" (old naming)
187
+ try {
188
+ const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
189
+ const lines = crontab.split('\n');
190
+ const filtered = lines.filter(line => {
191
+ const lower = line.toLowerCase();
192
+ // Remove any line (active or commented) that references LDMDevTools
193
+ if (lower.includes('ldmdevtools.app') || lower.includes('ldm dev tools.app')) {
194
+ cleaned++;
195
+ return false;
196
+ }
197
+ // Remove orphaned descriptive comment for the old backup verification cron
198
+ if (line.trim() === '# Verify daily backup ran - 00:30 PST') return false;
199
+ return true;
200
+ });
201
+ if (cleaned > 0) {
202
+ // Write filtered crontab via temp file (avoids shell escaping issues)
203
+ const tmpCron = join(LDM_TMP, 'crontab.tmp');
204
+ mkdirSync(LDM_TMP, { recursive: true });
205
+ writeFileSync(tmpCron, filtered.join('\n'));
206
+ execSync(`crontab "${tmpCron}"`, { stdio: 'pipe' });
207
+ try { unlinkSync(tmpCron); } catch {}
208
+ console.log(` + Removed ${cleaned} dead cron entry(s) (LDMDevTools.app)`);
209
+ }
210
+ } catch {
211
+ // No crontab or crontab command failed. Not critical.
212
+ }
213
+
214
+ // 2. Unload and disable com.wipcomputer.daily-backup LaunchAgent
215
+ const oldPlist = join(HOME, 'Library', 'LaunchAgents', 'com.wipcomputer.daily-backup.plist');
216
+ const disabledPlist = oldPlist + '.disabled';
217
+ if (existsSync(oldPlist)) {
218
+ try { execSync(`launchctl unload "${oldPlist}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
219
+ try {
220
+ renameSync(oldPlist, disabledPlist);
221
+ } catch {
222
+ // If rename fails, just try to remove it
223
+ try { unlinkSync(oldPlist); } catch {}
224
+ }
225
+ console.log(' + Disabled dead LaunchAgent: com.wipcomputer.daily-backup');
226
+ cleaned++;
227
+ }
228
+
229
+ // 3. Unload and disable com.wipcomputer.cc-watcher LaunchAgent
230
+ // Broken since Mar 24 migration (old iCloud path, wrong node path).
231
+ // The agent communication channel needs redesign, not screen automation.
232
+ const ccWatcherPlist = join(HOME, 'Library', 'LaunchAgents', 'com.wipcomputer.cc-watcher.plist');
233
+ const ccWatcherDisabled = ccWatcherPlist + '.disabled';
234
+ if (existsSync(ccWatcherPlist)) {
235
+ try { execSync(`launchctl unload "${ccWatcherPlist}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
236
+ try {
237
+ renameSync(ccWatcherPlist, ccWatcherDisabled);
238
+ } catch {
239
+ try { unlinkSync(ccWatcherPlist); } catch {}
240
+ }
241
+ console.log(' + Disabled dead LaunchAgent: com.wipcomputer.cc-watcher');
242
+ cleaned++;
243
+ }
244
+
245
+ return cleaned;
246
+ }
247
+
155
248
  // ── Stale hook cleanup (#30) ──
156
249
 
157
250
  function cleanStaleHooks() {
@@ -319,6 +412,32 @@ async function cmdInit() {
319
412
  join(LDM_ROOT, 'hooks'),
320
413
  ];
321
414
 
415
+ // Migrate config-from-home.json into config.json (one-time merge)
416
+ // config-from-home.json held org identity (coAuthors, paths, agents, backup, etc.)
417
+ // config.json held runtime/harness info. Now they are one file.
418
+ const configFromHomePath = join(LDM_ROOT, 'config-from-home.json');
419
+ if (existsSync(configFromHomePath) && existsSync(join(LDM_ROOT, 'config.json'))) {
420
+ try {
421
+ const existing = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
422
+ const fromHome = JSON.parse(readFileSync(configFromHomePath, 'utf8'));
423
+ // Merge: config-from-home.json wins where keys overlap (richer data)
424
+ const merged = { ...existing, ...fromHome };
425
+ // Preserve harnesses from existing config (not in config-from-home.json)
426
+ if (existing.harnesses) merged.harnesses = existing.harnesses;
427
+ // Preserve version and created from existing config
428
+ if (existing.version) merged.version = existing.version;
429
+ if (existing.created) merged.created = existing.created;
430
+ // Update timestamp
431
+ merged.updatedAt = new Date().toISOString();
432
+ writeFileSync(join(LDM_ROOT, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
433
+ renameSync(configFromHomePath, configFromHomePath + '.migrated');
434
+ console.log(` + config-from-home.json merged into config.json`);
435
+ console.log(` + config-from-home.json renamed to config-from-home.json.migrated`);
436
+ } catch (e) {
437
+ console.log(` ! config-from-home.json migration failed: ${e.message}`);
438
+ }
439
+ }
440
+
322
441
  // Scaffold per-agent memory dirs
323
442
  try {
324
443
  const config = JSON.parse(readFileSync(join(LDM_ROOT, 'config.json'), 'utf8'));
@@ -597,10 +716,9 @@ async function cmdInit() {
597
716
  mkdirSync(docsDest, { recursive: true });
598
717
  let docsCount = 0;
599
718
 
600
- // Build template values from BOTH configs:
601
- // ~/.ldm/config.json (harnesses, workspace) + settings/config.json (agents, paths, org)
602
- const settingsConfig = JSON.parse(readFileSync(join(workspacePath, 'settings', 'config.json'), 'utf8'));
603
- const sc = settingsConfig;
719
+ // Build template values from ~/.ldm/config.json (unified config)
720
+ // Legacy: settings/config.json was a separate file, now merged into config.json
721
+ const sc = ldmConfig;
604
722
  const lc = ldmConfig;
605
723
 
606
724
  // Agents from settings config (rich objects with harness/machine/prefix)
@@ -648,29 +766,57 @@ async function cmdInit() {
648
766
  }
649
767
 
650
768
  // Deploy LaunchAgents to ~/Library/LaunchAgents/
769
+ // Templates use {{HOME}} and {{OPENCLAW_GATEWAY_TOKEN}} placeholders, replaced at deploy time.
651
770
  const launchSrc = join(__dirname, '..', 'shared', 'launchagents');
652
771
  const launchDest = join(HOME, 'Library', 'LaunchAgents');
653
772
  if (existsSync(launchSrc) && existsSync(launchDest)) {
773
+ // Ensure log directory exists for LaunchAgent output
774
+ mkdirSync(join(LDM_ROOT, 'logs'), { recursive: true });
775
+
776
+ // Read gateway token from openclaw.json (if it exists)
777
+ let gatewayToken = '';
778
+ try {
779
+ const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
780
+ gatewayToken = ocConfig?.gateway?.auth?.token || '';
781
+ } catch {}
782
+
654
783
  let launchCount = 0;
784
+ let launchUpToDate = 0;
655
785
  for (const file of readdirSync(launchSrc)) {
656
786
  if (!file.endsWith('.plist')) continue;
657
787
  const src = join(launchSrc, file);
658
788
  const dest = join(launchDest, file);
659
- const srcContent = readFileSync(src, 'utf8');
789
+ // Replace template placeholders with actual values
790
+ let srcContent = readFileSync(src, 'utf8');
791
+ srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
792
+ srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, gatewayToken);
660
793
  const destContent = existsSync(dest) ? readFileSync(dest, 'utf8') : '';
661
794
  if (srcContent !== destContent) {
662
- // Unload old, write new, load new
795
+ // Unload old agent before overwriting
663
796
  try { execSync(`launchctl unload "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
664
- cpSync(src, dest);
797
+ writeFileSync(dest, srcContent);
665
798
  try { execSync(`launchctl load "${dest}" 2>/dev/null`, { stdio: 'pipe' }); } catch {}
799
+ const label = file.replace('.plist', '');
800
+ console.log(` + ${label} deployed and loaded`);
801
+ installLog(`LaunchAgent deployed: ${file}`);
666
802
  launchCount++;
803
+ } else {
804
+ launchUpToDate++;
667
805
  }
668
806
  }
669
807
  if (launchCount > 0) {
670
808
  console.log(` + ${launchCount} LaunchAgent(s) deployed to ~/Library/LaunchAgents/`);
671
809
  }
810
+ if (launchUpToDate > 0) {
811
+ console.log(` - ${launchUpToDate} LaunchAgent(s) already up to date`);
812
+ }
672
813
  }
673
814
 
815
+ // Clean up dead backup triggers (#207)
816
+ // Bug: three backup systems were competing. Only ai.openclaw.ldm-backup (3am) works.
817
+ // The old cron entry (LDMDevTools.app) and com.wipcomputer.daily-backup are dead.
818
+ cleanDeadBackupTriggers();
819
+
674
820
  console.log('');
675
821
  console.log(` LDM OS v${PKG_VERSION} initialized at ${LDM_ROOT}`);
676
822
  console.log('');
@@ -1041,54 +1187,53 @@ async function cmdInstallCatalog() {
1041
1187
  const state = detectSystemState();
1042
1188
  const reconciled = reconcileState(state);
1043
1189
 
1044
- // Show the real system state
1045
- console.log(formatReconciliation(reconciled));
1046
-
1047
1190
  // Check catalog: use registryMatches + cliMatches to detect what's really installed
1048
1191
  const registry = readJSON(REGISTRY_PATH);
1049
- const registeredNames = Object.keys(registry?.extensions || {});
1050
- const reconciledNames = Object.keys(reconciled);
1051
1192
  const components = loadCatalog();
1052
1193
 
1053
- function isCatalogItemInstalled(c) {
1054
- // Direct ID match
1055
- if (registeredNames.includes(c.id) || reconciled[c.id]) return true;
1056
- // Check registryMatches (aliases)
1057
- const matches = c.registryMatches || [];
1058
- if (matches.some(m => registeredNames.includes(m) || reconciled[m])) return true;
1059
- // Check CLI binaries
1060
- const cliMatches = c.cliMatches || [];
1061
- if (cliMatches.some(b => state.cliBinaries[b])) return true;
1062
- return false;
1063
- }
1064
-
1065
- const available = components.filter(c =>
1066
- c.status !== 'coming-soon' && !isCatalogItemInstalled(c)
1067
- );
1068
-
1069
- if (available.length > 0) {
1070
- console.log(' Available in catalog (not yet installed):');
1071
- for (const c of available) {
1072
- console.log(` [ ] ${c.name} ... ${c.description}`);
1073
- }
1074
- console.log('');
1075
- } else {
1076
- console.log(' All catalog components are installed.');
1077
- console.log('');
1078
- }
1079
-
1080
1194
  // Clean ghost entries from registry (#134, #135)
1195
+ // Run BEFORE system state display so ghosts don't appear in the installed list.
1081
1196
  if (registry?.extensions) {
1082
1197
  const names = Object.keys(registry.extensions);
1083
1198
  let cleaned = 0;
1084
1199
  for (const name of names) {
1085
1200
  // Remove -private duplicates (e.g. wip-xai-grok-private when wip-xai-grok exists)
1201
+ // Only public versions should be installed as extensions. Private repos are for development.
1086
1202
  const publicName = name.replace(/-private$/, '');
1087
1203
  if (name !== publicName && registry.extensions[publicName]) {
1088
1204
  delete registry.extensions[name];
1205
+ if (!DRY_RUN) {
1206
+ for (const base of [LDM_EXTENSIONS, join(HOME, '.openclaw', 'extensions')]) {
1207
+ const ghostDir = join(base, name);
1208
+ if (existsSync(ghostDir)) {
1209
+ const trashDir = join(LDM_TRASH, `${name}.ghost-${Date.now()}`);
1210
+ try { execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' }); } catch {}
1211
+ }
1212
+ }
1213
+ }
1089
1214
  cleaned++;
1090
1215
  continue;
1091
1216
  }
1217
+ // Fix -private path mismatch: registry says "wip-xai-x" but paths point to "wip-xai-x-private".
1218
+ // This happens when the installer cloned a public repo whose package.json had a -private name.
1219
+ // Rename the directories to match the public registry name.
1220
+ const ext = registry.extensions[name];
1221
+ if (ext && !name.endsWith('-private')) {
1222
+ const privateName = name + '-private';
1223
+ let pathFixed = false;
1224
+ for (const [pathKey, base] of [['ldmPath', LDM_EXTENSIONS], ['ocPath', join(HOME, '.openclaw', 'extensions')]]) {
1225
+ if (ext[pathKey] && ext[pathKey].endsWith(privateName)) {
1226
+ const privateDir = join(base, privateName);
1227
+ const publicDir = join(base, name);
1228
+ if (!DRY_RUN && existsSync(privateDir) && !existsSync(publicDir)) {
1229
+ try { execSync(`mv "${privateDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1230
+ }
1231
+ ext[pathKey] = publicDir;
1232
+ pathFixed = true;
1233
+ }
1234
+ }
1235
+ if (pathFixed) cleaned++;
1236
+ }
1092
1237
  // Rename ldm-install- prefixed entries to clean names (#141)
1093
1238
  if (name.startsWith('ldm-install-')) {
1094
1239
  const cleanName = name.replace(/^ldm-install-/, '');
@@ -1127,6 +1272,122 @@ async function cmdInstallCatalog() {
1127
1272
  }
1128
1273
  }
1129
1274
 
1275
+ // Clean orphaned -private directories (#132)
1276
+ // Pre-v0.4.30 installs could create -private extension dirs that linger
1277
+ // even after registry entries are cleaned. If the public name is in the
1278
+ // registry, rename the directory (or trash it if public dir already exists).
1279
+ try {
1280
+ const extDirs = readdirSync(LDM_EXTENSIONS, { withFileTypes: true })
1281
+ .filter(d => d.isDirectory() && d.name.endsWith('-private'));
1282
+ for (const d of extDirs) {
1283
+ const publicName = d.name.replace(/-private$/, '');
1284
+ // Only act if the public name is known (registry entry or catalog match)
1285
+ const inRegistry = !!registry?.extensions?.[publicName];
1286
+ const inCatalog = components.some(c =>
1287
+ c.id === publicName || (c.registryMatches || []).includes(publicName)
1288
+ );
1289
+ if (!inRegistry && !inCatalog) continue;
1290
+
1291
+ const ghostDir = join(LDM_EXTENSIONS, d.name);
1292
+ const publicDir = join(LDM_EXTENSIONS, publicName);
1293
+
1294
+ if (!DRY_RUN) {
1295
+ if (!existsSync(publicDir)) {
1296
+ // No public dir yet. Rename -private to public name.
1297
+ console.log(` Renaming ghost: ${d.name} -> ${publicName}`);
1298
+ try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1299
+ } else {
1300
+ // Public dir exists. Trash the ghost.
1301
+ console.log(` Trashing ghost: ${d.name} (public "${publicName}" exists)`);
1302
+ const trashDir = join(LDM_EXTENSIONS, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
1303
+ try {
1304
+ mkdirSync(join(LDM_EXTENSIONS, '_trash'), { recursive: true });
1305
+ execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
1306
+ } catch {}
1307
+ }
1308
+ // Fix registry paths that still reference the -private name
1309
+ if (registry?.extensions?.[publicName]) {
1310
+ const entry = registry.extensions[publicName];
1311
+ if (entry.ldmPath && entry.ldmPath.includes(d.name)) {
1312
+ entry.ldmPath = entry.ldmPath.replace(d.name, publicName);
1313
+ }
1314
+ if (entry.ocPath && entry.ocPath.includes(d.name)) {
1315
+ entry.ocPath = entry.ocPath.replace(d.name, publicName);
1316
+ }
1317
+ writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2));
1318
+ }
1319
+ } else {
1320
+ if (!existsSync(publicDir)) {
1321
+ console.log(` Would rename ghost: ${d.name} -> ${publicName}`);
1322
+ } else {
1323
+ console.log(` Would trash ghost: ${d.name} (public "${publicName}" exists)`);
1324
+ }
1325
+ }
1326
+ // Remove from reconciled so it doesn't appear in installed list or update checks
1327
+ delete reconciled[d.name];
1328
+ }
1329
+ // Same for OC extensions
1330
+ const ocExtDir = join(HOME, '.openclaw', 'extensions');
1331
+ if (existsSync(ocExtDir)) {
1332
+ const ocDirs = readdirSync(ocExtDir, { withFileTypes: true })
1333
+ .filter(d => d.isDirectory() && d.name.endsWith('-private'));
1334
+ for (const d of ocDirs) {
1335
+ const publicName = d.name.replace(/-private$/, '');
1336
+ const publicDir = join(ocExtDir, publicName);
1337
+ const ghostDir = join(ocExtDir, d.name);
1338
+ const inCatalog = components.some(c =>
1339
+ c.id === publicName || (c.registryMatches || []).includes(publicName)
1340
+ );
1341
+ if (!inCatalog) continue;
1342
+ if (!DRY_RUN) {
1343
+ if (!existsSync(publicDir)) {
1344
+ try { execSync(`mv "${ghostDir}" "${publicDir}"`, { stdio: 'pipe' }); } catch {}
1345
+ } else {
1346
+ const trashDir = join(ocExtDir, '_trash', d.name + '--' + new Date().toISOString().slice(0, 10));
1347
+ try {
1348
+ mkdirSync(join(ocExtDir, '_trash'), { recursive: true });
1349
+ execSync(`mv "${ghostDir}" "${trashDir}"`, { stdio: 'pipe' });
1350
+ } catch {}
1351
+ }
1352
+ }
1353
+ delete reconciled[d.name];
1354
+ }
1355
+ }
1356
+ } catch {}
1357
+
1358
+ // Show the system state (after ghost cleanup, so ghosts don't appear)
1359
+ console.log(formatReconciliation(reconciled));
1360
+
1361
+ const registeredNames = Object.keys(registry?.extensions || {});
1362
+ const reconciledNames = Object.keys(reconciled);
1363
+
1364
+ function isCatalogItemInstalled(c) {
1365
+ // Direct ID match
1366
+ if (registeredNames.includes(c.id) || reconciled[c.id]) return true;
1367
+ // Check registryMatches (aliases)
1368
+ const matches = c.registryMatches || [];
1369
+ if (matches.some(m => registeredNames.includes(m) || reconciled[m])) return true;
1370
+ // Check CLI binaries
1371
+ const cliMatches = c.cliMatches || [];
1372
+ if (cliMatches.some(b => state.cliBinaries[b])) return true;
1373
+ return false;
1374
+ }
1375
+
1376
+ const available = components.filter(c =>
1377
+ c.status !== 'coming-soon' && !isCatalogItemInstalled(c)
1378
+ );
1379
+
1380
+ if (available.length > 0) {
1381
+ console.log(' Available in catalog (not yet installed):');
1382
+ for (const c of available) {
1383
+ console.log(` [ ] ${c.name} ... ${c.description}`);
1384
+ }
1385
+ console.log('');
1386
+ } else {
1387
+ console.log(' All catalog components are installed.');
1388
+ console.log('');
1389
+ }
1390
+
1130
1391
  // Build the update plan: check ALL installed extensions against npm (#55)
1131
1392
  const npmUpdates = [];
1132
1393
 
@@ -1164,6 +1425,9 @@ async function cmdInstallCatalog() {
1164
1425
  return matches.includes(name) || c.id === name;
1165
1426
  });
1166
1427
 
1428
+ // Skip pinned components (e.g. OpenClaw). Upgrades must be explicit.
1429
+ if (catalogEntry?.pinned) continue;
1430
+
1167
1431
  // Fallback: use repository.url from extension's package.json (#82)
1168
1432
  let repoUrl = catalogEntry?.repo || null;
1169
1433
  if (!repoUrl && extPkg?.repository) {
@@ -1249,9 +1513,12 @@ async function cmdInstallCatalog() {
1249
1513
  encoding: 'utf8', timeout: 10000,
1250
1514
  }).trim();
1251
1515
  if (latest && latest !== currentVersion) {
1252
- // Remove any sub-tool entries that duplicate this parent
1516
+ // Remove any sub-tool entries that belong to this parent.
1517
+ // Match by name in registryMatches (sub-tools have their own npm names,
1518
+ // not the parent's, so catalogNpm comparison doesn't work).
1519
+ const parentMatches = new Set(comp.registryMatches || []);
1253
1520
  for (let i = npmUpdates.length - 1; i >= 0; i--) {
1254
- if (npmUpdates[i].catalogNpm === comp.npm && !npmUpdates[i].isCLI) {
1521
+ if (!npmUpdates[i].isCLI && parentMatches.has(npmUpdates[i].name)) {
1255
1522
  npmUpdates.splice(i, 1);
1256
1523
  }
1257
1524
  }
@@ -1782,6 +2049,69 @@ async function cmdDoctor() {
1782
2049
  console.log(` + CLI binaries: ${Object.keys(state.cliBinaries).join(', ')}`);
1783
2050
  }
1784
2051
 
2052
+ // 8. LaunchAgents health check
2053
+ const managedAgents = [
2054
+ 'ai.openclaw.ldm-backup',
2055
+ 'ai.openclaw.healthcheck',
2056
+ 'ai.openclaw.gateway',
2057
+ ];
2058
+ const launchAgentsDir = join(HOME, 'Library', 'LaunchAgents');
2059
+ const launchAgentsSrc = join(__dirname, '..', 'shared', 'launchagents');
2060
+
2061
+ // Read gateway token for template comparison
2062
+ let doctorGatewayToken = '';
2063
+ try {
2064
+ const ocConfig = JSON.parse(readFileSync(join(HOME, '.openclaw', 'openclaw.json'), 'utf8'));
2065
+ doctorGatewayToken = ocConfig?.gateway?.auth?.token || '';
2066
+ } catch {}
2067
+
2068
+ let launchOk = 0;
2069
+ let launchIssues = 0;
2070
+ for (const label of managedAgents) {
2071
+ const plistFile = `${label}.plist`;
2072
+ const deployedPath = join(launchAgentsDir, plistFile);
2073
+ const srcPath = join(launchAgentsSrc, plistFile);
2074
+
2075
+ if (!existsSync(deployedPath)) {
2076
+ console.log(` x LaunchAgent ${label}: plist missing from ~/Library/LaunchAgents/`);
2077
+ launchIssues++;
2078
+ continue;
2079
+ }
2080
+
2081
+ // Check if deployed plist matches source template (after placeholder substitution)
2082
+ if (existsSync(srcPath)) {
2083
+ let srcContent = readFileSync(srcPath, 'utf8');
2084
+ srcContent = srcContent.replace(/\{\{HOME\}\}/g, HOME);
2085
+ srcContent = srcContent.replace(/\{\{OPENCLAW_GATEWAY_TOKEN\}\}/g, doctorGatewayToken);
2086
+ const deployedContent = readFileSync(deployedPath, 'utf8');
2087
+ if (srcContent !== deployedContent) {
2088
+ console.log(` ! LaunchAgent ${label}: plist out of date (run: ldm install)`);
2089
+ launchIssues++;
2090
+ continue;
2091
+ }
2092
+ }
2093
+
2094
+ // Check if loaded via launchctl
2095
+ try {
2096
+ const result = execSync(`launchctl list 2>/dev/null | grep "${label}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
2097
+ if (result.trim()) {
2098
+ launchOk++;
2099
+ } else {
2100
+ console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
2101
+ launchIssues++;
2102
+ }
2103
+ } catch {
2104
+ console.log(` ! LaunchAgent ${label}: plist exists but not loaded`);
2105
+ launchIssues++;
2106
+ }
2107
+ }
2108
+ if (launchOk > 0) {
2109
+ console.log(` + LaunchAgents: ${launchOk}/${managedAgents.length} loaded`);
2110
+ }
2111
+ if (launchIssues > 0) {
2112
+ issues += launchIssues;
2113
+ }
2114
+
1785
2115
  console.log('');
1786
2116
  if (issues === 0) {
1787
2117
  console.log(' All checks passed.');
@@ -1882,16 +2212,29 @@ async function cmdBackup() {
1882
2212
  const backupArgs = [];
1883
2213
  if (DRY_RUN) backupArgs.push('--dry-run');
1884
2214
 
2215
+ // --full is explicit but currently all backups are full (incrementals are Phase 2)
2216
+ // Accept it as a no-op so the command reads naturally: ldm backup --full
2217
+ const FULL_FLAG = args.includes('--full');
2218
+
2219
+ // --keep N: pass through to backup script
2220
+ const keepIndex = args.indexOf('--keep');
2221
+ if (keepIndex !== -1 && args[keepIndex + 1]) {
2222
+ backupArgs.push('--keep', args[keepIndex + 1]);
2223
+ }
2224
+
1885
2225
  // --pin: mark the latest backup to skip rotation
1886
2226
  const pinIndex = args.indexOf('--pin');
1887
2227
  if (pinIndex !== -1) {
1888
2228
  const reason = args[pinIndex + 1] || 'pinned';
1889
2229
  // Find latest backup dir
1890
2230
  const backupRoot = join(LDM_ROOT, 'backups');
1891
- const dirs = readdirSync(backupRoot)
1892
- .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
1893
- .sort()
1894
- .reverse();
2231
+ let dirs = [];
2232
+ try {
2233
+ dirs = readdirSync(backupRoot)
2234
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2235
+ .sort()
2236
+ .reverse();
2237
+ } catch {}
1895
2238
  if (dirs.length === 0) {
1896
2239
  console.error(' x No backups found to pin.');
1897
2240
  process.exit(1);
@@ -1904,7 +2247,70 @@ async function cmdBackup() {
1904
2247
  return;
1905
2248
  }
1906
2249
 
1907
- console.log(' Running backup...');
2250
+ // --unpin: remove .pinned marker from the latest (or specified) backup
2251
+ const unpinIndex = args.indexOf('--unpin');
2252
+ if (unpinIndex !== -1) {
2253
+ const backupRoot = join(LDM_ROOT, 'backups');
2254
+ let dirs = [];
2255
+ try {
2256
+ dirs = readdirSync(backupRoot)
2257
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2258
+ .sort()
2259
+ .reverse();
2260
+ } catch {}
2261
+ // Find first pinned backup
2262
+ let unpinned = false;
2263
+ for (const d of dirs) {
2264
+ const pinFile = join(backupRoot, d, '.pinned');
2265
+ if (existsSync(pinFile)) {
2266
+ unlinkSync(pinFile);
2267
+ console.log(` - Unpinned backup ${d}`);
2268
+ unpinned = true;
2269
+ break;
2270
+ }
2271
+ }
2272
+ if (!unpinned) {
2273
+ console.log(' No pinned backups found.');
2274
+ }
2275
+ return;
2276
+ }
2277
+
2278
+ // --list: show existing backups with pinned status
2279
+ const LIST_FLAG = args.includes('--list');
2280
+ if (LIST_FLAG) {
2281
+ const backupRoot = join(LDM_ROOT, 'backups');
2282
+ let dirs = [];
2283
+ try {
2284
+ dirs = readdirSync(backupRoot)
2285
+ .filter(d => d.match(/^20\d\d-\d\d-\d\d--/))
2286
+ .sort()
2287
+ .reverse();
2288
+ } catch {}
2289
+ if (dirs.length === 0) {
2290
+ console.log(' No backups found.');
2291
+ return;
2292
+ }
2293
+ console.log('');
2294
+ console.log(' Backups:');
2295
+ for (const d of dirs) {
2296
+ const pinFile = join(backupRoot, d, '.pinned');
2297
+ const pinned = existsSync(pinFile);
2298
+ let size = '?';
2299
+ try {
2300
+ size = execSync(`du -sh "${join(backupRoot, d)}" | cut -f1`, { encoding: 'utf8', timeout: 10000 }).trim();
2301
+ } catch {}
2302
+ const marker = pinned ? ' [pinned]' : '';
2303
+ console.log(` ${d} ${size}${marker}`);
2304
+ }
2305
+ console.log('');
2306
+ return;
2307
+ }
2308
+
2309
+ if (FULL_FLAG) {
2310
+ console.log(' Running full backup...');
2311
+ } else {
2312
+ console.log(' Running backup...');
2313
+ }
1908
2314
  console.log('');
1909
2315
  try {
1910
2316
  execSync(`bash "${BACKUP_SCRIPT}" ${backupArgs.join(' ')}`, {
@@ -2449,6 +2855,10 @@ async function main() {
2449
2855
  console.log(' ldm msg broadcast <body> Send to all sessions');
2450
2856
  console.log(' ldm stack list Show available stacks');
2451
2857
  console.log(' ldm stack install <name> Install a stack (core, web, all)');
2858
+ console.log(' ldm backup Run a full backup now');
2859
+ console.log(' ldm backup --dry-run Preview what would be backed up (with sizes)');
2860
+ console.log(' ldm backup --keep N Keep last N backups (default: 7)');
2861
+ console.log(' ldm backup --pin "reason" Pin latest backup so rotation skips it');
2452
2862
  console.log(' ldm updates Show available updates from cache');
2453
2863
  console.log(' ldm updates --check Re-check npm registry for updates');
2454
2864
  console.log('');
package/catalog.json CHANGED
@@ -116,7 +116,8 @@
116
116
  "wip-license-guard",
117
117
  "wip-repo-init",
118
118
  "wip-readme-format",
119
- "wip-branch-guard"
119
+ "wip-branch-guard",
120
+ "universal-installer"
120
121
  ],
121
122
  "cliMatches": [
122
123
  "wip-release",
@@ -254,6 +255,8 @@
254
255
  ],
255
256
  "recommended": false,
256
257
  "status": "stable",
258
+ "pinned": true,
259
+ "pinnedReason": "OpenClaw is the runtime. Upgrades overwrite dist patches and can change API behavior. Use: ldm upgrade openclaw",
257
260
  "postInstall": null,
258
261
  "installs": {
259
262
  "cli": [
@@ -106,7 +106,7 @@ async function sendMessage(openclawDir, message, options) {
106
106
  "Content-Type": "application/json"
107
107
  },
108
108
  body: JSON.stringify({
109
- model: agentId,
109
+ model: `openclaw/${agentId}`,
110
110
  user: "main",
111
111
  messages: [
112
112
  {
@@ -8,7 +8,7 @@ import {
8
8
  searchConversations,
9
9
  searchWorkspace,
10
10
  sendMessage
11
- } from "./chunk-I5FNBIR2.js";
11
+ } from "./chunk-QZ4DNVJM.js";
12
12
 
13
13
  // cli.ts
14
14
  import { existsSync, statSync } from "fs";
@@ -17,7 +17,7 @@ import {
17
17
  searchConversations,
18
18
  searchWorkspace,
19
19
  sendMessage
20
- } from "./chunk-I5FNBIR2.js";
20
+ } from "./chunk-QZ4DNVJM.js";
21
21
  export {
22
22
  LDM_ROOT,
23
23
  blobToEmbedding,
@@ -9,7 +9,7 @@ import {
9
9
  searchConversations,
10
10
  searchWorkspace,
11
11
  sendMessage
12
- } from "./chunk-I5FNBIR2.js";
12
+ } from "./chunk-QZ4DNVJM.js";
13
13
 
14
14
  // mcp-server.ts
15
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.61",
3
+ "version": "0.4.63",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  # ldm-backup.sh — Unified backup for LDM OS
3
- # Backs up: ~/.ldm/, ~/.openclaw/, ~/.claude/, ~/wipcomputerinc/
3
+ # Backs up: ~/.ldm/, ~/.openclaw/, ~/.claude/, $WORKSPACE/
4
4
  # Handles SQLite safely (sqlite3 .backup). Tars to iCloud for offsite.
5
5
  #
6
6
  # Source of truth: wip-ldm-os-private/scripts/ldm-backup.sh
@@ -12,7 +12,7 @@
12
12
  # ldm-backup.sh --keep 14 # keep last 14 backups (default: 7)
13
13
  # ldm-backup.sh --include-secrets # include ~/.ldm/secrets/
14
14
  #
15
- # Config: ~/.ldm/config.json (workspace path) + {workspace}/settings/config.json (backup settings)
15
+ # Config: ~/.ldm/config.json (workspace path, backup settings, iCloud path)
16
16
 
17
17
  set -euo pipefail
18
18
 
@@ -45,20 +45,29 @@ if [ -z "$WORKSPACE" ]; then
45
45
  echo "WARNING: No workspace in ~/.ldm/config.json. Skipping workspace backup."
46
46
  fi
47
47
 
48
- # Read iCloud backup path from workspace config
48
+ # Read org name from config (used for tar filename)
49
+ ORG=""
50
+ if [ -f "$LDM_HOME/config.json" ]; then
51
+ ORG=$(python3 -c "import json; print(json.load(open('$LDM_HOME/config.json')).get('org',''))" 2>/dev/null || true)
52
+ fi
53
+ if [ -z "$ORG" ]; then
54
+ ORG="workspace"
55
+ fi
56
+
57
+ # Read iCloud backup path from ~/.ldm/config.json
49
58
  ICLOUD_BACKUP=""
50
- if [ -n "$WORKSPACE" ] && [ -f "$WORKSPACE/settings/config.json" ]; then
59
+ if [ -f "$LDM_HOME/config.json" ]; then
51
60
  ICLOUD_BACKUP=$(python3 -c "
52
61
  import json, os
53
- c = json.load(open('$WORKSPACE/settings/config.json'))
62
+ c = json.load(open('$LDM_HOME/config.json'))
54
63
  p = c.get('paths',{}).get('icloudBackup','')
55
64
  print(os.path.expanduser(p))
56
65
  " 2>/dev/null || true)
57
66
  fi
58
67
 
59
- # Read keep from workspace config (override if set there)
60
- if [ -n "$WORKSPACE" ] && [ -f "$WORKSPACE/settings/config.json" ]; then
61
- CONFIG_KEEP=$(python3 -c "import json; print(json.load(open('$WORKSPACE/settings/config.json')).get('backup',{}).get('keep',0))" 2>/dev/null || true)
68
+ # Read keep from ~/.ldm/config.json (override if set there)
69
+ if [ -f "$LDM_HOME/config.json" ]; then
70
+ CONFIG_KEEP=$(python3 -c "import json; print(json.load(open('$LDM_HOME/config.json')).get('backup',{}).get('keep',0))" 2>/dev/null || true)
62
71
  if [ -n "$CONFIG_KEEP" ] && [ "$CONFIG_KEEP" -gt 0 ] 2>/dev/null; then
63
72
  KEEP="$CONFIG_KEEP"
64
73
  fi
@@ -81,14 +90,23 @@ if [ "$DRY_RUN" = true ]; then
81
90
  echo " ~/.ldm/state/ (cp -a)"
82
91
  echo " ~/.ldm/config.json (cp)"
83
92
  [ -f "$OC_HOME/memory/main.sqlite" ] && echo " ~/.openclaw/memory/main.sqlite (sqlite3 .backup) [$(du -sh "$OC_HOME/memory/main.sqlite" | cut -f1)]"
84
- [ -f "$OC_HOME/memory/context-embeddings.sqlite" ] && echo " ~/.openclaw/memory/context-embeddings.sqlite (sqlite3 .backup)"
85
- [ -d "$OC_HOME/workspace" ] && echo " ~/.openclaw/workspace/ (tar)"
86
- [ -d "$OC_HOME/agents/main/sessions" ] && echo " ~/.openclaw/agents/main/sessions/ (tar)"
93
+ [ -f "$OC_HOME/memory/context-embeddings.sqlite" ] && echo " ~/.openclaw/memory/context-embeddings.sqlite (sqlite3 .backup) [$(du -sh "$OC_HOME/memory/context-embeddings.sqlite" | cut -f1)]"
94
+ [ -d "$OC_HOME/workspace" ] && echo " ~/.openclaw/workspace/ (tar) [$(du -sh "$OC_HOME/workspace" | cut -f1)]"
95
+ [ -d "$OC_HOME/agents/main/sessions" ] && echo " ~/.openclaw/agents/main/sessions/ (tar) [$(du -sh "$OC_HOME/agents/main/sessions" | cut -f1)]"
87
96
  [ -f "$OC_HOME/openclaw.json" ] && echo " ~/.openclaw/openclaw.json (cp)"
88
97
  [ -f "$CLAUDE_HOME/CLAUDE.md" ] && echo " ~/.claude/CLAUDE.md (cp)"
89
98
  [ -f "$CLAUDE_HOME/settings.json" ] && echo " ~/.claude/settings.json (cp)"
90
- [ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar)"
91
- [ -n "$WORKSPACE" ] && echo " $WORKSPACE/ (tar, excludes node_modules/.git/objects)"
99
+ [ -d "$CLAUDE_HOME/projects" ] && echo " ~/.claude/projects/ (tar) [$(du -sh "$CLAUDE_HOME/projects" | cut -f1)]"
100
+ if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
101
+ # macOS du uses -I for exclusions (not --exclude)
102
+ WS_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "?")
103
+ WS_MB=$((WS_KB / 1024))
104
+ echo " $WORKSPACE/ (tar, excludes node_modules/.git/objects/_temp/_trash)"
105
+ echo " estimated size: ${WS_MB}MB (${WS_KB}KB)"
106
+ if [ "$WS_KB" -gt 10000000 ] 2>/dev/null; then
107
+ echo " WARNING: exceeds 10GB limit. Backup would abort."
108
+ fi
109
+ fi
92
110
  [ "$INCLUDE_SECRETS" = true ] && echo " ~/.ldm/secrets/ (cp -a)"
93
111
  echo ""
94
112
  echo "[DRY RUN] No files modified."
@@ -195,13 +213,14 @@ if [ -n "$WORKSPACE" ] && [ -d "$WORKSPACE" ]; then
195
213
  echo "--- $WORKSPACE/ ---"
196
214
 
197
215
  # Size guard: estimate workspace size before tarring
198
- ESTIMATED_KB=$(du -sk --exclude="node_modules" --exclude=".git" --exclude="_temp/_archive" --exclude="_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "0")
216
+ # macOS du uses -I for exclusions (not --exclude)
217
+ ESTIMATED_KB=$(du -sk -I "node_modules" -I ".git" -I "_temp" -I "_trash" "$WORKSPACE" 2>/dev/null | cut -f1 || echo "0")
199
218
  MAX_KB=10000000 # 10GB
200
219
  if [ "$ESTIMATED_KB" -gt "$MAX_KB" ] 2>/dev/null; then
201
220
  echo " ERROR: Workspace estimated at ${ESTIMATED_KB}KB (>10GB). Aborting tar to prevent disk fill."
202
221
  echo " Check for large directories: du -sh $WORKSPACE/*/"
203
222
  else
204
- tar -cf "$DEST/wipcomputerinc.tar" \
223
+ tar -cf "$DEST/$ORG.tar" \
205
224
  --exclude "node_modules" \
206
225
  --exclude ".git/objects" \
207
226
  --exclude ".DS_Store" \
@@ -0,0 +1,41 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>ai.openclaw.gateway</string>
7
+ <key>Comment</key>
8
+ <string>OpenClaw Gateway (managed by ldm install)</string>
9
+ <key>RunAtLoad</key>
10
+ <true/>
11
+ <key>KeepAlive</key>
12
+ <true/>
13
+ <key>ProgramArguments</key>
14
+ <array>
15
+ <string>/opt/homebrew/bin/node</string>
16
+ <string>/opt/homebrew/lib/node_modules/openclaw/dist/index.js</string>
17
+ <string>gateway</string>
18
+ <string>--port</string>
19
+ <string>18789</string>
20
+ </array>
21
+ <key>StandardOutPath</key>
22
+ <string>{{HOME}}/.openclaw/logs/gateway.log</string>
23
+ <key>StandardErrorPath</key>
24
+ <string>{{HOME}}/.openclaw/logs/gateway.err.log</string>
25
+ <key>EnvironmentVariables</key>
26
+ <dict>
27
+ <key>HOME</key>
28
+ <string>{{HOME}}</string>
29
+ <key>PATH</key>
30
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
31
+ <key>OPENCLAW_GATEWAY_PORT</key>
32
+ <string>18789</string>
33
+ <key>OPENCLAW_LAUNCHD_LABEL</key>
34
+ <string>ai.openclaw.gateway</string>
35
+ <key>OPENCLAW_GATEWAY_TOKEN</key>
36
+ <string>{{OPENCLAW_GATEWAY_TOKEN}}</string>
37
+ <key>NODE_OPTIONS</key>
38
+ <string>--max-old-space-size=8192</string>
39
+ </dict>
40
+ </dict>
41
+ </plist>
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>ai.openclaw.healthcheck</string>
7
+ <key>ProgramArguments</key>
8
+ <array>
9
+ <string>/opt/homebrew/bin/node</string>
10
+ <string>{{HOME}}/.openclaw/wip-healthcheck/healthcheck.mjs</string>
11
+ </array>
12
+ <key>StartInterval</key>
13
+ <integer>180</integer>
14
+ <key>StandardOutPath</key>
15
+ <string>{{HOME}}/.ldm/logs/healthcheck-stdout.log</string>
16
+ <key>StandardErrorPath</key>
17
+ <string>{{HOME}}/.ldm/logs/healthcheck-stderr.log</string>
18
+ <key>EnvironmentVariables</key>
19
+ <dict>
20
+ <key>HOME</key>
21
+ <string>{{HOME}}</string>
22
+ <key>PATH</key>
23
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
24
+ </dict>
25
+ <key>RunAtLoad</key>
26
+ <true/>
27
+ </dict>
28
+ </plist>
@@ -7,7 +7,7 @@
7
7
  <key>ProgramArguments</key>
8
8
  <array>
9
9
  <string>bash</string>
10
- <string>/Users/lesa/.ldm/bin/ldm-backup.sh</string>
10
+ <string>{{HOME}}/.ldm/bin/ldm-backup.sh</string>
11
11
  </array>
12
12
  <key>StartCalendarInterval</key>
13
13
  <dict>
@@ -17,13 +17,13 @@
17
17
  <integer>0</integer>
18
18
  </dict>
19
19
  <key>StandardOutPath</key>
20
- <string>/Users/lesa/.ldm/logs/backup.log</string>
20
+ <string>{{HOME}}/.ldm/logs/backup.log</string>
21
21
  <key>StandardErrorPath</key>
22
- <string>/Users/lesa/.ldm/logs/backup.log</string>
22
+ <string>{{HOME}}/.ldm/logs/backup.log</string>
23
23
  <key>EnvironmentVariables</key>
24
24
  <dict>
25
25
  <key>HOME</key>
26
- <string>/Users/lesa</string>
26
+ <string>{{HOME}}</string>
27
27
  <key>PATH</key>
28
28
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
29
29
  </dict>
@@ -198,7 +198,7 @@ export async function sendMessage(
198
198
  "Content-Type": "application/json",
199
199
  },
200
200
  body: JSON.stringify({
201
- model: agentId,
201
+ model: `openclaw/${agentId}`,
202
202
  user: "main",
203
203
  messages: [
204
204
  {