@phnx-labs/agents-cli 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +143 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +793 -83
  7. package/dist/commands/cloud.js +8 -0
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +70 -1
  11. package/dist/commands/hooks.js +71 -26
  12. package/dist/commands/mcp.js +81 -39
  13. package/dist/commands/plugins.js +224 -17
  14. package/dist/commands/prune.js +29 -1
  15. package/dist/commands/pull.js +3 -3
  16. package/dist/commands/repo.js +1 -1
  17. package/dist/commands/routines.js +2 -2
  18. package/dist/commands/secrets.js +154 -20
  19. package/dist/commands/sessions.js +62 -19
  20. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  21. package/dist/commands/{init.js → setup.js} +22 -21
  22. package/dist/commands/skills.js +60 -19
  23. package/dist/commands/subagents.js +41 -13
  24. package/dist/commands/utils.d.ts +16 -0
  25. package/dist/commands/utils.js +32 -0
  26. package/dist/commands/view.js +78 -20
  27. package/dist/commands/workflows.d.ts +10 -0
  28. package/dist/commands/workflows.js +457 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +48 -36
  31. package/dist/lib/agents.js +2 -2
  32. package/dist/lib/auto-pull-worker.js +2 -3
  33. package/dist/lib/auto-pull.js +2 -2
  34. package/dist/lib/browser/cdp.d.ts +7 -1
  35. package/dist/lib/browser/cdp.js +32 -1
  36. package/dist/lib/browser/chrome.d.ts +10 -0
  37. package/dist/lib/browser/chrome.js +41 -3
  38. package/dist/lib/browser/devices.d.ts +4 -0
  39. package/dist/lib/browser/devices.js +27 -0
  40. package/dist/lib/browser/drivers/local.js +22 -6
  41. package/dist/lib/browser/drivers/ssh.js +9 -2
  42. package/dist/lib/browser/input.d.ts +1 -0
  43. package/dist/lib/browser/input.js +3 -0
  44. package/dist/lib/browser/ipc.js +158 -23
  45. package/dist/lib/browser/profiles.d.ts +10 -2
  46. package/dist/lib/browser/profiles.js +122 -37
  47. package/dist/lib/browser/service.d.ts +91 -13
  48. package/dist/lib/browser/service.js +767 -132
  49. package/dist/lib/browser/types.d.ts +91 -3
  50. package/dist/lib/browser/types.js +16 -0
  51. package/dist/lib/cloud/rush.d.ts +28 -1
  52. package/dist/lib/cloud/rush.js +69 -14
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -15
  55. package/dist/lib/commands.js +11 -7
  56. package/dist/lib/daemon.js +2 -3
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.js +2 -2
  59. package/dist/lib/hooks.d.ts +11 -7
  60. package/dist/lib/hooks.js +138 -49
  61. package/dist/lib/migrate.d.ts +1 -1
  62. package/dist/lib/migrate.js +1237 -22
  63. package/dist/lib/models.js +2 -2
  64. package/dist/lib/permissions.d.ts +8 -66
  65. package/dist/lib/permissions.js +18 -18
  66. package/dist/lib/plugins.d.ts +94 -24
  67. package/dist/lib/plugins.js +702 -123
  68. package/dist/lib/pty-server.js +9 -10
  69. package/dist/lib/resource-patterns.d.ts +41 -0
  70. package/dist/lib/resource-patterns.js +82 -0
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/resources/index.d.ts +17 -0
  74. package/dist/lib/resources/index.js +7 -0
  75. package/dist/lib/resources/types.d.ts +1 -1
  76. package/dist/lib/resources/workflows.d.ts +24 -0
  77. package/dist/lib/resources/workflows.js +110 -0
  78. package/dist/lib/resources.d.ts +6 -1
  79. package/dist/lib/resources.js +12 -2
  80. package/dist/lib/rotate.js +3 -4
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +18 -0
  85. package/dist/lib/session/db.js +109 -5
  86. package/dist/lib/session/discover.d.ts +6 -0
  87. package/dist/lib/session/discover.js +55 -29
  88. package/dist/lib/session/team-filter.js +2 -2
  89. package/dist/lib/shims.d.ts +4 -52
  90. package/dist/lib/shims.js +23 -15
  91. package/dist/lib/skills.js +6 -2
  92. package/dist/lib/sqlite.js +10 -4
  93. package/dist/lib/state.d.ts +101 -16
  94. package/dist/lib/state.js +179 -31
  95. package/dist/lib/subagents.d.ts +28 -0
  96. package/dist/lib/subagents.js +98 -1
  97. package/dist/lib/sync-manifest.d.ts +1 -1
  98. package/dist/lib/sync-manifest.js +3 -3
  99. package/dist/lib/teams/persistence.js +15 -5
  100. package/dist/lib/teams/registry.js +2 -2
  101. package/dist/lib/types.d.ts +75 -17
  102. package/dist/lib/types.js +3 -3
  103. package/dist/lib/usage.js +2 -2
  104. package/dist/lib/versions.d.ts +3 -0
  105. package/dist/lib/versions.js +158 -47
  106. package/dist/lib/workflows.d.ts +79 -0
  107. package/dist/lib/workflows.js +233 -0
  108. package/package.json +1 -5
  109. package/scripts/postinstall.js +60 -59
  110. package/dist/commands/fork.d.ts +0 -10
  111. package/dist/commands/fork.js +0 -146
@@ -7,9 +7,12 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
- const HOME = os.homedir();
10
+ import * as yaml from 'yaml';
11
+ const HOME = process.env.HOME ?? os.homedir();
11
12
  const SYSTEM_DIR = path.join(HOME, '.agents-system');
12
13
  const USER_DIR = path.join(HOME, '.agents');
14
+ const HISTORY_DIR = path.join(USER_DIR, '.history');
15
+ const CACHE_DIR = path.join(USER_DIR, '.cache');
13
16
  /**
14
17
  * Move ~/.agents-system/agents.yaml -> ~/.agents/agents.yaml.
15
18
  * No-op if user file already exists or system file absent.
@@ -19,12 +22,22 @@ const USER_DIR = path.join(HOME, '.agents');
19
22
  function migrateAgentsYaml() {
20
23
  const src = path.join(SYSTEM_DIR, 'agents.yaml');
21
24
  const dest = path.join(USER_DIR, 'agents.yaml');
22
- if (fs.existsSync(dest) || !fs.existsSync(src))
25
+ if (!fs.existsSync(src))
26
+ return;
27
+ if (fs.existsSync(dest)) {
28
+ // User copy is authoritative — drop the stale system leftover. The system
29
+ // repo (npm-shipped) does not track agents.yaml; any copy here is residue
30
+ // from the pre-split layout.
31
+ try {
32
+ fs.unlinkSync(src);
33
+ }
34
+ catch { /* best-effort */ }
23
35
  return;
36
+ }
24
37
  try {
25
38
  fs.mkdirSync(USER_DIR, { recursive: true, mode: 0o700 });
26
39
  fs.renameSync(src, dest);
27
- console.log('Migrated agents.yaml to ~/.agents/');
40
+ console.error('Migrated agents.yaml to ~/.agents/');
28
41
  }
29
42
  catch { /* best-effort */ }
30
43
  }
@@ -77,28 +90,32 @@ function migratePromptcutsIntoHooks() {
77
90
  }
78
91
  }
79
92
  /**
80
- * Move installed agent versions from the legacy single-root layout
81
- * (~/.agents/versions/<agent>/<ver>/) into the system root
82
- * (~/.agents-system/versions/<agent>/<ver>/).
93
+ * Move installed agent versions from the legacy system-root layout
94
+ * (~/.agents-system/versions/<agent>/<ver>/) into the user root
95
+ * (~/.agents/versions/<agent>/<ver>/).
83
96
  *
84
- * Pre-split installs put binaries and home dirs under ~/.agents/. After the
85
- * split, the system code only scans ~/.agents-system/versions/, so without
86
- * this migration the versions become invisible to listInstalledVersions and
87
- * every command that depends on it (view, prune, run).
97
+ * Earlier installs (and an inverted prior version of this migrator) put
98
+ * binaries and home dirs under ~/.agents-system/. The current architecture
99
+ * keeps all operational state (versions, sessions, shims, trash) under
100
+ * ~/.agents/, and getVersionsDir() in state.ts resolves there. Without this
101
+ * migration the legacy versions become invisible to listInstalledVersions
102
+ * and every command that depends on it (view, prune, run, sync) writes a
103
+ * second copy to ~/.agents/versions/ while the agent CLIs keep reading the
104
+ * stale ~/.agents-system/versions/ copy via the existing ~/.<agent> symlink.
88
105
  *
89
106
  * Idempotent and non-destructive: if a same-named dest already exists we
90
107
  * leave the legacy copy in place so the user can reconcile manually.
91
108
  */
92
- function migrateUserVersionsToSystem() {
93
- const userVersions = path.join(USER_DIR, 'versions');
109
+ function migrateSystemVersionsToUser() {
94
110
  const sysVersions = path.join(SYSTEM_DIR, 'versions');
95
- if (!fs.existsSync(userVersions))
111
+ const userVersions = path.join(USER_DIR, 'versions');
112
+ if (!fs.existsSync(sysVersions))
96
113
  return;
97
114
  let movedCount = 0;
98
115
  let skippedCount = 0;
99
116
  let agentEntries;
100
117
  try {
101
- agentEntries = fs.readdirSync(userVersions, { withFileTypes: true });
118
+ agentEntries = fs.readdirSync(sysVersions, { withFileTypes: true });
102
119
  }
103
120
  catch {
104
121
  return;
@@ -106,8 +123,8 @@ function migrateUserVersionsToSystem() {
106
123
  for (const agent of agentEntries) {
107
124
  if (!agent.isDirectory())
108
125
  continue;
109
- const srcAgentDir = path.join(userVersions, agent.name);
110
- const dstAgentDir = path.join(sysVersions, agent.name);
126
+ const srcAgentDir = path.join(sysVersions, agent.name);
127
+ const dstAgentDir = path.join(userVersions, agent.name);
111
128
  try {
112
129
  fs.mkdirSync(dstAgentDir, { recursive: true, mode: 0o700 });
113
130
  }
@@ -141,15 +158,15 @@ function migrateUserVersionsToSystem() {
141
158
  catch { /* best-effort */ }
142
159
  }
143
160
  try {
144
- if (fs.readdirSync(userVersions).length === 0)
145
- fs.rmdirSync(userVersions);
161
+ if (fs.readdirSync(sysVersions).length === 0)
162
+ fs.rmdirSync(sysVersions);
146
163
  }
147
164
  catch { /* best-effort */ }
148
165
  if (movedCount > 0) {
149
- console.log(`Migrated ${movedCount} version dir${movedCount === 1 ? '' : 's'} from ~/.agents/versions/ to ~/.agents-system/versions/`);
166
+ console.error(`Migrated ${movedCount} version dir${movedCount === 1 ? '' : 's'} from ~/.agents-system/versions/ to ~/.agents/versions/`);
150
167
  }
151
168
  if (skippedCount > 0) {
152
- console.log(`Skipped ${skippedCount} version dir${skippedCount === 1 ? '' : 's'} already present in ~/.agents-system/versions/ (kept legacy copy at ~/.agents/versions/)`);
169
+ console.error(`Skipped ${skippedCount} version dir${skippedCount === 1 ? '' : 's'} already present in ~/.agents/versions/ (kept legacy copy at ~/.agents-system/versions/)`);
153
170
  }
154
171
  }
155
172
  /**
@@ -195,14 +212,1212 @@ function migrateBackupsToHidden() {
195
212
  }
196
213
  catch { /* best-effort */ }
197
214
  }
215
+ /**
216
+ * Fold ~/.agents/hooks.yaml into ~/.agents/agents.yaml under a `hooks:` key,
217
+ * then delete the standalone hooks.yaml. Single user file to sync.
218
+ *
219
+ * On collision (a hook name already exists in agents.yaml hooks:), the
220
+ * existing agents.yaml entry wins and the standalone copy is dropped — this
221
+ * matches the behavior a user would get if they had already migrated
222
+ * manually and edited agents.yaml.
223
+ *
224
+ * Idempotent: skips if hooks.yaml is absent or unparseable.
225
+ */
226
+ function foldUserHooksYamlIntoAgentsYaml() {
227
+ const hooksFile = path.join(USER_DIR, 'hooks.yaml');
228
+ if (!fs.existsSync(hooksFile))
229
+ return;
230
+ let hooks;
231
+ try {
232
+ const raw = fs.readFileSync(hooksFile, 'utf-8');
233
+ const parsed = yaml.parse(raw);
234
+ hooks = parsed && typeof parsed === 'object' ? parsed : {};
235
+ }
236
+ catch {
237
+ return;
238
+ }
239
+ const metaFile = path.join(USER_DIR, 'agents.yaml');
240
+ let meta = {};
241
+ if (fs.existsSync(metaFile)) {
242
+ try {
243
+ const raw = fs.readFileSync(metaFile, 'utf-8');
244
+ const parsed = yaml.parse(raw);
245
+ if (parsed && typeof parsed === 'object')
246
+ meta = parsed;
247
+ }
248
+ catch {
249
+ return;
250
+ }
251
+ }
252
+ const existingHooks = meta.hooks ?? {};
253
+ const merged = { ...hooks, ...existingHooks };
254
+ meta.hooks = merged;
255
+ const header = `# agents-cli metadata
256
+ # Auto-generated - do not edit manually
257
+ # https://github.com/phnx-labs/agents-cli
258
+
259
+ `;
260
+ try {
261
+ fs.mkdirSync(USER_DIR, { recursive: true, mode: 0o700 });
262
+ fs.writeFileSync(metaFile, header + yaml.stringify(meta), 'utf-8');
263
+ fs.unlinkSync(hooksFile);
264
+ console.error('Folded ~/.agents/hooks.yaml into ~/.agents/agents.yaml (hooks: section)');
265
+ }
266
+ catch { /* best-effort */ }
267
+ }
268
+ /**
269
+ * Fold ~/.agents/browser/profiles/*.yaml into ~/.agents/agents.yaml under a
270
+ * `browser:` key, then delete the profiles directory. Single user file to sync.
271
+ *
272
+ * On collision (a profile name already exists in agents.yaml browser:), the
273
+ * existing agents.yaml entry wins and the standalone copy is dropped.
274
+ *
275
+ * Idempotent: skips if profiles dir is absent or empty.
276
+ */
277
+ function foldBrowserProfilesIntoAgentsYaml() {
278
+ const profilesDir = path.join(USER_DIR, 'browser', 'profiles');
279
+ if (!fs.existsSync(profilesDir))
280
+ return;
281
+ let files;
282
+ try {
283
+ files = fs.readdirSync(profilesDir).filter((f) => f.endsWith('.yaml'));
284
+ }
285
+ catch {
286
+ return;
287
+ }
288
+ if (files.length === 0)
289
+ return;
290
+ const profiles = {};
291
+ for (const file of files) {
292
+ try {
293
+ const raw = fs.readFileSync(path.join(profilesDir, file), 'utf-8');
294
+ const parsed = yaml.parse(raw);
295
+ if (!parsed || typeof parsed !== 'object')
296
+ continue;
297
+ const name = parsed.name || file.replace(/\.yaml$/, '');
298
+ const { name: _, ...config } = parsed;
299
+ profiles[name] = config;
300
+ }
301
+ catch { /* skip unreadable file */ }
302
+ }
303
+ if (Object.keys(profiles).length === 0)
304
+ return;
305
+ const metaFile = path.join(USER_DIR, 'agents.yaml');
306
+ let meta = {};
307
+ if (fs.existsSync(metaFile)) {
308
+ try {
309
+ const raw = fs.readFileSync(metaFile, 'utf-8');
310
+ const parsed = yaml.parse(raw);
311
+ if (parsed && typeof parsed === 'object')
312
+ meta = parsed;
313
+ }
314
+ catch {
315
+ return;
316
+ }
317
+ }
318
+ const existingBrowser = meta.browser ?? {};
319
+ const merged = { ...profiles, ...existingBrowser };
320
+ meta.browser = merged;
321
+ const header = `# agents-cli metadata
322
+ # Auto-generated - do not edit manually
323
+ # https://github.com/phnx-labs/agents-cli
324
+
325
+ `;
326
+ try {
327
+ fs.mkdirSync(USER_DIR, { recursive: true, mode: 0o700 });
328
+ fs.writeFileSync(metaFile, header + yaml.stringify(meta), 'utf-8');
329
+ for (const file of files) {
330
+ try {
331
+ fs.unlinkSync(path.join(profilesDir, file));
332
+ }
333
+ catch { /* best-effort */ }
334
+ }
335
+ try {
336
+ fs.rmdirSync(profilesDir);
337
+ }
338
+ catch { /* may not be empty */ }
339
+ try {
340
+ const browserDir = path.join(USER_DIR, 'browser');
341
+ if (fs.existsSync(browserDir) && fs.readdirSync(browserDir).length === 0) {
342
+ fs.rmdirSync(browserDir);
343
+ }
344
+ }
345
+ catch { /* best-effort */ }
346
+ console.error('Folded ~/.agents/browser/profiles/ into ~/.agents/agents.yaml (browser: section)');
347
+ }
348
+ catch { /* best-effort */ }
349
+ }
350
+ /**
351
+ * Delete ~/.agents/linear.json. The linear-cli now manages its own
352
+ * credentials in the OS keychain; this file was a legacy plaintext store.
353
+ */
354
+ function deleteUserLinearJson() {
355
+ const f = path.join(USER_DIR, 'linear.json');
356
+ if (!fs.existsSync(f))
357
+ return;
358
+ try {
359
+ fs.unlinkSync(f);
360
+ }
361
+ catch { /* best-effort */ }
362
+ }
363
+ /**
364
+ * Delete ~/.agents/prompts.json. Dead file with zero refs in src/ (the
365
+ * system-repo copy was cleared by deleteSystemPromptsJson; this is the
366
+ * matching cleanup at the user layer).
367
+ */
368
+ function deleteUserPromptsJson() {
369
+ const f = path.join(USER_DIR, 'prompts.json');
370
+ if (!fs.existsSync(f))
371
+ return;
372
+ try {
373
+ fs.unlinkSync(f);
374
+ }
375
+ catch { /* best-effort */ }
376
+ }
377
+ /**
378
+ * Delete ~/.agents/config.json. The canonical teams config is at
379
+ * ~/.agents/teams/config.json (teams/persistence.ts). If the canonical
380
+ * file exists we just unlink the legacy copy; otherwise migrate first.
381
+ */
382
+ function cleanupUserConfigJson() {
383
+ const legacy = path.join(USER_DIR, 'config.json');
384
+ if (!fs.existsSync(legacy))
385
+ return;
386
+ const canonical = path.join(USER_DIR, 'teams', 'config.json');
387
+ try {
388
+ if (!fs.existsSync(canonical)) {
389
+ fs.mkdirSync(path.dirname(canonical), { recursive: true, mode: 0o700 });
390
+ fs.copyFileSync(legacy, canonical);
391
+ }
392
+ fs.unlinkSync(legacy);
393
+ }
394
+ catch { /* best-effort */ }
395
+ }
396
+ /**
397
+ * Remove an empty ~/.agents/runs/ directory left over after the
398
+ * migrateRunsIntoRoutines() rename. Some older code paths re-created the
399
+ * empty parent; this trims it once it has no contents.
400
+ */
401
+ function cleanupEmptyTopLevelRuns() {
402
+ const dir = path.join(USER_DIR, 'runs');
403
+ if (!fs.existsSync(dir))
404
+ return;
405
+ try {
406
+ if (fs.readdirSync(dir).length === 0)
407
+ fs.rmdirSync(dir);
408
+ }
409
+ catch { /* best-effort */ }
410
+ }
411
+ /**
412
+ * Move ~/.agents-system/aliases.json -> ~/.agents/aliases.json.
413
+ * Aliases are per-user state and were previously written to the system root
414
+ * by mistake. Idempotent: skips if dest already exists or src absent.
415
+ */
416
+ function migrateAliasesToUser() {
417
+ const src = path.join(SYSTEM_DIR, 'aliases.json');
418
+ const dest = path.join(USER_DIR, 'aliases.json');
419
+ if (fs.existsSync(dest) || !fs.existsSync(src))
420
+ return;
421
+ try {
422
+ fs.mkdirSync(USER_DIR, { recursive: true, mode: 0o700 });
423
+ fs.renameSync(src, dest);
424
+ }
425
+ catch { /* best-effort */ }
426
+ }
427
+ /**
428
+ * For overlapping versions that exist in BOTH ~/.agents-system/versions/ and
429
+ * ~/.agents/versions/, merge legacy operational state (history, sessions,
430
+ * settings) into the user copy without overwriting any files synced by
431
+ * agents-cli (skills, commands, hooks, memory). Then drop the legacy copy.
432
+ *
433
+ * Why: when both paths exist, the inverted-prior migrator left them split.
434
+ * Agent CLIs read from ~/.<agent> (a symlink that historically targeted
435
+ * the system path), while sync writes to the user path. Same skill ends up
436
+ * in both → duplicate entries in the skills picker, stale state, broken
437
+ * resource resolution.
438
+ *
439
+ * Strategy: copy legacy → user with "skip if exists". Sync-managed dirs
440
+ * (which live in user already, freshly written) stay untouched. Anything
441
+ * the agent CLI created on its own (history.jsonl, sessions/, settings.json,
442
+ * file-history/, paste-cache/, …) lands in user where it belongs. The
443
+ * legacy version-home is then renamed into ~/.agents/.trash/versions/ so
444
+ * it's recoverable.
445
+ */
446
+ function mergeOverlappingVersionHomes() {
447
+ const sysVersions = path.join(SYSTEM_DIR, 'versions');
448
+ const userVersions = path.join(USER_DIR, 'versions');
449
+ if (!fs.existsSync(sysVersions))
450
+ return;
451
+ let mergedCount = 0;
452
+ let agentEntries;
453
+ try {
454
+ agentEntries = fs.readdirSync(sysVersions, { withFileTypes: true });
455
+ }
456
+ catch {
457
+ return;
458
+ }
459
+ for (const agent of agentEntries) {
460
+ if (!agent.isDirectory())
461
+ continue;
462
+ const sysAgentDir = path.join(sysVersions, agent.name);
463
+ const userAgentDir = path.join(userVersions, agent.name);
464
+ let verEntries;
465
+ try {
466
+ verEntries = fs.readdirSync(sysAgentDir, { withFileTypes: true });
467
+ }
468
+ catch {
469
+ continue;
470
+ }
471
+ for (const ver of verEntries) {
472
+ if (!ver.isDirectory())
473
+ continue;
474
+ const sysHome = path.join(sysAgentDir, ver.name, 'home');
475
+ const userHome = path.join(userAgentDir, ver.name, 'home');
476
+ if (!fs.existsSync(sysHome) || !fs.existsSync(userHome))
477
+ continue;
478
+ try {
479
+ copyDirSkipExisting(sysHome, userHome);
480
+ const trashRoot = path.join(USER_DIR, '.trash', 'versions', agent.name, ver.name);
481
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
482
+ fs.mkdirSync(trashRoot, { recursive: true, mode: 0o700 });
483
+ fs.renameSync(path.join(sysAgentDir, ver.name), path.join(trashRoot, `legacy-${stamp}`));
484
+ mergedCount++;
485
+ }
486
+ catch { /* best-effort */ }
487
+ }
488
+ try {
489
+ if (fs.readdirSync(sysAgentDir).length === 0)
490
+ fs.rmdirSync(sysAgentDir);
491
+ }
492
+ catch { /* best-effort */ }
493
+ }
494
+ try {
495
+ if (fs.readdirSync(sysVersions).length === 0)
496
+ fs.rmdirSync(sysVersions);
497
+ }
498
+ catch { /* best-effort */ }
499
+ if (mergedCount > 0) {
500
+ console.error(`Merged ${mergedCount} overlapping version home${mergedCount === 1 ? '' : 's'} from legacy ~/.agents-system/versions/ into ~/.agents/versions/ (legacy moved to ~/.agents/.trash/versions/)`);
501
+ }
502
+ }
503
+ function copyDirSkipExisting(src, dest) {
504
+ let entries;
505
+ try {
506
+ entries = fs.readdirSync(src, { withFileTypes: true });
507
+ }
508
+ catch {
509
+ return;
510
+ }
511
+ fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
512
+ for (const entry of entries) {
513
+ const s = path.join(src, entry.name);
514
+ const d = path.join(dest, entry.name);
515
+ if (fs.existsSync(d)) {
516
+ if (entry.isDirectory()) {
517
+ const dStat = fs.lstatSync(d);
518
+ if (dStat.isDirectory())
519
+ copyDirSkipExisting(s, d);
520
+ }
521
+ continue;
522
+ }
523
+ try {
524
+ fs.renameSync(s, d);
525
+ }
526
+ catch {
527
+ try {
528
+ if (entry.isDirectory()) {
529
+ copyDirSkipExisting(s, d);
530
+ }
531
+ else if (entry.isSymbolicLink()) {
532
+ fs.symlinkSync(fs.readlinkSync(s), d);
533
+ }
534
+ else {
535
+ fs.copyFileSync(s, d);
536
+ }
537
+ }
538
+ catch { /* best-effort */ }
539
+ }
540
+ }
541
+ }
542
+ /**
543
+ * Rename ~/.agents/permissions/sets/ -> ~/.agents/permissions/presets/.
544
+ * Also handles ~/.agents-system/permissions/sets/ for system repo.
545
+ * Idempotent: skips if dest already exists or src absent.
546
+ */
547
+ function migratePermissionSetsToPresets() {
548
+ for (const root of [USER_DIR, SYSTEM_DIR]) {
549
+ const src = path.join(root, 'permissions', 'sets');
550
+ const dest = path.join(root, 'permissions', 'presets');
551
+ if (!fs.existsSync(src) || fs.existsSync(dest))
552
+ continue;
553
+ try {
554
+ fs.renameSync(src, dest);
555
+ const label = root === USER_DIR ? '~/.agents' : '~/.agents-system';
556
+ console.error(`Migrated ${label}/permissions/sets/ to ${label}/permissions/presets/`);
557
+ }
558
+ catch { /* best-effort */ }
559
+ }
560
+ }
561
+ /**
562
+ * After versions are migrated to ~/.agents/versions/, rewrite the per-agent
563
+ * config symlinks (~/.claude, ~/.codex, …) to point at the user-side
564
+ * version-home so the agent CLIs read fresh resources.
565
+ *
566
+ * Idempotent: if the symlink already points at the right user-path target,
567
+ * leave it. If it points at the legacy system path, re-create it. If a real
568
+ * directory exists there (no symlink yet), leave it alone — version-config
569
+ * switching is owned by `agents use`, not the migrator.
570
+ */
571
+ function repairAgentConfigSymlinks() {
572
+ let yaml;
573
+ try {
574
+ yaml = fs.readFileSync(path.join(USER_DIR, 'agents.yaml'), 'utf-8');
575
+ }
576
+ catch {
577
+ return;
578
+ }
579
+ const agentsBlock = yaml.match(/^agents:\s*\n((?: [^\n]*\n)+)/m);
580
+ if (!agentsBlock)
581
+ return;
582
+ const defaults = [];
583
+ for (const line of agentsBlock[1].split('\n')) {
584
+ const m = line.match(/^\s+([a-z][a-z0-9_-]*):\s*([^\s#]+)/);
585
+ if (m)
586
+ defaults.push({ agent: m[1], version: m[2] });
587
+ }
588
+ let repaired = 0;
589
+ for (const { agent, version } of defaults) {
590
+ const userTarget = fs.existsSync(path.join(HISTORY_DIR, 'versions', agent, version, 'home', `.${agent}`))
591
+ ? path.join(HISTORY_DIR, 'versions', agent, version, 'home', `.${agent}`)
592
+ : path.join(USER_DIR, 'versions', agent, version, 'home', `.${agent}`);
593
+ if (!fs.existsSync(userTarget))
594
+ continue;
595
+ const symlinkPath = path.join(HOME, `.${agent}`);
596
+ let stat = null;
597
+ try {
598
+ stat = fs.lstatSync(symlinkPath);
599
+ }
600
+ catch { /* missing */ }
601
+ if (stat && stat.isSymbolicLink()) {
602
+ let current;
603
+ try {
604
+ current = fs.readlinkSync(symlinkPath);
605
+ }
606
+ catch {
607
+ continue;
608
+ }
609
+ const resolved = path.resolve(path.dirname(symlinkPath), current);
610
+ if (resolved === path.resolve(userTarget))
611
+ continue; // already correct
612
+ try {
613
+ fs.unlinkSync(symlinkPath);
614
+ fs.symlinkSync(userTarget, symlinkPath);
615
+ repaired++;
616
+ }
617
+ catch { /* best-effort */ }
618
+ }
619
+ else if (!stat) {
620
+ try {
621
+ fs.symlinkSync(userTarget, symlinkPath);
622
+ repaired++;
623
+ }
624
+ catch { /* best-effort */ }
625
+ }
626
+ }
627
+ if (repaired > 0) {
628
+ console.error(`Repaired ${repaired} agent config symlink${repaired === 1 ? '' : 's'} to point at ~/.agents/versions/`);
629
+ }
630
+ }
631
+ /**
632
+ * Move a directory from `src` to `dest`. No-op when src is absent. When dest
633
+ * already exists, merge by copying everything that isn't already there, then
634
+ * remove the source. Idempotent: re-running converges without duplicating.
635
+ *
636
+ * The merge-on-collision behavior matters because `ensureAgentsDir()` may have
637
+ * pre-created an empty `dest` during startup before the migrator gets to run.
638
+ */
639
+ function moveDirOnce(src, dest) {
640
+ if (!fs.existsSync(src))
641
+ return;
642
+ if (!fs.existsSync(dest)) {
643
+ try {
644
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
645
+ fs.renameSync(src, dest);
646
+ return;
647
+ }
648
+ catch {
649
+ /* fall through to copy + remove */
650
+ }
651
+ }
652
+ try {
653
+ copyDirSkipExisting(src, dest);
654
+ fs.rmSync(src, { recursive: true, force: true });
655
+ }
656
+ catch { /* best-effort */ }
657
+ }
658
+ /**
659
+ * Move a single file from `src` to `dest`. No-op when src is absent. When
660
+ * dest exists, the source is simply deleted (the in-place version is treated
661
+ * as the canonical state). Idempotent.
662
+ */
663
+ function moveFileOnce(src, dest) {
664
+ if (!fs.existsSync(src))
665
+ return;
666
+ if (fs.existsSync(dest)) {
667
+ try {
668
+ fs.unlinkSync(src);
669
+ }
670
+ catch { /* best-effort */ }
671
+ return;
672
+ }
673
+ try {
674
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
675
+ fs.renameSync(src, dest);
676
+ }
677
+ catch {
678
+ try {
679
+ fs.copyFileSync(src, dest);
680
+ fs.unlinkSync(src);
681
+ }
682
+ catch { /* best-effort */ }
683
+ }
684
+ }
685
+ /** Remove a directory tree if it exists and contains no files (best-effort). */
686
+ function rmEmptyDirTree(dir) {
687
+ if (!fs.existsSync(dir))
688
+ return;
689
+ try {
690
+ const entries = fs.readdirSync(dir);
691
+ for (const entry of entries) {
692
+ const child = path.join(dir, entry);
693
+ try {
694
+ const stat = fs.statSync(child);
695
+ if (stat.isDirectory())
696
+ rmEmptyDirTree(child);
697
+ }
698
+ catch { /* skip */ }
699
+ }
700
+ if (fs.readdirSync(dir).length === 0)
701
+ fs.rmdirSync(dir);
702
+ }
703
+ catch { /* best-effort */ }
704
+ }
705
+ /**
706
+ * Move durable runtime data into ~/.agents/.history/.
707
+ *
708
+ * Sources cleared:
709
+ * ~/.agents/sessions/ -> ~/.agents/.history/sessions/
710
+ * ~/.agents/versions/ -> ~/.agents/.history/versions/
711
+ * ~/.agents/.trash/ -> ~/.agents/.history/trash/
712
+ * ~/.agents/.backups/ -> ~/.agents/.history/backups/
713
+ * ~/.agents/routines/runs/ -> ~/.agents/.history/runs/
714
+ * ~/.agents/teams/agents/ -> ~/.agents/.history/teams/agents/
715
+ *
716
+ * Idempotent — skips entries whose destination already exists.
717
+ */
718
+ function migrateRuntimeToHistory() {
719
+ moveDirOnce(path.join(USER_DIR, 'sessions'), path.join(HISTORY_DIR, 'sessions'));
720
+ moveDirOnce(path.join(USER_DIR, 'versions'), path.join(HISTORY_DIR, 'versions'));
721
+ // Some installs left both `.trash/` (current) and `trash/` (legacy lowercase).
722
+ // Move whichever exist into the history bucket.
723
+ moveDirOnce(path.join(USER_DIR, '.trash'), path.join(HISTORY_DIR, 'trash'));
724
+ moveDirOnce(path.join(USER_DIR, 'trash'), path.join(HISTORY_DIR, 'trash'));
725
+ moveDirOnce(path.join(USER_DIR, '.backups'), path.join(HISTORY_DIR, 'backups'));
726
+ moveDirOnce(path.join(USER_DIR, 'routines', 'runs'), path.join(HISTORY_DIR, 'runs'));
727
+ moveDirOnce(path.join(USER_DIR, 'teams', 'agents'), path.join(HISTORY_DIR, 'teams', 'agents'));
728
+ // Drop any empty leftover skeletons created mid-rename (e.g. `versions/<agent>/<v>/home/`
729
+ // recreated by a concurrent process). The real data is already under .history/.
730
+ rmEmptyDirTree(path.join(USER_DIR, 'versions'));
731
+ rmEmptyDirTree(path.join(USER_DIR, 'sessions'));
732
+ // Old empty zero-byte sessions.db at user root is obsolete once the new db lives at
733
+ // .history/sessions/sessions.db.
734
+ const oldSessionsDb = path.join(USER_DIR, 'sessions.db');
735
+ if (fs.existsSync(oldSessionsDb)) {
736
+ try {
737
+ if (fs.statSync(oldSessionsDb).size === 0)
738
+ fs.unlinkSync(oldSessionsDb);
739
+ }
740
+ catch { /* best-effort */ }
741
+ }
742
+ }
743
+ /**
744
+ * Move regenerable runtime data into ~/.agents/.cache/.
745
+ *
746
+ * Sources cleared:
747
+ * ~/.agents/shims/ -> ~/.agents/.cache/shims/
748
+ * ~/.agents/bin/ -> ~/.agents/.cache/bin/
749
+ * ~/.agents/packages/ -> ~/.agents/.cache/packages/
750
+ * ~/.agents/plugins/ -> ~/.agents/.cache/plugins/
751
+ * ~/.agents/cloud/ -> ~/.agents/.cache/cloud/
752
+ * ~/.agents/drive/ -> ~/.agents/.cache/drive/
753
+ * ~/.agents/terminals/ -> ~/.agents/.cache/terminals/
754
+ * ~/.agents/logs/ -> ~/.agents/.cache/logs/
755
+ * ~/.agents/swarmify/ -> ~/.agents/.cache/swarmify/
756
+ * ~/.agents/runtime/ -> ~/.agents/.cache/state/
757
+ * ~/.agents/cache/ -> ~/.agents/.cache/ (flatten — already a cache subdir)
758
+ * ~/.agents/helpers/{daemon,pty,...} -> ~/.agents/.cache/helpers/...
759
+ * ~/.agents-system/helpers/{daemon,pty,...} -> ~/.agents/.cache/helpers/...
760
+ * ~/.agents/browser/<profile>/ -> ~/.agents/.cache/browser/<profile>/ (profiles/ dir stays)
761
+ * ~/.agents-system/.fetch/ -> ~/.agents/.cache/.fetch/
762
+ *
763
+ * Loose dot-files at user root that are runtime caches:
764
+ * ~/.agents/.cli-version-cache.json -> ~/.agents/.cache/.cli-version-cache.json
765
+ * ~/.agents/.update-check -> ~/.agents/.cache/.update-check
766
+ * ~/.agents/.migrated -> ~/.agents/.cache/.migrated
767
+ * ~/.agents/watchdog.log -> ~/.agents/.cache/logs/watchdog.log
768
+ *
769
+ * Idempotent.
770
+ */
771
+ function migrateRuntimeToCache() {
772
+ moveDirOnce(path.join(USER_DIR, 'shims'), path.join(CACHE_DIR, 'shims'));
773
+ moveDirOnce(path.join(USER_DIR, 'bin'), path.join(CACHE_DIR, 'bin'));
774
+ moveDirOnce(path.join(USER_DIR, 'packages'), path.join(CACHE_DIR, 'packages'));
775
+ moveDirOnce(path.join(USER_DIR, 'plugins'), path.join(CACHE_DIR, 'plugins'));
776
+ moveDirOnce(path.join(USER_DIR, 'cloud'), path.join(CACHE_DIR, 'cloud'));
777
+ moveDirOnce(path.join(USER_DIR, 'drive'), path.join(CACHE_DIR, 'drive'));
778
+ // terminals/ stays at the top level: the agents-cli IDE extension publishes
779
+ // ~/.agents/terminals/live-terminals.json and would race with the move on
780
+ // VS Code restart. Leave the path where the extension expects it.
781
+ moveDirOnce(path.join(USER_DIR, 'logs'), path.join(CACHE_DIR, 'logs'));
782
+ moveDirOnce(path.join(USER_DIR, 'swarmify'), path.join(CACHE_DIR, 'swarmify'));
783
+ moveDirOnce(path.join(USER_DIR, 'runtime'), path.join(CACHE_DIR, 'state'));
784
+ // Pre-existing user `cache/` dir (claude usage cache, cloud-runs, etc.) — flatten
785
+ // so it's not confused with the new bucket. Its contents merge into .cache/.
786
+ const oldCache = path.join(USER_DIR, 'cache');
787
+ if (fs.existsSync(oldCache) && fs.statSync(oldCache).isDirectory()) {
788
+ try {
789
+ fs.mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 });
790
+ copyDirSkipExisting(oldCache, CACHE_DIR);
791
+ fs.rmSync(oldCache, { recursive: true, force: true });
792
+ }
793
+ catch { /* best-effort */ }
794
+ }
795
+ // helpers/ at user-root and system-root → .cache/helpers/
796
+ for (const root of [USER_DIR, SYSTEM_DIR]) {
797
+ const src = path.join(root, 'helpers');
798
+ if (!fs.existsSync(src))
799
+ continue;
800
+ const destBase = path.join(CACHE_DIR, 'helpers');
801
+ try {
802
+ fs.mkdirSync(destBase, { recursive: true, mode: 0o700 });
803
+ copyDirSkipExisting(src, destBase);
804
+ fs.rmSync(src, { recursive: true, force: true });
805
+ }
806
+ catch { /* best-effort */ }
807
+ }
808
+ // browser runtime — keep browser/profiles/ in place, move everything else under
809
+ // browser/ into .cache/browser/.
810
+ const browserSrc = path.join(USER_DIR, 'browser');
811
+ if (fs.existsSync(browserSrc) && fs.statSync(browserSrc).isDirectory()) {
812
+ let entries = [];
813
+ try {
814
+ entries = fs.readdirSync(browserSrc);
815
+ }
816
+ catch { /* skip */ }
817
+ for (const entry of entries) {
818
+ if (entry === 'profiles')
819
+ continue;
820
+ const src = path.join(browserSrc, entry);
821
+ const dest = path.join(CACHE_DIR, 'browser', entry);
822
+ moveDirOnce(src, dest);
823
+ }
824
+ }
825
+ // System-root operational state that should not live in the npm-shipped repo.
826
+ moveDirOnce(path.join(SYSTEM_DIR, '.fetch'), path.join(CACHE_DIR, '.fetch'));
827
+ moveDirOnce(path.join(SYSTEM_DIR, 'browser'), path.join(CACHE_DIR, 'browser'));
828
+ moveDirOnce(path.join(SYSTEM_DIR, 'state'), path.join(CACHE_DIR, 'state'));
829
+ moveDirOnce(path.join(SYSTEM_DIR, 'swarmify'), path.join(CACHE_DIR, 'swarmify'));
830
+ moveFileOnce(path.join(SYSTEM_DIR, '.cli-version-cache.json'), path.join(CACHE_DIR, '.cli-version-cache.json'));
831
+ moveFileOnce(path.join(SYSTEM_DIR, '.update-check'), path.join(CACHE_DIR, '.update-check'));
832
+ moveFileOnce(path.join(SYSTEM_DIR, '.migrated'), path.join(CACHE_DIR, '.migrated'));
833
+ moveFileOnce(path.join(SYSTEM_DIR, '.models-cache.json'), path.join(CACHE_DIR, '.models-cache.json'));
834
+ // Loose dot-files at user root that belong in the cache bucket.
835
+ moveFileOnce(path.join(USER_DIR, '.cli-version-cache.json'), path.join(CACHE_DIR, '.cli-version-cache.json'));
836
+ moveFileOnce(path.join(USER_DIR, '.update-check'), path.join(CACHE_DIR, '.update-check'));
837
+ moveFileOnce(path.join(USER_DIR, '.migrated'), path.join(CACHE_DIR, '.migrated'));
838
+ moveFileOnce(path.join(USER_DIR, '.models-cache.json'), path.join(CACHE_DIR, '.models-cache.json'));
839
+ moveFileOnce(path.join(USER_DIR, 'watchdog.log'), path.join(CACHE_DIR, 'logs', 'watchdog.log'));
840
+ }
841
+ /**
842
+ * Merge a SQLite database file at `src` into the one at `dest`, then delete the
843
+ * source (including its WAL/SHM sidecars). User-side rows win on collision via
844
+ * INSERT OR IGNORE.
845
+ *
846
+ * If `dest` is missing, the source is simply moved into place (no merge). If
847
+ * the SQLite open fails (corrupt / zero-byte / locked), the source is dropped
848
+ * so the system repo returns to npm-shipped state — the data is stale-anyway
849
+ * runtime state, not user resources.
850
+ */
851
+ async function mergeSqliteDb(src, dest) {
852
+ if (!fs.existsSync(src))
853
+ return;
854
+ try {
855
+ if (fs.statSync(src).size === 0) {
856
+ // Zero-byte legacy DB — drop sidecars too and leave dest alone.
857
+ try {
858
+ fs.unlinkSync(src);
859
+ }
860
+ catch { /* best-effort */ }
861
+ for (const ext of ['-shm', '-wal']) {
862
+ try {
863
+ fs.unlinkSync(src + ext);
864
+ }
865
+ catch { /* best-effort */ }
866
+ }
867
+ return;
868
+ }
869
+ }
870
+ catch { /* best-effort */ }
871
+ if (!fs.existsSync(dest)) {
872
+ try {
873
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
874
+ fs.renameSync(src, dest);
875
+ for (const ext of ['-shm', '-wal']) {
876
+ if (fs.existsSync(src + ext)) {
877
+ try {
878
+ fs.renameSync(src + ext, dest + ext);
879
+ }
880
+ catch { /* best-effort */ }
881
+ }
882
+ }
883
+ return;
884
+ }
885
+ catch { /* fall through to merge */ }
886
+ }
887
+ // Both files exist — open the dest DB and ATTACH src, then INSERT OR IGNORE
888
+ // every user table. Dynamic import keeps the sqlite shim off the hot path
889
+ // for CLI starts that don't actually need a merge.
890
+ try {
891
+ const sqliteMod = (await import('./sqlite.js'));
892
+ const Database = sqliteMod.default;
893
+ const db = new Database(dest);
894
+ try {
895
+ db.exec(`ATTACH DATABASE '${src.replace(/'/g, "''")}' AS src`);
896
+ const tables = db.prepare(`SELECT name FROM src.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all();
897
+ // FTS5 virtual tables maintain shadow tables (<name>_data, _idx, _content,
898
+ // _docsize, _config) with internal segids/pgnos that MUST stay consistent.
899
+ // Row-merging shadow tables across two DBs corrupts the index. Skip them
900
+ // here — the indexer reconstructs FTS content on the next scan.
901
+ const ftsVirtuals = new Set(db.prepare(`SELECT name FROM src.sqlite_master WHERE type='table' AND sql LIKE '%fts5%'`).all().map((r) => r.name));
902
+ const ftsShadowSuffixes = ['_data', '_idx', '_content', '_docsize', '_config'];
903
+ const isFtsShadow = (name) => {
904
+ for (const v of ftsVirtuals) {
905
+ for (const suf of ftsShadowSuffixes) {
906
+ if (name === `${v}${suf}`)
907
+ return true;
908
+ }
909
+ }
910
+ return false;
911
+ };
912
+ for (const { name } of tables) {
913
+ if (ftsVirtuals.has(name) || isFtsShadow(name))
914
+ continue;
915
+ try {
916
+ const row = db.prepare(`SELECT sql FROM src.sqlite_master WHERE type='table' AND name = ?`).get(name);
917
+ if (row?.sql) {
918
+ const ddl = row.sql.replace(/^CREATE TABLE\s+/i, 'CREATE TABLE IF NOT EXISTS ');
919
+ db.exec(ddl);
920
+ }
921
+ }
922
+ catch { /* table likely exists already */ }
923
+ const quoted = '"' + name.replace(/"/g, '""') + '"';
924
+ try {
925
+ db.exec(`INSERT OR IGNORE INTO main.${quoted} SELECT * FROM src.${quoted}`);
926
+ }
927
+ catch { /* schema drift — skip table */ }
928
+ }
929
+ try {
930
+ db.exec('DETACH DATABASE src');
931
+ }
932
+ catch { /* best-effort */ }
933
+ }
934
+ finally {
935
+ try {
936
+ db.close();
937
+ }
938
+ catch { /* best-effort */ }
939
+ }
940
+ try {
941
+ fs.unlinkSync(src);
942
+ }
943
+ catch { /* best-effort */ }
944
+ for (const ext of ['-shm', '-wal']) {
945
+ try {
946
+ fs.unlinkSync(src + ext);
947
+ }
948
+ catch { /* best-effort */ }
949
+ }
950
+ }
951
+ catch {
952
+ // Merge failed — drop the source so the system repo returns to clean state.
953
+ // The user-side DB is authoritative; system-side rows were duplicate state.
954
+ try {
955
+ fs.unlinkSync(src);
956
+ }
957
+ catch { /* best-effort */ }
958
+ for (const ext of ['-shm', '-wal']) {
959
+ try {
960
+ fs.unlinkSync(src + ext);
961
+ }
962
+ catch { /* best-effort */ }
963
+ }
964
+ }
965
+ }
966
+ /**
967
+ * Move ~/.agents-system/sessions/ into ~/.agents/.history/sessions/.
968
+ *
969
+ * Filesystem entries (claude/, index.jsonl, content_index.jsonl, etc.) merge
970
+ * directory-by-directory with user-side winning on collision. The bundled
971
+ * sessions.db (plus WAL/SHM) goes through mergeSqliteDb so historical rows
972
+ * land in the user DB.
973
+ */
974
+ async function migrateSystemSessionsToHistory() {
975
+ const src = path.join(SYSTEM_DIR, 'sessions');
976
+ if (!fs.existsSync(src))
977
+ return;
978
+ const dest = path.join(HISTORY_DIR, 'sessions');
979
+ try {
980
+ fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
981
+ }
982
+ catch { /* best-effort */ }
983
+ let entries;
984
+ try {
985
+ entries = fs.readdirSync(src, { withFileTypes: true });
986
+ }
987
+ catch {
988
+ return;
989
+ }
990
+ for (const entry of entries) {
991
+ const s = path.join(src, entry.name);
992
+ if (entry.name === 'sessions.db' || entry.name === 'sessions.db-shm' || entry.name === 'sessions.db-wal') {
993
+ // Handled by mergeSqliteDb on the canonical .db name below.
994
+ continue;
995
+ }
996
+ const d = path.join(dest, entry.name);
997
+ if (entry.isDirectory()) {
998
+ moveDirOnce(s, d);
999
+ }
1000
+ else {
1001
+ moveFileOnce(s, d);
1002
+ }
1003
+ }
1004
+ await mergeSqliteDb(path.join(src, 'sessions.db'), path.join(dest, 'sessions.db'));
1005
+ try {
1006
+ if (fs.readdirSync(src).length === 0)
1007
+ fs.rmdirSync(src);
1008
+ }
1009
+ catch { /* best-effort */ }
1010
+ }
1011
+ /**
1012
+ * Move ~/.agents-system/teams/ contents to ~/.agents/teams/ (registry/config)
1013
+ * and ~/.agents/.history/teams/ (per-run dirs).
1014
+ *
1015
+ * Strategy:
1016
+ * config.json, registry.json -> ~/.agents/teams/ (live state)
1017
+ * agents/ -> ~/.agents/.history/teams/agents/
1018
+ * <anything else> -> ~/.agents/.history/teams/<name>/ (per-run dirs)
1019
+ */
1020
+ function migrateSystemTeamsToUser() {
1021
+ const src = path.join(SYSTEM_DIR, 'teams');
1022
+ if (!fs.existsSync(src))
1023
+ return;
1024
+ const liveDest = path.join(USER_DIR, 'teams');
1025
+ const historyDest = path.join(HISTORY_DIR, 'teams');
1026
+ let entries;
1027
+ try {
1028
+ entries = fs.readdirSync(src, { withFileTypes: true });
1029
+ }
1030
+ catch {
1031
+ return;
1032
+ }
1033
+ for (const entry of entries) {
1034
+ const s = path.join(src, entry.name);
1035
+ if (entry.name === 'config.json' || entry.name === 'registry.json') {
1036
+ moveFileOnce(s, path.join(liveDest, entry.name));
1037
+ continue;
1038
+ }
1039
+ if (entry.name === 'agents' && entry.isDirectory()) {
1040
+ moveDirOnce(s, path.join(historyDest, 'agents'));
1041
+ continue;
1042
+ }
1043
+ if (entry.isDirectory()) {
1044
+ moveDirOnce(s, path.join(historyDest, entry.name));
1045
+ }
1046
+ else {
1047
+ moveFileOnce(s, path.join(historyDest, entry.name));
1048
+ }
1049
+ }
1050
+ try {
1051
+ if (fs.readdirSync(src).length === 0)
1052
+ fs.rmdirSync(src);
1053
+ }
1054
+ catch { /* best-effort */ }
1055
+ }
1056
+ /**
1057
+ * Move ~/.agents-system/trash/ -> ~/.agents/.history/trash/.
1058
+ *
1059
+ * The system trash was where the legacy `mergeOverlappingVersionHomes()` parked
1060
+ * orphan version homes. Folding it into the history bucket keeps "everything
1061
+ * recoverable" in one place.
1062
+ */
1063
+ function migrateSystemTrashToHistory() {
1064
+ const src = path.join(SYSTEM_DIR, 'trash');
1065
+ if (!fs.existsSync(src))
1066
+ return;
1067
+ moveDirOnce(src, path.join(HISTORY_DIR, 'trash'));
1068
+ }
1069
+ /**
1070
+ * Move ~/.agents-system/cache/ contents into ~/.agents/.cache/.
1071
+ *
1072
+ * Special cases:
1073
+ * sessions.db -> mergeSqliteDb into HISTORY_DIR/sessions/sessions.db
1074
+ * cloud-runs/ -> .cache/cloud-runs/
1075
+ * claude-usage.json -> drop (regenerable per-version cache)
1076
+ * <anything else> -> .cache/<name> (merge-on-collision)
1077
+ */
1078
+ async function migrateSystemCacheToUserCache() {
1079
+ const src = path.join(SYSTEM_DIR, 'cache');
1080
+ if (!fs.existsSync(src))
1081
+ return;
1082
+ let entries;
1083
+ try {
1084
+ entries = fs.readdirSync(src, { withFileTypes: true });
1085
+ }
1086
+ catch {
1087
+ return;
1088
+ }
1089
+ for (const entry of entries) {
1090
+ const s = path.join(src, entry.name);
1091
+ if (entry.name === 'sessions.db' || entry.name === 'sessions.db-shm' || entry.name === 'sessions.db-wal') {
1092
+ // Sessions DB belongs in HISTORY, not CACHE — merge into the durable one.
1093
+ if (entry.name === 'sessions.db') {
1094
+ await mergeSqliteDb(s, path.join(HISTORY_DIR, 'sessions', 'sessions.db'));
1095
+ }
1096
+ // Sidecars get dropped if they outlived the merge or were orphaned.
1097
+ try {
1098
+ if (fs.existsSync(s))
1099
+ fs.unlinkSync(s);
1100
+ }
1101
+ catch { /* best-effort */ }
1102
+ continue;
1103
+ }
1104
+ if (entry.name === 'claude-usage.json') {
1105
+ try {
1106
+ fs.unlinkSync(s);
1107
+ }
1108
+ catch { /* best-effort */ }
1109
+ continue;
1110
+ }
1111
+ const d = path.join(CACHE_DIR, entry.name);
1112
+ if (entry.isDirectory()) {
1113
+ moveDirOnce(s, d);
1114
+ }
1115
+ else {
1116
+ moveFileOnce(s, d);
1117
+ }
1118
+ }
1119
+ try {
1120
+ if (fs.readdirSync(src).length === 0)
1121
+ fs.rmdirSync(src);
1122
+ }
1123
+ catch { /* best-effort */ }
1124
+ }
1125
+ /**
1126
+ * Merge ~/.agents-system/cloud/tasks.db into ~/.agents/.cache/cloud/tasks.db.
1127
+ */
1128
+ async function migrateSystemCloudToCache() {
1129
+ const srcDir = path.join(SYSTEM_DIR, 'cloud');
1130
+ if (!fs.existsSync(srcDir))
1131
+ return;
1132
+ await mergeSqliteDb(path.join(srcDir, 'tasks.db'), path.join(CACHE_DIR, 'cloud', 'tasks.db'));
1133
+ // Any other files in cloud/ get moved into the user cache bucket.
1134
+ let entries;
1135
+ try {
1136
+ entries = fs.readdirSync(srcDir, { withFileTypes: true });
1137
+ }
1138
+ catch {
1139
+ return;
1140
+ }
1141
+ for (const entry of entries) {
1142
+ const s = path.join(srcDir, entry.name);
1143
+ const d = path.join(CACHE_DIR, 'cloud', entry.name);
1144
+ if (entry.isDirectory()) {
1145
+ moveDirOnce(s, d);
1146
+ }
1147
+ else {
1148
+ moveFileOnce(s, d);
1149
+ }
1150
+ }
1151
+ try {
1152
+ if (fs.readdirSync(srcDir).length === 0)
1153
+ fs.rmdirSync(srcDir);
1154
+ }
1155
+ catch { /* best-effort */ }
1156
+ }
1157
+ /**
1158
+ * Legacy ~/.agents-system/swarm/ predates the rename to teams/. Fold any
1159
+ * per-agent dirs into ~/.agents/.history/teams/agents/ and drop the bookkeeping
1160
+ * JSONs (cache.json, config.json, teams.json) — those are regenerable.
1161
+ */
1162
+ function migrateLegacySwarmToTeams() {
1163
+ const src = path.join(SYSTEM_DIR, 'swarm');
1164
+ if (!fs.existsSync(src))
1165
+ return;
1166
+ const agentsSrc = path.join(src, 'agents');
1167
+ if (fs.existsSync(agentsSrc)) {
1168
+ moveDirOnce(agentsSrc, path.join(HISTORY_DIR, 'teams', 'agents'));
1169
+ }
1170
+ for (const dead of ['cache.json', 'config.json', 'teams.json']) {
1171
+ const f = path.join(src, dead);
1172
+ if (fs.existsSync(f)) {
1173
+ try {
1174
+ fs.unlinkSync(f);
1175
+ }
1176
+ catch { /* best-effort */ }
1177
+ }
1178
+ }
1179
+ try {
1180
+ if (fs.readdirSync(src).length === 0)
1181
+ fs.rmdirSync(src);
1182
+ }
1183
+ catch { /* best-effort */ }
1184
+ }
1185
+ /**
1186
+ * Move ~/.agents-system/repos/<alias>/ to ~/.agents-<alias>/ peer dirs.
1187
+ *
1188
+ * Extra DotAgents repos are user-defined config and belong as peer dirs to
1189
+ * ~/.agents/, not nested under the npm-shipped system repo. The dir name in
1190
+ * `repos/` becomes the alias.
1191
+ */
1192
+ function migrateSystemReposToPeerDirs() {
1193
+ const src = path.join(SYSTEM_DIR, 'repos');
1194
+ if (!fs.existsSync(src))
1195
+ return;
1196
+ let entries;
1197
+ try {
1198
+ entries = fs.readdirSync(src, { withFileTypes: true });
1199
+ }
1200
+ catch {
1201
+ return;
1202
+ }
1203
+ let moved = 0;
1204
+ for (const entry of entries) {
1205
+ if (!entry.isDirectory())
1206
+ continue;
1207
+ const alias = entry.name;
1208
+ const s = path.join(src, alias);
1209
+ const d = path.join(HOME, `.agents-${alias}`);
1210
+ if (fs.existsSync(d)) {
1211
+ // Peer dir already exists — drop the system-side copy to avoid drift.
1212
+ try {
1213
+ fs.rmSync(s, { recursive: true, force: true });
1214
+ }
1215
+ catch { /* best-effort */ }
1216
+ continue;
1217
+ }
1218
+ try {
1219
+ fs.renameSync(s, d);
1220
+ moved++;
1221
+ }
1222
+ catch { /* best-effort, leave in place */ }
1223
+ }
1224
+ try {
1225
+ if (fs.readdirSync(src).length === 0)
1226
+ fs.rmdirSync(src);
1227
+ }
1228
+ catch { /* best-effort */ }
1229
+ if (moved > 0) {
1230
+ console.error(`Moved ${moved} extra repo${moved === 1 ? '' : 's'} from ~/.agents-system/repos/ to ~/.agents-<alias>/ peer dirs`);
1231
+ }
1232
+ }
1233
+ /**
1234
+ * Drop known-dead artifacts from ~/.agents-system/ that are pure regenerable
1235
+ * runtime state and don't belong anywhere:
1236
+ * bin/agents-keychain-* — per-version keychain helper, rebuilt on demand
1237
+ * shims/ — moved long ago, only empty leftover remains
1238
+ */
1239
+ function dropDeadSystemArtifacts() {
1240
+ const binDir = path.join(SYSTEM_DIR, 'bin');
1241
+ if (fs.existsSync(binDir)) {
1242
+ try {
1243
+ for (const name of fs.readdirSync(binDir)) {
1244
+ if (name.startsWith('agents-keychain-')) {
1245
+ try {
1246
+ fs.unlinkSync(path.join(binDir, name));
1247
+ }
1248
+ catch { /* best-effort */ }
1249
+ }
1250
+ }
1251
+ if (fs.readdirSync(binDir).length === 0)
1252
+ fs.rmdirSync(binDir);
1253
+ }
1254
+ catch { /* best-effort */ }
1255
+ }
1256
+ const shimsDir = path.join(SYSTEM_DIR, 'shims');
1257
+ if (fs.existsSync(shimsDir)) {
1258
+ try {
1259
+ if (fs.readdirSync(shimsDir).length === 0)
1260
+ fs.rmdirSync(shimsDir);
1261
+ }
1262
+ catch { /* best-effort */ }
1263
+ }
1264
+ // After migrateSystemVersionsToUser() moves real version dirs out, the system
1265
+ // may still hold an empty `versions/<agent>/` skeleton with a stray .DS_Store.
1266
+ // Sweep it: if every leaf file is .DS_Store, drop the tree entirely.
1267
+ const versionsDir = path.join(SYSTEM_DIR, 'versions');
1268
+ if (fs.existsSync(versionsDir)) {
1269
+ try {
1270
+ if (containsOnlyDsStore(versionsDir)) {
1271
+ fs.rmSync(versionsDir, { recursive: true, force: true });
1272
+ }
1273
+ }
1274
+ catch { /* best-effort */ }
1275
+ }
1276
+ }
1277
+ function containsOnlyDsStore(dir) {
1278
+ let entries;
1279
+ try {
1280
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1281
+ }
1282
+ catch {
1283
+ return false;
1284
+ }
1285
+ for (const entry of entries) {
1286
+ if (entry.isDirectory()) {
1287
+ if (!containsOnlyDsStore(path.join(dir, entry.name)))
1288
+ return false;
1289
+ }
1290
+ else if (entry.name !== '.DS_Store') {
1291
+ return false;
1292
+ }
1293
+ }
1294
+ return true;
1295
+ }
1296
+ /**
1297
+ * After the sweep runs, warn (once per invocation) about any unrecognized
1298
+ * subdirectory left in ~/.agents-system/. The system repo is the npm-shipped
1299
+ * defaults — anything outside the allowlist is drift that future maintainers
1300
+ * need to handle explicitly.
1301
+ */
1302
+ function warnSystemOrphans() {
1303
+ const SHIPPED_ALLOWLIST = new Set([
1304
+ // resource directories shipped by the npm package
1305
+ 'commands', 'hooks', 'skills', 'rules', 'mcp', 'permissions', 'subagents', 'profiles', 'agents',
1306
+ // top-level metadata files
1307
+ 'agents.yaml', 'hooks.yaml', 'README.md', 'CHANGELOG.md',
1308
+ // git + repo metadata
1309
+ '.git', '.githooks', '.gitignore', '.assets', '.environment', '.plans',
1310
+ // benign noise that's safe to ignore
1311
+ '.DS_Store', '.claude',
1312
+ ]);
1313
+ let entries;
1314
+ try {
1315
+ entries = fs.readdirSync(SYSTEM_DIR);
1316
+ }
1317
+ catch {
1318
+ return;
1319
+ }
1320
+ // Transient runtime sockets (.sock) are bound by long-running helpers like the
1321
+ // VS Code extension; they're live state, not stale data, and a future fix in
1322
+ // those helpers will move them into ~/.agents/.cache/ — until then, suppress.
1323
+ const orphans = entries.filter((name) => !SHIPPED_ALLOWLIST.has(name) && !name.endsWith('.sock'));
1324
+ if (orphans.length === 0)
1325
+ return;
1326
+ console.error(`~/.agents-system/ has unexpected entries (not part of the npm-shipped defaults): ${orphans.join(', ')}`);
1327
+ }
1328
+ const VERSION_RESOURCE_FLAT_KEYS = ['commands', 'skills', 'hooks', 'memory', 'subagents', 'plugins', 'workflows', 'permissions', 'mcp'];
1329
+ /**
1330
+ * Convert agents.yaml versions: entries from the old flat name-list format to
1331
+ * the new pattern format. Flat entries are detected by checking whether all
1332
+ * items in the array lack a ':' separator (plain names have no source prefix).
1333
+ *
1334
+ * The rulesPreset field is preserved. Flat resource lists are dropped — the
1335
+ * next `agents sync` will write default patterns (system:* user:* project:*).
1336
+ *
1337
+ * Idempotent: entries already in pattern format are left untouched.
1338
+ */
1339
+ function migrateVersionResourcesToPatterns() {
1340
+ const metaFile = path.join(USER_DIR, 'agents.yaml');
1341
+ if (!fs.existsSync(metaFile))
1342
+ return;
1343
+ let meta;
1344
+ try {
1345
+ const raw = fs.readFileSync(metaFile, 'utf-8');
1346
+ meta = yaml.parse(raw) || {};
1347
+ }
1348
+ catch {
1349
+ return;
1350
+ }
1351
+ const versions = meta.versions;
1352
+ if (!versions || typeof versions !== 'object')
1353
+ return;
1354
+ let changed = false;
1355
+ for (const agentVersions of Object.values(versions)) {
1356
+ if (!agentVersions || typeof agentVersions !== 'object')
1357
+ continue;
1358
+ for (const vr of Object.values(agentVersions)) {
1359
+ if (!vr || typeof vr !== 'object')
1360
+ continue;
1361
+ for (const key of VERSION_RESOURCE_FLAT_KEYS) {
1362
+ const val = vr[key];
1363
+ if (!Array.isArray(val) || val.length === 0)
1364
+ continue;
1365
+ // Detect legacy: all items are plain names (no ':' separator)
1366
+ if (val.every(item => typeof item === 'string' && !item.includes(':'))) {
1367
+ if (key === 'memory') {
1368
+ // memory was a single-element array holding the preset name — move to rulesPreset
1369
+ if (val.length === 1 && !vr['rulesPreset']) {
1370
+ vr['rulesPreset'] = val[0];
1371
+ }
1372
+ }
1373
+ delete vr[key];
1374
+ changed = true;
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ if (changed) {
1380
+ const META_HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1381
+ fs.writeFileSync(metaFile, META_HEADER + yaml.stringify(meta), 'utf-8');
1382
+ console.error('Migrated agents.yaml versions: entries to pattern format');
1383
+ }
1384
+ }
198
1385
  /** Run all idempotent migrations. Safe to call multiple times. */
199
- export function runMigration() {
1386
+ export async function runMigration() {
200
1387
  migrateAgentsYaml();
201
1388
  deleteSystemPromptsJson();
202
1389
  migrateSystemConfigJson();
203
1390
  migratePromptcutsIntoHooks();
204
- migrateUserVersionsToSystem();
1391
+ migrateSystemVersionsToUser();
1392
+ mergeOverlappingVersionHomes();
205
1393
  migrateRunsIntoRoutines();
206
1394
  migrateTrashToHidden();
207
1395
  migrateBackupsToHidden();
1396
+ migrateAliasesToUser();
1397
+ migratePermissionSetsToPresets();
1398
+ deleteUserLinearJson();
1399
+ deleteUserPromptsJson();
1400
+ cleanupUserConfigJson();
1401
+ cleanupEmptyTopLevelRuns();
1402
+ foldUserHooksYamlIntoAgentsYaml();
1403
+ foldBrowserProfilesIntoAgentsYaml();
1404
+ migrateVersionResourcesToPatterns();
1405
+ // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
1406
+ migrateRuntimeToHistory();
1407
+ migrateRuntimeToCache();
1408
+ // System-repo sweep: move every remaining operational dir into its canonical
1409
+ // user-bucket location, then drop known-dead artifacts and warn about
1410
+ // anything we don't recognize. Order: durable (sessions/teams/trash/repos/
1411
+ // legacy-swarm) -> caches (cache/, cloud/) -> drops -> orphan check.
1412
+ await migrateSystemSessionsToHistory();
1413
+ migrateSystemTeamsToUser();
1414
+ migrateSystemTrashToHistory();
1415
+ migrateLegacySwarmToTeams();
1416
+ migrateSystemReposToPeerDirs();
1417
+ await migrateSystemCacheToUserCache();
1418
+ await migrateSystemCloudToCache();
1419
+ dropDeadSystemArtifacts();
1420
+ warnSystemOrphans();
1421
+ // Symlink repair runs LAST so it can find the post-move version homes.
1422
+ repairAgentConfigSymlinks();
208
1423
  }