@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -35,6 +35,8 @@ import { Option } from 'commander';
35
35
  import chalk from 'chalk';
36
36
  import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
37
37
  import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
38
+ import { autoEvictCentralBrowserProfiles } from '../lib/browser/registry.js';
39
+ import { shouldAutoClaimCentralProfile } from '../lib/browser/profiles.js';
38
40
  import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
39
41
  import { capableAgents } from '../lib/capabilities.js';
40
42
  import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
@@ -352,6 +354,12 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
352
354
  else
353
355
  outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
354
356
  }
357
+ // Drain the legacy central `browser:` tombstone now that the repos are pulled.
358
+ // Running AFTER the pull is load-bearing: it acts on a view of central that
359
+ // already reflects peers' drains, so it never re-claims a profile another box
360
+ // already migrated (which would flip that profile's kind identity->fungible).
361
+ // The interactive path is always non-quiet, non-json.
362
+ evictCentralBrowserProfilesForSync(false, false, outLog, errLog);
355
363
  // 2. One selection spanning the chosen repos.
356
364
  const selection = mergeRepoScopedSelections(repos, cwd);
357
365
  const hasResources = selection.memory === 'all' || Object.entries(selection).some(([kind, v]) => kind !== 'memory' && Array.isArray(v) && v.length > 0);
@@ -374,6 +382,36 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
374
382
  }
375
383
  }
376
384
  }
385
+ /**
386
+ * Drain the legacy central `browser:` tombstone during `agents sync` (PHNX-3315).
387
+ * New profiles write the per-device doc, but profiles created before the
388
+ * device-scoped store lingered in the shared top-level `agents.yaml` and churned
389
+ * every fleet pull until someone ran `agents browser profiles claim` by hand.
390
+ * Fold the ones THIS box can host into its device doc — host-gated, and the
391
+ * selection is computed under the meta lock (see autoEvictCentralBrowserProfiles)
392
+ * so it never races itself. Callers MUST invoke this AFTER the repo pull: acting
393
+ * on a pre-pull view of central risks re-claiming a profile a peer already
394
+ * drained, which would flip its kind identity->fungible. Non-fatal so a hiccup
395
+ * can never wedge the sync.
396
+ */
397
+ function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
398
+ try {
399
+ // Auto-claim ONLY remote (ssh://) tombstones — they are fungible by design,
400
+ // so a concurrent cross-machine double-claim is harmless. Local/cdp profiles
401
+ // have no per-machine ownership signal and are left central for an explicit
402
+ // `agents browser profiles claim` (PHNX-3315 review). See
403
+ // shouldAutoClaimCentralProfile.
404
+ const result = autoEvictCentralBrowserProfiles(shouldAutoClaimCentralProfile);
405
+ if (!quiet && !json && result.claimed.length > 0) {
406
+ outLog(chalk.gray(` Claimed ${result.claimed.length} central browser profile(s) into this device: ${result.claimed.join(', ')}`));
407
+ }
408
+ }
409
+ catch (err) {
410
+ if (!quiet && !json) {
411
+ errLog(chalk.yellow(` ! browser profile eviction skipped: ${err.message}`));
412
+ }
413
+ }
414
+ }
377
415
  /**
378
416
  * The umbrella verb: bare `agents sync` (no agent) makes this machine current.
379
417
  * Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
@@ -415,6 +453,12 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
415
453
  log: (msg) => { if (!quiet && !json)
416
454
  outLog(chalk.gray(` ${msg}`)); },
417
455
  });
456
+ // Drain the legacy central `browser:` tombstone AFTER the umbrella pull, so
457
+ // we act on central as converged by this sync rather than a stale pre-pull
458
+ // copy that could re-claim a profile a peer already migrated. Skipped under
459
+ // --cloud (fetch-only, no local reconcile).
460
+ if (!opts.cloud)
461
+ evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
418
462
  if (json) {
419
463
  emitJson({
420
464
  // A refused resource is not a clean sync (RUSH-2700).
@@ -81,7 +81,9 @@ export declare function parseResourceSections(options: {
81
81
  * agents-cli extension's "resume current session in best available version"
82
82
  * command).
83
83
  */
84
- export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>): Promise<ViewJsonAgent[]>;
84
+ export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>, opts?: {
85
+ forceRefresh?: boolean;
86
+ }): Promise<ViewJsonAgent[]>;
85
87
  /**
86
88
  * Identity key for duplicate-install detection. Prefers accountKey — which
87
89
  * encodes account AND org — over the bare email: two installs can share an
@@ -1209,7 +1209,7 @@ export function parseResourceSections(options, jsonMode) {
1209
1209
  * agents-cli extension's "resume current session in best available version"
1210
1210
  * command).
1211
1211
  */
1212
- export async function collectAgentsJson(filterAgentId, resourceSections) {
1212
+ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1213
1213
  const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
1214
1214
  const authCache = readAuthHealthCache();
1215
1215
  const host = machineId();
@@ -1230,7 +1230,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1230
1230
  home,
1231
1231
  cliVersion: version,
1232
1232
  info,
1233
- })));
1233
+ })), { forceRefresh: opts?.forceRefresh === true });
1234
1234
  const mergeCanonical = (info) => {
1235
1235
  const key = getUsageLookupKey(info);
1236
1236
  if (!key)
@@ -1273,6 +1273,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1273
1273
  plan: info.plan,
1274
1274
  usageStatus: info.usageStatus,
1275
1275
  overageCredits: info.overageCredits,
1276
+ usageError: usageInfo?.error ?? null,
1276
1277
  windows: snapshot
1277
1278
  ? snapshot.windows.map((w) => ({
1278
1279
  key: w.key,
@@ -1562,7 +1563,7 @@ export async function viewAction(agentArg, options) {
1562
1563
  return;
1563
1564
  }
1564
1565
  if (json) {
1565
- const data = await collectAgentsJson(undefined, resourceSections);
1566
+ const data = await collectAgentsJson(undefined, resourceSections, { forceRefresh });
1566
1567
  console.log(JSON.stringify(data, null, 2));
1567
1568
  return;
1568
1569
  }
@@ -1624,7 +1625,7 @@ export async function viewAction(agentArg, options) {
1624
1625
  if (json) {
1625
1626
  // --json ignores the @version suffix, but --resources/--detailed (or a
1626
1627
  // section flag) now attach each version's resource inventory + sync-state.
1627
- const data = await collectAgentsJson(agentId, resourceSections);
1628
+ const data = await collectAgentsJson(agentId, resourceSections, { forceRefresh });
1628
1629
  console.log(JSON.stringify(data[0] ?? { agent: agentId, versions: [], harnesses: [] }, null, 2));
1629
1630
  return;
1630
1631
  }
@@ -34,7 +34,15 @@ export type UnifiedAccount = (CredentialAccount & {
34
34
  export declare function accountRegistryPath(base?: string): string;
35
35
  export declare function readAccountRegistry(base?: string): AccountRegistryDocument;
36
36
  export declare function findAccount(name: string, doc?: AccountRegistryDocument): CredentialAccount | null;
37
- export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): NativeAccount[];
37
+ /**
38
+ * Effective native accounts: the fleet-shared central store (version-scoped
39
+ * identities) merged with THIS box's own device doc (device-scoped identities).
40
+ * A native login is machine-local — its home follows its scope (PHNX-3315), so
41
+ * a `scope:'device'` identity is read from this box's device doc and never the
42
+ * shared central agents.yaml (which is where its email/identityKey PII used to
43
+ * accumulate). On an id collision the device slice wins.
44
+ */
45
+ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
38
46
  /**
39
47
  * Resolve one account by name or id across both stores, native first.
40
48
  *
@@ -46,15 +54,17 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): Native
46
54
  * running before the body, so callers that only need a native lookup must be
47
55
  * able to omit it.
48
56
  */
49
- export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
57
+ export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
50
58
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
51
59
  /** Create or replace the version-independent label for one native identity. */
52
60
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
53
61
  export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
54
62
  export declare function unbindAccount(nameOrId: string, target: string): void;
55
- export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts'>): string[];
63
+ /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
+ * the fleet-shared central bindings (PHNX-3315). */
65
+ export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
56
66
  /** Explicit selection wins over a configured per-harness default. */
57
- export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts'>, opts?: {
67
+ export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
58
68
  useDefault?: boolean;
59
69
  target?: string;
60
70
  }): string | undefined;
@@ -100,7 +110,7 @@ export type SpawnAccount = {
100
110
  * A native account bound to a different harness than the one being launched
101
111
  * fails loudly (EXEC-ACCOUNT-4). Returns null when nothing is selected.
102
112
  */
103
- export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts'>, opts?: {
113
+ export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
104
114
  useDefault?: boolean;
105
115
  provider?: string;
106
116
  base?: string;
@@ -12,7 +12,9 @@
12
12
  * `{ version: 2, accounts }` view so existing consumers (harness, profiles,
13
13
  * exec) keep working — it is now a projection over the account bundles, not a
14
14
  * file read. Native OAuth logins are NOT accounts here; they stay native and
15
- * surface through unified discovery in [[account-catalog]].
15
+ * surface through unified discovery in [[account-catalog]]. Labels bind to
16
+ * `(agent, identityKey)` on the central `accounts.native` rows in agents.yaml,
17
+ * which `agents repo push/pull` already syncs fleet-wide.
16
18
  */
17
19
  import * as fs from 'node:fs';
18
20
  import * as path from 'node:path';
@@ -21,9 +23,9 @@ import * as yaml from 'yaml';
21
23
  import { atomicWriteFileSync } from './fs-atomic.js';
22
24
  import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
23
25
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
24
- import { bundleExists, deleteBundle, listBundles, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
26
+ import { bundleExists, deleteBundle, listBundles, readAndResolveBundleEnv, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
25
27
  import { getAccountProvider } from './account-provider-registry.js';
26
- import { accountSecretItem, buildAccountBundle, parseAccountBundle } from './account-schema.js';
28
+ import { accountSecretItem, buildAccountBundle, parseAccountBundle, secretVarFor } from './account-schema.js';
27
29
  const NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
28
30
  const NATIVE_LABEL = /^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$/;
29
31
  const AUTH_KINDS = ['api-key', 'setup-token', 'bearer-token'];
@@ -133,8 +135,17 @@ export function readAccountRegistry(base = getUserAgentsDir()) {
133
135
  export function findAccount(name, doc = readAccountRegistry()) {
134
136
  return doc.accounts[name] ?? Object.values(doc.accounts).find(account => account.name === name) ?? null;
135
137
  }
138
+ /**
139
+ * Effective native accounts: the fleet-shared central store (version-scoped
140
+ * identities) merged with THIS box's own device doc (device-scoped identities).
141
+ * A native login is machine-local — its home follows its scope (PHNX-3315), so
142
+ * a `scope:'device'` identity is read from this box's device doc and never the
143
+ * shared central agents.yaml (which is where its email/identityKey PII used to
144
+ * accumulate). On an id collision the device slice wins.
145
+ */
136
146
  export function listNativeAccounts(meta) {
137
- return Object.values(meta.accounts?.native ?? {}).map(account => ({ ...account, kind: 'native' }));
147
+ const merged = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
148
+ return Object.values(merged).map(account => ({ ...account, kind: 'native' }));
138
149
  }
139
150
  /**
140
151
  * Resolve one account by name or id across both stores, native first.
@@ -155,8 +166,28 @@ export function findUnifiedAccount(nameOrId, meta, doc) {
155
166
  const provider = findAccount(nameOrId, doc ?? readAccountRegistry());
156
167
  return provider ? { ...provider, kind: 'provider' } : null;
157
168
  }
158
- function assertUniqueUnifiedName(name, meta, doc) {
159
- if (findUnifiedAccount(name, meta, doc))
169
+ function nativeIdentityRows(meta, agent, identityKey) {
170
+ return listNativeAccounts(meta).filter(account => account.agent === agent && account.identityKey === identityKey);
171
+ }
172
+ /** Every row (central + this box's device store) for the identity that `name`
173
+ * (id or account name) resolves to. */
174
+ function nativeRowsForNameOrId(meta, name) {
175
+ const found = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
176
+ if (!found)
177
+ return [];
178
+ return nativeIdentityRows(meta, found.agent, found.identityKey);
179
+ }
180
+ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
181
+ const needle = name.toLowerCase();
182
+ const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
183
+ if (nativeHits.some(account => !exceptIds?.has(account.id)))
184
+ throw new Error(`Account '${name}' already exists.`);
185
+ // Same laziness as findUnifiedAccount: a native row that already owns this
186
+ // name (even one we are mutating) means we never open the provider store.
187
+ if (nativeHits.length > 0)
188
+ return;
189
+ const provider = findAccount(name, doc ?? readAccountRegistry());
190
+ if (provider && !exceptIds?.has(provider.id))
160
191
  throw new Error(`Account '${name}' already exists.`);
161
192
  }
162
193
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
@@ -167,13 +198,28 @@ export function addNativeAccount(name, agent, identityKey, identityLabel, scope)
167
198
  if (duplicate)
168
199
  throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
169
200
  const account = { id: crypto.randomUUID(), name, kind: 'native', agent, identityKey, identityLabel, scope };
170
- updateMeta(current => ({
171
- ...current,
172
- accounts: {
173
- ...current.accounts,
174
- native: { ...current.accounts?.native, [account.id]: { id: account.id, name, agent, identityKey, identityLabel, scope } },
175
- },
176
- }));
201
+ const entry = { id: account.id, name, agent, identityKey, identityLabel, scope };
202
+ // A native login's home follows its scope (PHNX-3315): a device-scoped
203
+ // identity lands in THIS box's device doc (its PII never touches the shared
204
+ // central agents.yaml); a version-scoped one stays in the fleet-shared store.
205
+ if (scope === 'device') {
206
+ updateMeta(current => ({
207
+ ...current,
208
+ deviceAccounts: {
209
+ ...current.deviceAccounts,
210
+ native: { ...current.deviceAccounts?.native, [account.id]: entry },
211
+ },
212
+ }));
213
+ }
214
+ else {
215
+ updateMeta(current => ({
216
+ ...current,
217
+ accounts: {
218
+ ...current.accounts,
219
+ native: { ...current.accounts?.native, [account.id]: entry },
220
+ },
221
+ }));
222
+ }
177
223
  return account;
178
224
  }
179
225
  /** Create or replace the version-independent label for one native identity. */
@@ -183,30 +229,49 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
183
229
  throw new Error(`${agent} does not expose an email; pass a manual label.`);
184
230
  assertNativeLabel(resolvedLabel);
185
231
  const meta = readMeta();
186
- const existing = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
187
- const collision = findUnifiedAccount(resolvedLabel, meta);
188
- if (collision && collision.id !== existing?.id)
189
- throw new Error(`Account '${resolvedLabel}' already exists.`);
190
- if (!existing)
232
+ const matches = nativeIdentityRows(meta, agent, identityKey);
233
+ assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
234
+ if (matches.length === 0)
191
235
  return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
192
- updateMeta(current => ({
193
- ...current,
194
- accounts: {
195
- ...current.accounts,
196
- native: { ...current.accounts?.native, [existing.id]: { ...current.accounts?.native?.[existing.id], name: resolvedLabel, identityLabel } },
197
- },
198
- }));
199
- return { ...existing, name: resolvedLabel, identityLabel };
236
+ // Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
237
+ // store that owns them: all rows for one identityKey share a scope (same agent),
238
+ // so a device-scoped login lands in this box's device doc, a version-scoped one
239
+ // in central (PHNX-3315).
240
+ const rowScope = matches[0].scope;
241
+ updateMeta(current => {
242
+ if (rowScope === 'device') {
243
+ const native = { ...current.deviceAccounts?.native };
244
+ for (const row of matches)
245
+ native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
246
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
247
+ }
248
+ const native = { ...current.accounts?.native };
249
+ for (const row of matches)
250
+ native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
251
+ return { ...current, accounts: { ...current.accounts, native } };
252
+ });
253
+ return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
200
254
  }
201
255
  export function bindAccount(nameOrId, target) {
202
256
  const meta = readMeta();
203
257
  const account = findUnifiedAccount(nameOrId, meta);
204
258
  if (!account)
205
259
  throw new Error(`Unknown account '${nameOrId}'.`);
206
- updateMeta(current => ({
207
- ...current,
208
- accounts: { ...current.accounts, bindings: { ...current.accounts?.bindings, [target]: account.id } },
209
- }));
260
+ // A binding follows its account: one that targets a device-scoped native login
261
+ // is itself machine-local and lands in this box's device doc (PHNX-3315);
262
+ // every other binding stays fleet-shared in central.
263
+ if (account.kind === 'native' && account.scope === 'device') {
264
+ updateMeta(current => ({
265
+ ...current,
266
+ deviceAccounts: { ...current.deviceAccounts, bindings: { ...current.deviceAccounts?.bindings, [target]: account.id } },
267
+ }));
268
+ }
269
+ else {
270
+ updateMeta(current => ({
271
+ ...current,
272
+ accounts: { ...current.accounts, bindings: { ...current.accounts?.bindings, [target]: account.id } },
273
+ }));
274
+ }
210
275
  return account;
211
276
  }
212
277
  export function unbindAccount(nameOrId, target) {
@@ -214,25 +279,43 @@ export function unbindAccount(nameOrId, target) {
214
279
  const account = findUnifiedAccount(nameOrId, meta);
215
280
  if (!account)
216
281
  throw new Error(`Unknown account '${nameOrId}'.`);
217
- if (meta.accounts?.bindings?.[target] !== account.id)
282
+ const inCentral = meta.accounts?.bindings?.[target] === account.id;
283
+ const inDevice = meta.deviceAccounts?.bindings?.[target] === account.id;
284
+ if (!inCentral && !inDevice)
218
285
  throw new Error(`Account '${account.name}' is not attached to '${target}'.`);
219
286
  updateMeta(current => {
220
- const bindings = { ...current.accounts?.bindings };
221
- delete bindings[target];
222
- return { ...current, accounts: { ...current.accounts, bindings } };
287
+ let next = current;
288
+ if (current.accounts?.bindings?.[target] === account.id) {
289
+ const bindings = { ...current.accounts?.bindings };
290
+ delete bindings[target];
291
+ next = { ...next, accounts: { ...next.accounts, bindings } };
292
+ }
293
+ if (current.deviceAccounts?.bindings?.[target] === account.id) {
294
+ const bindings = { ...current.deviceAccounts?.bindings };
295
+ delete bindings[target];
296
+ next = { ...next, deviceAccounts: { ...next.deviceAccounts, bindings } };
297
+ }
298
+ return next;
223
299
  });
224
300
  }
301
+ /** Every target bound to `accountId`: this box's device-doc bindings merged over
302
+ * the fleet-shared central bindings (PHNX-3315). */
225
303
  export function accountBindings(accountId, meta) {
226
- return Object.entries(meta.accounts?.bindings ?? {}).filter(([, id]) => id === accountId).map(([target]) => target).sort();
304
+ const merged = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
305
+ return Object.entries(merged).filter(([, id]) => id === accountId).map(([target]) => target).sort();
227
306
  }
228
307
  /** Explicit selection wins over a configured per-harness default. */
229
308
  export function resolveAccountSelection(explicit, agent, meta, opts = {}) {
230
309
  if (explicit)
231
310
  return explicit;
232
- const bound = opts.target ? meta.accounts?.bindings?.[opts.target] : undefined;
311
+ // This box's device-doc bindings win over the fleet-shared central bindings
312
+ // (PHNX-3315), so a per-box account attachment resolves without touching the
313
+ // shared file. Defaults are genuinely fleet-shared and stay central.
314
+ const bindings = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
315
+ const bound = opts.target ? bindings[opts.target] : undefined;
233
316
  if (bound)
234
317
  return bound;
235
- const deviceScoped = meta.accounts?.bindings?.[agent];
318
+ const deviceScoped = bindings[agent];
236
319
  if (deviceScoped)
237
320
  return deviceScoped;
238
321
  return opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
@@ -285,16 +368,23 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
285
368
  assertName(newName);
286
369
  const doc = readAccountRegistry(base);
287
370
  const meta = readMeta();
288
- const native = listNativeAccounts(meta).find(account => account.id === oldName || account.name === oldName);
289
- if (native) {
290
- assertUniqueUnifiedName(newName, meta, doc);
291
- updateMeta(current => ({
292
- ...current,
293
- accounts: {
294
- ...current.accounts,
295
- native: { ...current.accounts?.native, [native.id]: { ...current.accounts?.native?.[native.id], name: newName } },
296
- },
297
- }));
371
+ const rows = nativeRowsForNameOrId(meta, oldName);
372
+ if (rows.length) {
373
+ assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
374
+ // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315).
375
+ const rowScope = rows[0].scope;
376
+ updateMeta(current => {
377
+ if (rowScope === 'device') {
378
+ const native = { ...current.deviceAccounts?.native };
379
+ for (const row of rows)
380
+ native[row.id] = { ...native[row.id], name: newName };
381
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
382
+ }
383
+ const native = { ...current.accounts?.native };
384
+ for (const row of rows)
385
+ native[row.id] = { ...native[row.id], name: newName };
386
+ return { ...current, accounts: { ...current.accounts, native } };
387
+ });
298
388
  return;
299
389
  }
300
390
  const account = findAccount(oldName, doc);
@@ -306,14 +396,24 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
306
396
  }
307
397
  export function removeAccount(name, base = getUserAgentsDir()) {
308
398
  const meta = readMeta();
309
- const native = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
310
- if (native) {
311
- const bindings = accountBindings(native.id, meta);
399
+ const rows = nativeRowsForNameOrId(meta, name);
400
+ if (rows.length) {
401
+ const bindings = [...new Set(rows.flatMap(row => accountBindings(row.id, meta)))].sort();
312
402
  if (bindings.length)
313
- throw new Error(`Account '${native.name}' is attached to: ${bindings.join(', ')}. Detach it before removing it.`);
403
+ throw new Error(`Account '${rows[0].name}' is attached to: ${bindings.join(', ')}. Detach it before removing it.`);
404
+ const ids = new Set(rows.map(row => row.id));
405
+ // Sweep every row for the identity (PHNX-3206) from its owning store (PHNX-3315).
406
+ const rowScope = rows[0].scope;
314
407
  updateMeta(current => {
408
+ if (rowScope === 'device') {
409
+ const accounts = { ...current.deviceAccounts?.native };
410
+ for (const id of ids)
411
+ delete accounts[id];
412
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native: accounts } };
413
+ }
315
414
  const accounts = { ...current.accounts?.native };
316
- delete accounts[native.id];
415
+ for (const id of ids)
416
+ delete accounts[id];
317
417
  return { ...current, accounts: { ...current.accounts, native: accounts } };
318
418
  });
319
419
  return;
@@ -355,6 +455,18 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
355
455
  const envVar = account.auth === 'setup-token' ? 'CLAUDE_CODE_OAUTH_TOKEN' : adapter.envFor(host, account.auth);
356
456
  if (!hasKeychainToken(account.secretRef))
357
457
  throw new Error(`Credential for account '${account.name}' is missing on this device. Add it with 'agents accounts set-key ${account.name}'.`);
458
+ const secretVar = secretVarFor(account.auth);
459
+ // Account bundles are policy `never`, so their value items carry no biometry
460
+ // ACL. Resolve through the bundle path that verifies that policy and attests
461
+ // `silentNoAcl` to the headless keychain guard. Calling getKeychainToken()
462
+ // directly makes a headless --account launch reject the prompt-free item as
463
+ // if it required Touch ID before the helper ever reads it (PHNX-2939).
464
+ const secret = readAndResolveBundleEnv(account.name, {
465
+ keys: [secretVar],
466
+ keyMode: 'storage',
467
+ agentOnly: true,
468
+ caller: 'accounts resolve',
469
+ }).env[secretVar];
358
470
  const connectionEnv = { ...adapter.connectionEnvFor(host) };
359
471
  if (account.baseUrl) {
360
472
  const baseUrlEnv = adapter.baseUrlEnvFor(host);
@@ -367,7 +479,7 @@ export function resolveCredentialAccount(name, host, expectedProvider, base = ge
367
479
  name: account.name,
368
480
  provider: account.provider,
369
481
  auth: account.auth,
370
- env: { ...connectionEnv, [envVar]: getKeychainToken(account.secretRef) },
482
+ env: { ...connectionEnv, [envVar]: secret },
371
483
  };
372
484
  }
373
485
  /**
@@ -336,16 +336,30 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
336
336
  export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
337
337
  /** Select the configured version if available, otherwise another available version. */
338
338
  export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
339
+ /**
340
+ * Resolve the version `agents run` should use when the caller did not pin
341
+ * one with `@version`. The caller supplies the effective strategy.
342
+ *
343
+ * `pinned` still prefers the workspace/global default, but it MUST NOT launch
344
+ * a logged-out (or revoked) default when the same device holds a signed-in
345
+ * version that can run — that was a silent 1-second death on fleet workers
346
+ * whose default home had no credential (PHNX-2685). A rate-limited pin is
347
+ * still honoured: `--strategy pinned` remains the escape hatch to force the
348
+ * default through a throttle. When the default is auth-blocked and nothing
349
+ * else is healthy, `exhausted` is set so the caller fails loud instead of
350
+ * spawning into a credential-less home.
351
+ */
339
352
  export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
340
353
  version: string | null;
341
354
  rotation: RotateResult | null;
342
355
  /**
343
- * Set when a non-pinned strategy found ZERO healthy candidates among the
344
- * installed versions: the full excluded set, so callers fail loud with
345
- * per-account reasons instead of launching the exhausted pinned default
346
- * (RUSH-2132). Undefined for pinned, for successful picks, and when no
347
- * version is installed at all (the pre-existing not-installed path — there
348
- * is no account to be "unhealthy").
356
+ * Set when a strategy found ZERO healthy candidates among the installed
357
+ * versions: the full excluded set, so callers fail loud with per-account
358
+ * reasons instead of launching the exhausted pinned default (RUSH-2132).
359
+ * Also set for `pinned` when the default is logged out / revoked and no
360
+ * signed-in alternative exists (PHNX-2685). Undefined for successful picks
361
+ * and when no version is installed at all (the pre-existing not-installed
362
+ * path — there is no account to be "unhealthy").
349
363
  */
350
364
  exhausted?: RotateCandidate[];
351
365
  }>;
@@ -690,12 +690,6 @@ export async function selectBalancedVersion(agent) {
690
690
  export async function selectAvailableVersion(agent, preferredVersion) {
691
691
  return pickAvailableCandidate(await collectRunCandidates(agent), preferredVersion);
692
692
  }
693
- /**
694
- * Resolve the version `agents run` should use when the caller did not pin
695
- * one with `@version`. The caller supplies the effective strategy; if that
696
- * strategy cannot find a usable candidate, fall back to the pinned
697
- * workspace/global version.
698
- */
699
693
  /**
700
694
  * Record a rotation pick so parallel callers see it as recently-used.
701
695
  * Writes a stamp file per agent — lightweight, no locking needed since
@@ -722,12 +716,49 @@ function readRotationStamp(agent) {
722
716
  catch { /* missing or corrupt — treat as no stamp */ }
723
717
  return null;
724
718
  }
719
+ /**
720
+ * Resolve the version `agents run` should use when the caller did not pin
721
+ * one with `@version`. The caller supplies the effective strategy.
722
+ *
723
+ * `pinned` still prefers the workspace/global default, but it MUST NOT launch
724
+ * a logged-out (or revoked) default when the same device holds a signed-in
725
+ * version that can run — that was a silent 1-second death on fleet workers
726
+ * whose default home had no credential (PHNX-2685). A rate-limited pin is
727
+ * still honoured: `--strategy pinned` remains the escape hatch to force the
728
+ * default through a throttle. When the default is auth-blocked and nothing
729
+ * else is healthy, `exhausted` is set so the caller fails loud instead of
730
+ * spawning into a credential-less home.
731
+ */
725
732
  export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
726
733
  const fallback = resolveVersion(agent, cwd);
734
+ const candidates = await collect(agent);
727
735
  if (strategy === 'pinned') {
736
+ const pinnedCandidate = fallback
737
+ ? candidates.find((c) => c.version === fallback)
738
+ : undefined;
739
+ // Auth-blocked pin: the home cannot authenticate, so launching it is a
740
+ // guaranteed miss. Prefer a signed-in sibling on this device.
741
+ if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
742
+ const rotation = pickAvailableCandidate(candidates, fallback);
743
+ if (rotation) {
744
+ emit('rotation.resolved', {
745
+ module: 'rotate',
746
+ agent,
747
+ version: rotation.picked.version,
748
+ strategy,
749
+ healthy: rotation.healthy.length,
750
+ excluded: rotation.excluded.length,
751
+ });
752
+ return { version: rotation.picked.version, rotation };
753
+ }
754
+ return {
755
+ version: fallback,
756
+ rotation: null,
757
+ exhausted: candidates.length > 0 ? candidates : undefined,
758
+ };
759
+ }
728
760
  return { version: fallback, rotation: null };
729
761
  }
730
- const candidates = await collect(agent);
731
762
  const rotation = strategy === 'available'
732
763
  ? pickAvailableCandidate(candidates, fallback)
733
764
  : pickBalancedCandidate(candidates);