@phnx-labs/agents-cli 1.22.81 → 1.22.83

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.83
4
+
5
+ - **The Claude status line shows the registered account NAME (PHNX-3987).** A login named with `agents accounts` (`work`, `dev`, …) now renders that name between the host and the model — `yosemite · work · Fable 5.1 · …` — which is what tells several same-harness logins apart at a glance; an unnamed login keeps the email label. The lookup is the one `agents view` and the device inventories already use, lifted into `findNativeAccountByIdentity` so the three copies share it. Source: `cli/src/lib/claude-statusline.ts`, `cli/src/lib/account-registry.ts`.
6
+
7
+ - **`agents accounts rename` / `remove` / `view` honor per-harness account names (PHNX-3988).** Native labels are unique per harness (PHNX-3887), but rename/remove still ran a fleet-wide uniqueness check and resolved a bare name to whichever harness row the store ordered first — so renaming Codex's `cxicloud` to `icloud` failed because Claude already owned `icloud`. Rename now scopes uniqueness to the target row's harness, so one harness can take a name another already uses; a duplicate within the same harness is still refused. Rename, remove, and view accept `<harness>#<name>` and refuse an ambiguous bare name rather than guessing. Source: `cli/src/lib/account-registry.ts`, `cli/src/commands/accounts.ts`.
8
+
9
+ - **Feed activity polling reuses unchanged log tails (PHNX-3939).** The activity reader caches file-version-validated tails and timestamp summaries within a bounded process-local budget. Cursor polls skip unchanged historical events without parsing them again; appends, rewrites, replacements and tail-budget changes invalidate the cached snapshot. Returned events remain independently mutable, with timestamp, filter and limit behavior preserved.
10
+
11
+ ## 1.22.82
12
+
13
+ - **The Claude status line names the signed-in account before the model (PHNX-3987).** The managed `agents __claude-statusline` render now reads `host · account · model · 5h n% · 7d n% · ◆ reminder`, where the account is the email the running Claude is signed into (plus the org name for a Team/Enterprise seat, the same label `agents view` renders), read from the `.claude.json` of the home Claude is actually running with — the version home under the launch shim, `$HOME` otherwise. A never-signed-in home omits the part. Source: `cli/src/lib/claude-statusline.ts`.
14
+
3
15
  ## 1.22.81
4
16
 
5
17
  - **Same-task page reopen refreshes the SAME tab instead of duplicating it (PHNX-2399).** When an agent reopens a URL that is already live in one of its browser task's OWN tabs, `agents browser navigate` / `tab add` now issue a real `Page.reload` on that same tab — keeping its tab id and CDP target, marking it current without stealing window focus — and report `refreshed: true` with the note `Tab already open—refreshed`, rather than opening a second copy. Borrowed Arc/user tabs are excluded, a stale registered tab or a failed reload never reports a phantom refresh, and the lookup→reload/create→persist section is serialized per task (and first-use creation per caller) so concurrent requests converge on one tab and the same id. The implicit first-open at the create boundary is reported as a genuine open, not disguised as a refresh. `navigate` / `tab add` gain `--json` (`{ ok, task, tabId, url, created, refreshed, message }`), and `agents browser tabs` again marks the current tab. Source: `cli/src/lib/browser/service.ts`, `cli/src/lib/browser/ipc.ts`, `cli/src/lib/browser/types.ts`, `cli/src/commands/browser.ts`.
@@ -21,7 +21,7 @@ import { connectRefusal, connectSupported, runConnect } from '../lib/accounts/co
21
21
  import { acquireAuthOperationLock } from '../lib/accounts/auth-operation-lock.js';
22
22
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
23
23
  import { getAccountProvider, listAccountProviders, providerAuthenticatesHarness } from '../lib/account-provider-registry.js';
24
- import { accountBindings, addAccount, addNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, nativeAccountHome, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
24
+ import { accountBindings, addAccount, addNativeAccount, assertUnambiguousNativeAccount, bindAccount, findAccount, findUnifiedAccount, inspectAccount, labelNativeAccount, listNativeAccounts, nativeAccountHome, parseAccountSelector, readAccountRegistry, removeAccount, renameAccount, setAccountSecret, unbindAccount } from '../lib/account-registry.js';
25
25
  import { registerMintCommand } from './auth-mint.js';
26
26
  /** Comma-joined list of harnesses `accounts connect` can drive today. */
27
27
  function connectSupportedList() {
@@ -580,12 +580,21 @@ agents run claude --account work`,
580
580
  console.log(chalk.green(`Updated credential for account '${name}'.`));
581
581
  });
582
582
  });
583
- accounts.command('view <name>').alias('inspect').description('Show safe account metadata, custody, and attachments').option('--json', 'Machine-readable output').action(async (name, o, command) => {
583
+ const viewCmd = accounts.command('view <name>')
584
+ .alias('inspect')
585
+ .description('Show safe account metadata, custody, and attachments. Target may be <harness>#<name> when the name exists for several harnesses')
586
+ .option('--json', 'Machine-readable output')
587
+ .action(async (name, o, command) => {
584
588
  await runAccountsAction(command, async () => {
585
589
  const meta = readMeta();
586
- const unified = findUnifiedAccount(name, meta);
590
+ const selector = parseAccountSelector(name);
591
+ assertUnambiguousNativeAccount(meta, selector.name, selector.agent);
592
+ const unified = findUnifiedAccount(selector.name, meta, undefined, selector.agent);
587
593
  if (!unified)
588
594
  throw new Error(`Unknown account '${name}'.`);
595
+ if (selector.agent && unified.kind === 'native' && unified.agent !== selector.agent) {
596
+ throw new Error(`Unknown ${selector.agent} account '${selector.name}'.`);
597
+ }
589
598
  const account = unified.kind === 'provider'
590
599
  ? { ...publicAccount(inspectAccount(unified.name)), custody: 'agents secrets (policy never)', attached: accountBindings(unified.id, meta) }
591
600
  : { ...unified, custody: `${unified.agent} (not stored by agents-cli)`, attached: accountBindings(unified.id, meta) };
@@ -607,6 +616,11 @@ agents run claude --account work`,
607
616
  console.log(` attached: ${account.attached.length ? account.attached.join(', ') : 'none'}`);
608
617
  });
609
618
  });
619
+ setHelpSections(viewCmd, {
620
+ examples: `agents accounts view work --json
621
+ agents accounts view codex#icloud`,
622
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
623
+ });
610
624
  accounts.command('name <source> <name>')
611
625
  .description('Name a signed-in native installation without copying its OAuth credentials')
612
626
  .action(async (source, name, _o, command) => {
@@ -723,12 +737,25 @@ agents run codex#work`,
723
737
  console.log(chalk.green(`Detached ${name} from ${target}.`));
724
738
  });
725
739
  });
726
- accounts.command('rename <old> <new>').description('Rename an account without changing its stable id').action(async (oldName, newName, _o, command) => {
740
+ const renameCmd = accounts.command('rename <old> <new>')
741
+ .description('Rename an account without changing its stable id. Target may be <harness>#<name> when the name exists for several harnesses')
742
+ .action(async (oldName, newName, _o, command) => {
727
743
  await runAccountsAction(command, () => { renameAccount(oldName, newName); });
728
744
  });
729
- accounts.command('remove <name>').description('Remove an account and its device-local credential').action(async (name, _o, command) => {
745
+ setHelpSections(renameCmd, {
746
+ examples: `agents accounts rename cxicloud icloud
747
+ agents accounts rename codex#icloud cloud`,
748
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
749
+ });
750
+ const removeCmd = accounts.command('remove <name>')
751
+ .description('Remove an account and its device-local credential. Target may be <harness>#<name> when the name exists for several harnesses')
752
+ .action(async (name, _o, command) => {
730
753
  await runAccountsAction(command, () => { removeAccount(name); });
731
754
  });
755
+ setHelpSections(removeCmd, {
756
+ examples: `agents accounts remove claude#icloud`,
757
+ notes: 'Native names are unique per harness. A bare name that exists for several harnesses is refused — pick one with <harness>#<name>.',
758
+ });
732
759
  accounts.command('set-default <agent> <name>')
733
760
  .description('Use this account for a harness when --account is omitted')
734
761
  .action(async (agentRaw, name, _o, command) => {
@@ -849,6 +876,7 @@ agents accounts name claude@2.1.220 work
849
876
  agents accounts label codex@0.146.0 personal
850
877
  agents accounts attach work claude@2.1.225
851
878
  agents accounts view work --json
879
+ agents accounts view codex#icloud
852
880
  agents accounts switch claude
853
881
  agents accounts switch claude work
854
882
  agents accounts set-default claude work
@@ -20,7 +20,7 @@ import { listCliStatus } from '../lib/cli-resources.js';
20
20
  import { isCapable } from '../lib/capabilities.js';
21
21
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
22
22
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
23
- import { listNativeAccounts } from '../lib/account-registry.js';
23
+ import { findNativeAccountByIdentity } from '../lib/account-registry.js';
24
24
  import { buildNativeCatalog } from '../lib/account-catalog.js';
25
25
  import { connectSupported } from '../lib/accounts/connect.js';
26
26
  import { readInstallation } from '../lib/installations/store.js';
@@ -67,11 +67,8 @@ export const accountColumnLabel = accountDisplayLabel;
67
67
  */
68
68
  export function namedAccountColumnLabel(agentId, info) {
69
69
  const display = accountColumnLabel(info);
70
- const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
71
- if (!identityKey)
72
- return display;
73
- const saved = listNativeAccounts(readMeta()).find(item => item.agent === agentId && item.identityKey === identityKey);
74
- return saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display;
70
+ const saved = findNativeAccountByIdentity(readMeta(), agentId, info);
71
+ return saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display;
75
72
  }
76
73
  /** Human account identity; release labels belong only in installation diagnostics. */
77
74
  export function nativeAccountViewLabel(row) {
@@ -1,4 +1,4 @@
1
- import type { AgentId, Meta } from './types.js';
1
+ import { type AgentId, type Meta } from './types.js';
2
2
  import { type AccountAuthKind } from './account-provider-registry.js';
3
3
  export interface CredentialAccount {
4
4
  id: string;
@@ -43,6 +43,18 @@ export declare function findAccount(name: string, doc?: AccountRegistryDocument)
43
43
  * accumulate). On an id collision the device slice wins.
44
44
  */
45
45
  export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
46
+ /**
47
+ * The native account registered for one harness login, or null when that login
48
+ * is unnamed. `info` is the login's identity as `getAccountInfo` /
49
+ * `readClaudeHomeConfig` report it; the match key is the same value every
50
+ * catalog and inventory groups a home on — the stable `accountKey`, else the
51
+ * lowercased email. The one lookup behind `agents view`, the device
52
+ * inventories, and the Claude status line.
53
+ */
54
+ export declare function findNativeAccountByIdentity(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agent: AgentId, info: {
55
+ accountKey?: string | null;
56
+ email?: string | null;
57
+ } | null | undefined): NativeAccount | null;
46
58
  /**
47
59
  * Resolve one account by name or id across both stores, native first.
48
60
  *
@@ -55,6 +67,23 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
55
67
  * able to omit it.
56
68
  */
57
69
  export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
70
+ /**
71
+ * Split a management selector into its harness and name. `<harness>#<name>`
72
+ * pins the harness; a bare name (or row id) carries none. Native names are
73
+ * unique per harness (PHNX-3887), so a bare name alone can legitimately match a
74
+ * claude row AND a codex row — the selector is how rename/remove say which.
75
+ */
76
+ export declare function parseAccountSelector(input: string): {
77
+ agent?: AgentId;
78
+ name: string;
79
+ };
80
+ /**
81
+ * Refuse a bare name that native rows in several harnesses share. A
82
+ * harness-qualified selector (`agent` set) never hits this — uniqueness is
83
+ * per harness. Ids are unique so they never collide either. Shared by
84
+ * rename/remove/view so the message stays one string.
85
+ */
86
+ export declare function assertUnambiguousNativeAccount(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, name: string, agent?: AgentId): void;
58
87
  /**
59
88
  * Validate a native account NAME (charset + per-harness uniqueness) WITHOUT a
60
89
  * known identity — the pre-flight `agents accounts connect` runs before it
@@ -120,8 +149,16 @@ export interface AddAccountOptions {
120
149
  }
121
150
  export declare function addAccount(name: string, provider: string, auth: AccountAuthKind, secret: string, base?: string, opts?: AddAccountOptions): CredentialAccount;
122
151
  export declare function setAccountSecret(name: string, secret: string, base?: string): void;
123
- export declare function renameAccount(oldName: string, newName: string, base?: string): void;
124
- export declare function removeAccount(name: string, base?: string): void;
152
+ /**
153
+ * `oldSelector` is a bare name, a row id, or `<harness>#<name>`. A native row
154
+ * carries its harness, so the new name only has to be free within THAT harness
155
+ * (PHNX-3887 / PHNX-3988): renaming codex's `cxicloud` to `icloud` is fine
156
+ * while claude's `icloud` stays untouched. Provider accounts have no harness
157
+ * and stay globally unique.
158
+ */
159
+ export declare function renameAccount(oldSelector: string, newName: string, base?: string): void;
160
+ /** `selector` is a bare name, a row id, or `<harness>#<name>` (see {@link renameAccount}). */
161
+ export declare function removeAccount(selector: string, base?: string): void;
125
162
  export declare function inspectAccount(name: string, base?: string): CredentialAccount & {
126
163
  secretPresent: boolean;
127
164
  policy: 'never';
@@ -23,6 +23,7 @@ import * as yaml from 'yaml';
23
23
  import chalk from 'chalk';
24
24
  import { atomicWriteFileSync } from './fs-atomic.js';
25
25
  import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
26
+ import { isAgentId } from './types.js';
26
27
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
27
28
  import { bundleExists, deleteBundle, listBundles, readAndResolveBundleEnv, readBundle, renameBundle, writeBundleWithItems } from './secrets/bundles.js';
28
29
  import { getAccountProvider } from './account-provider-registry.js';
@@ -148,6 +149,20 @@ export function listNativeAccounts(meta) {
148
149
  const merged = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
149
150
  return Object.values(merged).map(account => ({ ...account, kind: 'native' }));
150
151
  }
152
+ /**
153
+ * The native account registered for one harness login, or null when that login
154
+ * is unnamed. `info` is the login's identity as `getAccountInfo` /
155
+ * `readClaudeHomeConfig` report it; the match key is the same value every
156
+ * catalog and inventory groups a home on — the stable `accountKey`, else the
157
+ * lowercased email. The one lookup behind `agents view`, the device
158
+ * inventories, and the Claude status line.
159
+ */
160
+ export function findNativeAccountByIdentity(meta, agent, info) {
161
+ const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
162
+ if (!identityKey)
163
+ return null;
164
+ return listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey) ?? null;
165
+ }
151
166
  /**
152
167
  * Resolve one account by name or id across both stores, native first.
153
168
  *
@@ -168,8 +183,9 @@ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
168
183
  // merged store happened to order first, so `agents run claude#<email>` died with
169
184
  // "Account 'personal' is a codex login and cannot authenticate the claude harness"
170
185
  // while that identity's own claude login sat right there. Prefer the harness being
171
- // launched; fall back to the first match so a management lookup with no harness in
172
- // hand (rename/remove/view) behaves exactly as before.
186
+ // launched; fall back to the first match when the caller has no harness in hand.
187
+ // rename/remove/view refuse an ambiguous *name* via assertUnambiguousNativeAccount
188
+ // before they get here — this fallback is for identityLabel collisions.
173
189
  const native = matches.find(account => account.agent === preferAgent) ?? matches[0];
174
190
  if (native)
175
191
  return native;
@@ -179,10 +195,46 @@ export function findUnifiedAccount(nameOrId, meta, doc, preferAgent) {
179
195
  function nativeIdentityRows(meta, agent, identityKey) {
180
196
  return listNativeAccounts(meta).filter(account => account.agent === agent && account.identityKey === identityKey);
181
197
  }
198
+ /**
199
+ * Split a management selector into its harness and name. `<harness>#<name>`
200
+ * pins the harness; a bare name (or row id) carries none. Native names are
201
+ * unique per harness (PHNX-3887), so a bare name alone can legitimately match a
202
+ * claude row AND a codex row — the selector is how rename/remove say which.
203
+ */
204
+ export function parseAccountSelector(input) {
205
+ const hash = input.indexOf('#');
206
+ if (hash < 0)
207
+ return { name: input };
208
+ const agentRaw = input.slice(0, hash);
209
+ const name = input.slice(hash + 1).trim();
210
+ if (!isAgentId(agentRaw))
211
+ throw new Error(`Unknown agent '${agentRaw}'.`);
212
+ if (!name)
213
+ throw new Error('Select an account after #.');
214
+ return { agent: agentRaw, name };
215
+ }
216
+ /**
217
+ * Refuse a bare name that native rows in several harnesses share. A
218
+ * harness-qualified selector (`agent` set) never hits this — uniqueness is
219
+ * per harness. Ids are unique so they never collide either. Shared by
220
+ * rename/remove/view so the message stays one string.
221
+ */
222
+ export function assertUnambiguousNativeAccount(meta, name, agent) {
223
+ const matches = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent) && (account.id === name || account.name === name));
224
+ const harnesses = [...new Set(matches.map(account => account.agent))].sort();
225
+ if (harnesses.length > 1) {
226
+ throw new Error(`Account '${name}' exists for several harnesses (${harnesses.join(', ')}). `
227
+ + `Pick one with <harness>#${name}, e.g. ${harnesses[0]}#${name}.`);
228
+ }
229
+ }
182
230
  /** Every row (central + this box's device store) for the identity that `name`
183
- * (id or account name) resolves to. */
184
- function nativeRowsForNameOrId(meta, name) {
185
- const found = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
231
+ * (id or account name) resolves to. With `agent` set only that harness's rows
232
+ * are considered; without it, a name owned by rows in several harnesses is
233
+ * refused rather than resolved to whichever the store ordered first. */
234
+ function nativeRowsForNameOrId(meta, name, agent) {
235
+ assertUnambiguousNativeAccount(meta, name, agent);
236
+ const matches = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent) && (account.id === name || account.name === name));
237
+ const found = matches[0];
186
238
  if (!found)
187
239
  return [];
188
240
  return nativeIdentityRows(meta, found.agent, found.identityKey);
@@ -197,9 +249,9 @@ function nativeRowsForNameOrId(meta, name) {
197
249
  * actually ambiguous at the point of use: the selector is `<harness>#<label>`,
198
250
  * and `findUnifiedAccount` already disambiguates via `preferAgent`.
199
251
  *
200
- * Pass `agent` to scope the check to that harness. Omit it for management
201
- * lookups with no harness in hand (rename/remove), which keep the old
202
- * fleet-wide check so a rename cannot collide with an unrelated harness's row.
252
+ * Pass `agent` to scope the check to that harness. Every native path has one
253
+ * in hand — connect/label from the caller, rename from the row being renamed —
254
+ * so the un-scoped form is only for provider accounts.
203
255
  *
204
256
  * Provider (non-native) accounts stay globally unique — they are selected by
205
257
  * bare name via `--account`, with no harness to scope them by.
@@ -493,21 +545,32 @@ export function setAccountSecret(name, secret, base = getUserAgentsDir()) {
493
545
  bundle.created_at = readBundle(account.name).created_at; // rotate the secret, keep the bundle's birth time
494
546
  writeBundleWithItems(bundle, items);
495
547
  }
496
- export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
548
+ /**
549
+ * `oldSelector` is a bare name, a row id, or `<harness>#<name>`. A native row
550
+ * carries its harness, so the new name only has to be free within THAT harness
551
+ * (PHNX-3887 / PHNX-3988): renaming codex's `cxicloud` to `icloud` is fine
552
+ * while claude's `icloud` stays untouched. Provider accounts have no harness
553
+ * and stay globally unique.
554
+ */
555
+ export function renameAccount(oldSelector, newName, base = getUserAgentsDir()) {
497
556
  assertName(newName);
498
- const doc = readAccountRegistry(base);
499
557
  const meta = readMeta();
500
- const rows = nativeRowsForNameOrId(meta, oldName);
558
+ const selector = parseAccountSelector(oldSelector);
559
+ const rows = nativeRowsForNameOrId(meta, selector.name, selector.agent);
501
560
  if (rows.length) {
502
- assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
561
+ assertUniqueUnifiedName(newName, meta, undefined, new Set(rows.map(account => account.id)), rows[0].agent);
503
562
  // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315)
504
563
  // and any per-harness default that points to the old name or row ids.
505
564
  const rowScope = rows[0].scope;
506
- const idsToRename = new Set([oldName, ...rows.map(row => row.id)]);
565
+ const renamedAgent = rows[0].agent;
566
+ const renamedIds = new Set(rows.map(row => row.id));
507
567
  updateMeta(current => {
508
568
  const defaults = { ...current.accounts?.defaults };
509
569
  for (const [agent, value] of Object.entries(defaults)) {
510
- if (idsToRename.has(value))
570
+ // Ids are unique so an id match may rewrite any harness's default. A
571
+ // bare-name match must only rewrite THIS harness — two harnesses may
572
+ // legitimately share the same native name (PHNX-3988).
573
+ if (renamedIds.has(value) || (agent === renamedAgent && value === selector.name))
511
574
  defaults[agent] = newName;
512
575
  }
513
576
  const next = { ...current, accounts: { ...current.accounts, defaults } };
@@ -527,9 +590,12 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
527
590
  });
528
591
  return;
529
592
  }
530
- const account = findAccount(oldName, doc);
593
+ if (selector.agent)
594
+ throw new Error(`Unknown ${selector.agent} account '${selector.name}'.`);
595
+ const doc = readAccountRegistry(base);
596
+ const account = findAccount(selector.name, doc);
531
597
  if (!account)
532
- throw new Error(`Unknown account '${oldName}'.`);
598
+ throw new Error(`Unknown account '${selector.name}'.`);
533
599
  assertUniqueUnifiedName(newName, meta, doc);
534
600
  const idsToRename = new Set([account.name, account.id]);
535
601
  updateMeta(current => {
@@ -543,9 +609,11 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
543
609
  renameBundle(account.name, newName); // moves metadata + secret, preserves ACCOUNT_ID
544
610
  renameProfileConsumers(account.name, newName, base);
545
611
  }
546
- export function removeAccount(name, base = getUserAgentsDir()) {
612
+ /** `selector` is a bare name, a row id, or `<harness>#<name>` (see {@link renameAccount}). */
613
+ export function removeAccount(selector, base = getUserAgentsDir()) {
547
614
  const meta = readMeta();
548
- const rows = nativeRowsForNameOrId(meta, name);
615
+ const parsed = parseAccountSelector(selector);
616
+ const rows = nativeRowsForNameOrId(meta, parsed.name, parsed.agent);
549
617
  if (rows.length) {
550
618
  const bindings = [...new Set(rows.flatMap(row => accountBindings(row.id, meta)))].sort();
551
619
  if (bindings.length)
@@ -571,6 +639,9 @@ export function removeAccount(name, base = getUserAgentsDir()) {
571
639
  });
572
640
  return;
573
641
  }
642
+ if (parsed.agent)
643
+ throw new Error(`Unknown ${parsed.agent} account '${parsed.name}'.`);
644
+ const name = parsed.name;
574
645
  const account = findAccount(name, readAccountRegistry(base));
575
646
  if (!account)
576
647
  throw new Error(`Unknown account '${name}'.`);
@@ -1,3 +1,5 @@
1
+ import { type ClaudeHomeIdentity } from './agent-spec/agents.js';
2
+ import { type NativeAccount } from './account-registry.js';
1
3
  export declare const CLAUDE_STATUSLINE_COMMAND = "agents __claude-statusline";
2
4
  /**
3
5
  * True when `command` re-invokes THIS status-line producer (our private
@@ -28,7 +30,35 @@ interface ClaudeStatusLinePayload {
28
30
  };
29
31
  };
30
32
  }
31
- export declare function ingestClaudeStatusLineUsage(payload: ClaudeStatusLinePayload, versionHome: string): boolean;
33
+ export declare function ingestClaudeStatusLineUsage(payload: ClaudeStatusLinePayload, identity: ClaudeHomeIdentity | null): boolean;
34
+ /**
35
+ * Where the running Claude keeps the config this render attributes to: the version
36
+ * home when the launch shim set CLAUDE_CONFIG_DIR, otherwise the real HOME — the
37
+ * same resolution Claude Code applies, so a Claude launched without the shim (IDE
38
+ * extension, direct binary) still shows the account it is actually signed into.
39
+ */
40
+ export declare function claudeHomeFromEnv(env: NodeJS.ProcessEnv): string;
41
+ /**
42
+ * The identity of the running Claude, read ONCE per render from the `.claude.json`
43
+ * of the home it is actually running with: it keys the usage ingest above and
44
+ * names the account part below. Null when the home has never signed in. Sync and
45
+ * file-only on purpose — this runs on every status-line refresh.
46
+ */
47
+ export declare function readClaudeIdentity(claudeHome: string): ClaudeHomeIdentity | null;
48
+ /**
49
+ * The registered native account for the running Claude's identity (`agents
50
+ * accounts` — `work`, `dev`, …), or null when that login is unnamed.
51
+ */
52
+ export declare function resolveNativeAccount(identity: ClaudeHomeIdentity | null): NativeAccount | null;
53
+ /**
54
+ * Format the signed-in account as a statusline part ('' when never signed in).
55
+ * A named login renders its registered NAME — the short handle the owner chose
56
+ * (`work`, `dev`), which is what tells eight same-harness logins apart at a
57
+ * glance. An unnamed login renders the label every other account-aware surface
58
+ * (`agents view`, `agents accounts`) uses: the email, plus the org name for a
59
+ * multi-seat Team/Enterprise seat.
60
+ */
61
+ export declare function formatAccountPart(identity: ClaudeHomeIdentity | null, account: NativeAccount | null): string;
32
62
  export declare function renderDelegate(payload: string, versionHome: string): string;
33
63
  /**
34
64
  * Format a reminder as a dimmed statusline part (empty string when none). The
@@ -36,7 +66,7 @@ export declare function renderDelegate(payload: string, versionHome: string): st
36
66
  * keeps it quiet next to the live figures.
37
67
  */
38
68
  export declare function formatReminderPart(short: string | undefined): string;
39
- export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string, reminder?: string): string;
69
+ export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string, reminder?: string, account?: string): string;
40
70
  /**
41
71
  * Resolve the per-session reminder for the statusline, or '' when none is
42
72
  * configured. A malformed reminders file is swallowed here on purpose — a broken
@@ -2,10 +2,12 @@ import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { spawnSync } from 'child_process';
5
- import { readClaudeHomeConfig } from './agent-spec/agents.js';
5
+ import { accountDisplayLabel, readClaudeHomeConfig, } from './agent-spec/agents.js';
6
+ import { findNativeAccountByIdentity } from './account-registry.js';
6
7
  import { atomicWriteFileSync } from './fs-atomic.js';
7
8
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
8
9
  import { loadReminders, pickReminderForSession } from './reminders.js';
10
+ import { readMeta } from './state.js';
9
11
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
10
12
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
11
13
  // The private subcommand this feature runs. It is only ever invoked internally,
@@ -61,8 +63,7 @@ function windowFromNative(key, value) {
61
63
  windowMinutes: key === 'session' ? 300 : 10_080,
62
64
  };
63
65
  }
64
- export function ingestClaudeStatusLineUsage(payload, versionHome) {
65
- const identity = readClaudeHomeConfig(versionHome)?.identity;
66
+ export function ingestClaudeStatusLineUsage(payload, identity) {
66
67
  if (!identity?.usageKey || !payload.rate_limits)
67
68
  return false;
68
69
  const windows = [
@@ -79,6 +80,46 @@ export function ingestClaudeStatusLineUsage(payload, versionHome) {
79
80
  });
80
81
  return true;
81
82
  }
83
+ /**
84
+ * Where the running Claude keeps the config this render attributes to: the version
85
+ * home when the launch shim set CLAUDE_CONFIG_DIR, otherwise the real HOME — the
86
+ * same resolution Claude Code applies, so a Claude launched without the shim (IDE
87
+ * extension, direct binary) still shows the account it is actually signed into.
88
+ */
89
+ export function claudeHomeFromEnv(env) {
90
+ return versionHomeFromEnv(env) ?? os.homedir();
91
+ }
92
+ /**
93
+ * The identity of the running Claude, read ONCE per render from the `.claude.json`
94
+ * of the home it is actually running with: it keys the usage ingest above and
95
+ * names the account part below. Null when the home has never signed in. Sync and
96
+ * file-only on purpose — this runs on every status-line refresh.
97
+ */
98
+ export function readClaudeIdentity(claudeHome) {
99
+ return readClaudeHomeConfig(claudeHome)?.identity ?? null;
100
+ }
101
+ /**
102
+ * The registered native account for the running Claude's identity (`agents
103
+ * accounts` — `work`, `dev`, …), or null when that login is unnamed.
104
+ */
105
+ export function resolveNativeAccount(identity) {
106
+ return identity ? findNativeAccountByIdentity(readMeta(), 'claude', identity) : null;
107
+ }
108
+ /**
109
+ * Format the signed-in account as a statusline part ('' when never signed in).
110
+ * A named login renders its registered NAME — the short handle the owner chose
111
+ * (`work`, `dev`), which is what tells eight same-harness logins apart at a
112
+ * glance. An unnamed login renders the label every other account-aware surface
113
+ * (`agents view`, `agents accounts`) uses: the email, plus the org name for a
114
+ * multi-seat Team/Enterprise seat.
115
+ */
116
+ export function formatAccountPart(identity, account) {
117
+ if (!identity)
118
+ return '';
119
+ if (account)
120
+ return account.name;
121
+ return accountDisplayLabel({ ...identity, signedIn: true });
122
+ }
82
123
  function delegatePath(versionHome) {
83
124
  return path.join(versionHome, DELEGATE_FILE);
84
125
  }
@@ -113,9 +154,14 @@ export function formatReminderPart(short) {
113
154
  const text = short?.trim();
114
155
  return text ? `\x1b[2m◆ ${text}\x1b[22m` : '';
115
156
  }
116
- export function renderClaudeStatusLine(payload, host = os.hostname().split('.')[0] || os.hostname(), delegated = '', reminder = '') {
157
+ export function renderClaudeStatusLine(payload, host = os.hostname().split('.')[0] || os.hostname(), delegated = '', reminder = '', account = '') {
117
158
  const model = payload.model?.display_name?.trim() || payload.model?.id?.trim() || 'model pending';
118
- const parts = [host, model];
159
+ // host · account · model: who is running, as which account, on which model —
160
+ // the account sits before the model so a glance answers "whose quota is this".
161
+ const parts = [host];
162
+ if (account)
163
+ parts.push(account);
164
+ parts.push(model);
119
165
  if (delegated)
120
166
  parts.push(delegated);
121
167
  const fiveHour = payload.rate_limits?.five_hour?.used_percentage;
@@ -158,9 +204,10 @@ export async function runClaudeStatusLine() {
158
204
  payload = {};
159
205
  }
160
206
  const versionHome = versionHomeFromEnv(process.env);
207
+ const identity = readClaudeIdentity(claudeHomeFromEnv(process.env));
161
208
  if (versionHome)
162
- ingestClaudeStatusLineUsage(payload, versionHome);
163
- process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : '', resolveReminderPart(payload.session_id)));
209
+ ingestClaudeStatusLineUsage(payload, identity);
210
+ process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : '', resolveReminderPart(payload.session_id), formatAccountPart(identity, resolveNativeAccount(identity))));
164
211
  return 0;
165
212
  }
166
213
  export function installClaudeStatusLine(versionHome) {
@@ -12,7 +12,7 @@ import { getAvailableResources, getVersionHomePath, isVersionIsolated, listInsta
12
12
  import { supports } from '../capabilities.js';
13
13
  import { checkVersionHookWiring } from '../hooks/install.js';
14
14
  import { getUserAgentsDir, getSystemAgentsDir, readMeta } from '../state.js';
15
- import { listNativeAccounts } from '../account-registry.js';
15
+ import { findNativeAccountByIdentity } from '../account-registry.js';
16
16
  import { readRepoState } from '../git.js';
17
17
  import { ALL_AGENT_IDS, accountDisplayLabel, credentialPresence, getAccountInfo, supportsAccountInspection, } from '../agents.js';
18
18
  import { FLEET_RESOURCE_KINDS, } from './fleet-divergence.js';
@@ -46,9 +46,8 @@ export async function collectLocalFleetSignIn() {
46
46
  signedIn = info.signedIn;
47
47
  // Prefix the durable account name when this identity has been named.
48
48
  const display = accountDisplayLabel(info);
49
- const identityKey = info.accountKey ?? info.email?.toLowerCase();
50
- const saved = identityKey ? listNativeAccounts(readMeta()).find(item => item.agent === agent && item.identityKey === identityKey) : undefined;
51
- account = (saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display) || null;
49
+ const saved = findNativeAccountByIdentity(readMeta(), agent, info);
50
+ account = (saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display) || null;
52
51
  }
53
52
  catch {
54
53
  /* advisory only — treat as logged out, provability decided below */
@@ -18,7 +18,7 @@ import { ALL_AGENT_IDS, accountDisplayLabel, getAccountInfo, } from '../agents.j
18
18
  import { deriveUsageStatusFromSnapshot, getUsageInfoByIdentity, getUsageLookupKey, usageErrorForDisplay, } from '../accounting/usage.js';
19
19
  import { getVersionHomePath, listInstalledVersions } from '../installations/store.js';
20
20
  import { readMeta } from '../state.js';
21
- import { listNativeAccounts } from '../account-registry.js';
21
+ import { findNativeAccountByIdentity } from '../account-registry.js';
22
22
  /**
23
23
  * Roll one usage snapshot into a {@link QuotaSummary}. Mirrors the blocking-window
24
24
  * selection of {@link deriveUsageStatusFromSnapshot} (the model-specific
@@ -118,7 +118,7 @@ export async function collectLocalHarnessInventory(opts) {
118
118
  const usageByKey = usageInputs.length
119
119
  ? (await getUsageInfoByIdentity(usageInputs, opts?.refresh ? { forceRefresh: true } : undefined)).usageByKey
120
120
  : new Map();
121
- const savedNative = listNativeAccounts(readMeta());
121
+ const meta = readMeta();
122
122
  return pending.map(({ agent, version, info }) => {
123
123
  const key = getUsageLookupKey(info);
124
124
  const usage = key ? usageByKey.get(key) : undefined;
@@ -130,9 +130,8 @@ export async function collectLocalHarnessInventory(opts) {
130
130
  const signedIn = !!info?.signedIn;
131
131
  const { ready, reason } = computeReady(signedIn, quota);
132
132
  const display = info ? accountDisplayLabel(info) || null : null;
133
- const identityKey = info?.accountKey ?? info?.email?.toLowerCase();
134
- const saved = identityKey ? savedNative.find(item => item.agent === agent && item.identityKey === identityKey) : undefined;
135
- const account = saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display;
133
+ const saved = findNativeAccountByIdentity(meta, agent, info);
134
+ const account = saved ? `${saved.name} · ${display || saved.identityLabel || saved.identityKey}` : display;
136
135
  return { agent, version, account, signedIn, quota, ready, reason };
137
136
  });
138
137
  }
@@ -181,24 +181,105 @@ function readTail(file, maxBytes) {
181
181
  return text;
182
182
  }
183
183
  catch {
184
- return '';
184
+ return undefined;
185
185
  }
186
186
  finally {
187
187
  if (fd !== undefined)
188
188
  fs.closeSync(fd);
189
189
  }
190
190
  }
191
+ // Process-local tail cache: bounded independently of the number of historical
192
+ // sessions or directories a long-lived consumer visits. Never expose cached
193
+ // objects: callers enrich and mutate returned events (including nested values).
194
+ const ACTIVITY_CACHE_BYTES = 32 * 1024 * 1024;
195
+ const ACTIVITY_CACHE_FILES = 1024;
196
+ const activityTails = new Map();
197
+ let activityTailBytes = 0;
198
+ function forgetActivityTail(key) {
199
+ const old = activityTails.get(key);
200
+ if (old)
201
+ activityTailBytes -= old.weight;
202
+ activityTails.delete(key);
203
+ }
204
+ function activityStamp(file) {
205
+ // ctime catches same-size rewrites even when a producer restores mtime;
206
+ // device/inode catches atomic replacement. Nanoseconds avoid rounding away
207
+ // changes made between consecutive polls on high-resolution filesystems.
208
+ const st = fs.statSync(file, { bigint: true });
209
+ return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
210
+ }
211
+ function readActivityTail(file, maxBytes, sinceMs = -Infinity) {
212
+ const key = `${path.resolve(file)}\0${maxBytes}`;
213
+ let stamp;
214
+ try {
215
+ stamp = activityStamp(file);
216
+ }
217
+ catch {
218
+ forgetActivityTail(key);
219
+ return { stamp: '', events: [], newestMs: -Infinity, weight: 0 };
220
+ }
221
+ const cached = activityTails.get(key);
222
+ if (cached?.stamp === stamp && (cached.events || cached.newestMs < sinceMs)) {
223
+ activityTails.delete(key);
224
+ activityTails.set(key, cached);
225
+ return cached;
226
+ }
227
+ forgetActivityTail(key);
228
+ const text = readTail(file, maxBytes);
229
+ // Transient I/O failure is not an empty file snapshot; retry next read.
230
+ if (text === undefined)
231
+ return { stamp, events: [], newestMs: -Infinity, weight: 0 };
232
+ const events = [];
233
+ let newestMs = -Infinity;
234
+ for (const line of text.split('\n')) {
235
+ const event = parseLine(line);
236
+ if (!event)
237
+ continue;
238
+ events.push(event);
239
+ try {
240
+ const ms = Date.parse(event.ts);
241
+ if (Number.isFinite(ms))
242
+ newestMs = Math.max(newestMs, ms);
243
+ }
244
+ catch {
245
+ // The tolerant parser historically accepts truthy non-string values.
246
+ // Do not introduce a conversion error before the caller's event filters
247
+ // (or into session reads, which never converted timestamps at all).
248
+ newestMs = Infinity;
249
+ }
250
+ }
251
+ // Bound retained source-derived strings, objects, and entry overhead. Large
252
+ // one-off tails still read normally, but cannot displace the entire cache.
253
+ const weight = text.length * 2 + events.length * 1024 + key.length * 2 + 256;
254
+ const result = { stamp, events, newestMs, weight };
255
+ try {
256
+ // A writer racing the read must not pin a mixed snapshot as unchanged.
257
+ if (activityStamp(file) === stamp) {
258
+ if (activityTails.size >= ACTIVITY_CACHE_FILES)
259
+ forgetActivityTail(activityTails.keys().next().value);
260
+ const summaryWeight = key.length * 2 + 256;
261
+ const retained = weight <= ACTIVITY_CACHE_BYTES / 2 ? { ...result } : { ...result, events: undefined, weight: summaryWeight };
262
+ // Retain timestamp summaries even when parsed tails exceed the budget:
263
+ // otherwise a large historical corpus thrashes on every cursor poll.
264
+ for (const [oldKey, old] of activityTails) {
265
+ if (activityTailBytes + retained.weight <= ACTIVITY_CACHE_BYTES)
266
+ break;
267
+ const summary = oldKey.length * 2 + 256;
268
+ activityTailBytes -= old.weight - summary;
269
+ old.events = undefined;
270
+ old.weight = summary;
271
+ }
272
+ activityTails.set(key, retained);
273
+ activityTailBytes += retained.weight;
274
+ }
275
+ }
276
+ catch { /* Deleted during the read: return this snapshot without caching. */ }
277
+ return result;
278
+ }
191
279
  /** Read all events for one session (bounded tail). */
192
280
  export function readSessionActivity(sessionId, root, maxBytes = 256 * 1024) {
193
281
  const dir = root ?? getActivityDir();
194
- const text = readTail(activityPath(dir, sessionId), maxBytes);
195
- const out = [];
196
- for (const line of text.split('\n')) {
197
- const ev = parseLine(line);
198
- if (ev)
199
- out.push(ev);
200
- }
201
- return out;
282
+ return structuredClone(readActivityTail(activityPath(dir, sessionId), maxBytes).events ?? []);
202
283
  }
203
284
  /** List session ids that have an activity log. */
204
285
  export function listActivitySessions(root) {
@@ -220,7 +301,10 @@ export function readRecentActivity(opts = {}) {
220
301
  const wanted = opts.events && opts.events.length > 0 ? new Set(opts.events) : null;
221
302
  const all = [];
222
303
  for (const sessionId of listActivitySessions(dir)) {
223
- for (const ev of readSessionActivity(sessionId, dir, opts.maxBytesPerSession)) {
304
+ const tail = readActivityTail(activityPath(dir, sessionId), opts.maxBytesPerSession ?? 256 * 1024, sinceMs);
305
+ if (tail.newestMs < sinceMs)
306
+ continue;
307
+ for (const ev of tail.events ?? []) {
224
308
  if (wanted && !wanted.has(ev.event))
225
309
  continue;
226
310
  if (opts.tier && ev.tier !== opts.tier)
@@ -231,7 +315,7 @@ export function readRecentActivity(opts = {}) {
231
315
  }
232
316
  }
233
317
  all.sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
234
- return typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all;
318
+ return structuredClone(typeof opts.limit === 'number' ? all.slice(0, opts.limit) : all);
235
319
  }
236
320
  /**
237
321
  * Split a chronological event list into individual milestones plus a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.81",
3
+ "version": "1.22.83",
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",