@phnx-labs/agents-cli 1.22.78 → 1.22.79

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 (90) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +4 -0
  12. package/dist/commands/view.js +114 -19
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.79
4
+
5
+ - **Managed harnesses follow the latest release without replacing account homes (PHNX-3940).** Automatic updates are on by default and configurable with `updates.auto` and per-harness `updates.<harness>.auto`. Updates preserve existing home paths and identities, defer active installations, and stage and verify the executable before committing it. A concrete `--to` release pins an installation; `--to latest` resumes automatic updates. `agents update --check` previews without changing local state. Source: `cli/src/lib/installations/update.ts`, `cli/src/lib/daemon/harness-update-service.ts`, `cli/src/commands/update.ts`.
6
+ - **`agents accounts connect <harness> [name]` — stable accounts independent of releases (PHNX-3940).** A new account gets a fresh opaque installation label with its own isolated home, installs the current release into it (even when that release is already installed under another label), and drives the harness's native OAuth login there — the credential is never copied or fleet-synced. Reconnecting an existing account by name reuses that account's home and refuses to bind a different identity after login. Supported for the version-scoped, isolable harnesses (claude, codex first); others refuse with a clear reason instead of faking a flow. Source: `cli/src/lib/accounts/connect.ts`, `cli/src/commands/accounts.ts`.
7
+ - **`agents accounts logout` honors `@label` / `#account` selectors (PHNX-3940).** `agents accounts logout claude@<label>`, `claude#<account>`, or a bare native account name now signs out that exact home instead of always selecting the global default, and the sign-out runs under the harness's canonical config-dir env (CLAUDE_CONFIG_DIR / CODEX_HOME) rather than HOME alone. Source: `cli/src/commands/accounts.ts`.
8
+ - **Account-first read model for `agents accounts` / `view` (PHNX-3940).** `loadAccountCatalog()` / `buildNativeCatalog()` group installed homes by identity and derive a live connection state — a registered account whose homes are all signed out reads `reconnect-needed`, never a bare `connected` on the registry label alone — with the release/home kept as secondary diagnostics. Source: `cli/src/lib/account-catalog.ts`.
9
+ - **`installVersion` accepts an `installationLabel` independent of the release (PHNX-3940).** The npm install path can now stage the current release under an opaque label distinct from the version string, so many accounts can share one upstream `latest` under separate isolated homes; the frozen installation record keeps label and `releaseVersion` separate. Source: `cli/src/lib/installations/versions.ts`.
10
+ - **`agents accounts connect` allocates account homes safely — no cross-account overwrite (PHNX-3940 security fix).** Connect slots are now RANDOM opaque labels allocated to be disjoint from every home already owned by an account or currently signed in, so `connect work` after `work` was renamed `personal` can never land on and overwrite `personal`'s login. Retry-idempotency is provided by a device-scoped pending-connect map (cleared on success), not a name-derived slot. Connection state is now strict — a live credential in the home, not a bare metadata identity, so a stale/expired login reads `reconnect-needed`. A first connect selects the harness default only when none is configured, and an unnamed connect forces no name (it returns a hint to name the login). Source: `cli/src/lib/accounts/connect.ts`, `cli/src/lib/account-registry.ts`, `cli/src/lib/account-catalog.ts`, `cli/src/lib/state.ts`.
11
+
3
12
  ## 1.22.78
4
13
 
5
14
  - **The launch env carries the human's Phoenix ID profile picture.** A resolved human actor now gets `AGENTS_ACTOR_AVATAR` (https URL) when the Phoenix ID session signed in on the device belongs to the same email, so tools an agent runs — `artifacts render`, shares — can show the person instead of initials. A shared box signed in as someone else lends nothing to whoever SSH-ed in, and a child spawn inherits the value like the rest of `AGENTS_ACTOR*`. `agents auth whoami` now also refreshes the persisted picture from `/api/v1/auth/me`, which Phoenix ID returns as of phnx-labs/phoenix-id#7. Source: `cli/src/lib/actor.ts`, `cli/src/commands/auth.ts`.
6
15
 
16
+ ## 1.22.77
17
+
7
18
  - **`agents repo pull` / `agents sync` no longer wedge on a CLI-regenerated `~/.agents/agents.yaml` (PHNX-3968).** The shared central config was rewritten by config commands without being committed, so between the daemon's publish ticks the tree sat dirty on `agents.yaml`; an incoming peer commit that also touched it made the pull refuse with *"incoming changes touch uncommitted paths: agents.yaml"*, forcing manual autostash gymnastics on the fleet. Central config edits are now committed synchronously by the command that makes them (after the meta lock releases, skipped for the daemon whose own publish tick owns its commits), so the tree is clean at rest and the trip cannot occur. A genuinely dirty-and-differing central file now *refuses* the pull (data-safe, self-heals on the next commit) rather than silently taking the remote copy. The user-repo `CHANGELOG.md` (a duplicate of the shipped one) is de-tracked via `.git/info/exclude` so it stops churning the tree. Source: `cli/src/lib/state.ts`, `cli/src/lib/git.ts`, `cli/src/lib/browser/registry.ts`, `cli/src/lib/project-resources.ts`.
8
19
 
9
20
  ## 1.22.76
package/README.md CHANGED
@@ -1151,7 +1151,34 @@ Distinct from **Accounts** below: this is *your human identity*; those are the *
1151
1151
 
1152
1152
  ## Accounts
1153
1153
 
1154
- Give a provider credential a durable name once, reuse it everywhere -- across harnesses, across machines.
1154
+ Choose an agent and an account, independently of its release number. Connect a
1155
+ native account once on this device; its home, login, settings, and sessions stay
1156
+ in place when the executable is updated.
1157
+
1158
+ ```bash
1159
+ agents accounts connect codex work
1160
+ agents accounts connect claude personal
1161
+ agents accounts switch codex work
1162
+ agents run codex#work
1163
+ agents view codex # accounts and usage, one row per identity
1164
+ agents view codex --versions # every retained installation and actual release
1165
+ ```
1166
+
1167
+ Connecting another account creates its own isolated home, even when it uses the
1168
+ same release. Reconnecting a named account reuses its existing home. Old
1169
+ release-named homes are adopted in place: no credential copying, directory moves,
1170
+ or automatic duplicate deletion. Native login remains device-local; an account
1171
+ known on another device is not necessarily connected here.
1172
+
1173
+ Agents checks for updates to safely managed harness installations automatically.
1174
+ Disable this globally or per harness with `agents config set updates.auto false`
1175
+ or `agents config set updates.codex.auto false`. Use `agents update codex --check`
1176
+ to inspect eligibility without installing, or `agents update codex` to update
1177
+ that harness's installations. Running sessions and deliberately pinned
1178
+ installations are protected. See [installation and update policy](cli/docs/version-management.md).
1179
+
1180
+ For unattended workers, give a **provider credential** a durable name and reuse it
1181
+ across supported harnesses and machines:
1155
1182
 
1156
1183
  ```bash
1157
1184
  agents accounts mint claude # drive `claude setup-token`, seed a named account + reserved auth bundle
package/dist/bootstrap.js CHANGED
@@ -108,6 +108,8 @@ const AUDIT_EXEMPT_COMMANDS = new Set([
108
108
  '_internal friction',
109
109
  ]);
110
110
  program.hook('preAction', (_thisCommand, actionCommand) => {
111
+ if (isReadOnlyUpdatePreview)
112
+ return;
111
113
  try {
112
114
  const parts = auditCommandPath(actionCommand);
113
115
  if (parts.length === 0)
@@ -130,6 +132,8 @@ program.hook('preAction', (_thisCommand, actionCommand) => {
130
132
  }
131
133
  });
132
134
  program.hook('postAction', (_thisCommand, actionCommand) => {
135
+ if (isReadOnlyUpdatePreview)
136
+ return;
133
137
  try {
134
138
  const parts = auditCommandPath(actionCommand);
135
139
  if (parts.length === 0)
@@ -1026,18 +1030,35 @@ if (helpAllRequested) {
1026
1030
  program.outputHelp();
1027
1031
  process.exit(0);
1028
1032
  }
1033
+ // `agents update --check` (with or without a <target>) is a pure read-only
1034
+ // preview of the automatic-update plan — it reports what `--auto` WOULD do and
1035
+ // changes nothing (`planAutoUpdates` reads installation SNAPSHOTS, never
1036
+ // mutating disk). So it MUST NOT fire the mutating startup steps every other
1037
+ // real command runs: the CLI self-update check + multi-install sentinel, the
1038
+ // detached background repo sync, the legacy-fold and migration passes, the
1039
+ // macOS menu-bar install, and the interactive shim self-heal — each writes to
1040
+ // ~/.agents or a helper dir the user did not ask to touch (PHNX-3940). It is
1041
+ // NOT a documentation request: it still parses, resolves installations, and
1042
+ // routes over `--device`, so it is gated separately from isDocumentationRequest
1043
+ // rather than folded into it (which would also skip the passthrough router).
1044
+ // Re-read the command after spell correction, allowing leading root flags.
1045
+ const isReadOnlyUpdatePreview = !isDocumentationRequest &&
1046
+ passedArgs.find((arg) => !arg.startsWith('-')) === 'update' &&
1047
+ passedArgs.includes('--check');
1029
1048
  // Pure documentation paths (--version / --help / -h / --help-all) return
1030
1049
  // immediately: skip the update check (PATH scan + cache read) and the detached
1031
1050
  // background sync (spawns a child process) that every other invocation runs.
1032
1051
  if (!isDocumentationRequest) {
1033
1052
  bootMark('bootstrap:evaluated');
1034
- // Run update check before parsing so the upgrade notice/prompt precedes output.
1035
- await checkForUpdates();
1036
- // Fire-and-forget the background sync. System repo gets a real fast-forward
1037
- // pull (read-only locally, safe). User repo and extras get fetch-only + a
1038
- // status marker that `agents doctor` surfaces as a repo-behind warning.
1039
- const { spawnDetachedSync } = await import('./lib/auto-pull.js');
1040
- spawnDetachedSync();
1053
+ if (!isReadOnlyUpdatePreview) {
1054
+ // Run update check before parsing so the upgrade notice/prompt precedes output.
1055
+ await checkForUpdates();
1056
+ // Fire-and-forget the background sync. System repo gets a real fast-forward
1057
+ // pull (read-only locally, safe). User repo and extras get fetch-only + a
1058
+ // status marker that `agents doctor` surfaces as a repo-behind warning.
1059
+ const { spawnDetachedSync } = await import('./lib/auto-pull.js');
1060
+ spawnDetachedSync();
1061
+ }
1041
1062
  }
1042
1063
  // First-run experience: no args + no config yet + TTY -> launch interactive setup.
1043
1064
  // Skipped when stdin/stdout isn't a terminal (CI, pipes) or when user passes any args.
@@ -1074,7 +1095,7 @@ const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help', 'uninstall']);
1074
1095
  // must not load any migration graph. Loaded from migrate-fold.js (leaf: fs +
1075
1096
  // createLink), not migrate.js, so a real command pays only the fold hop unless
1076
1097
  // the v20 sentinel is missing and runMigration() is required below.
1077
- if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest) {
1098
+ if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest && !isReadOnlyUpdatePreview) {
1078
1099
  try {
1079
1100
  const { foldLegacySystemRepo } = await import('./lib/migrate-fold.js');
1080
1101
  foldLegacySystemRepo();
@@ -1084,7 +1105,8 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest) {
1084
1105
  if (!firstRun &&
1085
1106
  requestedCommand &&
1086
1107
  !SETUP_EXEMPT_COMMANDS.has(requestedCommand) &&
1087
- !isDocumentationRequest) {
1108
+ !isDocumentationRequest &&
1109
+ !isReadOnlyUpdatePreview) {
1088
1110
  const { ensureInitialized } = await import('./commands/setup.js');
1089
1111
  await ensureInitialized(program);
1090
1112
  }
@@ -1101,7 +1123,7 @@ if (!firstRun &&
1101
1123
  // — only a missing/stale sentinel pays for
1102
1124
  // `await import('./lib/installations/migrate.js')` (which pulls the
1103
1125
  // hosts/routine/teams/daemon/menubar graph).
1104
- if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest) {
1126
+ if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest && !isReadOnlyUpdatePreview) {
1105
1127
  try {
1106
1128
  const sentinel = getMigratedSentinelPath();
1107
1129
  // Sentinel is keyed to the migration SCHEMA version, not the binary version.
@@ -1141,7 +1163,8 @@ if (process.env.AGENTS_SKIP_MIGRATION !== '1' && !isDocumentationRequest) {
1141
1163
  // gate the update check, background sync, and ensureInitialized above already use.
1142
1164
  if (process.platform === 'darwin' &&
1143
1165
  process.env.AGENTS_SKIP_MIGRATION !== '1' &&
1144
- !isDocumentationRequest) {
1166
+ !isDocumentationRequest &&
1167
+ !isReadOnlyUpdatePreview) {
1145
1168
  try {
1146
1169
  const { installMenubarLaunchAgentOnUpgrade } = await import('./lib/menubar/install-menubar.js');
1147
1170
  installMenubarLaunchAgentOnUpgrade();
@@ -1158,7 +1181,12 @@ if (passedArgs.length === 0) {
1158
1181
  process.exit(0);
1159
1182
  }
1160
1183
  try {
1161
- await maybeBootstrapShimIntegration(requestedCommand, isDocumentationRequest, verboseStartup);
1184
+ // The shim self-heal regenerates shims / adopts launchers / edits PATH — all
1185
+ // writes, so a read-only `agents update --check` skips it too (same gate as
1186
+ // the migration/menu-bar steps above) while still parsing normally.
1187
+ if (!isReadOnlyUpdatePreview) {
1188
+ await maybeBootstrapShimIntegration(requestedCommand, isDocumentationRequest, verboseStartup);
1189
+ }
1162
1190
  bootMark('bootstrap:pre-parse');
1163
1191
  await program.parseAsync();
1164
1192
  }
@@ -53,6 +53,8 @@ export declare function renderAccountList(records: {
53
53
  name?: string;
54
54
  display: string;
55
55
  versions: string[];
56
+ isDefault?: boolean;
57
+ state?: 'connected' | 'reconnect-needed';
56
58
  }[]): string;
57
59
  /** One labelable signed-in identity, folded across every version it is signed into. */
58
60
  export interface LabelIdentity {
@@ -102,5 +104,25 @@ export declare function setDefaultAccount(agentRaw: string, name: string): {
102
104
  export declare function runAccountsSwitch(harness: string, accountName: string | undefined, opts: {
103
105
  json: boolean;
104
106
  }): Promise<void>;
107
+ /**
108
+ * Parse a `logout` target into its parts (pure). Supports `<harness>`,
109
+ * `<harness>@<label>`, `<harness>#<account>`, and a bare account name (no
110
+ * harness). `#` binds tighter than `@` so a `<harness>#<label>` selector is
111
+ * never mis-split on a `@` inside the selector.
112
+ */
113
+ export declare function parseLogoutTarget(target: string): {
114
+ agentRaw: string;
115
+ installationLabel?: string;
116
+ identitySelector?: string;
117
+ };
118
+ /**
119
+ * Resolve a `logout` target to the exact `(agent, installed label)` whose home
120
+ * should be signed out — honoring a passed `@label` or `#account` selector
121
+ * instead of always selecting the global default (PHNX-3940).
122
+ */
123
+ export declare function resolveLogoutTarget(target: string): Promise<{
124
+ agent: AgentId;
125
+ version: string;
126
+ }>;
105
127
  export declare function registerAccountsCommand(program: Command): void;
106
128
  export {};
@@ -16,11 +16,17 @@ import { pushBundleToHost } from '../lib/secrets/push.js';
16
16
  import { assertCredentialTransportHostPinned, resolveHostSshTarget } from '../lib/secrets/remote.js';
17
17
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
18
18
  import { runDevicesAccounts } from './ssh.js';
19
- import { discoverNativeAccounts } from '../lib/account-catalog.js';
19
+ import { collectNativeHomeRows, loadAccountCatalog } from '../lib/account-catalog.js';
20
+ import { connectRefusal, connectSupported, runConnect } from '../lib/accounts/connect.js';
21
+ import { acquireAuthOperationLock } from '../lib/accounts/auth-operation-lock.js';
20
22
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
21
23
  import { getAccountProvider, listAccountProviders, providerAuthenticatesHarness } from '../lib/account-provider-registry.js';
22
- import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
24
+ import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, nativeAccountHome, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
23
25
  import { registerMintCommand } from './auth-mint.js';
26
+ /** Comma-joined list of harnesses `accounts connect` can drive today. */
27
+ function connectSupportedList() {
28
+ return ALL_AGENT_IDS.filter(connectSupported).join(', ');
29
+ }
24
30
  function cleanCommandError(command, err) {
25
31
  command.error(err instanceof Error ? err.message : String(err), { exitCode: 1, code: 'accounts.error' });
26
32
  }
@@ -142,12 +148,11 @@ async function printAccounts(json, fleet = false) {
142
148
  if (fleet)
143
149
  return runDevicesAccounts({ json });
144
150
  const records = Object.values(readAccountRegistry().accounts).sort((a, b) => a.name.localeCompare(b.name));
145
- const discovered = await discoverNativeAccounts();
146
- const savedNative = listNativeAccounts(readMeta());
147
- const native = discovered.map(row => {
148
- const saved = savedNative.find(account => account.agent === row.agent && account.identityKey === row.id);
149
- return { ...row, name: saved?.name, id: saved?.id ?? row.id };
150
- });
151
+ const catalog = await loadAccountCatalog();
152
+ const native = catalog.native.map(row => ({
153
+ ...row, name: row.name ?? undefined, id: row.id ?? row.identityKey,
154
+ versions: row.installations.map(home => home.label),
155
+ }));
151
156
  if (json) {
152
157
  console.log(JSON.stringify([...records.map(account => publicAccount(inspectAccount(account.name))), ...native], null, 2));
153
158
  return;
@@ -160,7 +165,7 @@ export function renderAccountList(records, native) {
160
165
  // Native logins, grouped by harness — the selection surface for `<harness>#<label>`.
161
166
  out.push(chalk.bold('Native logins') + chalk.gray(' ') + chalk.gray('run <harness>#<label>'));
162
167
  if (!native.length) {
163
- out.push(chalk.gray(' No signed-in native accounts found.'));
168
+ out.push(chalk.gray(' No native accounts found. Connect one: agents accounts connect <harness> [name]'));
164
169
  }
165
170
  else {
166
171
  const byHarness = new Map();
@@ -179,11 +184,12 @@ export function renderAccountList(records, native) {
179
184
  const harnessCell = chalk.gray((i === 0 ? harness : '').padEnd(harnessW));
180
185
  const rawLabel = acct.name ?? '—';
181
186
  const labelCell = acct.name ? chalk.cyan(rawLabel.padEnd(labelW)) : chalk.gray(rawLabel.padEnd(labelW));
182
- const isDefault = defaultVersion ? acct.versions.includes(defaultVersion) : false;
187
+ const isDefault = acct.isDefault ?? (defaultVersion ? acct.versions.includes(defaultVersion) : false);
183
188
  if (isDefault)
184
189
  sawDefault = true;
185
190
  const marker = isDefault ? chalk.green('*') : ' ';
186
- out.push(` ${harnessCell} ${labelCell}${marker} ${acct.display.padEnd(idW)} ${chalk.gray(acct.versions.join(', '))}`);
191
+ const state = acct.state === 'reconnect-needed' ? 'not connected here' : 'connected';
192
+ out.push(` ${harnessCell} ${labelCell}${marker} ${acct.display.padEnd(idW)} ${chalk.gray(state)}`);
187
193
  });
188
194
  }
189
195
  if (sawDefault)
@@ -412,6 +418,85 @@ export async function runAccountsSwitch(harness, accountName, opts) {
412
418
  const { account } = setDefaultAccount(agent, selected);
413
419
  console.log(chalk.green(`${agent} now uses account '${account.name}' unless --account overrides it.`));
414
420
  }
421
+ /**
422
+ * Parse a `logout` target into its parts (pure). Supports `<harness>`,
423
+ * `<harness>@<label>`, `<harness>#<account>`, and a bare account name (no
424
+ * harness). `#` binds tighter than `@` so a `<harness>#<label>` selector is
425
+ * never mis-split on a `@` inside the selector.
426
+ */
427
+ export function parseLogoutTarget(target) {
428
+ const hash = target.indexOf('#');
429
+ if (hash > 0) {
430
+ if (!target.slice(hash + 1).trim())
431
+ throw new Error('Select an account after #.');
432
+ return { agentRaw: target.slice(0, hash), identitySelector: target.slice(hash + 1) };
433
+ }
434
+ const at = target.indexOf('@');
435
+ if (at > 0) {
436
+ if (!target.slice(at + 1).trim())
437
+ throw new Error('Select an installation after @.');
438
+ return { agentRaw: target.slice(0, at), installationLabel: target.slice(at + 1) };
439
+ }
440
+ return { agentRaw: target };
441
+ }
442
+ /** Explicit account logout must never fall back to another account's home. */
443
+ async function resolveAccountHomeLabel(account) {
444
+ const rows = await collectNativeHomeRows();
445
+ const homes = rows
446
+ .filter(r => r.signedIn && (r.accountKey ?? r.email))
447
+ .map(r => ({ agent: r.agent, identityKey: (r.accountKey ?? r.email.toLowerCase()), label: r.label }));
448
+ const deviceHome = nativeAccountHome(account.id, readMeta());
449
+ const matching = homes.filter(home => home.agent === account.agent && home.identityKey === account.identityKey);
450
+ const label = matching.find(home => home.label === deviceHome)?.label ?? matching[0]?.label;
451
+ if (!label || !listInstalledVersions(account.agent).includes(label)) {
452
+ throw new Error(`Account '${account.name}' has no installed ${account.agent} home to sign out of.`);
453
+ }
454
+ return label;
455
+ }
456
+ /**
457
+ * Resolve a `logout` target to the exact `(agent, installed label)` whose home
458
+ * should be signed out — honoring a passed `@label` or `#account` selector
459
+ * instead of always selecting the global default (PHNX-3940).
460
+ */
461
+ export async function resolveLogoutTarget(target) {
462
+ const parsed = parseLogoutTarget(target);
463
+ const meta = readMeta();
464
+ if (ALL_AGENT_IDS.includes(parsed.agentRaw)) {
465
+ const agent = parsed.agentRaw;
466
+ if (parsed.installationLabel) {
467
+ if (!listInstalledVersions(agent).includes(parsed.installationLabel)) {
468
+ throw new Error(`${agent}@${parsed.installationLabel} is not installed.`);
469
+ }
470
+ return { agent, version: parsed.installationLabel };
471
+ }
472
+ if (parsed.identitySelector) {
473
+ const account = findUnifiedAccount(parsed.identitySelector, meta, undefined, agent);
474
+ if (!account || account.kind !== 'native' || account.agent !== agent) {
475
+ throw new Error(`No ${agent} account '${parsed.identitySelector}'.`);
476
+ }
477
+ return { agent, version: await resolveAccountHomeLabel(account) };
478
+ }
479
+ const configuredDefault = meta.accounts?.defaults?.[agent];
480
+ if (configuredDefault) {
481
+ const account = findUnifiedAccount(configuredDefault, meta, undefined, agent);
482
+ if (!account || account.kind !== 'native') {
483
+ throw new Error(`${agent}'s default is not a locally connected native account. Select the native account to sign out explicitly.`);
484
+ }
485
+ return { agent, version: await resolveAccountHomeLabel(account) };
486
+ }
487
+ const installed = listInstalledVersions(agent);
488
+ const version = getGlobalDefault(agent) ?? installed[installed.length - 1];
489
+ if (!version)
490
+ throw new Error(`No installed version of ${agent}. Install one with: agents add ${agent}`);
491
+ return { agent, version };
492
+ }
493
+ // A bare, non-harness target may be a native account name.
494
+ const account = findUnifiedAccount(target, meta);
495
+ if (account?.kind === 'native') {
496
+ return { agent: account.agent, version: await resolveAccountHomeLabel(account) };
497
+ }
498
+ throw new Error(`Unknown target '${target}'. Pass a native harness (claude, codex, …), <harness>@<label>, <harness>#<account>, or a native account name. Provider API-key accounts use \`agents accounts remove\`.`);
499
+ }
415
500
  export function registerAccountsCommand(program) {
416
501
  const accounts = program.command('accounts').description('Browse native logins and manage provider account bundles')
417
502
  .option('--json', 'Machine-readable account metadata')
@@ -423,6 +508,35 @@ export function registerAccountsCommand(program) {
423
508
  await runAccountsAction(command, () => printAccounts(!!(o.json || command.optsWithGlobals().json)));
424
509
  });
425
510
  registerMintCommand(accounts);
511
+ const connectCmd = accounts.command('connect <harness> [name]')
512
+ .description('Connect a stable account: install the current release into a fresh isolated home and drive the harness native login (reconnect an existing account by name to reuse its home)')
513
+ .option('--json', 'Machine-readable connect result')
514
+ .action(async (harness, name, o, command) => {
515
+ await runAccountsAction(command, async () => {
516
+ const agent = parseHarness(harness);
517
+ const reason = connectRefusal(agent);
518
+ if (reason)
519
+ throw new Error(reason);
520
+ const result = await runConnect(agent, name, {
521
+ meta: readMeta(),
522
+ onProgress: (m) => console.log(chalk.gray(` ${m}`)),
523
+ });
524
+ if (o.json || command.optsWithGlobals().json)
525
+ return console.log(JSON.stringify(result, null, 2));
526
+ const who = result.email ?? result.identityKey;
527
+ const named = result.name ? `'${result.name}' ` : '';
528
+ const release = result.releaseVersion ? ` [${result.releaseVersion}]` : '';
529
+ console.log(chalk.green(`${result.mode === 'reconnect' ? 'Reconnected' : 'Connected'} ${agent} account ${named}(${who}) in ${agent}@${result.label}${release}.`));
530
+ console.log(chalk.gray(' The native OAuth login stays owned by the harness in that home; nothing was copied.'));
531
+ });
532
+ });
533
+ setHelpSections(connectCmd, {
534
+ examples: `agents accounts connect claude work
535
+ agents accounts connect codex personal
536
+ agents accounts connect claude work # again → reuses work's home, fails closed on a different identity
537
+ agents run claude --account work`,
538
+ notes: `Each NEW account gets a fresh opaque installation label and its own isolated home, so ten accounts can share the same upstream release while keeping separate logins. Connect installs the current release even when it is already installed under another label, then launches the harness's native login there — the OAuth credential is never copied or fleet-synced. Reconnecting an existing account by name reuses its home and refuses to overwrite a home now signed in as a different identity. Supported for version-scoped, isolable harnesses (${connectSupportedList()}); others fail with a clear reason. Provider API-key/token accounts use 'agents accounts add' instead.`,
539
+ });
426
540
  accounts.command('add <name>')
427
541
  .description('Add a durable API key, setup token, or bearer token')
428
542
  .requiredOption('--provider <provider>', `Credential provider (${listAccountProviders().join(', ')})`)
@@ -643,36 +757,43 @@ agents run codex#work`,
643
757
  });
644
758
  });
645
759
  accounts.command('logout <target>')
646
- .description('Sign out a harness-native OAuth login. API-key / setup-token / bearer accounts use `accounts remove` instead.')
760
+ .description('Sign out a harness-native OAuth login by <harness>, <harness>@<label>, <harness>#<account>, or account name. API-key / setup-token / bearer accounts use `accounts remove` instead.')
647
761
  .action(async (target, _o, command) => {
648
762
  await runAccountsAction(command, async () => {
649
763
  const provider = findAccount(target);
650
764
  if (provider) {
651
765
  throw new Error(`Account '${provider.name}' uses ${provider.auth}, not OAuth. Remove it with: agents accounts remove ${provider.name}`);
652
766
  }
653
- const agentRaw = target.split('@')[0];
654
- if (!ALL_AGENT_IDS.includes(agentRaw)) {
655
- throw new Error(`Unknown target '${target}'. Pass a native harness (claude, codex, …) or a provider account name. Provider API-key accounts use \`agents accounts remove\`.`);
767
+ const { agent } = await resolveLogoutTarget(target);
768
+ // Acquire the per-harness auth-operation mutex before logout — a concurrent
769
+ // connect for the same harness could allocate and install into the same home
770
+ // while this logout is in-flight, leaving the home in an ambiguous state.
771
+ const lock = acquireAuthOperationLock(agent);
772
+ try {
773
+ const resolved = await resolveLogoutTarget(target);
774
+ if (resolved.agent !== agent)
775
+ throw new Error('Account selection changed; retry sign-out.');
776
+ const { version } = resolved;
777
+ const { runNativeAccountCommand } = await import('../lib/installations/native-command.js');
778
+ const { getVersionHomePath } = await import('../lib/installations/versions.js');
779
+ const { buildExecEnv } = await import('../lib/exec.js');
780
+ // Pin the harness's own config-dir env (CLAUDE_CONFIG_DIR / CODEX_HOME) to
781
+ // the resolved home so `logout` signs out THAT account's home — not
782
+ // whichever the global default happens to be. HOME alone was insufficient
783
+ // for a config-dir-env harness, which is why a passed @label was ignored.
784
+ const env = buildExecEnv({ agent, version, configVersion: version, interactive: true, mode: 'auto', effort: 'auto', cwd: process.cwd() });
785
+ env.HOME = getVersionHomePath(agent, version);
786
+ lock.assertHeld();
787
+ const result = await runNativeAccountCommand(agent, version, agent === 'claude' ? ['auth', 'logout'] : ['logout'], env, lock.signal);
788
+ lock.assertHeld();
789
+ if ((result.code ?? 1) !== 0) {
790
+ throw new Error(`${agent} logout exited ${result.code ?? 'null'}. If this harness has no logout verb, sign out from its own UI.`);
791
+ }
792
+ console.log(chalk.green(`Signed out native ${agent} login (${agent}@${version}).`));
656
793
  }
657
- const agent = agentRaw;
658
- const { spawnSync } = await import('child_process');
659
- const { getBinaryPath, getGlobalDefault, getVersionHomePath, listInstalledVersions } = await import('../lib/installations/versions.js');
660
- const installed = listInstalledVersions(agent);
661
- const version = getGlobalDefault(agent) ?? installed[installed.length - 1];
662
- if (!version)
663
- throw new Error(`No installed version of ${agent}. Install one with: agents add ${agent}`);
664
- const bin = getBinaryPath(agent, version);
665
- const home = getVersionHomePath(agent, version);
666
- const result = spawnSync(bin, ['logout'], {
667
- env: { ...process.env, HOME: home },
668
- stdio: 'inherit',
669
- });
670
- if (result.error)
671
- throw result.error;
672
- if ((result.status ?? 1) !== 0) {
673
- throw new Error(`${agent} logout exited ${result.status ?? 'null'}. If this harness has no logout verb, sign out from its own UI.`);
794
+ finally {
795
+ lock.release();
674
796
  }
675
- console.log(chalk.green(`Signed out native ${agent} login (${version}).`));
676
797
  });
677
798
  });
678
799
  accounts.command('sync <name> [device]')
@@ -717,7 +838,9 @@ agents run claude`,
717
838
  notes: 'Switch writes the existing per-harness default (same binding as set-default). Rotation already honors it. Pass an account name to skip the picker. Native name/attach is only for harnesses agents-cli can isolate (claude, codex, grok); provider add is unrestricted.',
718
839
  });
719
840
  setHelpSections(accounts, {
720
- examples: `agents accounts mint claude
841
+ examples: `agents accounts connect claude work
842
+ agents accounts connect codex personal
843
+ agents accounts mint claude
721
844
  agents accounts mint claude --account work --email ada@example.com --fleet
722
845
  agents accounts add work --provider anthropic --auth setup-token
723
846
  agents accounts add openrouter-work --provider openrouter --auth api-key --from-secrets openrouter.ai:OPENROUTER_API_KEY
@@ -732,6 +855,6 @@ agents accounts set-default claude work
732
855
  agents accounts sync openrouter-work yosemite-s0
733
856
  agents run claude --account work
734
857
  agents accounts logout claude`,
735
- notes: 'Native account records contain metadata only; harness-owned OAuth credentials are never copied. Provider accounts are explicit portable bundles with policy never. `accounts mint claude` drives `claude setup-token` and seeds both the named account and the reserved file-based auth bundle (same command as `agents auth mint`). `accounts switch` is the fast picker over the same default `set-default` writes. Harness-native OAuth sign-out is `agents accounts logout <harness>` (API-key accounts use `accounts remove`). Synced vault unlock is `agents secrets vault unlock`.',
858
+ notes: 'Accounts are stable independent of releases: `accounts connect <harness> [name]` gives each new account a fresh isolated home on the current release and drives its native login (reconnect by name to reuse the home). Native account records contain metadata only; harness-owned OAuth credentials are never copied. Provider accounts are explicit portable bundles with policy never. `accounts mint claude` drives `claude setup-token` and seeds both the named account and the reserved file-based auth bundle (same command as `agents auth mint`). `accounts switch` is the fast picker over the same default `set-default` writes. Harness-native OAuth sign-out is `agents accounts logout <harness>` (API-key accounts use `accounts remove`). Synced vault unlock is `agents secrets vault unlock`.',
736
859
  });
737
860
  }
@@ -13,6 +13,7 @@
13
13
  * grammar into reads/writes of the existing stores.
14
14
  */
15
15
  import chalk from 'chalk';
16
+ import { AGENTS } from '../lib/agents.js';
16
17
  import { setHelpSections } from '../lib/help.js';
17
18
  import { parseConfigKey, formatAgentVersion, devicePropertyToConfigName, configKeyStorageHint, } from '../lib/config-keys.js';
18
19
  import { registerBudgetCommand } from './budget.js';
@@ -23,6 +24,7 @@ import { readMeta, updateMeta } from '../lib/state.js';
23
24
  import { MODEL_TIERS } from '../lib/model-tiers.js';
24
25
  import { machineId } from '../lib/machine-id.js';
25
26
  import { getProjectRoot, setProjectRoot } from '../lib/project-root.js';
27
+ import { rawAgentAutoUpdateSetting, rawGlobalAutoUpdateSetting, setAgentAutoUpdateEnabled, setGlobalAutoUpdateEnabled, unsetAgentAutoUpdateEnabled, unsetGlobalAutoUpdateEnabled, } from '../lib/installations/update-policy.js';
26
28
  /** Parse a boolean value the same way `agents devices configure` does. */
27
29
  function parseBool(value, key) {
28
30
  const v = value.trim().toLowerCase();
@@ -47,6 +49,8 @@ function parseValue(key, parsed, raw) {
47
49
  return raw.trim();
48
50
  case 'summarizer':
49
51
  return parsed.property === 'enabled' ? parseBool(raw, key) : raw.trim();
52
+ case 'updates':
53
+ return parseBool(raw, key);
50
54
  case 'device': {
51
55
  const property = parsed.property;
52
56
  switch (property) {
@@ -132,6 +136,13 @@ function setConfig(parsed, value) {
132
136
  setConfigValue(`summarizer.${parsed.property}`, value);
133
137
  return;
134
138
  }
139
+ case 'updates': {
140
+ if (parsed.agent)
141
+ setAgentAutoUpdateEnabled(parsed.agent, value);
142
+ else
143
+ setGlobalAutoUpdateEnabled(value);
144
+ return;
145
+ }
135
146
  case 'device': {
136
147
  const configName = devicePropertyToConfigName(parsed.property);
137
148
  if (parsed.property === 'notes') {
@@ -196,6 +207,16 @@ function unsetConfig(parsed) {
196
207
  unsetConfigValue(name);
197
208
  return had;
198
209
  }
210
+ case 'updates': {
211
+ if (parsed.agent) {
212
+ const had = rawAgentAutoUpdateSetting(parsed.agent) !== undefined;
213
+ unsetAgentAutoUpdateEnabled(parsed.agent);
214
+ return had;
215
+ }
216
+ const had = rawGlobalAutoUpdateSetting() !== undefined;
217
+ unsetGlobalAutoUpdateEnabled();
218
+ return had;
219
+ }
199
220
  case 'device': {
200
221
  const configName = devicePropertyToConfigName(parsed.property);
201
222
  const had = getConfigValue(configName, { device: parsed.device }).value !== undefined;
@@ -228,6 +249,8 @@ function getConfig(parsed) {
228
249
  return getProjectRoot();
229
250
  case 'summarizer':
230
251
  return getConfigValue(`summarizer.${parsed.property}`).value;
252
+ case 'updates':
253
+ return parsed.agent ? rawAgentAutoUpdateSetting(parsed.agent) : rawGlobalAutoUpdateSetting();
231
254
  case 'device': {
232
255
  const configName = devicePropertyToConfigName(parsed.property);
233
256
  return getConfigValue(configName, { device: parsed.device }).value;
@@ -310,6 +333,18 @@ function* listCentralConfigEntries() {
310
333
  yield { key, value, hint: configKeyStorageHint(parseConfigKey(key)) };
311
334
  }
312
335
  }
336
+ // Managed-harness auto-update switches (PHNX-3940) — user scope, syncs fleet-wide.
337
+ const globalAutoUpdate = rawGlobalAutoUpdateSetting();
338
+ if (globalAutoUpdate !== undefined) {
339
+ yield { key: 'updates.auto', value: globalAutoUpdate, hint: configKeyStorageHint(parseConfigKey('updates.auto')) };
340
+ }
341
+ for (const agent of Object.keys(AGENTS)) {
342
+ const value = rawAgentAutoUpdateSetting(agent);
343
+ if (value !== undefined) {
344
+ const key = `updates.${agent}.auto`;
345
+ yield { key, value, hint: configKeyStorageHint(parseConfigKey(key)) };
346
+ }
347
+ }
313
348
  }
314
349
  /** Collect device-scope config entries. */
315
350
  function* listDeviceConfigEntries(device) {
@@ -382,6 +417,8 @@ export function registerConfigCommand(program) {
382
417
  agents config set browser.profile work
383
418
  agents config set project.root ~/src/github.com/<you>
384
419
  agents config set devices.mac-mini.max-agents 4
420
+ agents config set updates.auto off
421
+ agents config set updates.claude.auto off
385
422
  agents config get run.claude@*.model
386
423
  agents config unset run.claude@*.tier.best
387
424
  agents config list