@phnx-labs/agents-cli 1.22.51 → 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 (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -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.
@@ -12,6 +12,50 @@ export declare function getSocketPath(): string;
12
12
  /** Is the daemon reachable? A real connect probe on every platform — a socket
13
13
  * file existing on disk is not proof a daemon is listening on it. */
14
14
  export declare function isDaemonReachable(): Promise<boolean>;
15
+ /**
16
+ * Wait until the browser daemon is genuinely reachable, or throw.
17
+ *
18
+ * Re-probes across the whole window rather than latching on the first accept, so
19
+ * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
20
+ * just resets the consecutive-accept counter, and the loop keeps going until the
21
+ * daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
22
+ * that never comes up throws a message naming the endpoint and the budget, never
23
+ * a silent hang.
24
+ */
25
+ export declare function waitForSocket(_socketPath: string, timeoutMs?: number): Promise<void>;
26
+ /** Outcome of {@link resetBrowserDaemon} — what the wedge-recovery actually did. */
27
+ export interface BrowserDaemonResetResult {
28
+ /** The daemon was reachable before the reset and a stop was issued. */
29
+ wasRunning: boolean;
30
+ /** A leftover `browser.sock` file was unlinked (POSIX only). */
31
+ socketCleared: boolean;
32
+ }
33
+ /**
34
+ * Clear a wedged browser daemon so a subsequent `start` comes up clean
35
+ * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
36
+ * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
37
+ * endpoint to stop accepting, then unlinks any stale `browser.sock` a
38
+ * hard-crashed daemon left behind — the file a fresh `start` would otherwise
39
+ * `unlink` blindly, racing whatever still holds it.
40
+ *
41
+ * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
42
+ * live server is holding it and clearing the socket under it would orphan two
43
+ * servers on one path, so we throw rather than pretend the reset worked. The
44
+ * daemon auto-restarts on the next browser command.
45
+ */
46
+ export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
47
+ /**
48
+ * Remove a leftover browser socket FILE, but only when nothing is listening on
49
+ * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
50
+ * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
51
+ * deciding the endpoint was unreachable and this unlink, a concurrent
52
+ * `browser start` could bind a NEW listener on the same path; an unconditional
53
+ * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
54
+ * this function exists to prevent. A listener seen here means the wedge is already
55
+ * resolved (a fresh daemon owns the path), so its socket is left intact. Returns
56
+ * true only when a genuinely dead file was removed.
57
+ */
58
+ export declare function clearDeadSocketFile(endpoint: string, socketPath: string): Promise<boolean>;
15
59
  /**
16
60
  * One long-lived connection to the existing browser daemon. Requests are
17
61
  * serialized so the daemon's newline-delimited responses always map to the
@@ -69,6 +69,29 @@ const SOCKET_NAME = 'browser.sock';
69
69
  * coming near the grace window.
70
70
  */
71
71
  const IPC_CLOSE_TIMEOUT_MS = 1_500;
72
+ /**
73
+ * How long {@link waitForSocket} waits for the browser daemon to come up before
74
+ * failing loud (PHNX-3289).
75
+ *
76
+ * The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
77
+ * restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
78
+ * successor claiming the socket), and a client that started its wait *during*
79
+ * one of those restart windows could burn the whole 6s and throw
80
+ * `Timeout waiting for browser daemon socket` on an otherwise-healthy daemon. A
81
+ * browser start/navigate then failed intermittently and self-healed on the next
82
+ * try. 15s comfortably spans a restart; the stable-probe requirement below is
83
+ * what keeps a socket that "appears and is immediately destroyed" (#556) from
84
+ * being mistaken for a ready daemon.
85
+ */
86
+ const SOCKET_WAIT_TIMEOUT_MS = 15_000;
87
+ /**
88
+ * Consecutive successful probes required before {@link waitForSocket} declares
89
+ * the daemon ready. A single accept can land in the sliver between a restarting
90
+ * server binding and tearing back down; requiring two accepts ~100ms apart means
91
+ * we only return once the daemon is *staying* up, so the caller's real request
92
+ * doesn't race a restart it happened to probe mid-flight.
93
+ */
94
+ const SOCKET_WAIT_STABLE_PROBES = 2;
72
95
  export class BrowserDaemonNotRunningError extends Error {
73
96
  constructor() {
74
97
  super(formatBrowserDaemonNotRunningError());
@@ -78,8 +101,8 @@ export class BrowserDaemonNotRunningError extends Error {
78
101
  export function formatBrowserDaemonNotRunningError() {
79
102
  return [
80
103
  'Browser daemon not running.',
81
- 'Start it with: agents browser start (auto-picks an installed browser)',
82
- 'Or pin a profile: agents browser start --profile <name>',
104
+ 'Start it with: agents browser start (uses this machine\'s configured default browser)',
105
+ 'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
83
106
  'List profiles: agents browser profiles list',
84
107
  ].join('\n');
85
108
  }
@@ -117,15 +140,104 @@ function probeDaemon(endpoint, timeoutMs = 500) {
117
140
  export async function isDaemonReachable() {
118
141
  return probeDaemon(getIpcEndpoint());
119
142
  }
120
- async function waitForSocket(_socketPath, timeoutMs) {
143
+ /**
144
+ * Wait until the browser daemon is genuinely reachable, or throw.
145
+ *
146
+ * Re-probes across the whole window rather than latching on the first accept, so
147
+ * it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
148
+ * just resets the consecutive-accept counter, and the loop keeps going until the
149
+ * daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
150
+ * that never comes up throws a message naming the endpoint and the budget, never
151
+ * a silent hang.
152
+ */
153
+ export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
121
154
  const endpoint = getIpcEndpoint();
122
155
  const deadline = Date.now() + timeoutMs;
156
+ let consecutive = 0;
123
157
  while (Date.now() < deadline) {
124
- if (await probeDaemon(endpoint))
125
- return;
158
+ if (await probeDaemon(endpoint)) {
159
+ consecutive += 1;
160
+ if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
161
+ return;
162
+ }
163
+ else {
164
+ // A dropped probe means a restart (or the daemon isn't up yet) — start the
165
+ // stability count over rather than counting accepts from before the gap.
166
+ consecutive = 0;
167
+ }
168
+ await new Promise((resolve) => setTimeout(resolve, 100));
169
+ }
170
+ throw new Error(`Timeout waiting for browser daemon socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
171
+ }
172
+ /** How long {@link resetBrowserDaemon} waits for the endpoint to go quiet after
173
+ * signalling a stop before it clears the socket and re-checks. Bounded so the
174
+ * command fails loud instead of hanging on a daemon that will not die. */
175
+ const DAEMON_RESET_QUIESCE_MS = 5_000;
176
+ /**
177
+ * Clear a wedged browser daemon so a subsequent `start` comes up clean
178
+ * (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
179
+ * `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
180
+ * endpoint to stop accepting, then unlinks any stale `browser.sock` a
181
+ * hard-crashed daemon left behind — the file a fresh `start` would otherwise
182
+ * `unlink` blindly, racing whatever still holds it.
183
+ *
184
+ * Fails loud: if the endpoint is *still* reachable after the quiesce window, a
185
+ * live server is holding it and clearing the socket under it would orphan two
186
+ * servers on one path, so we throw rather than pretend the reset worked. The
187
+ * daemon auto-restarts on the next browser command.
188
+ */
189
+ export async function resetBrowserDaemon() {
190
+ const endpoint = getIpcEndpoint();
191
+ const wasRunning = await probeDaemon(endpoint);
192
+ stopDaemon();
193
+ // Wait for the listener to actually release the endpoint. We must decide
194
+ // reachability BEFORE touching the socket file: unlinking a path out from
195
+ // under a live server makes new connects ENOENT (so it would *look* cleared)
196
+ // while the server keeps running — the two-servers orphan the eviction
197
+ // protocol exists to prevent. So a still-reachable endpoint fails loud here,
198
+ // and only a genuinely dead one gets its stale file removed below.
199
+ const deadline = Date.now() + DAEMON_RESET_QUIESCE_MS;
200
+ let reachable = wasRunning;
201
+ while (reachable && Date.now() < deadline) {
126
202
  await new Promise((resolve) => setTimeout(resolve, 100));
203
+ reachable = await probeDaemon(endpoint);
204
+ }
205
+ if (reachable) {
206
+ throw new Error(actionable('Browser daemon is still reachable after stop — a live server is holding the socket.', `Endpoint: ${endpoint}`, 'Next: agents daemon status (find and stop the process holding it)'));
207
+ }
208
+ // Nothing is listening now — clear the leftover socket file a hard-crashed
209
+ // daemon left behind, so the next `start` binds clean instead of unlinking it
210
+ // blindly. (Named pipes vanish with their owning process, so Windows has no
211
+ // stale file to clear.)
212
+ const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
213
+ return { wasRunning, socketCleared };
214
+ }
215
+ /**
216
+ * Remove a leftover browser socket FILE, but only when nothing is listening on
217
+ * it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
218
+ * window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
219
+ * deciding the endpoint was unreachable and this unlink, a concurrent
220
+ * `browser start` could bind a NEW listener on the same path; an unconditional
221
+ * unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
222
+ * this function exists to prevent. A listener seen here means the wedge is already
223
+ * resolved (a fresh daemon owns the path), so its socket is left intact. Returns
224
+ * true only when a genuinely dead file was removed.
225
+ */
226
+ export async function clearDeadSocketFile(endpoint, socketPath) {
227
+ if (!fs.existsSync(socketPath))
228
+ return false;
229
+ // A listener bound again since the quiesce loop → do not touch its socket.
230
+ if (await probeDaemon(endpoint))
231
+ return false;
232
+ try {
233
+ fs.unlinkSync(socketPath);
234
+ return true;
235
+ }
236
+ catch {
237
+ // Raced with a fresh start that already claimed it — the goal (no stale
238
+ // socket) still holds, so report cleared only when it is genuinely gone.
239
+ return !fs.existsSync(socketPath);
127
240
  }
128
- throw new Error('Timeout waiting for browser daemon socket');
129
241
  }
130
242
  /**
131
243
  * One long-lived connection to the existing browser daemon. Requests are
@@ -865,7 +977,7 @@ async function reconcileDaemonVersion(socketPath) {
865
977
  stopDaemon();
866
978
  startDaemon();
867
979
  if (!(await isDaemonReachable())) {
868
- await waitForSocket(socketPath, 6000);
980
+ await waitForSocket(socketPath);
869
981
  }
870
982
  await new Promise((r) => setTimeout(r, 300));
871
983
  }
@@ -968,7 +1080,7 @@ async function prepareIPC(action, opts) {
968
1080
  }
969
1081
  startDaemon();
970
1082
  if (!(await isDaemonReachable())) {
971
- await waitForSocket(socketPath, 6000);
1083
+ await waitForSocket(socketPath);
972
1084
  }
973
1085
  if (!(await isDaemonReachable())) {
974
1086
  throw new Error(actionable('Failed to start browser daemon.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
@@ -1,15 +1,19 @@
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';
4
5
  /**
5
- * Name of the profile `ensureDefaultBrowserProfile` auto-detects and pins.
6
+ * Name of the profile the setup wizards pin as this machine's default browser
7
+ * (`agents setup`, `agents setup browser`). Older builds also auto-created it
8
+ * silently on the first `agents browser start`; PHNX-3296 removed that — see
9
+ * {@link ensureDefaultBrowserProfile}.
6
10
  *
7
11
  * It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
8
12
  * both this concrete profile AND the alias meaning "whatever profile the user
9
- * configured", so `--profile default` landed on a literal auto-detected Chrome
10
- * on one command and on the user's configured Comet on another. The alias now
11
- * lives alone in {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one
12
- * place ({@link resolveProfileRef}).
13
+ * configured", so `--profile default` landed on a literal Chrome on one command
14
+ * and on the user's configured Comet on another. The alias now lives alone in
15
+ * {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
16
+ * ({@link resolveProfileRef}).
13
17
  */
14
18
  export declare const DEFAULT_BROWSER_PROFILE_NAME = "auto-chrome";
15
19
  /**
@@ -106,33 +110,52 @@ export declare function resolveProfileRef(ref?: string): Promise<string | undefi
106
110
  * no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
107
111
  * which additionally verifies the resolved default can launch on THIS machine.
108
112
  * An undeclared configured default is an error. A declared default whose
109
- * browser isn't installed here warns and falls through to auto-detect.
113
+ * browser isn't installed here warns and falls through to an existing profile,
114
+ * else the actionable throw below.
110
115
  *
111
116
  * `start` is the only command that launches a browser, so it is the only one
112
117
  * that may do those things; routing a filter-only command through this would
113
- * warn about, and rewrite, config the user never asked it to touch.
118
+ * warn about config the user never asked it to touch.
114
119
  *
115
- * Throws when the configured default is undeclared, or when no profile exists
116
- * and no supported browser is installed.
120
+ * Throws ({@link noDefaultBrowserError}) when the configured default is
121
+ * undeclared, or when no profile exists that can launch here.
117
122
  */
118
123
  export declare function resolveProfileRefForStart(ref?: string): Promise<string>;
124
+ /**
125
+ * The error a bare `agents browser start` raises when this machine has no
126
+ * launchable default browser. Its own function so the wording — the one thing
127
+ * the user reads when a browser won't start — stays in one place and is
128
+ * testable without spawning anything.
129
+ *
130
+ * Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
131
+ * behavior probed the installed Chromium-family browsers and minted a
132
+ * logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
133
+ * Chrome that popped up on the user's Mac unbidden. Which browser agents drive
134
+ * is a choice the user makes once, in `agents setup` — never one this code
135
+ * makes for them.
136
+ */
137
+ export declare function noDefaultBrowserError(): Error;
119
138
  /**
120
139
  * Resolve the profile a bare `agents browser start` uses.
121
140
  *
122
141
  * Order: (1) the device-local configured default (`agents browser use <name>`)
123
142
  * when it names a profile that exists and can launch here; (2) an existing
124
- * auto-detected profile that can launch here; (3) auto-pick the first installed
125
- * Chromium-family browser and pin `auto-chrome` (or repair a stale legacy
126
- * `default`) to it.
143
+ * auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
144
+ * here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
145
+ * detect-and-create — see that function for why (PHNX-3296).
127
146
  *
128
147
  * Two failure modes at the configured-default step are not the same:
129
148
  * - No device declares the name (including a leftover central `browser:`
130
- * entry that was never claimed) → throw. Auto-creating `auto-chrome` would
131
- * hand the agent a logged-out browser while `browser.profile` still names
132
- * the credentialed one.
149
+ * entry that was never claimed) → throw. Falling back to a minted profile
150
+ * would hand the agent a logged-out browser while `browser.profile` still
151
+ * names the credentialed one.
133
152
  * - The name is declared, but its browser/binary is not installed HERE →
134
- * warn and fall through. That is a missing binary on this box, not a
135
- * missing identity; auto-detect is the existing repair.
153
+ * warn and fall through to an existing profile, else the actionable throw.
154
+ * That is a missing binary on this box, not a missing identity.
155
+ *
156
+ * This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
157
+ * already carry one keep resolving it (and its running browser + runtime dirs),
158
+ * but it never CREATES one.
136
159
  */
137
160
  export declare function ensureDefaultBrowserProfile(): Promise<BrowserProfile>;
138
161
  /**
@@ -153,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
153
176
  * skip remote profiles in this scan.
154
177
  */
155
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;
156
196
  /**
157
197
  * Refuse a profile whose LOCAL port another profile already owns.
158
198
  *