@phnx-labs/agents-cli 1.22.84 → 1.22.86

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 (259) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +80 -53
  3. package/dist/bootstrap.d.ts +7 -6
  4. package/dist/bootstrap.js +20 -27
  5. package/dist/cli/command-registry.js +5 -1
  6. package/dist/commands/accounts.d.ts +6 -13
  7. package/dist/commands/accounts.js +330 -143
  8. package/dist/commands/alias.js +7 -3
  9. package/dist/commands/apply.js +1 -1
  10. package/dist/commands/auth-mint.d.ts +7 -3
  11. package/dist/commands/auth-mint.js +21 -9
  12. package/dist/commands/auth.js +2 -3
  13. package/dist/commands/browser.js +10 -10
  14. package/dist/commands/daemon.d.ts +7 -3
  15. package/dist/commands/daemon.js +40 -25
  16. package/dist/commands/doctor.js +28 -2
  17. package/dist/commands/exec.js +87 -39
  18. package/dist/commands/fleet-capture.js +21 -2
  19. package/dist/commands/harness-wizard.js +2 -2
  20. package/dist/commands/lease.js +7 -12
  21. package/dist/commands/profiles.d.ts +2 -2
  22. package/dist/commands/profiles.js +20 -13
  23. package/dist/commands/repo.js +3 -3
  24. package/dist/commands/run-account-picker.d.ts +3 -3
  25. package/dist/commands/run-account-picker.js +29 -30
  26. package/dist/commands/secrets-passthrough.d.ts +20 -0
  27. package/dist/commands/secrets-passthrough.js +43 -0
  28. package/dist/commands/setup-accounts.d.ts +3 -2
  29. package/dist/commands/setup-accounts.js +4 -4
  30. package/dist/commands/setup-secrets.d.ts +16 -21
  31. package/dist/commands/setup-secrets.js +43 -224
  32. package/dist/commands/ssh.js +2 -2
  33. package/dist/commands/sync.js +1 -1
  34. package/dist/commands/update.d.ts +10 -0
  35. package/dist/commands/update.js +43 -40
  36. package/dist/commands/versions.d.ts +11 -11
  37. package/dist/commands/versions.js +131 -83
  38. package/dist/commands/view.d.ts +1 -9
  39. package/dist/commands/view.js +41 -55
  40. package/dist/commands/webhook.js +5 -5
  41. package/dist/commands/workflows.js +2 -1
  42. package/dist/index.d.ts +12 -12
  43. package/dist/index.js +13 -42
  44. package/dist/lib/account-capabilities.d.ts +1 -1
  45. package/dist/lib/account-capabilities.js +1 -1
  46. package/dist/lib/account-catalog.d.ts +121 -6
  47. package/dist/lib/account-catalog.js +421 -7
  48. package/dist/lib/account-registry.d.ts +8 -30
  49. package/dist/lib/account-registry.js +36 -79
  50. package/dist/lib/account-schema.d.ts +1 -1
  51. package/dist/lib/account-schema.js +1 -1
  52. package/dist/lib/accounting/account-pool-collect.js +16 -4
  53. package/dist/lib/accounting/rotate.d.ts +20 -7
  54. package/dist/lib/accounting/rotate.js +89 -13
  55. package/dist/lib/accounting/usage.js +11 -16
  56. package/dist/lib/accounts/add.d.ts +138 -0
  57. package/dist/lib/accounts/add.js +651 -0
  58. package/dist/lib/accounts/migrate.d.ts +117 -0
  59. package/dist/lib/accounts/migrate.js +536 -0
  60. package/dist/lib/accounts/slots.d.ts +13 -0
  61. package/dist/lib/accounts/slots.js +100 -0
  62. package/dist/lib/agent-spec/agents.d.ts +16 -0
  63. package/dist/lib/agent-spec/agents.js +38 -4
  64. package/dist/lib/app-bundle-install.js +5 -4
  65. package/dist/lib/auth-health.d.ts +3 -0
  66. package/dist/lib/auth-health.js +11 -0
  67. package/dist/lib/auth-mint.d.ts +52 -16
  68. package/dist/lib/auth-mint.js +117 -39
  69. package/dist/lib/browser/chrome.d.ts +1 -1
  70. package/dist/lib/browser/chrome.js +5 -5
  71. package/dist/lib/byok-usage.js +3 -3
  72. package/dist/lib/claude-account-token.d.ts +47 -3
  73. package/dist/lib/claude-account-token.js +172 -50
  74. package/dist/lib/cloud/antigravity.js +4 -4
  75. package/dist/lib/cloud/cursor.js +4 -4
  76. package/dist/lib/crabbox/cli.d.ts +1 -1
  77. package/dist/lib/crabbox/cli.js +6 -6
  78. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  79. package/dist/lib/crabbox/runtimes.js +5 -5
  80. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  81. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  82. package/dist/lib/daemon/auth-sync-service.js +32 -1
  83. package/dist/lib/daemon/daemon.d.ts +6 -16
  84. package/dist/lib/daemon/daemon.js +52 -97
  85. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  86. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  87. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  88. package/dist/lib/daemon/harness-update-service.js +1 -1
  89. package/dist/lib/daemon/runner.d.ts +15 -3
  90. package/dist/lib/daemon/runner.js +49 -24
  91. package/dist/lib/daemon-health.d.ts +1 -2
  92. package/dist/lib/daemon-health.js +7 -6
  93. package/dist/lib/daemon-services.d.ts +1 -1
  94. package/dist/lib/daemon-services.js +1 -11
  95. package/dist/lib/daemon-webhooks.d.ts +8 -7
  96. package/dist/lib/daemon-webhooks.js +10 -9
  97. package/dist/lib/device-config.js +1 -1
  98. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  99. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  100. package/dist/lib/devices/harness-inventory.js +126 -4
  101. package/dist/lib/doctor-diff.js +2 -1
  102. package/dist/lib/exec-account-home.d.ts +38 -0
  103. package/dist/lib/exec-account-home.js +164 -0
  104. package/dist/lib/exec.d.ts +30 -0
  105. package/dist/lib/exec.js +101 -31
  106. package/dist/lib/fleet/apply.d.ts +2 -2
  107. package/dist/lib/fleet/apply.js +4 -4
  108. package/dist/lib/fleet/auth-sync.js +4 -3
  109. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  110. package/dist/lib/harness/adapter.d.ts +7 -7
  111. package/dist/lib/harness/adapter.js +10 -6
  112. package/dist/lib/harness/adapters/grok.js +8 -3
  113. package/dist/lib/harness/adapters/muse.js +1 -1
  114. package/dist/lib/harness/adapters/opencode.js +12 -3
  115. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  116. package/dist/lib/harness-auth-capabilities.js +52 -0
  117. package/dist/lib/helper-versions.d.ts +6 -4
  118. package/dist/lib/helper-versions.js +5 -4
  119. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  120. package/dist/lib/hosts/credential-transport.js +37 -0
  121. package/dist/lib/hosts/dispatch.d.ts +7 -0
  122. package/dist/lib/hosts/dispatch.js +13 -6
  123. package/dist/lib/identity/client.d.ts +3 -3
  124. package/dist/lib/identity/client.js +3 -3
  125. package/dist/lib/installations/index.d.ts +1 -1
  126. package/dist/lib/installations/index.js +1 -1
  127. package/dist/lib/installations/migrate.js +9 -0
  128. package/dist/lib/installations/resolve.js +1 -1
  129. package/dist/lib/installations/shims.d.ts +21 -4
  130. package/dist/lib/installations/shims.js +86 -5
  131. package/dist/lib/installations/store.d.ts +43 -0
  132. package/dist/lib/installations/store.js +75 -7
  133. package/dist/lib/installations/versions.js +1 -1
  134. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  135. package/dist/lib/menubar/install-menubar.js +3 -3
  136. package/dist/lib/native-accounts.d.ts +35 -0
  137. package/dist/lib/native-accounts.js +41 -0
  138. package/dist/lib/net-close.d.ts +11 -0
  139. package/dist/lib/net-close.js +25 -0
  140. package/dist/lib/openclaw-keychain.js +27 -1
  141. package/dist/lib/profiles.js +22 -9
  142. package/dist/lib/project-resources.js +1 -1
  143. package/dist/lib/reserved-stores.d.ts +53 -0
  144. package/dist/lib/reserved-stores.js +120 -0
  145. package/dist/lib/secrets-client.d.ts +199 -0
  146. package/dist/lib/secrets-client.js +718 -0
  147. package/dist/lib/secrets-policy.d.ts +235 -0
  148. package/dist/lib/secrets-policy.js +505 -0
  149. package/dist/lib/secrets-types.d.ts +188 -0
  150. package/dist/lib/secrets-types.js +24 -0
  151. package/dist/lib/self-heal/checks/shims.js +5 -3
  152. package/dist/lib/service-manifest.js +3 -4
  153. package/dist/lib/session/db.d.ts +18 -0
  154. package/dist/lib/session/db.js +47 -14
  155. package/dist/lib/session/sync/config.js +2 -2
  156. package/dist/lib/sha256-asset.d.ts +11 -16
  157. package/dist/lib/sha256-asset.js +11 -16
  158. package/dist/lib/share/config.js +14 -5
  159. package/dist/lib/signin-badge.d.ts +26 -0
  160. package/dist/lib/signin-badge.js +42 -0
  161. package/dist/lib/staleness/detectors/workflows.js +3 -105
  162. package/dist/lib/staleness/writers/workflows.js +2 -1
  163. package/dist/lib/state.d.ts +7 -4
  164. package/dist/lib/state.js +18 -7
  165. package/dist/lib/sync-umbrella.js +2 -2
  166. package/dist/lib/teams/agents.js +1 -1
  167. package/dist/lib/types.d.ts +74 -33
  168. package/dist/lib/view-types.d.ts +6 -3
  169. package/dist/lib/workflows-registry.d.ts +71 -0
  170. package/dist/lib/workflows-registry.js +280 -0
  171. package/dist/lib/workflows.d.ts +21 -21
  172. package/dist/lib/workflows.js +12 -327
  173. package/package.json +2 -3
  174. package/scripts/postinstall.js +35 -30
  175. package/dist/commands/secrets-import.d.ts +0 -18
  176. package/dist/commands/secrets-import.js +0 -74
  177. package/dist/commands/secrets-migrate.d.ts +0 -25
  178. package/dist/commands/secrets-migrate.js +0 -334
  179. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  180. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  181. package/dist/commands/secrets-sync.d.ts +0 -11
  182. package/dist/commands/secrets-sync.js +0 -153
  183. package/dist/commands/secrets-vault.d.ts +0 -10
  184. package/dist/commands/secrets-vault.js +0 -130
  185. package/dist/commands/secrets.d.ts +0 -212
  186. package/dist/commands/secrets.js +0 -3030
  187. package/dist/lib/accounts/connect.d.ts +0 -176
  188. package/dist/lib/accounts/connect.js +0 -453
  189. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  190. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  191. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  192. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  193. package/dist/lib/secrets/agent.d.ts +0 -358
  194. package/dist/lib/secrets/agent.js +0 -1291
  195. package/dist/lib/secrets/audit.d.ts +0 -46
  196. package/dist/lib/secrets/audit.js +0 -101
  197. package/dist/lib/secrets/bundles.d.ts +0 -290
  198. package/dist/lib/secrets/bundles.js +0 -1547
  199. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  200. package/dist/lib/secrets/download-keychain.js +0 -70
  201. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  202. package/dist/lib/secrets/drivers/rush.js +0 -90
  203. package/dist/lib/secrets/fallback.d.ts +0 -48
  204. package/dist/lib/secrets/fallback.js +0 -48
  205. package/dist/lib/secrets/filestore.d.ts +0 -222
  206. package/dist/lib/secrets/filestore.js +0 -1099
  207. package/dist/lib/secrets/headless.d.ts +0 -43
  208. package/dist/lib/secrets/headless.js +0 -56
  209. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  210. package/dist/lib/secrets/icloud-import.js +0 -206
  211. package/dist/lib/secrets/index.d.ts +0 -451
  212. package/dist/lib/secrets/index.js +0 -1568
  213. package/dist/lib/secrets/install-helper.d.ts +0 -72
  214. package/dist/lib/secrets/install-helper.js +0 -245
  215. package/dist/lib/secrets/lease.d.ts +0 -25
  216. package/dist/lib/secrets/lease.js +0 -44
  217. package/dist/lib/secrets/linux.d.ts +0 -77
  218. package/dist/lib/secrets/linux.js +0 -393
  219. package/dist/lib/secrets/list-filter.d.ts +0 -109
  220. package/dist/lib/secrets/list-filter.js +0 -261
  221. package/dist/lib/secrets/mcp.d.ts +0 -93
  222. package/dist/lib/secrets/mcp.js +0 -211
  223. package/dist/lib/secrets/profiles.d.ts +0 -10
  224. package/dist/lib/secrets/profiles.js +0 -13
  225. package/dist/lib/secrets/push.d.ts +0 -133
  226. package/dist/lib/secrets/push.js +0 -273
  227. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  228. package/dist/lib/secrets/rc-hygiene.js +0 -143
  229. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  230. package/dist/lib/secrets/read-backoff.js +0 -64
  231. package/dist/lib/secrets/reaper.d.ts +0 -90
  232. package/dist/lib/secrets/reaper.js +0 -243
  233. package/dist/lib/secrets/remote.d.ts +0 -152
  234. package/dist/lib/secrets/remote.js +0 -344
  235. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  236. package/dist/lib/secrets/reserved-sync.js +0 -147
  237. package/dist/lib/secrets/scope.d.ts +0 -26
  238. package/dist/lib/secrets/scope.js +0 -29
  239. package/dist/lib/secrets/session-store.d.ts +0 -107
  240. package/dist/lib/secrets/session-store.js +0 -342
  241. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  242. package/dist/lib/secrets/sync-backend.js +0 -13
  243. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  244. package/dist/lib/secrets/sync-commands.js +0 -21
  245. package/dist/lib/secrets/sync.d.ts +0 -48
  246. package/dist/lib/secrets/sync.js +0 -237
  247. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  248. package/dist/lib/secrets/unlock-hints.js +0 -36
  249. package/dist/lib/secrets/usage-db.d.ts +0 -46
  250. package/dist/lib/secrets/usage-db.js +0 -96
  251. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  252. package/dist/lib/secrets/vault-age-helper.js +0 -34
  253. package/dist/lib/secrets/vault.d.ts +0 -49
  254. package/dist/lib/secrets/vault.js +0 -399
  255. package/dist/lib/secrets/windows.d.ts +0 -81
  256. package/dist/lib/secrets/windows.js +0 -556
  257. package/scripts/install-helper.js +0 -97
  258. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  259. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -0,0 +1,117 @@
1
+ import type { AgentId } from '../types.js';
2
+ export declare const ACCOUNT_MIGRATION_SCHEMA = 1;
3
+ export type MigrationActionKind = 'canonical' | 'slot' | 'trash' | 'defer' | 'skip';
4
+ export interface InstallationInventory {
5
+ agent: AgentId;
6
+ label: string;
7
+ release: string;
8
+ dir: string;
9
+ home: string;
10
+ hasBinary: boolean;
11
+ isolated: boolean;
12
+ launchable: boolean;
13
+ signedIn: boolean;
14
+ hasCredential: boolean;
15
+ identityKey: string | null;
16
+ email: string | null;
17
+ accountKey: string | null;
18
+ accountId: string | null;
19
+ sessionCount: number;
20
+ busy: boolean;
21
+ }
22
+ export interface MigrationAction {
23
+ kind: MigrationActionKind;
24
+ label: string;
25
+ release: string;
26
+ reason: string;
27
+ accountId?: string;
28
+ accountName?: string;
29
+ slotDir?: string;
30
+ identityKey?: string | null;
31
+ email?: string | null;
32
+ sessionCount: number;
33
+ /** Planned filesystem remap for sessions.db (from prefix → to prefix). */
34
+ pathMoves: Array<{
35
+ from: string;
36
+ to: string;
37
+ }>;
38
+ }
39
+ export interface HarnessMigrationPlan {
40
+ agent: AgentId;
41
+ canonical: string | null;
42
+ defaultBefore: string | null;
43
+ inventory: InstallationInventory[];
44
+ actions: MigrationAction[];
45
+ counts: {
46
+ installations: number;
47
+ keep: number;
48
+ slots: number;
49
+ trash: number;
50
+ deferred: number;
51
+ skipped: number;
52
+ };
53
+ }
54
+ export interface AccountMigrationPlan {
55
+ at: string;
56
+ harnesses: HarnessMigrationPlan[];
57
+ totals: {
58
+ installations: number;
59
+ keep: number;
60
+ slots: number;
61
+ trash: number;
62
+ deferred: number;
63
+ skipped: number;
64
+ };
65
+ }
66
+ export type AccountMigrationManifestStatus = 'planned' | 'complete';
67
+ export interface AccountMigrationManifest {
68
+ schema: number;
69
+ at: string;
70
+ dryRun: boolean;
71
+ /** `planned` until every irreversible step finishes; `complete` is last. */
72
+ status: AccountMigrationManifestStatus;
73
+ /** Full plan, written before the first move so a crash still names the intent. */
74
+ plan: AccountMigrationPlan;
75
+ harnesses: Record<string, {
76
+ canonical: string | null;
77
+ slots: Array<{
78
+ oldLabel: string;
79
+ accountId: string;
80
+ accountName: string;
81
+ slotDir: string;
82
+ }>;
83
+ trashed: Array<{
84
+ label: string;
85
+ reason: string;
86
+ trashPath: string;
87
+ }>;
88
+ deferred: Array<{
89
+ label: string;
90
+ reason: string;
91
+ }>;
92
+ }>;
93
+ /** old `agent@label` → new slot dir (or trash path). */
94
+ map: Record<string, string>;
95
+ }
96
+ export interface AccountMigrateDeps {
97
+ isActive?: (installation: {
98
+ agent: AgentId;
99
+ label: string;
100
+ }) => Promise<boolean>;
101
+ now?: () => Date;
102
+ }
103
+ export declare function planAccountMigration(agents?: AgentId[], deps?: AccountMigrateDeps): Promise<AccountMigrationPlan>;
104
+ export declare function formatMigrationPlan(plan: AccountMigrationPlan): string;
105
+ export interface ApplyMigrationResult {
106
+ plan: AccountMigrationPlan;
107
+ manifest: AccountMigrationManifest;
108
+ manifestPath: string;
109
+ sessionsReindexed: number;
110
+ }
111
+ export declare function applyAccountMigration(agents?: AgentId[], deps?: AccountMigrateDeps): Promise<ApplyMigrationResult>;
112
+ /**
113
+ * Upgrade hook: print a dry-run report when leftover per-account installations
114
+ * exist. Never applies. `--apply` stays an explicit `agents accounts migrate`
115
+ * flag this release.
116
+ */
117
+ export declare function reportAccountSlotMigrationOnUpgrade(): Promise<void>;
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Fold N per-account installations into 1 harness install + N credential slots
3
+ * (PHNX-3940 T7).
4
+ *
5
+ * Homes are moved, never copied. Empty logged-out homes and duplicate identities
6
+ * go to `agents trash` (restore reverses). Session transcript paths are re-indexed
7
+ * in the same transaction as the moves. A running/leased home is deferred, never
8
+ * moved. `--apply` is explicit; dry-run (and the upgrade hook) touch nothing.
9
+ *
10
+ * Native OAuth files stay inside the moved home on this device. Nothing here
11
+ * reads or writes a reserved store, a setup-token, or a worker bundle.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { ALL_AGENT_IDS, credentialPresence, getAccountInfo } from '../agents.js';
16
+ import { nativeAccountCapability, nativeIdentityKey } from '../account-capabilities.js';
17
+ import { addNativeAccount, bindAccount, findNativeAccountByIdentity, listNativeAccounts, } from '../account-registry.js';
18
+ import { isLaunchableSignedIn } from '../accounting/rotate.js';
19
+ import { compareVersions } from '../agent-spec/primitives.js';
20
+ import { isInstallationLikelyActive } from '../installations/active-check.js';
21
+ import { getGlobalDefault, getVersionDir, getVersionHomePath, invalidateInstalledVersionsCache, isVersionIsolated, listInstalledVersionDirs, readInstallation, setGlobalDefault, softDeleteVersionDir, } from '../installations/versions.js';
22
+ import { removeVersionedAlias } from '../installations/shims.js';
23
+ import { countSessionsWithFilePrefix, reindexMovedSessionPaths } from '../session/db.js';
24
+ import { atomicWriteFileSync } from '../fs-atomic.js';
25
+ import { getHistoryDir, readMeta, updateMeta } from '../state.js';
26
+ import { recordSlot, slotDir } from './slots.js';
27
+ export const ACCOUNT_MIGRATION_SCHEMA = 1;
28
+ const OPAQUE_LABEL = /^(?:latest|main|acct-[0-9a-f]+|\d+\.\d+.*)$/i;
29
+ function defaultIsActive(installation) {
30
+ return isInstallationLikelyActive(installation);
31
+ }
32
+ function readInstallRecord(agent, label) {
33
+ try {
34
+ const rec = readInstallation(agent, label);
35
+ if (rec)
36
+ return { release: rec.releaseVersion };
37
+ return { release: label };
38
+ }
39
+ catch (err) {
40
+ throw new Error(`Unreadable installation.json for ${agent}@${label}: ${err instanceof Error ? err.message : String(err)}`);
41
+ }
42
+ }
43
+ function emailLocalpart(email) {
44
+ if (!email)
45
+ return null;
46
+ const local = email.split('@')[0]?.trim();
47
+ return local || null;
48
+ }
49
+ function isOpaqueInstallLabel(label) {
50
+ return OPAQUE_LABEL.test(label);
51
+ }
52
+ function uniqueAccountName(agent, preferred, taken) {
53
+ const base = preferred.replace(/[^A-Za-z0-9@._+-]/g, '-').replace(/^[^A-Za-z0-9]+/, '') || 'account';
54
+ if (!taken.has(`${agent}:${base.toLowerCase()}`))
55
+ return base;
56
+ for (let i = 2; i < 1000; i++) {
57
+ const candidate = `${base}-${i}`;
58
+ if (!taken.has(`${agent}:${candidate.toLowerCase()}`))
59
+ return candidate;
60
+ }
61
+ throw new Error(`Could not allocate a unique account name for ${agent} starting from '${preferred}'.`);
62
+ }
63
+ function invertHomes(agent, meta) {
64
+ const out = new Map();
65
+ const homes = meta.deviceAccounts?.homes ?? {};
66
+ for (const [accountId, label] of Object.entries(homes)) {
67
+ if (!label)
68
+ continue;
69
+ const row = listNativeAccounts(meta).find((a) => a.id === accountId);
70
+ if (row && row.agent !== agent)
71
+ continue;
72
+ out.set(label, accountId);
73
+ }
74
+ return out;
75
+ }
76
+ function assertIdentityMatch(agent, label, identityKey, row) {
77
+ if (identityKey && row.identityKey !== identityKey) {
78
+ throw new Error(`Identity mismatch for ${agent}@${label}: home is '${identityKey}' but account row '${row.name}' is '${row.identityKey}'.`);
79
+ }
80
+ }
81
+ async function inventoryHarness(agent, deps) {
82
+ const dirs = listInstalledVersionDirs(agent);
83
+ const meta = readMeta();
84
+ const homesByLabel = invertHomes(agent, meta);
85
+ const isActive = deps.isActive ?? defaultIsActive;
86
+ const out = [];
87
+ for (const { version: label, hasBinary } of dirs) {
88
+ const rec = readInstallRecord(agent, label);
89
+ const home = getVersionHomePath(agent, label);
90
+ const info = await getAccountInfo(agent, home);
91
+ const presence = credentialPresence(agent, home);
92
+ const launchable = isLaunchableSignedIn(info.signedIn, presence);
93
+ const identityKey = nativeIdentityKey(info, nativeAccountCapability(agent));
94
+ const accountIdFromHome = homesByLabel.get(label) ?? null;
95
+ if (accountIdFromHome) {
96
+ const row = listNativeAccounts(meta).find((a) => a.id === accountIdFromHome);
97
+ if (row)
98
+ assertIdentityMatch(agent, label, identityKey, row);
99
+ }
100
+ const matched = findNativeAccountByIdentity(meta, agent, info);
101
+ out.push({
102
+ agent,
103
+ label,
104
+ release: rec.release,
105
+ dir: getVersionDir(agent, label),
106
+ home,
107
+ hasBinary,
108
+ isolated: isVersionIsolated(agent, label),
109
+ launchable,
110
+ signedIn: info.signedIn,
111
+ hasCredential: presence.perVersion,
112
+ identityKey,
113
+ email: info.email,
114
+ accountKey: info.accountKey,
115
+ accountId: accountIdFromHome ?? matched?.id ?? null,
116
+ sessionCount: countSessionsWithFilePrefix(home),
117
+ busy: await isActive({ agent, label }),
118
+ });
119
+ }
120
+ return out;
121
+ }
122
+ function newestLaunchable(items) {
123
+ const launchable = items.filter((i) => i.launchable && !i.isolated);
124
+ if (launchable.length === 0)
125
+ return null;
126
+ return [...launchable].sort((a, b) => {
127
+ const rel = compareVersions(a.release, b.release);
128
+ if (rel !== 0)
129
+ return rel;
130
+ return compareVersions(a.label, b.label);
131
+ }).at(-1) ?? null;
132
+ }
133
+ function chooseCanonical(items, defaultLabel, trashLabels) {
134
+ const eligible = items.filter((i) => !i.isolated && !trashLabels.has(i.label));
135
+ if (defaultLabel) {
136
+ const def = eligible.find((i) => i.label === defaultLabel);
137
+ if (def?.launchable)
138
+ return def;
139
+ }
140
+ const newest = newestLaunchable(eligible);
141
+ if (newest)
142
+ return newest;
143
+ const withBinary = eligible.filter((i) => i.hasBinary);
144
+ if (withBinary.length === 0)
145
+ return eligible[0] ?? null;
146
+ return [...withBinary].sort((a, b) => compareVersions(a.release, b.release) || compareVersions(a.label, b.label)).at(-1) ?? null;
147
+ }
148
+ async function duplicateTrashLabels(items) {
149
+ const slotish = items.filter((i) => i.hasCredential && i.identityKey && !i.isolated);
150
+ if (slotish.length < 2)
151
+ return new Set();
152
+ const { planDuplicatePrune } = await import('../../commands/view.js');
153
+ const dups = planDuplicatePrune(slotish.map((i) => ({
154
+ version: i.label,
155
+ release: i.release,
156
+ email: i.email,
157
+ accountKey: i.accountKey,
158
+ signedIn: i.signedIn,
159
+ hasBinary: i.hasBinary,
160
+ })));
161
+ return new Set(dups.map((d) => d.version));
162
+ }
163
+ function plannedTrashPath(agent, label) {
164
+ return path.join(getHistoryDir(), 'trash', 'versions', agent, label, '<stamp>');
165
+ }
166
+ function emptyDir(dir) {
167
+ if (!fs.existsSync(dir))
168
+ return true;
169
+ try {
170
+ return fs.readdirSync(dir).length === 0;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ function assertSlotAbsent(agent, accountId) {
177
+ const dest = slotDir(agent, accountId);
178
+ if (fs.existsSync(dest) && !emptyDir(dest)) {
179
+ throw new Error(`Slot already exists for ${agent} account ${accountId} at ${dest}.`);
180
+ }
181
+ return dest;
182
+ }
183
+ function takenNames(meta) {
184
+ const taken = new Set();
185
+ for (const row of listNativeAccounts(meta)) {
186
+ taken.add(`${row.agent}:${row.name.toLowerCase()}`);
187
+ }
188
+ return taken;
189
+ }
190
+ function resolveOrRegisterAccount(item, taken) {
191
+ const meta = readMeta();
192
+ if (item.accountId) {
193
+ const row = listNativeAccounts(meta).find((a) => a.id === item.accountId);
194
+ if (!row)
195
+ throw new Error(`${item.agent}@${item.label} points at unknown account id '${item.accountId}'.`);
196
+ assertIdentityMatch(item.agent, item.label, item.identityKey, row);
197
+ return row;
198
+ }
199
+ if (!item.identityKey) {
200
+ throw new Error(`${item.agent}@${item.label} has a credential but no inspectable identity.`);
201
+ }
202
+ const existing = listNativeAccounts(meta).find((a) => a.agent === item.agent && a.identityKey === item.identityKey);
203
+ if (existing)
204
+ return existing;
205
+ const preferred = (!isOpaqueInstallLabel(item.label) ? item.label : null)
206
+ ?? emailLocalpart(item.email)
207
+ ?? 'account';
208
+ const name = uniqueAccountName(item.agent, preferred, taken);
209
+ taken.add(`${item.agent}:${name.toLowerCase()}`);
210
+ const cap = nativeAccountCapability(item.agent);
211
+ const scope = cap.scope === 'device' ? 'device' : 'version';
212
+ return addNativeAccount(name, item.agent, item.identityKey, item.email ?? undefined, scope);
213
+ }
214
+ async function planHarness(agent, deps) {
215
+ const inventory = await inventoryHarness(agent, deps);
216
+ const defaultBefore = getGlobalDefault(agent);
217
+ const trashFromDups = await duplicateTrashLabels(inventory);
218
+ const canonical = chooseCanonical(inventory, defaultBefore, trashFromDups);
219
+ const actions = [];
220
+ for (const item of inventory) {
221
+ if (item.isolated) {
222
+ actions.push({
223
+ kind: 'skip',
224
+ label: item.label,
225
+ release: item.release,
226
+ reason: 'isolated copy — left untouched',
227
+ sessionCount: item.sessionCount,
228
+ pathMoves: [],
229
+ });
230
+ continue;
231
+ }
232
+ if (item.busy) {
233
+ actions.push({
234
+ kind: 'defer',
235
+ label: item.label,
236
+ release: item.release,
237
+ reason: 'installation is busy (live process or launch lease)',
238
+ identityKey: item.identityKey,
239
+ email: item.email,
240
+ sessionCount: item.sessionCount,
241
+ pathMoves: [],
242
+ });
243
+ continue;
244
+ }
245
+ if (trashFromDups.has(item.label)) {
246
+ actions.push({
247
+ kind: 'trash',
248
+ label: item.label,
249
+ release: item.release,
250
+ reason: 'duplicate identity — keeping the better home via planDuplicatePrune',
251
+ identityKey: item.identityKey,
252
+ email: item.email,
253
+ sessionCount: item.sessionCount,
254
+ pathMoves: [{ from: item.dir, to: plannedTrashPath(agent, item.label) }],
255
+ });
256
+ continue;
257
+ }
258
+ if (item.hasCredential && item.identityKey) {
259
+ const dest = item.accountId ? slotDir(agent, item.accountId) : slotDir(agent, '<new-account>');
260
+ actions.push({
261
+ kind: 'slot',
262
+ label: item.label,
263
+ release: item.release,
264
+ reason: item.label === canonical?.label
265
+ ? 'credential-bearing canonical home → slot (binary stays)'
266
+ : 'credential-bearing home → slot; binary trashed',
267
+ accountId: item.accountId ?? undefined,
268
+ identityKey: item.identityKey,
269
+ email: item.email,
270
+ slotDir: dest,
271
+ sessionCount: item.sessionCount,
272
+ pathMoves: [
273
+ { from: item.home, to: dest },
274
+ ...(item.label === canonical?.label ? [] : [{ from: item.dir, to: plannedTrashPath(agent, item.label) }]),
275
+ ],
276
+ });
277
+ continue;
278
+ }
279
+ if (canonical && item.label === canonical.label) {
280
+ actions.push({
281
+ kind: 'canonical',
282
+ label: item.label,
283
+ release: item.release,
284
+ reason: 'canonical install (binary kept; no credential to move)',
285
+ sessionCount: item.sessionCount,
286
+ pathMoves: [],
287
+ });
288
+ continue;
289
+ }
290
+ actions.push({
291
+ kind: 'trash',
292
+ label: item.label,
293
+ release: item.release,
294
+ reason: item.hasBinary ? 'empty logged-out home' : 'home-only leftover (no binary)',
295
+ sessionCount: item.sessionCount,
296
+ pathMoves: [{ from: item.dir, to: plannedTrashPath(agent, item.label) }],
297
+ });
298
+ }
299
+ const counts = {
300
+ installations: inventory.length,
301
+ // A busy canonical is still the kept install — we did not move it, we
302
+ // deferred the fold. Counting it as 0 install is a lie.
303
+ keep: actions.filter((a) => a.kind === 'canonical'
304
+ || ((a.kind === 'slot' || a.kind === 'defer') && a.label === canonical?.label)).length,
305
+ slots: actions.filter((a) => a.kind === 'slot').length,
306
+ trash: actions.filter((a) => a.kind === 'trash').length,
307
+ deferred: actions.filter((a) => a.kind === 'defer').length,
308
+ skipped: actions.filter((a) => a.kind === 'skip').length,
309
+ };
310
+ return { agent, canonical: canonical?.label ?? null, defaultBefore, inventory, actions, counts };
311
+ }
312
+ export async function planAccountMigration(agents = [...ALL_AGENT_IDS], deps = {}) {
313
+ const harnesses = [];
314
+ for (const agent of agents) {
315
+ const plan = await planHarness(agent, deps);
316
+ if (plan.inventory.length === 0)
317
+ continue;
318
+ harnesses.push(plan);
319
+ }
320
+ const totals = harnesses.reduce((acc, h) => ({
321
+ installations: acc.installations + h.counts.installations,
322
+ keep: acc.keep + h.counts.keep,
323
+ slots: acc.slots + h.counts.slots,
324
+ trash: acc.trash + h.counts.trash,
325
+ deferred: acc.deferred + h.counts.deferred,
326
+ skipped: acc.skipped + h.counts.skipped,
327
+ }), { installations: 0, keep: 0, slots: 0, trash: 0, deferred: 0, skipped: 0 });
328
+ return { at: (deps.now ?? (() => new Date()))().toISOString(), harnesses, totals };
329
+ }
330
+ function canonicalBusy(h) {
331
+ return !!h.canonical && h.actions.some((a) => a.kind === 'defer' && a.label === h.canonical);
332
+ }
333
+ export function formatMigrationPlan(plan) {
334
+ const lines = [];
335
+ if (plan.harnesses.length === 0 || plan.totals.installations === 0) {
336
+ return 'No per-account installations to fold.';
337
+ }
338
+ for (const h of plan.harnesses) {
339
+ if (h.counts.installations === 0)
340
+ continue;
341
+ lines.push(`${h.agent}: ${h.counts.installations} installation${h.counts.installations === 1 ? '' : 's'}`
342
+ + ` → ${h.counts.keep} install + ${h.counts.slots} slot${h.counts.slots === 1 ? '' : 's'}`
343
+ + ` + ${h.counts.trash} trashed`
344
+ + (h.counts.deferred ? ` + ${h.counts.deferred} deferred` : '')
345
+ + (h.canonical ? ` (canonical ${h.agent}@${h.canonical}${canonicalBusy(h) ? ' busy' : ''})` : ''));
346
+ if (h.defaultBefore && h.defaultBefore !== h.canonical) {
347
+ lines.push(` default ${h.agent}@${h.defaultBefore} is not launchable → repoint to ${h.canonical ?? '(none)'}`);
348
+ }
349
+ for (const a of h.actions) {
350
+ const who = a.email ? ` ${a.email}` : '';
351
+ lines.push(` ${a.kind.padEnd(9)} ${h.agent}@${a.label}${who} ${a.reason}`);
352
+ }
353
+ }
354
+ lines.push(`totals: ${plan.totals.installations} installations → ${plan.totals.keep} install + ${plan.totals.slots} slots + ${plan.totals.trash} trashed`
355
+ + (plan.totals.deferred ? ` + ${plan.totals.deferred} deferred` : ''));
356
+ return lines.join('\n');
357
+ }
358
+ function rewriteBindings(agent, labelToAccount, stillPresent) {
359
+ const meta = readMeta();
360
+ const targets = new Set([
361
+ ...Object.keys(meta.accounts?.bindings ?? {}),
362
+ ...Object.keys(meta.deviceAccounts?.bindings ?? {}),
363
+ ]);
364
+ const prefix = `${agent}@`;
365
+ for (const target of targets) {
366
+ if (!target.startsWith(prefix))
367
+ continue;
368
+ const label = target.slice(prefix.length);
369
+ const accountId = labelToAccount.get(label);
370
+ if (!accountId) {
371
+ if (stillPresent.has(label))
372
+ continue;
373
+ throw new Error(`Cannot rewrite binding '${target}': ${agent}@${label} was removed and has no kept account of the same identity.`);
374
+ }
375
+ const row = listNativeAccounts(readMeta()).find((a) => a.id === accountId);
376
+ if (!row) {
377
+ throw new Error(`Cannot rewrite binding '${target}': account '${accountId}' is missing.`);
378
+ }
379
+ // Keep the per-target key (`agent@label` → account id). Collapsing every
380
+ // label onto one bare-agent binding would drop every account after the first.
381
+ bindAccount(row.id, target, agent);
382
+ }
383
+ }
384
+ function clearHomes(accountIds) {
385
+ if (accountIds.length === 0)
386
+ return;
387
+ updateMeta((current) => {
388
+ const homes = { ...current.deviceAccounts?.homes };
389
+ for (const id of accountIds)
390
+ delete homes[id];
391
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, homes } };
392
+ });
393
+ }
394
+ function moveHomeToSlot(item, dest) {
395
+ if (fs.existsSync(dest) && !emptyDir(dest)) {
396
+ throw new Error(`Slot already exists for ${item.agent} account at ${dest}.`);
397
+ }
398
+ if (fs.existsSync(dest))
399
+ fs.rmdirSync(dest);
400
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
401
+ if (!fs.existsSync(item.home)) {
402
+ throw new Error(`Home ${item.home} is missing; cannot move it into a slot.`);
403
+ }
404
+ fs.renameSync(item.home, dest);
405
+ }
406
+ function recordMovedSlot(agent, account, dest) {
407
+ const cap = nativeAccountCapability(agent);
408
+ const slot = {
409
+ accountId: account.id,
410
+ slotDir: dest,
411
+ authMode: cap.scope === 'device' ? 'per-device' : 'native',
412
+ verdict: 'unverified',
413
+ };
414
+ recordSlot(account.id, slot);
415
+ }
416
+ function persistManifest(manifestPath, manifest) {
417
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true, mode: 0o700 });
418
+ atomicWriteFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
419
+ }
420
+ function harnessManifest(manifest, agent, canonical) {
421
+ const existing = manifest.harnesses[agent];
422
+ if (existing)
423
+ return existing;
424
+ const created = { canonical, slots: [], trashed: [], deferred: [] };
425
+ manifest.harnesses[agent] = created;
426
+ return created;
427
+ }
428
+ export async function applyAccountMigration(agents = [...ALL_AGENT_IDS], deps = {}) {
429
+ const plan = await planAccountMigration(agents, deps);
430
+ const now = deps.now ?? (() => new Date());
431
+ const at = now();
432
+ const stamp = at.toISOString();
433
+ const manifestPath = path.join(getHistoryDir(), 'accounts', `migration-${stamp.replace(/[:.]/g, '-')}.json`);
434
+ const manifest = {
435
+ schema: ACCOUNT_MIGRATION_SCHEMA,
436
+ at: stamp,
437
+ dryRun: false,
438
+ status: 'planned',
439
+ plan,
440
+ harnesses: {},
441
+ map: {},
442
+ };
443
+ persistManifest(manifestPath, manifest);
444
+ const remaps = [];
445
+ const taken = takenNames(readMeta());
446
+ for (const h of plan.harnesses) {
447
+ const entry = harnessManifest(manifest, h.agent, h.canonical);
448
+ const labelToAccount = new Map();
449
+ const identityToAccount = new Map();
450
+ const movedAccountIds = [];
451
+ const stillPresent = new Set(h.inventory.map((i) => i.label));
452
+ const byLabel = new Map(h.inventory.map((i) => [i.label, i]));
453
+ const slotActions = h.actions.filter((a) => a.kind === 'slot');
454
+ const trashActions = h.actions.filter((a) => a.kind === 'trash');
455
+ for (const action of h.actions.filter((a) => a.kind === 'defer')) {
456
+ entry.deferred.push({ label: action.label, reason: action.reason });
457
+ }
458
+ persistManifest(manifestPath, manifest);
459
+ for (const action of slotActions) {
460
+ const item = byLabel.get(action.label);
461
+ if (!item)
462
+ throw new Error(`Plan named ${h.agent}@${action.label} but inventory has no such install.`);
463
+ const account = resolveOrRegisterAccount(item, taken);
464
+ const dest = assertSlotAbsent(h.agent, account.id);
465
+ moveHomeToSlot(item, dest);
466
+ recordMovedSlot(h.agent, account, dest);
467
+ remaps.push({ from: item.home, to: dest });
468
+ labelToAccount.set(item.label, account.id);
469
+ if (item.identityKey)
470
+ identityToAccount.set(item.identityKey, account.id);
471
+ movedAccountIds.push(account.id);
472
+ entry.slots.push({ oldLabel: item.label, accountId: account.id, accountName: account.name, slotDir: dest });
473
+ manifest.map[`${h.agent}@${item.label}`] = dest;
474
+ persistManifest(manifestPath, manifest);
475
+ if (item.label === h.canonical) {
476
+ fs.mkdirSync(item.home, { recursive: true, mode: 0o700 });
477
+ }
478
+ else {
479
+ const trashPath = softDeleteVersionDir(h.agent, item.label);
480
+ if (!trashPath)
481
+ throw new Error(`Failed to trash binary leftover ${h.agent}@${item.label}.`);
482
+ removeVersionedAlias(h.agent, item.label);
483
+ remaps.push({ from: item.dir, to: trashPath });
484
+ stillPresent.delete(item.label);
485
+ entry.trashed.push({ label: item.label, reason: 'binary leftover after home moved to slot', trashPath });
486
+ manifest.map[`${h.agent}@${item.label}#binary`] = trashPath;
487
+ persistManifest(manifestPath, manifest);
488
+ }
489
+ }
490
+ for (const item of h.inventory) {
491
+ if (!item.identityKey)
492
+ continue;
493
+ const kept = identityToAccount.get(item.identityKey);
494
+ if (kept)
495
+ labelToAccount.set(item.label, kept);
496
+ }
497
+ for (const action of trashActions) {
498
+ const item = byLabel.get(action.label);
499
+ if (!item)
500
+ throw new Error(`Plan named ${h.agent}@${action.label} but inventory has no such install.`);
501
+ const trashPath = softDeleteVersionDir(h.agent, item.label);
502
+ if (!trashPath)
503
+ throw new Error(`Failed to trash ${h.agent}@${item.label}.`);
504
+ removeVersionedAlias(h.agent, item.label);
505
+ remaps.push({ from: item.dir, to: trashPath });
506
+ stillPresent.delete(item.label);
507
+ entry.trashed.push({ label: item.label, reason: action.reason, trashPath });
508
+ manifest.map[`${h.agent}@${item.label}`] = trashPath;
509
+ persistManifest(manifestPath, manifest);
510
+ }
511
+ if (h.canonical)
512
+ setGlobalDefault(h.agent, h.canonical);
513
+ rewriteBindings(h.agent, labelToAccount, stillPresent);
514
+ clearHomes(movedAccountIds);
515
+ invalidateInstalledVersionsCache(h.agent);
516
+ }
517
+ const sessionsReindexed = reindexMovedSessionPaths(remaps);
518
+ manifest.status = 'complete';
519
+ persistManifest(manifestPath, manifest);
520
+ return { plan, manifest, manifestPath, sessionsReindexed };
521
+ }
522
+ /**
523
+ * Upgrade hook: print a dry-run report when leftover per-account installations
524
+ * exist. Never applies. `--apply` stays an explicit `agents accounts migrate`
525
+ * flag this release.
526
+ */
527
+ export async function reportAccountSlotMigrationOnUpgrade() {
528
+ const plan = await planAccountMigration();
529
+ const work = plan.totals.slots + plan.totals.trash + plan.totals.deferred;
530
+ if (work === 0)
531
+ return;
532
+ const summary = formatMigrationPlan(plan);
533
+ process.stderr.write(`[agents] Account model v2: ${plan.totals.installations} leftover per-account installation(s) can fold into slots.\n`
534
+ + `${summary}\n`
535
+ + `Dry-run (this upgrade does not move anything). Apply with: agents accounts migrate --apply\n`);
536
+ }
@@ -0,0 +1,13 @@
1
+ import type { AgentId, DeviceAccountSlot, Meta } from '../types.js';
2
+ export type { DeviceAccountSlot };
3
+ export declare function slotDir(harness: AgentId, accountId: string): string;
4
+ export declare function readSlots(meta: Pick<Meta, 'deviceAccounts'>): Record<string, DeviceAccountSlot>;
5
+ export declare function recordSlot(accountId: string, slot: DeviceAccountSlot): void;
6
+ /**
7
+ * Create the HOME-shaped slot dir and project settings/resources from the
8
+ * managed install. Does not copy credentials (carryForwardSettings excludes
9
+ * them; resource writers write skills/hooks/commands, never OAuth files).
10
+ * Does not persist the slot record — call {@link recordSlot} after identity
11
+ * is captured.
12
+ */
13
+ export declare function ensureSlot(harness: AgentId, accountId: string): DeviceAccountSlot;