@phnx-labs/agents-cli 1.22.98 → 1.22.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.99
4
+
5
+ - **`agents setup secrets` installs the published secrets CLI instead of only printing a hint (PHNX-3989).** A missing `secrets` binary tries `agents clis install secrets` then a pinned `npm i -g @phnx-labs/secrets-cli@0.1.2`, then hands off to `secrets migrate`. Docs point at the standalone engine; the broker is `secrets _agent-run` on macOS, not the agents daemon. Source: `cli/src/commands/setup-secrets.ts`, `cli/docs/secrets.md`.
6
+
7
+ - `agents accounts list` / `agents view` no longer report a signed-in account slot as `MISSING`. A slot's STATE now comes from a live read of the slot's own credential and from the daemon's auth probe, which now covers account slots (`slot:<accountId>` rows) as well as version homes. Before, a slot's verdict was written only by `accounts add/login`, the daemon never re-derived it, and `signedIn` was read from an unrelated version home — so a slot re-materialized as `unconfigured` while the device doc was unreadable (a duplicate YAML key from a shared-repo autostash merge) stayed `MISSING` forever even though `agents run` used its login fine.
8
+
3
9
  ## 1.22.98
4
10
 
5
11
  - `agents run codex#<account>` on macOS now runs as that account. The SUN_LEN short-home relocation keyed `CODEX_HOME` by version alone, so an account slot (long enough to overflow too) was silently redirected to the default version's `~/.agents/.codex-homes/<version>/.codex` and its login — `agents run codex#getrush` opened the gmail session while the banner still said `getrush`. The short home is now keyed by the origin (`a-<account id>` for a slot). A version/account reinstall that recreates a fresh `.codex` adopts its existing short home (which holds the login) instead of failing, and a slot mis-linked onto a foreign home by the old layout is repointed to its own home rather than run as the wrong account.
package/README.md CHANGED
@@ -1228,19 +1228,28 @@ replacement (`add` / `login` / `default` / `run <harness>#<name>`).
1228
1228
 
1229
1229
  ## Secrets
1230
1230
 
1231
- > **Platform:** `agents secrets` requires macOS Keychain or Linux libsecret.
1232
- > On Windows (non-WSL), use environment variables or a `.env` file instead.
1231
+ Secrets are the standalone **`secrets` CLI** (`@phnx-labs/secrets-cli`). agi-cli does not ship the engine. `agents secrets` is a passthrough to the same binary.
1233
1232
 
1234
1233
  ```bash
1235
- # API keys in Keychain, not in .env files.
1236
- agents secrets create prod-stripe
1237
- agents secrets add prod-stripe STRIPE_SECRET_KEY # Prompts, stores in Keychain
1238
- agents secrets add prod-stripe TEST_CARD --value "4242..."
1234
+ # Install (pick one — no extra env vars)
1235
+ agents clis install secrets
1236
+ # or
1237
+ npm i -g @phnx-labs/secrets-cli@0.1.2
1238
+ # or, after agents is installed:
1239
+ agents setup secrets
1240
+ ```
1239
1241
 
1240
- # Injected at run time. Bundle definitions live in the Keychain, not on disk.
1242
+ ```bash
1243
+ # API keys in Keychain, not in .env files.
1244
+ secrets create prod-stripe
1245
+ secrets add prod-stripe STRIPE_SECRET_KEY # Prompts, stores in Keychain
1241
1246
  agents run claude "charge a test card" --secrets prod-stripe
1242
1247
  ```
1243
1248
 
1249
+ > **Platform:** macOS Keychain, Linux libsecret / file store. On Windows (non-WSL), use environment variables or a `.env` file instead.
1250
+
1251
+ The macOS broker is **not** the agents daemon. It is `secrets _agent-run` (started by `secrets start` or on first unlock), socket under `$SECRETS_HOME/.cache/helpers/secrets-agent/` (`~/.agents` when you go through `agents secrets`). Linux has no broker. `agents daemon` never hosts it.
1252
+
1244
1253
  <p align="center">
1245
1254
  <img src="assets/secrets.svg" alt="How agi-cli secrets work: bundle definitions live in the macOS Keychain alongside their values, agi-cli resolves at runtime and injects the env into the child process" width="100%" />
1246
1255
  </p>
@@ -1,22 +1,29 @@
1
1
  /**
2
- * `agents setup secrets` — install guidance for the standalone `secrets` CLI,
3
- * then a pass-through to its own `secrets migrate` onboarding (PHNX-3989).
2
+ * `agents setup secrets` — install the standalone `secrets` CLI if missing,
3
+ * then hand off to its own `secrets migrate` onboarding (PHNX-3989).
4
4
  *
5
- * The wizard that used to pick a default backend/policy and delegate imports
6
- * lived entirely against the in-repo secrets engine. That engine is extracted
7
- * to `@phnx-labs/secrets-cli`, which owns its own interactive onboarding
8
- * (`secrets migrate`) — agents-cli never rebundles it (DIST-1), so there is no
9
- * in-process fallback when it isn't installed: this command names the install
10
- * command and stops.
5
+ * The engine lives in `@phnx-labs/secrets-cli`. agents-cli never rebundles it
6
+ * (DIST-1). A missing binary is a routine install, not a fatal gap: try
7
+ * `agents clis install secrets` (system `clis/secrets.yaml`) then a pinned
8
+ * `npm i -g`. Users do not set extra env vars.
11
9
  */
12
10
  import type { Command } from 'commander';
11
+ export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.2";
12
+ export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.2";
13
13
  export declare function setupSecretsPrefsPath(): string;
14
14
  /** True when the standalone `secrets` executable resolves ($SECRETS_BIN or PATH). */
15
15
  export declare function isSecretsCliInstalled(): boolean;
16
16
  /**
17
- * Print install guidance when the standalone is missing, else hand off to its
18
- * own interactive `secrets migrate`. Returns whether setup is now complete
19
- * (installed, and — when it ran — `migrate` exited 0).
17
+ * Install the published secrets CLI. Prefers the host-CLI manifest
18
+ * (`clis/secrets.yaml` in the system repo) so doctor/clis stay one path.
19
+ * Falls back to a pinned npm global install. Returns whether `secrets` is
20
+ * on PATH afterwards. Never writes an `agents secrets` shim.
21
+ */
22
+ export declare function installSecretsCli(): boolean;
23
+ /**
24
+ * Install the standalone if missing, then hand off to `secrets migrate`.
25
+ * Returns whether setup is now complete (installed, and migrate exited 0
26
+ * when it ran).
20
27
  */
21
28
  export declare function runSecretsSetupWizard(): Promise<boolean>;
22
29
  /** Register `agents setup secrets` under the parent `setup` command. */
@@ -1,21 +1,21 @@
1
1
  /**
2
- * `agents setup secrets` — install guidance for the standalone `secrets` CLI,
3
- * then a pass-through to its own `secrets migrate` onboarding (PHNX-3989).
2
+ * `agents setup secrets` — install the standalone `secrets` CLI if missing,
3
+ * then hand off to its own `secrets migrate` onboarding (PHNX-3989).
4
4
  *
5
- * The wizard that used to pick a default backend/policy and delegate imports
6
- * lived entirely against the in-repo secrets engine. That engine is extracted
7
- * to `@phnx-labs/secrets-cli`, which owns its own interactive onboarding
8
- * (`secrets migrate`) — agents-cli never rebundles it (DIST-1), so there is no
9
- * in-process fallback when it isn't installed: this command names the install
10
- * command and stops.
5
+ * The engine lives in `@phnx-labs/secrets-cli`. agents-cli never rebundles it
6
+ * (DIST-1). A missing binary is a routine install, not a fatal gap: try
7
+ * `agents clis install secrets` (system `clis/secrets.yaml`) then a pinned
8
+ * `npm i -g`. Users do not set extra env vars.
11
9
  */
12
10
  import chalk from 'chalk';
13
11
  import * as fs from 'fs';
14
12
  import * as path from 'path';
15
13
  import { spawnSync } from 'node:child_process';
16
14
  import { getHistoryDir } from '../lib/state.js';
17
- import { resolveSecretsBin, invocation, SecretsClientError } from '../lib/secrets-client.js';
18
- const INSTALL_HINT = 'npm i -g @phnx-labs/secrets-cli';
15
+ import { resolveSecretsBin, invocation, SecretsClientError, _resetSecretsClientForTest } from '../lib/secrets-client.js';
16
+ import { installCli, resolveCliManifest } from '../lib/cli-resources.js';
17
+ export const SECRETS_CLI_PACKAGE = '@phnx-labs/secrets-cli@0.1.2';
18
+ export const INSTALL_HINT = `agents clis install secrets # or: npm i -g ${SECRETS_CLI_PACKAGE}`;
19
19
  export function setupSecretsPrefsPath() {
20
20
  return path.join(getHistoryDir(), 'setup', 'secrets.json');
21
21
  }
@@ -37,16 +37,48 @@ function recordSetupComplete() {
37
37
  fs.writeFileSync(file, JSON.stringify({ updatedAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 });
38
38
  }
39
39
  /**
40
- * Print install guidance when the standalone is missing, else hand off to its
41
- * own interactive `secrets migrate`. Returns whether setup is now complete
42
- * (installed, and — when it ran — `migrate` exited 0).
40
+ * Install the published secrets CLI. Prefers the host-CLI manifest
41
+ * (`clis/secrets.yaml` in the system repo) so doctor/clis stay one path.
42
+ * Falls back to a pinned npm global install. Returns whether `secrets` is
43
+ * on PATH afterwards. Never writes an `agents secrets` shim.
44
+ */
45
+ export function installSecretsCli() {
46
+ if (isSecretsCliInstalled())
47
+ return true;
48
+ const manifest = resolveCliManifest('secrets');
49
+ if (manifest) {
50
+ console.log(chalk.gray(`Installing ${manifest.name} via agents clis…`));
51
+ const result = installCli(manifest);
52
+ _resetSecretsClientForTest();
53
+ if (result.installed)
54
+ return true;
55
+ if (result.error)
56
+ console.error(chalk.gray(result.error));
57
+ }
58
+ console.log(chalk.gray(`Installing ${SECRETS_CLI_PACKAGE}…`));
59
+ const r = spawnSync('npm', ['install', '-g', SECRETS_CLI_PACKAGE], { stdio: 'inherit' });
60
+ _resetSecretsClientForTest();
61
+ if (r.error) {
62
+ console.error(chalk.red(`npm install failed: ${r.error.message}`));
63
+ return false;
64
+ }
65
+ if ((r.status ?? 1) !== 0)
66
+ return false;
67
+ return isSecretsCliInstalled();
68
+ }
69
+ /**
70
+ * Install the standalone if missing, then hand off to `secrets migrate`.
71
+ * Returns whether setup is now complete (installed, and migrate exited 0
72
+ * when it ran).
43
73
  */
44
74
  export async function runSecretsSetupWizard() {
45
75
  if (!isSecretsCliInstalled()) {
46
- console.log(chalk.yellow('The standalone `secrets` CLI is not installed.'));
47
- console.log(chalk.gray('Install it, then re-run `agents setup secrets`:'));
48
- console.log(chalk.cyan(` ${INSTALL_HINT}`));
49
- return false;
76
+ if (!installSecretsCli()) {
77
+ console.log(chalk.yellow('The standalone `secrets` CLI is not installed.'));
78
+ console.log(chalk.gray('Install it, then re-run `agents setup secrets`:'));
79
+ console.log(chalk.cyan(` ${INSTALL_HINT}`));
80
+ return false;
81
+ }
50
82
  }
51
83
  const bin = resolveSecretsBin();
52
84
  const { command, prefix } = invocation(bin);
@@ -60,7 +92,7 @@ export async function runSecretsSetupWizard() {
60
92
  export function registerSetupSecretsCommand(setupCmd) {
61
93
  setupCmd
62
94
  .command('secrets')
63
- .description('Install guidance for the standalone `secrets` CLI, then run its own `secrets migrate` onboarding.')
95
+ .description('Install the standalone `secrets` CLI if missing, then run its `secrets migrate` onboarding.')
64
96
  .action(async () => {
65
97
  if (!(await runSecretsSetupWizard()))
66
98
  process.exitCode = 1;
@@ -8,7 +8,7 @@ import { readSlots } from './accounts/slots.js';
8
8
  import { harnessWorkerIsPerDevice } from './harness-auth-capabilities.js';
9
9
  import { hasKeychainTokenSync, isSecretsTransportError } from './secrets-client.js';
10
10
  import { isLaunchableSignedIn as isCredentialLaunchable } from './accounting/rotate.js';
11
- import { authCacheKey, readAuthHealthCache } from './auth-health.js';
11
+ import { authCacheKey, readAuthHealthCache, slotAuthVersionKey } from './auth-health.js';
12
12
  import { readFleetSharedDeviceStates } from './fleet-shared-state.js';
13
13
  import { machineId } from './machine-id.js';
14
14
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
@@ -245,9 +245,21 @@ export async function loadAccountCatalog() {
245
245
  const localHome = row.installations.find((home) => home.label === row.home)
246
246
  ?? row.installations.find((home) => home.signedIn)
247
247
  ?? row.installations[0];
248
- const cached = localHome ? auth[authCacheKey(host, row.agent, localHome.label)] : undefined;
249
248
  const slot = row.id ? localSlots[row.id] : undefined;
250
- const observation = resolveLocalAccountObservation(slot, cached, localHome?.signedIn === true);
249
+ // A slot account's local truth is the SLOT — its own credential file and its
250
+ // own daemon probe row (keyed `slot:<id>`) — never the version home its label
251
+ // happens to match. Reading the version home here is what rendered a
252
+ // signed-in slot MISSING: the slot record held ensureSlot's `unconfigured`
253
+ // default, the daemon never probed the slot, and `signedIn` came from an
254
+ // unrelated home.
255
+ const slotSignedIn = slot
256
+ ? await getAccountInfo(row.agent, slot.slotDir)
257
+ .then((info) => isLaunchableSignedIn(row.agent, slot.slotDir, info))
258
+ .catch(() => false)
259
+ : false;
260
+ const cached = (slot && row.id ? auth[authCacheKey(host, row.agent, slotAuthVersionKey(row.id))] : undefined)
261
+ ?? (localHome ? auth[authCacheKey(host, row.agent, localHome.label)] : undefined);
262
+ const observation = resolveLocalAccountObservation(slot, cached, slot ? slotSignedIn : localHome?.signedIn === true);
251
263
  const local = {
252
264
  device: host,
253
265
  authMode: observation.authMode
@@ -529,8 +541,12 @@ export function listDevicesWithoutAccountVerdicts(userAgentsDir) {
529
541
  return out.sort((a, b) => a.localeCompare(b));
530
542
  }
531
543
  function normalizeAuthVerdict(verdict, signedIn) {
544
+ // `unconfigured` is the slot record's DEFAULT (ensureSlot), not a probe
545
+ // result — the daemon never publishes it (probeLocalFleetAuth drops those
546
+ // rows). So it says nothing about the credential on disk; the live signedIn
547
+ // read of the slot decides, exactly as it does when no verdict exists at all.
532
548
  if (verdict === 'unconfigured')
533
- return 'missing';
549
+ return signedIn ? 'unverified' : 'missing';
534
550
  if (verdict === 'error')
535
551
  return signedIn ? 'unverified' : 'missing';
536
552
  return verdict ?? (signedIn ? 'unverified' : 'missing');
@@ -3,6 +3,25 @@ export type { DeviceAccountSlot };
3
3
  export declare function slotDir(harness: AgentId, accountId: string): string;
4
4
  export declare function readSlots(meta: Pick<Meta, 'deviceAccounts'>): Record<string, DeviceAccountSlot>;
5
5
  export declare function recordSlot(accountId: string, slot: DeviceAccountSlot): void;
6
+ export interface SlotProjection {
7
+ accountId: string;
8
+ /** Account name as registered (`agents accounts list`). */
9
+ name: string;
10
+ slotDir: string;
11
+ /** The version home the slot was projected from. */
12
+ from: string;
13
+ /** `<kind>/<name>` artifacts removed because the source no longer has them. */
14
+ pruned: string[];
15
+ }
16
+ /**
17
+ * Re-project settings and resources into every account slot this device holds
18
+ * for `harness`, from the harness's default version home. `ensureSlot` does
19
+ * this once at `agents accounts add`; every reconcile calls this so a slot
20
+ * never lags the version home it was cloned from (PHNX-3940). Credentials are
21
+ * never touched: `carryForwardSettings` and the resource writers exclude them.
22
+ * A slot whose directory is gone is skipped, not recreated.
23
+ */
24
+ export declare function projectAccountSlots(harness: AgentId): SlotProjection[];
6
25
  /**
7
26
  * Create the HOME-shaped slot dir and project settings/resources from the
8
27
  * managed install. Does not copy credentials (carryForwardSettings excludes
@@ -17,7 +17,7 @@ import { agentConfigDirName } from '../agents.js';
17
17
  import { harnessAuth, harnessWorkerIsPerDevice } from '../harness-auth-capabilities.js';
18
18
  import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from '../installations/store.js';
19
19
  import { carryForwardSettings } from '../settings-manifest.js';
20
- import { getHistoryDir, updateMeta } from '../state.js';
20
+ import { getHistoryDir, readMeta, updateMeta } from '../state.js';
21
21
  import { ALL_RESOURCE_KINDS, getDetector, getWriter, kindToCapability } from '../staleness/registry.js';
22
22
  import { supports } from '../capabilities.js';
23
23
  import { isAgentId } from '../types.js';
@@ -72,6 +72,66 @@ function projectResources(harness, version, destHome, fromHome) {
72
72
  writer.write({ version, versionHome: destHome, selection: names, cwd });
73
73
  }
74
74
  }
75
+ /**
76
+ * Remove slot artifacts whose source name is gone. Only the name-keyed kinds
77
+ * that implement `remove` (commands, skills, hooks) are pruned; wholesale kinds
78
+ * (rules, permissions) are rewritten by the projection itself.
79
+ */
80
+ function pruneSlotResources(harness, version, slotHome, fromHome) {
81
+ const cwd = process.cwd();
82
+ const pruned = [];
83
+ for (const kind of ALL_RESOURCE_KINDS) {
84
+ if (!supports(harness, kindToCapability(kind), version).ok)
85
+ continue;
86
+ const writer = getWriter(kind, harness);
87
+ const detector = getDetector(kind, harness);
88
+ if (!writer?.remove || !detector)
89
+ continue;
90
+ const source = new Set(detector.list({ version, versionHome: fromHome, cwd }));
91
+ for (const name of detector.list({ version, versionHome: slotHome, cwd })) {
92
+ if (source.has(name))
93
+ continue;
94
+ if (writer.remove({ version, versionHome: slotHome, name, cwd }).removed)
95
+ pruned.push(`${kind}/${name}`);
96
+ }
97
+ }
98
+ return pruned;
99
+ }
100
+ /**
101
+ * Re-project settings and resources into every account slot this device holds
102
+ * for `harness`, from the harness's default version home. `ensureSlot` does
103
+ * this once at `agents accounts add`; every reconcile calls this so a slot
104
+ * never lags the version home it was cloned from (PHNX-3940). Credentials are
105
+ * never touched: `carryForwardSettings` and the resource writers exclude them.
106
+ * A slot whose directory is gone is skipped, not recreated.
107
+ */
108
+ export function projectAccountSlots(harness) {
109
+ const version = sourceVersion(harness);
110
+ if (!version)
111
+ return [];
112
+ const fromHome = getVersionHomePath(harness, version);
113
+ if (!fs.existsSync(fromHome))
114
+ return [];
115
+ const meta = readMeta();
116
+ const slots = readSlots(meta);
117
+ const native = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
118
+ const projected = [];
119
+ for (const account of Object.values(native)) {
120
+ if (account.agent !== harness)
121
+ continue;
122
+ // The slot record is device-local and written after identity capture; a
123
+ // slot dir minted by an earlier build, or an add that stopped short of
124
+ // recording, is still the HOME an account run uses — project it too.
125
+ const dir = slots[account.id]?.slotDir ?? slotDir(harness, account.id);
126
+ if (!fs.existsSync(path.join(dir, agentConfigDirName(harness))))
127
+ continue;
128
+ carryForwardSettings(harness, fromHome, dir);
129
+ const pruned = pruneSlotResources(harness, version, dir, fromHome);
130
+ projectResources(harness, version, dir, fromHome);
131
+ projected.push({ accountId: account.id, name: account.name, slotDir: dir, from: version, pruned });
132
+ }
133
+ return projected;
134
+ }
75
135
  /**
76
136
  * Create the HOME-shaped slot dir and project settings/resources from the
77
137
  * managed install. Does not copy credentials (carryForwardSettings excludes
@@ -1,5 +1,5 @@
1
1
  import { type AccountInfo } from './agents.js';
2
- import type { AgentId } from './types.js';
2
+ import type { AgentId, Meta } from './types.js';
3
3
  import { type ProviderProbe } from './accounting/usage.js';
4
4
  /**
5
5
  * - `live` — completed an authenticated request (200).
@@ -134,6 +134,28 @@ export declare function formatCheckedAge(checkedAt: number, now?: number): strin
134
134
  export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
135
135
  /** Cache key: one entry per install — (host, agent, version). Unique per token. */
136
136
  export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
137
+ /**
138
+ * The `version` slot an account SLOT occupies in the auth cache and the probe
139
+ * rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
140
+ * version, so it needs its own key or its verdict would collide with (or be
141
+ * silently dropped in favor of) whichever version home the account's label
142
+ * happened to match.
143
+ */
144
+ export declare function slotAuthVersionKey(accountId: string): string;
145
+ /** One account slot on this device that the auth probe must cover. */
146
+ export interface SlotAuthInstall extends FleetAuthInstall {
147
+ home: string;
148
+ accountId: string;
149
+ }
150
+ /**
151
+ * Every registered account's slot on this device (PHNX-3940 T1), as probe
152
+ * targets. Before this the probe walked `listInstalledVersions` only, so a
153
+ * slot's verdict was never re-derived after `accounts add/login` wrote it —
154
+ * a slot re-materialized as `unconfigured` while the device doc was unreadable
155
+ * stayed MISSING forever even though its login was live. A slot whose dir is
156
+ * gone is skipped: there is nothing to probe and the row would only say so.
157
+ */
158
+ export declare function enumerateSlotInstalls(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agentIds: readonly AgentId[]): SlotAuthInstall[];
137
159
  /** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
138
160
  export declare function readAuthHealthCache(): Record<string, AuthHealth>;
139
161
  /** Read one entry, or null. */
@@ -19,6 +19,8 @@
19
19
  import * as fs from 'fs';
20
20
  import * as path from 'path';
21
21
  import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
22
+ import { listNativeAccounts } from './account-registry.js';
23
+ import { readSlots } from './accounts/slots.js';
22
24
  import { getCacheDir } from './state.js';
23
25
  import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
24
26
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
@@ -218,6 +220,37 @@ export function authAccountLabel(info) {
218
220
  export function authCacheKey(host, agent, version) {
219
221
  return `${host}:${agent}:${version}`;
220
222
  }
223
+ /**
224
+ * The `version` slot an account SLOT occupies in the auth cache and the probe
225
+ * rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
226
+ * version, so it needs its own key or its verdict would collide with (or be
227
+ * silently dropped in favor of) whichever version home the account's label
228
+ * happened to match.
229
+ */
230
+ export function slotAuthVersionKey(accountId) {
231
+ return `slot:${accountId}`;
232
+ }
233
+ /**
234
+ * Every registered account's slot on this device (PHNX-3940 T1), as probe
235
+ * targets. Before this the probe walked `listInstalledVersions` only, so a
236
+ * slot's verdict was never re-derived after `accounts add/login` wrote it —
237
+ * a slot re-materialized as `unconfigured` while the device doc was unreadable
238
+ * stayed MISSING forever even though its login was live. A slot whose dir is
239
+ * gone is skipped: there is nothing to probe and the row would only say so.
240
+ */
241
+ export function enumerateSlotInstalls(meta, agentIds) {
242
+ const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
243
+ const out = [];
244
+ for (const [accountId, slot] of Object.entries(readSlots(meta))) {
245
+ const account = byId.get(accountId);
246
+ if (!account || !agentIds.includes(account.agent))
247
+ continue;
248
+ if (!fs.existsSync(slot.slotDir))
249
+ continue;
250
+ out.push({ agent: account.agent, version: slotAuthVersionKey(accountId), home: slot.slotDir, account: undefined, accountId });
251
+ }
252
+ return out;
253
+ }
221
254
  function cacheFilePath() {
222
255
  return path.join(getCacheDir(), '.auth-health.json');
223
256
  }
@@ -410,26 +443,31 @@ export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
410
443
  */
411
444
  export async function probeLocalFleetAuth(opts) {
412
445
  const agentIds = opts?.agents ?? ALL_AGENT_IDS;
413
- // Enumerate every install, then resolve its account label. getAccountInfo is a
414
- // local credential-file read (no network), so this fan-out is cheap and cannot
415
- // contribute to the rate limit the probe grouping below exists to avoid.
446
+ // Enumerate every install AND every account slot on this device, then resolve
447
+ // each one's account label. getAccountInfo is a local credential-file read
448
+ // (no network), so this fan-out is cheap and cannot contribute to the rate
449
+ // limit the probe grouping below exists to avoid.
450
+ const [{ readMeta }, { findNativeAccountByIdentity }] = await Promise.all([
451
+ import('./state.js'),
452
+ import('./account-registry.js'),
453
+ ]);
454
+ const meta = readMeta();
416
455
  const installs = [];
417
456
  for (const agent of agentIds) {
418
457
  for (const version of listInstalledVersions(agent)) {
419
458
  installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
420
459
  }
421
460
  }
461
+ for (const slot of enumerateSlotInstalls(meta, agentIds)) {
462
+ installs.push({ ...slot, info: null });
463
+ }
422
464
  await Promise.all(installs.map(async (inst) => {
423
465
  inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
424
466
  inst.account = authAccountLabel(inst.info);
425
467
  }));
426
- const [{ readMeta }, { findNativeAccountByIdentity }] = await Promise.all([
427
- import('./state.js'),
428
- import('./account-registry.js'),
429
- ]);
430
- const meta = readMeta();
431
468
  for (const inst of installs) {
432
- inst.accountId = findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
469
+ // A slot already knows its account; a version home is joined by identity.
470
+ inst.accountId ??= findNativeAccountByIdentity(meta, inst.agent, inst.info)?.id;
433
471
  }
434
472
  // Probe once per (agent, account) — but only for the network-probing agents
435
473
  // that can actually 429; best-effort agents stay per-install (see
@@ -36,7 +36,7 @@
36
36
  * helper release now, off this table entirely.
37
37
  */
38
38
  export const HELPER_RELEASES = {
39
- menubar: { tagPrefix: 'menubar', floor: '1.1.1' },
39
+ menubar: { tagPrefix: 'menubar', floor: '1.1.2' },
40
40
  'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
41
41
  // The Windows helper is a bare .exe, not an .app bundle, so it does not share
42
42
  // helper-download.ts's zip/codesign/notarize machinery -- but it has the same
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import type { AgentId } from './types.js';
20
20
  import { type HealResult } from './heal.js';
21
+ import { type SlotProjection } from './accounts/slots.js';
21
22
  import { type HookRuntimeRepairReport } from './hooks/install.js';
22
23
  import { type RemediateStaleInstallsResult, type FindAgentsCliInstallsOptions } from './self-update.js';
23
24
  export interface HookRewireResult {
@@ -36,6 +37,13 @@ export interface RepairAfterSyncReport {
36
37
  hookRuntimeRepair: HookRuntimeRepairReport;
37
38
  /** Only populated on the umbrella (no-agent) path; null otherwise. */
38
39
  staleInstallPurge: RemediateStaleInstallsResult | null;
40
+ /**
41
+ * Account slots re-projected from their harness's default version home
42
+ * (PHNX-3940): one entry per slot, with the artifacts pruned because the
43
+ * source no longer carries them. Errors are per-harness sentences.
44
+ */
45
+ slotProjection: SlotProjection[];
46
+ slotProjectionErrors: string[];
39
47
  }
40
48
  /**
41
49
  * Test/override hook that scopes the destructive stale-CLI purge to injected
@@ -1,6 +1,7 @@
1
1
  import { AGENTS, ALL_AGENT_IDS } from './agents.js';
2
2
  import chalk from 'chalk';
3
3
  import { heal, healChangedAnything } from './heal.js';
4
+ import { projectAccountSlots } from './accounts/slots.js';
4
5
  import { checkVersionHookWiring, registerHooksToSettings, repairManagedHookRuntimeArtifacts, } from './hooks/install.js';
5
6
  import { getVersionHomePath, listInstalledVersions, isVersionIsolated, } from './installations/versions.js';
6
7
  import { invalidateDoctorOverviewCache } from './devices/doctor-overview-cache.js';
@@ -126,11 +127,27 @@ export async function repairAfterSync(opts) {
126
127
  const staleInstallPurge = (!opts.agent && opts.pruneClis === true)
127
128
  ? purgeStaleAgentsCliCopies(opts.purgeInjection)
128
129
  : null;
130
+ // Account slots follow the version home (PHNX-3940). `agents accounts add`
131
+ // projects a slot once; without this every later sync would update the
132
+ // version home and leave each slot on the resources it was cloned with.
133
+ const slotProjection = [];
134
+ const slotProjectionErrors = [];
135
+ const slotAgents = opts.agent ? [opts.agent] : ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0);
136
+ for (const agent of slotAgents) {
137
+ try {
138
+ slotProjection.push(...projectAccountSlots(agent));
139
+ }
140
+ catch (err) {
141
+ slotProjectionErrors.push(`${agent}: ${err.message}`);
142
+ }
143
+ }
129
144
  const report = {
130
145
  heal: healResult,
131
146
  hookRewire,
132
147
  hookRuntimeRepair,
133
148
  staleInstallPurge,
149
+ slotProjection,
150
+ slotProjectionErrors,
134
151
  };
135
152
  if (repairChangedAnything(report))
136
153
  invalidateDoctorOverviewCache();
@@ -142,7 +159,9 @@ export function repairChangedAnything(report) {
142
159
  report.hookRewire.some((r) => r.rewired > 0 || r.remaining > 0 || r.failure !== undefined) ||
143
160
  report.hookRuntimeRepair.attempts.length > 0 ||
144
161
  (report.staleInstallPurge !== null &&
145
- (report.staleInstallPurge.removed.length > 0 || report.staleInstallPurge.failed.length > 0)));
162
+ (report.staleInstallPurge.removed.length > 0 || report.staleInstallPurge.failed.length > 0)) ||
163
+ report.slotProjection.length > 0 ||
164
+ report.slotProjectionErrors.length > 0);
146
165
  }
147
166
  /**
148
167
  * True when the RECONCILE repair left a per-version problem a human must fix — an
@@ -174,6 +193,8 @@ export function repairAfterSyncJson(report) {
174
193
  hookRewire: report.hookRewire,
175
194
  hookRuntimeRepair: report.hookRuntimeRepair,
176
195
  staleInstallPurge: report.staleInstallPurge,
196
+ slotProjection: report.slotProjection,
197
+ slotProjectionErrors: report.slotProjectionErrors,
177
198
  hadFailures: repairHadFailures(report),
178
199
  };
179
200
  }
@@ -264,4 +285,14 @@ export function renderRepairAfterSync(report, log = (s) => console.log(s)) {
264
285
  renderHookRuntimeRepairText(report.hookRuntimeRepair, log);
265
286
  if (report.staleInstallPurge)
266
287
  renderStaleInstallPurgeText(report.staleInstallPurge, log);
288
+ renderSlotProjectionText(report, log);
289
+ }
290
+ function renderSlotProjectionText(report, log) {
291
+ for (const slot of report.slotProjection) {
292
+ const pruned = slot.pruned.length > 0 ? chalk.gray(` — removed ${slot.pruned.join(', ')}`) : '';
293
+ log(` ${chalk.green('slot')} account ${chalk.bold(slot.name)} ${chalk.gray(`← ${slot.from}`)}${pruned}`);
294
+ }
295
+ for (const err of report.slotProjectionErrors) {
296
+ log(` ${chalk.yellow('slot')} ${err}`);
297
+ }
267
298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.98",
3
+ "version": "1.22.99",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",