@phnx-labs/agents-cli 1.22.52 → 1.22.53

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 (69) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/exec.js +16 -9
  5. package/dist/commands/fleet-capture.js +7 -0
  6. package/dist/commands/focus.js +2 -0
  7. package/dist/commands/go.js +2 -1
  8. package/dist/commands/sessions-inject.js +8 -3
  9. package/dist/commands/sessions-picker.js +2 -1
  10. package/dist/commands/sessions.js +30 -20
  11. package/dist/commands/ssh.js +35 -12
  12. package/dist/commands/sync.js +44 -0
  13. package/dist/lib/account-registry.d.ts +15 -5
  14. package/dist/lib/account-registry.js +150 -50
  15. package/dist/lib/answer-router.js +2 -1
  16. package/dist/lib/browser/profiles.d.ts +18 -0
  17. package/dist/lib/browser/profiles.js +26 -1
  18. package/dist/lib/browser/registry.d.ts +44 -14
  19. package/dist/lib/browser/registry.js +141 -45
  20. package/dist/lib/daemon/runner.js +10 -2
  21. package/dist/lib/device-config.js +3 -2
  22. package/dist/lib/devices/config-migration.js +147 -1
  23. package/dist/lib/devices/device-docs.d.ts +35 -0
  24. package/dist/lib/devices/device-docs.js +163 -0
  25. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  26. package/dist/lib/devices/discovery-policy.js +31 -21
  27. package/dist/lib/devices/registry.d.ts +11 -5
  28. package/dist/lib/devices/registry.js +46 -18
  29. package/dist/lib/exec.d.ts +60 -30
  30. package/dist/lib/exec.js +65 -27
  31. package/dist/lib/feed/feed.d.ts +10 -2
  32. package/dist/lib/feed/feed.js +12 -1
  33. package/dist/lib/hosts/dispatch.d.ts +4 -3
  34. package/dist/lib/hosts/dispatch.js +12 -8
  35. package/dist/lib/hosts/providers/local.d.ts +9 -3
  36. package/dist/lib/hosts/providers/local.js +23 -12
  37. package/dist/lib/hosts/reconnect.d.ts +7 -4
  38. package/dist/lib/hosts/reconnect.js +29 -25
  39. package/dist/lib/hosts/registry.js +4 -1
  40. package/dist/lib/hosts/remote-os.js +3 -1
  41. package/dist/lib/session/active.d.ts +10 -1
  42. package/dist/lib/session/active.js +7 -1
  43. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  44. package/dist/lib/session/actor-sidecar.js +2 -0
  45. package/dist/lib/session/db.d.ts +1 -1
  46. package/dist/lib/session/db.js +39 -3
  47. package/dist/lib/session/discover.js +7 -12
  48. package/dist/lib/session/live-metadata.js +1 -0
  49. package/dist/lib/session/pid-registry.d.ts +7 -0
  50. package/dist/lib/session/prompt.d.ts +15 -0
  51. package/dist/lib/session/prompt.js +21 -0
  52. package/dist/lib/session/types.d.ts +17 -0
  53. package/dist/lib/session/types.js +10 -0
  54. package/dist/lib/share/worker-template.js +12 -7
  55. package/dist/lib/state.d.ts +8 -0
  56. package/dist/lib/state.js +143 -11
  57. package/dist/lib/terminal/resolve.d.ts +7 -0
  58. package/dist/lib/terminal/resolve.js +41 -2
  59. package/dist/lib/traces/insights.d.ts +67 -0
  60. package/dist/lib/traces/insights.js +178 -0
  61. package/dist/lib/traces/phenotype.d.ts +67 -0
  62. package/dist/lib/traces/phenotype.js +437 -0
  63. package/dist/lib/traces/segments.d.ts +133 -0
  64. package/dist/lib/traces/segments.js +301 -0
  65. package/dist/lib/traces/sync.d.ts +33 -0
  66. package/dist/lib/traces/sync.js +11 -2
  67. package/dist/lib/types.d.ts +47 -1
  68. package/dist/lib/watchdog/runner.js +18 -4
  69. package/package.json +1 -1
@@ -35,6 +35,8 @@ import { Option } from 'commander';
35
35
  import chalk from 'chalk';
36
36
  import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
37
37
  import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
38
+ import { autoEvictCentralBrowserProfiles } from '../lib/browser/registry.js';
39
+ import { shouldAutoClaimCentralProfile } from '../lib/browser/profiles.js';
38
40
  import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
39
41
  import { capableAgents } from '../lib/capabilities.js';
40
42
  import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
@@ -352,6 +354,12 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
352
354
  else
353
355
  outLog(chalk.yellow(` ! ${repo}: ${(res.error ?? 'pull failed').split('\n')[0]}`));
354
356
  }
357
+ // Drain the legacy central `browser:` tombstone now that the repos are pulled.
358
+ // Running AFTER the pull is load-bearing: it acts on a view of central that
359
+ // already reflects peers' drains, so it never re-claims a profile another box
360
+ // already migrated (which would flip that profile's kind identity->fungible).
361
+ // The interactive path is always non-quiet, non-json.
362
+ evictCentralBrowserProfilesForSync(false, false, outLog, errLog);
355
363
  // 2. One selection spanning the chosen repos.
356
364
  const selection = mergeRepoScopedSelections(repos, cwd);
357
365
  const hasResources = selection.memory === 'all' || Object.entries(selection).some(([kind, v]) => kind !== 'memory' && Array.isArray(v) && v.length > 0);
@@ -374,6 +382,36 @@ async function runInteractiveReconcile(opts, outLog, errLog) {
374
382
  }
375
383
  }
376
384
  }
385
+ /**
386
+ * Drain the legacy central `browser:` tombstone during `agents sync` (PHNX-3315).
387
+ * New profiles write the per-device doc, but profiles created before the
388
+ * device-scoped store lingered in the shared top-level `agents.yaml` and churned
389
+ * every fleet pull until someone ran `agents browser profiles claim` by hand.
390
+ * Fold the ones THIS box can host into its device doc — host-gated, and the
391
+ * selection is computed under the meta lock (see autoEvictCentralBrowserProfiles)
392
+ * so it never races itself. Callers MUST invoke this AFTER the repo pull: acting
393
+ * on a pre-pull view of central risks re-claiming a profile a peer already
394
+ * drained, which would flip its kind identity->fungible. Non-fatal so a hiccup
395
+ * can never wedge the sync.
396
+ */
397
+ function evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog) {
398
+ try {
399
+ // Auto-claim ONLY remote (ssh://) tombstones — they are fungible by design,
400
+ // so a concurrent cross-machine double-claim is harmless. Local/cdp profiles
401
+ // have no per-machine ownership signal and are left central for an explicit
402
+ // `agents browser profiles claim` (PHNX-3315 review). See
403
+ // shouldAutoClaimCentralProfile.
404
+ const result = autoEvictCentralBrowserProfiles(shouldAutoClaimCentralProfile);
405
+ if (!quiet && !json && result.claimed.length > 0) {
406
+ outLog(chalk.gray(` Claimed ${result.claimed.length} central browser profile(s) into this device: ${result.claimed.join(', ')}`));
407
+ }
408
+ }
409
+ catch (err) {
410
+ if (!quiet && !json) {
411
+ errLog(chalk.yellow(` ! browser profile eviction skipped: ${err.message}`));
412
+ }
413
+ }
414
+ }
377
415
  /**
378
416
  * The umbrella verb: bare `agents sync` (no agent) makes this machine current.
379
417
  * Resolves the flags + a secrets passphrase (env-only for now; tokenized auth
@@ -415,6 +453,12 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
415
453
  log: (msg) => { if (!quiet && !json)
416
454
  outLog(chalk.gray(` ${msg}`)); },
417
455
  });
456
+ // Drain the legacy central `browser:` tombstone AFTER the umbrella pull, so
457
+ // we act on central as converged by this sync rather than a stale pre-pull
458
+ // copy that could re-claim a profile a peer already migrated. Skipped under
459
+ // --cloud (fetch-only, no local reconcile).
460
+ if (!opts.cloud)
461
+ evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
418
462
  if (json) {
419
463
  emitJson({
420
464
  // A refused resource is not a clean sync (RUSH-2700).
@@ -34,7 +34,15 @@ export type UnifiedAccount = (CredentialAccount & {
34
34
  export declare function accountRegistryPath(base?: string): string;
35
35
  export declare function readAccountRegistry(base?: string): AccountRegistryDocument;
36
36
  export declare function findAccount(name: string, doc?: AccountRegistryDocument): CredentialAccount | null;
37
- export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): NativeAccount[];
37
+ /**
38
+ * Effective native accounts: the fleet-shared central store (version-scoped
39
+ * identities) merged with THIS box's own device doc (device-scoped identities).
40
+ * A native login is machine-local — its home follows its scope (PHNX-3315), so
41
+ * a `scope:'device'` identity is read from this box's device doc and never the
42
+ * shared central agents.yaml (which is where its email/identityKey PII used to
43
+ * accumulate). On an id collision the device slice wins.
44
+ */
45
+ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): NativeAccount[];
38
46
  /**
39
47
  * Resolve one account by name or id across both stores, native first.
40
48
  *
@@ -46,15 +54,17 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts'>): Native
46
54
  * running before the body, so callers that only need a native lookup must be
47
55
  * able to omit it.
48
56
  */
49
- export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
57
+ export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument): UnifiedAccount | null;
50
58
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
51
59
  /** Create or replace the version-independent label for one native identity. */
52
60
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
53
61
  export declare function bindAccount(nameOrId: string, target: string): UnifiedAccount;
54
62
  export declare function unbindAccount(nameOrId: string, target: string): void;
55
- export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts'>): string[];
63
+ /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
+ * the fleet-shared central bindings (PHNX-3315). */
65
+ export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
56
66
  /** Explicit selection wins over a configured per-harness default. */
57
- export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts'>, opts?: {
67
+ export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
58
68
  useDefault?: boolean;
59
69
  target?: string;
60
70
  }): string | undefined;
@@ -100,7 +110,7 @@ export type SpawnAccount = {
100
110
  * A native account bound to a different harness than the one being launched
101
111
  * fails loudly (EXEC-ACCOUNT-4). Returns null when nothing is selected.
102
112
  */
103
- export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts'>, opts?: {
113
+ export declare function resolveSpawnAccount(explicit: string | undefined, agent: AgentId, version: string | undefined, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
104
114
  useDefault?: boolean;
105
115
  provider?: string;
106
116
  base?: string;
@@ -12,7 +12,9 @@
12
12
  * `{ version: 2, accounts }` view so existing consumers (harness, profiles,
13
13
  * exec) keep working — it is now a projection over the account bundles, not a
14
14
  * file read. Native OAuth logins are NOT accounts here; they stay native and
15
- * surface through unified discovery in [[account-catalog]].
15
+ * surface through unified discovery in [[account-catalog]]. Labels bind to
16
+ * `(agent, identityKey)` on the central `accounts.native` rows in agents.yaml,
17
+ * which `agents repo push/pull` already syncs fleet-wide.
16
18
  */
17
19
  import * as fs from 'node:fs';
18
20
  import * as path from 'node:path';
@@ -133,8 +135,17 @@ export function readAccountRegistry(base = getUserAgentsDir()) {
133
135
  export function findAccount(name, doc = readAccountRegistry()) {
134
136
  return doc.accounts[name] ?? Object.values(doc.accounts).find(account => account.name === name) ?? null;
135
137
  }
138
+ /**
139
+ * Effective native accounts: the fleet-shared central store (version-scoped
140
+ * identities) merged with THIS box's own device doc (device-scoped identities).
141
+ * A native login is machine-local — its home follows its scope (PHNX-3315), so
142
+ * a `scope:'device'` identity is read from this box's device doc and never the
143
+ * shared central agents.yaml (which is where its email/identityKey PII used to
144
+ * accumulate). On an id collision the device slice wins.
145
+ */
136
146
  export function listNativeAccounts(meta) {
137
- return Object.values(meta.accounts?.native ?? {}).map(account => ({ ...account, kind: 'native' }));
147
+ const merged = { ...meta.accounts?.native, ...meta.deviceAccounts?.native };
148
+ return Object.values(merged).map(account => ({ ...account, kind: 'native' }));
138
149
  }
139
150
  /**
140
151
  * Resolve one account by name or id across both stores, native first.
@@ -155,8 +166,28 @@ export function findUnifiedAccount(nameOrId, meta, doc) {
155
166
  const provider = findAccount(nameOrId, doc ?? readAccountRegistry());
156
167
  return provider ? { ...provider, kind: 'provider' } : null;
157
168
  }
158
- function assertUniqueUnifiedName(name, meta, doc) {
159
- if (findUnifiedAccount(name, meta, doc))
169
+ function nativeIdentityRows(meta, agent, identityKey) {
170
+ return listNativeAccounts(meta).filter(account => account.agent === agent && account.identityKey === identityKey);
171
+ }
172
+ /** Every row (central + this box's device store) for the identity that `name`
173
+ * (id or account name) resolves to. */
174
+ function nativeRowsForNameOrId(meta, name) {
175
+ const found = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
176
+ if (!found)
177
+ return [];
178
+ return nativeIdentityRows(meta, found.agent, found.identityKey);
179
+ }
180
+ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
181
+ const needle = name.toLowerCase();
182
+ const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
183
+ if (nativeHits.some(account => !exceptIds?.has(account.id)))
184
+ throw new Error(`Account '${name}' already exists.`);
185
+ // Same laziness as findUnifiedAccount: a native row that already owns this
186
+ // name (even one we are mutating) means we never open the provider store.
187
+ if (nativeHits.length > 0)
188
+ return;
189
+ const provider = findAccount(name, doc ?? readAccountRegistry());
190
+ if (provider && !exceptIds?.has(provider.id))
160
191
  throw new Error(`Account '${name}' already exists.`);
161
192
  }
162
193
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
@@ -167,13 +198,28 @@ export function addNativeAccount(name, agent, identityKey, identityLabel, scope)
167
198
  if (duplicate)
168
199
  throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
169
200
  const account = { id: crypto.randomUUID(), name, kind: 'native', agent, identityKey, identityLabel, scope };
170
- updateMeta(current => ({
171
- ...current,
172
- accounts: {
173
- ...current.accounts,
174
- native: { ...current.accounts?.native, [account.id]: { id: account.id, name, agent, identityKey, identityLabel, scope } },
175
- },
176
- }));
201
+ const entry = { id: account.id, name, agent, identityKey, identityLabel, scope };
202
+ // A native login's home follows its scope (PHNX-3315): a device-scoped
203
+ // identity lands in THIS box's device doc (its PII never touches the shared
204
+ // central agents.yaml); a version-scoped one stays in the fleet-shared store.
205
+ if (scope === 'device') {
206
+ updateMeta(current => ({
207
+ ...current,
208
+ deviceAccounts: {
209
+ ...current.deviceAccounts,
210
+ native: { ...current.deviceAccounts?.native, [account.id]: entry },
211
+ },
212
+ }));
213
+ }
214
+ else {
215
+ updateMeta(current => ({
216
+ ...current,
217
+ accounts: {
218
+ ...current.accounts,
219
+ native: { ...current.accounts?.native, [account.id]: entry },
220
+ },
221
+ }));
222
+ }
177
223
  return account;
178
224
  }
179
225
  /** Create or replace the version-independent label for one native identity. */
@@ -183,30 +229,49 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
183
229
  throw new Error(`${agent} does not expose an email; pass a manual label.`);
184
230
  assertNativeLabel(resolvedLabel);
185
231
  const meta = readMeta();
186
- const existing = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
187
- const collision = findUnifiedAccount(resolvedLabel, meta);
188
- if (collision && collision.id !== existing?.id)
189
- throw new Error(`Account '${resolvedLabel}' already exists.`);
190
- if (!existing)
232
+ const matches = nativeIdentityRows(meta, agent, identityKey);
233
+ assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
234
+ if (matches.length === 0)
191
235
  return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
192
- updateMeta(current => ({
193
- ...current,
194
- accounts: {
195
- ...current.accounts,
196
- native: { ...current.accounts?.native, [existing.id]: { ...current.accounts?.native?.[existing.id], name: resolvedLabel, identityLabel } },
197
- },
198
- }));
199
- return { ...existing, name: resolvedLabel, identityLabel };
236
+ // Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
237
+ // store that owns them: all rows for one identityKey share a scope (same agent),
238
+ // so a device-scoped login lands in this box's device doc, a version-scoped one
239
+ // in central (PHNX-3315).
240
+ const rowScope = matches[0].scope;
241
+ updateMeta(current => {
242
+ if (rowScope === 'device') {
243
+ const native = { ...current.deviceAccounts?.native };
244
+ for (const row of matches)
245
+ native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
246
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
247
+ }
248
+ const native = { ...current.accounts?.native };
249
+ for (const row of matches)
250
+ native[row.id] = { id: row.id, name: resolvedLabel, agent, identityKey, identityLabel, scope: rowScope };
251
+ return { ...current, accounts: { ...current.accounts, native } };
252
+ });
253
+ return { ...matches[0], name: resolvedLabel, identityLabel, scope: rowScope };
200
254
  }
201
255
  export function bindAccount(nameOrId, target) {
202
256
  const meta = readMeta();
203
257
  const account = findUnifiedAccount(nameOrId, meta);
204
258
  if (!account)
205
259
  throw new Error(`Unknown account '${nameOrId}'.`);
206
- updateMeta(current => ({
207
- ...current,
208
- accounts: { ...current.accounts, bindings: { ...current.accounts?.bindings, [target]: account.id } },
209
- }));
260
+ // A binding follows its account: one that targets a device-scoped native login
261
+ // is itself machine-local and lands in this box's device doc (PHNX-3315);
262
+ // every other binding stays fleet-shared in central.
263
+ if (account.kind === 'native' && account.scope === 'device') {
264
+ updateMeta(current => ({
265
+ ...current,
266
+ deviceAccounts: { ...current.deviceAccounts, bindings: { ...current.deviceAccounts?.bindings, [target]: account.id } },
267
+ }));
268
+ }
269
+ else {
270
+ updateMeta(current => ({
271
+ ...current,
272
+ accounts: { ...current.accounts, bindings: { ...current.accounts?.bindings, [target]: account.id } },
273
+ }));
274
+ }
210
275
  return account;
211
276
  }
212
277
  export function unbindAccount(nameOrId, target) {
@@ -214,25 +279,43 @@ export function unbindAccount(nameOrId, target) {
214
279
  const account = findUnifiedAccount(nameOrId, meta);
215
280
  if (!account)
216
281
  throw new Error(`Unknown account '${nameOrId}'.`);
217
- if (meta.accounts?.bindings?.[target] !== account.id)
282
+ const inCentral = meta.accounts?.bindings?.[target] === account.id;
283
+ const inDevice = meta.deviceAccounts?.bindings?.[target] === account.id;
284
+ if (!inCentral && !inDevice)
218
285
  throw new Error(`Account '${account.name}' is not attached to '${target}'.`);
219
286
  updateMeta(current => {
220
- const bindings = { ...current.accounts?.bindings };
221
- delete bindings[target];
222
- return { ...current, accounts: { ...current.accounts, bindings } };
287
+ let next = current;
288
+ if (current.accounts?.bindings?.[target] === account.id) {
289
+ const bindings = { ...current.accounts?.bindings };
290
+ delete bindings[target];
291
+ next = { ...next, accounts: { ...next.accounts, bindings } };
292
+ }
293
+ if (current.deviceAccounts?.bindings?.[target] === account.id) {
294
+ const bindings = { ...current.deviceAccounts?.bindings };
295
+ delete bindings[target];
296
+ next = { ...next, deviceAccounts: { ...next.deviceAccounts, bindings } };
297
+ }
298
+ return next;
223
299
  });
224
300
  }
301
+ /** Every target bound to `accountId`: this box's device-doc bindings merged over
302
+ * the fleet-shared central bindings (PHNX-3315). */
225
303
  export function accountBindings(accountId, meta) {
226
- return Object.entries(meta.accounts?.bindings ?? {}).filter(([, id]) => id === accountId).map(([target]) => target).sort();
304
+ const merged = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
305
+ return Object.entries(merged).filter(([, id]) => id === accountId).map(([target]) => target).sort();
227
306
  }
228
307
  /** Explicit selection wins over a configured per-harness default. */
229
308
  export function resolveAccountSelection(explicit, agent, meta, opts = {}) {
230
309
  if (explicit)
231
310
  return explicit;
232
- const bound = opts.target ? meta.accounts?.bindings?.[opts.target] : undefined;
311
+ // This box's device-doc bindings win over the fleet-shared central bindings
312
+ // (PHNX-3315), so a per-box account attachment resolves without touching the
313
+ // shared file. Defaults are genuinely fleet-shared and stay central.
314
+ const bindings = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
315
+ const bound = opts.target ? bindings[opts.target] : undefined;
233
316
  if (bound)
234
317
  return bound;
235
- const deviceScoped = meta.accounts?.bindings?.[agent];
318
+ const deviceScoped = bindings[agent];
236
319
  if (deviceScoped)
237
320
  return deviceScoped;
238
321
  return opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
@@ -285,16 +368,23 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
285
368
  assertName(newName);
286
369
  const doc = readAccountRegistry(base);
287
370
  const meta = readMeta();
288
- const native = listNativeAccounts(meta).find(account => account.id === oldName || account.name === oldName);
289
- if (native) {
290
- assertUniqueUnifiedName(newName, meta, doc);
291
- updateMeta(current => ({
292
- ...current,
293
- accounts: {
294
- ...current.accounts,
295
- native: { ...current.accounts?.native, [native.id]: { ...current.accounts?.native?.[native.id], name: newName } },
296
- },
297
- }));
371
+ const rows = nativeRowsForNameOrId(meta, oldName);
372
+ if (rows.length) {
373
+ assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
374
+ // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315).
375
+ const rowScope = rows[0].scope;
376
+ updateMeta(current => {
377
+ if (rowScope === 'device') {
378
+ const native = { ...current.deviceAccounts?.native };
379
+ for (const row of rows)
380
+ native[row.id] = { ...native[row.id], name: newName };
381
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
382
+ }
383
+ const native = { ...current.accounts?.native };
384
+ for (const row of rows)
385
+ native[row.id] = { ...native[row.id], name: newName };
386
+ return { ...current, accounts: { ...current.accounts, native } };
387
+ });
298
388
  return;
299
389
  }
300
390
  const account = findAccount(oldName, doc);
@@ -306,14 +396,24 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
306
396
  }
307
397
  export function removeAccount(name, base = getUserAgentsDir()) {
308
398
  const meta = readMeta();
309
- const native = listNativeAccounts(meta).find(account => account.id === name || account.name === name);
310
- if (native) {
311
- const bindings = accountBindings(native.id, meta);
399
+ const rows = nativeRowsForNameOrId(meta, name);
400
+ if (rows.length) {
401
+ const bindings = [...new Set(rows.flatMap(row => accountBindings(row.id, meta)))].sort();
312
402
  if (bindings.length)
313
- throw new Error(`Account '${native.name}' is attached to: ${bindings.join(', ')}. Detach it before removing it.`);
403
+ throw new Error(`Account '${rows[0].name}' is attached to: ${bindings.join(', ')}. Detach it before removing it.`);
404
+ const ids = new Set(rows.map(row => row.id));
405
+ // Sweep every row for the identity (PHNX-3206) from its owning store (PHNX-3315).
406
+ const rowScope = rows[0].scope;
314
407
  updateMeta(current => {
408
+ if (rowScope === 'device') {
409
+ const accounts = { ...current.deviceAccounts?.native };
410
+ for (const id of ids)
411
+ delete accounts[id];
412
+ return { ...current, deviceAccounts: { ...current.deviceAccounts, native: accounts } };
413
+ }
315
414
  const accounts = { ...current.accounts?.native };
316
- delete accounts[native.id];
415
+ for (const id of ids)
416
+ delete accounts[id];
317
417
  return { ...current, accounts: { ...current.accounts, native: accounts } };
318
418
  });
319
419
  return;
@@ -1,3 +1,4 @@
1
+ import { addressabilityRecoveryHint } from './terminal/resolve.js';
1
2
  import { injectTargetFromReplyRail } from './session/inject.js';
2
3
  /**
3
4
  * Match free-text answer against question options.
@@ -123,7 +124,7 @@ export function resolveAnswerRoute(input) {
123
124
  return {
124
125
  kind: 'refuse',
125
126
  reason: 'Agent is parked on a question but has no addressable terminal (no tmux/iterm/pty rail). ' +
126
- 'Open its terminal and answer there, or run `agents sessions resume <id>` first.',
127
+ addressabilityRecoveryHint(session),
127
128
  };
128
129
  }
129
130
  // Open block but agent is still looping between tool calls — mailbox is correct.
@@ -1,3 +1,4 @@
1
+ import type { BrowserProfileConfig } from '../types.js';
1
2
  import type { BrowserProfile, ProfileName } from './types.js';
2
3
  export type { BrowserProfile } from './types.js';
3
4
  export { declaringDevices, profileKind, profileRegistry, type ProfileDeclaration, } from './registry.js';
@@ -175,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
175
176
  * skip remote profiles in this scan.
176
177
  */
177
178
  export declare function findFreeProfilePort(): Promise<number>;
179
+ export declare function hasSshEndpoint(endpoints: BrowserProfileConfig['endpoints']): boolean;
180
+ /**
181
+ * Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
182
+ * into THIS device's doc during `agents sync`.
183
+ *
184
+ * Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
185
+ * host, so the profile is fungible by design — any box resolves it to the same
186
+ * browser, and a concurrent double-claim across machines is harmless. A
187
+ * local/`cdp://` profile has NO per-machine ownership signal: "that browser is
188
+ * installed here" is not "I hold this profile's credentialed session", so two
189
+ * boxes with the same common browser installed would each auto-claim the same
190
+ * tombstone on their first post-merge sync and flip it identity->fungible
191
+ * fleet-wide (the exact logged-out-browser failure this module exists to prevent
192
+ * — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
193
+ * `agents browser profiles claim`.
194
+ */
195
+ export declare function shouldAutoClaimCentralProfile(config: BrowserProfileConfig): boolean;
178
196
  /**
179
197
  * Refuse a profile whose LOCAL port another profile already owns.
180
198
  *
@@ -390,7 +390,7 @@ function validateRemoteBrowserBinary(binary) {
390
390
  throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
391
391
  }
392
392
  }
393
- function hasSshEndpoint(endpoints) {
393
+ export function hasSshEndpoint(endpoints) {
394
394
  const targets = Array.isArray(endpoints)
395
395
  ? endpoints
396
396
  : Object.values(endpoints).map((preset) => preset.target);
@@ -403,6 +403,31 @@ function hasSshEndpoint(endpoints) {
403
403
  }
404
404
  });
405
405
  }
406
+ /**
407
+ * Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
408
+ * into THIS device's doc during `agents sync`.
409
+ *
410
+ * Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
411
+ * host, so the profile is fungible by design — any box resolves it to the same
412
+ * browser, and a concurrent double-claim across machines is harmless. A
413
+ * local/`cdp://` profile has NO per-machine ownership signal: "that browser is
414
+ * installed here" is not "I hold this profile's credentialed session", so two
415
+ * boxes with the same common browser installed would each auto-claim the same
416
+ * tombstone on their first post-merge sync and flip it identity->fungible
417
+ * fleet-wide (the exact logged-out-browser failure this module exists to prevent
418
+ * — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
419
+ * `agents browser profiles claim`.
420
+ */
421
+ export function shouldAutoClaimCentralProfile(config) {
422
+ if (!hasSshEndpoint(config.endpoints))
423
+ return false;
424
+ return isProfileLaunchableHere({
425
+ name: '_',
426
+ browser: config.browser,
427
+ binary: config.binary,
428
+ endpoints: config.endpoints,
429
+ });
430
+ }
406
431
  /**
407
432
  * Refuse a profile whose LOCAL port another profile already owns.
408
433
  *
@@ -3,7 +3,15 @@ export interface ProfileDeclaration {
3
3
  device: string;
4
4
  config: BrowserProfileConfig;
5
5
  }
6
- /** Leftover central `browser:` map from before per-device declarations. Empty when none remain. */
6
+ /**
7
+ * The leftover central `browser:` tombstone from before per-device declarations.
8
+ * Empty when none remain — and `agents sync` now drains it automatically via
9
+ * {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
10
+ * {@link profileRegistry} is the single source of truth. It survives only to
11
+ * fold in migration and to explain an as-yet-unclaimed profile in a resolve
12
+ * error (a profile hostable on a peer that has not synced yet), never as a
13
+ * parallel store callers read for resolution.
14
+ */
7
15
  export declare function centralBrowserProfiles(): Record<string, BrowserProfileConfig>;
8
16
  export interface CentralClaimResult {
9
17
  claimed: string[];
@@ -12,22 +20,44 @@ export interface CentralClaimResult {
12
20
  /**
13
21
  * Fold leftover central `browser:` entries into THIS device's declaration file.
14
22
  *
15
- * Never called implicitly, and that is the point. Every device can read the
16
- * central map, so an implicit claim races: whichever box happens to read first
17
- * claims the name, {@link profileKind} then reports it `identity`, and the
18
- * daemon tunnels to that box — which for a fleet-wide `cdp://localhost:*`
19
- * profile is a logged-out headless browser wearing a credentialed browser's
20
- * name. That is the exact bug this module exists to remove, and an implicit
21
- * migration would write it to disk as a stored fact.
23
+ * The EXPLICIT operator action (`agents browser profiles claim`), run on the
24
+ * machine that actually owns the browser. It is never called implicitly from a
25
+ * read, and that is the point: every device can read the central map, so an
26
+ * implicit claim races — whichever box reads first claims the name, {@link
27
+ * profileKind} then reports it `identity`, and the daemon tunnels to that box,
28
+ * which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
29
+ * browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
30
+ * closes that race for the automatic path (host-gated + non-throwing on sync);
31
+ * this one is the manual, throw-on-conflict counterpart.
22
32
  *
23
- * The claim is an explicit operator action (`agents browser profiles claim`),
24
- * run on the machine that actually owns the browser. `canHostHere` is supplied
25
- * by the command layer so this module stays a leaf — it must not import
26
- * `isProfileLaunchableHere` (that would cycle through chrome.ts → profiles.ts).
27
- * Only profiles this machine can host are claimed; the rest stay central,
28
- * undeclared, and fail loudly on resolve.
33
+ * `canHostHere` is supplied by the command layer so this module stays a leaf —
34
+ * it must not import `isProfileLaunchableHere` (that would cycle through
35
+ * chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
36
+ * rest stay central, undeclared, and fail loudly on resolve.
29
37
  */
30
38
  export declare function migrateCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean, name?: string): CentralClaimResult;
39
+ /**
40
+ * Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
41
+ * fold every lingering central `browser:` profile THIS machine can host into its
42
+ * device doc and clear it from central, so the tombstone drains itself on
43
+ * `agents sync` with no manual `agents browser profiles claim`.
44
+ *
45
+ * Safe to call implicitly, unlike a claim at registry-read time (which races —
46
+ * see the `profileRegistry does not claim central declarations` guard), but the
47
+ * CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
48
+ * boxes can both host the same profile (`canHostHere` is launchability, not
49
+ * ownership), so a box acting on a PRE-pull view of central could re-claim a
50
+ * profile a peer already drained and pushed — both writes land in different
51
+ * device files with no git conflict, and the profile flips identity->fungible.
52
+ * Running post-pull means central already reflects peers' drains, so the tombstone
53
+ * is gone locally before this box looks. The selection is also computed INSIDE
54
+ * the meta lock, and the path never throws — an unhostable profile or one that
55
+ * conflicts with an existing local declaration is left central for an explicit
56
+ * claim rather than wedging the sync. After it runs, {@link profileRegistry} is
57
+ * the single source of truth: a claimed profile lives in the device doc alone,
58
+ * never double-counted across the two stores.
59
+ */
60
+ export declare function autoEvictCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean): CentralClaimResult;
31
61
  /**
32
62
  * Every profile any device declares, keyed by name to all declaring devices.
33
63
  * Reads every `devices/<name>/agents.yaml`; declarations never overwrite.