@phnx-labs/agents-cli 1.22.40 → 1.22.42

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 (201) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +14 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +0 -1
  5. package/dist/cli/command-registry.d.ts +2 -0
  6. package/dist/cli/command-registry.js +5 -2
  7. package/dist/commands/accounts.d.ts +29 -0
  8. package/dist/commands/accounts.js +220 -29
  9. package/dist/commands/alias.js +1 -1
  10. package/dist/commands/auth.d.ts +9 -0
  11. package/dist/commands/auth.js +104 -0
  12. package/dist/commands/browser-sessions-picker.js +42 -62
  13. package/dist/commands/browser.d.ts +15 -0
  14. package/dist/commands/browser.js +145 -78
  15. package/dist/commands/computer-actions.d.ts +1 -1
  16. package/dist/commands/computer-actions.js +1 -1
  17. package/dist/commands/computer-sessions-picker.js +32 -55
  18. package/dist/commands/computer.d.ts +1 -1
  19. package/dist/commands/computer.js +2 -2
  20. package/dist/commands/doctor.d.ts +1 -1
  21. package/dist/commands/doctor.js +3 -3
  22. package/dist/commands/feed.js +4 -12
  23. package/dist/commands/hooks.js +1 -1
  24. package/dist/commands/import.js +1 -1
  25. package/dist/commands/insights.js +163 -85
  26. package/dist/commands/inspect.js +2 -2
  27. package/dist/commands/mine.js +1 -1
  28. package/dist/commands/org.d.ts +6 -0
  29. package/dist/commands/org.js +174 -0
  30. package/dist/commands/packages.js +1 -1
  31. package/dist/commands/prune.js +1 -1
  32. package/dist/commands/routines.js +1 -1
  33. package/dist/commands/run-account-picker.d.ts +19 -0
  34. package/dist/commands/run-account-picker.js +81 -0
  35. package/dist/commands/secrets.js +87 -97
  36. package/dist/commands/sessions-picker-factory.d.ts +40 -0
  37. package/dist/commands/sessions-picker-factory.js +67 -0
  38. package/dist/commands/sessions-picker.d.ts +1 -0
  39. package/dist/commands/sessions-picker.js +27 -5
  40. package/dist/commands/sessions-share.d.ts +25 -0
  41. package/dist/commands/sessions-share.js +166 -0
  42. package/dist/commands/sessions.js +2 -0
  43. package/dist/commands/setup-browser.js +5 -5
  44. package/dist/commands/setup-mine.js +1 -1
  45. package/dist/commands/setup-preferences.js +4 -4
  46. package/dist/commands/setup.js +5 -3
  47. package/dist/commands/sync.js +1 -1
  48. package/dist/commands/versions.js +1 -1
  49. package/dist/commands/view.js +1 -1
  50. package/dist/commands/webhook.js +14 -9
  51. package/dist/lib/account-capabilities.d.ts +10 -0
  52. package/dist/lib/account-capabilities.js +34 -0
  53. package/dist/lib/agent-spec/agents.d.ts +584 -0
  54. package/dist/lib/agent-spec/agents.js +3127 -0
  55. package/dist/lib/agent-spec/provider.js +1 -1
  56. package/dist/lib/agents.d.ts +2 -584
  57. package/dist/lib/agents.js +2 -3127
  58. package/dist/lib/browser/cdp.js +4 -0
  59. package/dist/lib/browser/chrome.js +12 -1
  60. package/dist/lib/browser/drivers/local.d.ts +8 -2
  61. package/dist/lib/browser/drivers/local.js +8 -2
  62. package/dist/lib/browser/drivers/ssh.d.ts +8 -2
  63. package/dist/lib/browser/drivers/ssh.js +11 -4
  64. package/dist/lib/browser/hygiene.d.ts +1 -1
  65. package/dist/lib/browser/hygiene.js +5 -1
  66. package/dist/lib/browser/profiles.d.ts +77 -4
  67. package/dist/lib/browser/profiles.js +121 -12
  68. package/dist/lib/browser/runtime-state.d.ts +11 -5
  69. package/dist/lib/browser/runtime-state.js +13 -9
  70. package/dist/lib/browser/service.d.ts +91 -30
  71. package/dist/lib/browser/service.js +342 -207
  72. package/dist/lib/browser/types.d.ts +96 -4
  73. package/dist/lib/browser/types.js +51 -0
  74. package/dist/lib/catchup.js +1 -1
  75. package/dist/lib/computer/actions.d.ts +1 -1
  76. package/dist/lib/computer/actions.js +1 -1
  77. package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
  78. package/dist/lib/computer/dispatch.d.ts +1 -1
  79. package/dist/lib/computer/download.d.ts +1 -1
  80. package/dist/lib/computer/download.js +3 -3
  81. package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
  82. package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
  83. package/dist/lib/daemon/daemon.js +1 -1
  84. package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +10 -10
  85. package/dist/lib/{runner.js → daemon/runner.js} +59 -118
  86. package/dist/lib/daemon-webhooks.d.ts +3 -1
  87. package/dist/lib/daemon-webhooks.js +12 -7
  88. package/dist/lib/device-config.js +1 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  90. package/dist/lib/devices/doctor-findings.js +3 -3
  91. package/dist/lib/devices/fleet-inventory.js +1 -1
  92. package/dist/lib/devices/harness-inventory.js +1 -1
  93. package/dist/lib/devices/registry.js +4 -22
  94. package/dist/lib/doctor-diff.d.ts +1 -1
  95. package/dist/lib/doctor-diff.js +1 -1
  96. package/dist/lib/drift.js +1 -1
  97. package/dist/lib/entitlement.d.ts +31 -0
  98. package/dist/lib/entitlement.js +137 -0
  99. package/dist/lib/exec.js +39 -202
  100. package/dist/lib/feed/feed.js +2 -8
  101. package/dist/lib/fs-atomic.d.ts +7 -0
  102. package/dist/lib/fs-atomic.js +9 -0
  103. package/dist/lib/harness/adapter.d.ts +160 -0
  104. package/dist/lib/harness/adapter.js +47 -0
  105. package/dist/lib/harness/adapters/claude.d.ts +2 -0
  106. package/dist/lib/harness/adapters/claude.js +110 -0
  107. package/dist/lib/harness/adapters/codex.d.ts +2 -0
  108. package/dist/lib/harness/adapters/codex.js +83 -0
  109. package/dist/lib/harness/adapters/copilot.d.ts +2 -0
  110. package/dist/lib/harness/adapters/copilot.js +23 -0
  111. package/dist/lib/harness/adapters/cursor.d.ts +2 -0
  112. package/dist/lib/harness/adapters/cursor.js +38 -0
  113. package/dist/lib/harness/adapters/droid.d.ts +2 -0
  114. package/dist/lib/harness/adapters/droid.js +17 -0
  115. package/dist/lib/harness/adapters/grok.d.ts +2 -0
  116. package/dist/lib/harness/adapters/grok.js +14 -0
  117. package/dist/lib/harness/adapters/kimi.d.ts +2 -0
  118. package/dist/lib/harness/adapters/kimi.js +49 -0
  119. package/dist/lib/harness/adapters/muse.d.ts +2 -0
  120. package/dist/lib/harness/adapters/muse.js +44 -0
  121. package/dist/lib/harness/adapters/opencode.d.ts +2 -0
  122. package/dist/lib/harness/adapters/opencode.js +14 -0
  123. package/dist/lib/harness/exec-config-version.d.ts +8 -0
  124. package/dist/lib/harness/exec-config-version.js +22 -0
  125. package/dist/lib/harness/index.d.ts +1 -0
  126. package/dist/lib/harness/index.js +34 -0
  127. package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
  128. package/dist/lib/{hooks.js → hooks/install.js} +11 -11
  129. package/dist/lib/import.js +1 -1
  130. package/dist/lib/installations/index.d.ts +4 -4
  131. package/dist/lib/installations/index.js +3 -4
  132. package/dist/lib/installations/migrate.js +6 -6
  133. package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
  134. package/dist/lib/{shims.js → installations/shims.js} +12 -114
  135. package/dist/lib/installations/store.d.ts +141 -3
  136. package/dist/lib/installations/store.js +475 -5
  137. package/dist/lib/installations/versions.d.ts +1 -126
  138. package/dist/lib/installations/versions.js +5 -465
  139. package/dist/lib/isolation-boundary-report.d.ts +1 -1
  140. package/dist/lib/isolation-boundary-report.js +1 -1
  141. package/dist/lib/mailbox-gc.js +2 -3
  142. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  143. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  144. package/dist/lib/monitors/dispatch.d.ts +1 -1
  145. package/dist/lib/monitors/dispatch.js +2 -2
  146. package/dist/lib/monitors/state.d.ts +1 -1
  147. package/dist/lib/observe-aliases.d.ts +2 -2
  148. package/dist/lib/observe-aliases.js +2 -11
  149. package/dist/lib/plugins/plugins.js +1 -1
  150. package/dist/lib/plugins/skills.js +1 -1
  151. package/dist/lib/prix-account.d.ts +158 -0
  152. package/dist/lib/prix-account.js +214 -0
  153. package/dist/lib/project-key.js +7 -0
  154. package/dist/lib/project-launch.js +1 -1
  155. package/dist/lib/refresh.js +2 -2
  156. package/dist/lib/resource-inventory.d.ts +1 -1
  157. package/dist/lib/resource-inventory.js +1 -1
  158. package/dist/lib/rules/rules.js +1 -1
  159. package/dist/lib/scheduling/routines.js +1 -1
  160. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  161. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  162. package/dist/lib/secrets/bundles.d.ts +1 -1
  163. package/dist/lib/secrets/bundles.js +1 -1
  164. package/dist/lib/secrets/headless.d.ts +16 -0
  165. package/dist/lib/secrets/headless.js +21 -0
  166. package/dist/lib/secrets/remote.d.ts +9 -7
  167. package/dist/lib/secrets/remote.js +18 -9
  168. package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
  169. package/dist/lib/self-heal/checks/path.js +1 -1
  170. package/dist/lib/self-heal/checks/shadowing.js +1 -1
  171. package/dist/lib/self-heal/checks/shims.js +1 -1
  172. package/dist/lib/session/discover.d.ts +39 -0
  173. package/dist/lib/session/discover.js +3 -3
  174. package/dist/lib/session/parse.js +24 -42
  175. package/dist/lib/session/recovery.js +1 -1
  176. package/dist/lib/session/share-html.d.ts +55 -0
  177. package/dist/lib/session/share-html.js +319 -0
  178. package/dist/lib/settings-manifest.d.ts +2 -0
  179. package/dist/lib/settings-manifest.js +81 -3
  180. package/dist/lib/share/publish.d.ts +38 -0
  181. package/dist/lib/share/publish.js +81 -8
  182. package/dist/lib/staleness/prune.d.ts +1 -1
  183. package/dist/lib/staleness/prune.js +1 -1
  184. package/dist/lib/staleness/writers/hooks.js +1 -1
  185. package/dist/lib/startup/command-registry.d.ts +2 -1
  186. package/dist/lib/startup/command-registry.js +5 -3
  187. package/dist/lib/teams/agents.js +2 -19
  188. package/dist/lib/teams/parsers.js +20 -26
  189. package/dist/lib/teams/registry.js +2 -21
  190. package/dist/lib/triggers/handlers.d.ts +37 -2
  191. package/dist/lib/triggers/handlers.js +57 -6
  192. package/dist/lib/triggers/webhook.d.ts +80 -6
  193. package/dist/lib/triggers/webhook.js +128 -4
  194. package/dist/lib/types.d.ts +1 -1
  195. package/dist/lib/uninstall.js +1 -1
  196. package/dist/lib/version.d.ts +9 -2
  197. package/dist/lib/version.js +11 -9
  198. package/dist/lib/wrap.d.ts +33 -0
  199. package/dist/lib/wrap.js +70 -0
  200. package/package.json +1 -1
  201. /package/dist/lib/{computer-rpc.d.ts → computer/computer-rpc.d.ts} +0 -0
@@ -8,7 +8,7 @@ import { connectLocal } from './drivers/local.js';
8
8
  import { connectSSH, shellQuote } from './drivers/ssh.js';
9
9
  import { clearProfileRuntime, listProfileCacheDirs, readProfileRuntimeMeta, isProcessAlive } from './runtime-state.js';
10
10
  import { resolveDomainSkill } from './domain-skills.js';
11
- import { generateTaskId, generateShortId, } from './types.js';
11
+ import { generateTaskId, generateShortId, connectionKey, asConnectionKey, parseConnectionKey, keyBelongsToProfile, } from './types.js';
12
12
  import { reapAbandonedTasks, resolveLiveIdentities, taskOwnerIsGone, } from './hygiene.js';
13
13
  import { taskMatchesCaller } from './caller-identity.js';
14
14
  import { getRefs, resolveRefToCoords, describeRefs, healRef } from './refs.js';
@@ -243,6 +243,17 @@ async function isConnHealthy(conn, timeoutMs = 1000) {
243
243
  export function actionable(...lines) {
244
244
  return lines.filter((l) => l != null && l !== '').join('\n');
245
245
  }
246
+ /**
247
+ * Arc answers `Browser.getVersion` (so a connection succeeds) but exposes zero
248
+ * CDP page targets via every discovery method and CRASHES when a new tab is
249
+ * requested via `Target.createTarget` (verified live, PR #2778). It is therefore
250
+ * not drivable. This is the single clear, actionable error every tab-creating
251
+ * path throws instead of crashing the user's Arc window.
252
+ */
253
+ export function arcNotDrivableError(profileName) {
254
+ const scope = profileName ? ` (profile "${profileName}")` : '';
255
+ return new Error(actionable(`Browser "arc"${scope} is not drivable: Arc exposes no CDP page targets and`, 'crashes when a new tab is requested, so `agents browser` cannot control it.', 'Use a Chromium-family browser instead — Comet, Chrome, Chromium, or Brave:', ' agents browser profiles create <name> --browser comet'));
256
+ }
246
257
  /** Derive a human label from an explicit title or a navigated URL. */
247
258
  export function deriveTaskLabel(opts) {
248
259
  if (opts.title?.trim())
@@ -261,14 +272,6 @@ export function deriveTaskLabel(opts) {
261
272
  }
262
273
  return opts.existing ?? 'untitled';
263
274
  }
264
- /**
265
- * True when `key` is the bare profile name or one of its composite endpoint
266
- * keys (`name@endpoint`). Used by status/stop so `comet-local` matches
267
- * `comet-local@endpoint-0`.
268
- */
269
- export function connectionKeyMatchesProfile(key, profileName) {
270
- return key === profileName || key.startsWith(`${profileName}@`) || key.startsWith(`${profileName}.`);
271
- }
272
275
  /**
273
276
  * Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
274
277
  * (`launchId`). The forwarded values come from the caller's own CLI process and
@@ -290,6 +293,12 @@ export class BrowserService {
290
293
  'rush-app': 'rush-app-',
291
294
  'rush-cli': 'rush-cli-',
292
295
  };
296
+ /**
297
+ * Live browsers, keyed by {@link ConnectionKey} — NOT by profile name. The
298
+ * branded key type is what makes `connections.get(someProfileName)` (the
299
+ * RUSH-2709 miss that made `status --profile <name>` return empty for a
300
+ * running `<name>@endpoint-0`) fail to compile.
301
+ */
293
302
  connections = new Map();
294
303
  forkingProfiles = new Set();
295
304
  // Per-task storage for console, errors, network, downloads
@@ -308,19 +317,18 @@ export class BrowserService {
308
317
  throw new Error(`Profile "${profileName}" not found`);
309
318
  }
310
319
  // Pick the endpoint preset. Throws with the candidate list if the user
311
- // passed an unknown name. The composite identifier `<profile>@<endpoint>`
312
- // is what the connection map + per-profile runtime dirs are keyed on, so
313
- // a single YAML profile can run at multiple endpoints concurrently.
320
+ // passed an unknown name. The runtime key `<profile>@<endpoint>` is what
321
+ // the connection map + per-profile runtime dirs are keyed on, so a single
322
+ // YAML profile can run at multiple endpoints concurrently.
314
323
  //
315
- // The YAML profile's bare `name` stays on `profile.name` for status
316
- // matching; only the connection/runtime key uses the composite.
324
+ // `effectiveProfile.name` stays BARE. Overwriting it with the runtime key
325
+ // here (pre-RUSH-2709) is what leaked `comet-local@endpoint-0` into every
326
+ // listing and made `status --profile comet-local` miss its own browser.
327
+ // The key travels as its own argument instead.
317
328
  const resolved = resolveEndpoint(profile, opts.endpointName);
318
- const composite = `${profileName}@${resolved.name}`;
329
+ const composite = connectionKey(profileName, resolved.name);
319
330
  const effectiveProfile = {
320
331
  ...profile,
321
- // Runtime/connect path key — NOT the bare YAML name. Connections and
322
- // tasks.json live under this composite so multi-endpoint is safe.
323
- name: composite,
324
332
  binary: resolved.binary,
325
333
  targetFilter: resolved.targetFilter,
326
334
  };
@@ -338,7 +346,7 @@ export class BrowserService {
338
346
  }
339
347
  const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
340
348
  let conn = this.connections.get(composite);
341
- let effectiveProfileName = composite;
349
+ let effectiveKey = composite;
342
350
  // If we have a cached connection, confirm it's still usable before any
343
351
  // caller relies on it. A browser killed externally (Cmd-Q, crash, or a
344
352
  // user clearing the profile by hand) leaves a closed WebSocket here.
@@ -361,7 +369,7 @@ export class BrowserService {
361
369
  const existingFork = this.findAvailableFork(composite);
362
370
  if (existingFork) {
363
371
  conn = existingFork.conn;
364
- effectiveProfileName = existingFork.name;
372
+ effectiveKey = existingFork.name;
365
373
  }
366
374
  else {
367
375
  throw new Error(`Fork in progress but no available fork found for "${composite}"`);
@@ -370,9 +378,9 @@ export class BrowserService {
370
378
  else {
371
379
  this.forkingProfiles.add(composite);
372
380
  try {
373
- const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
381
+ const { forkName, connection } = await this.forkElectronProfile(effectiveProfile, composite);
374
382
  conn = connection;
375
- effectiveProfileName = forkName;
383
+ effectiveKey = forkName;
376
384
  }
377
385
  finally {
378
386
  this.forkingProfiles.delete(composite);
@@ -380,14 +388,14 @@ export class BrowserService {
380
388
  }
381
389
  }
382
390
  else if (!conn) {
383
- conn = await this.connectProfile(effectiveProfile, resolved.target);
384
- conn.profileName = composite;
385
- conn.bareName = profileName;
391
+ conn = await this.connectProfile(effectiveProfile, resolved.target, composite);
392
+ conn.key = composite;
393
+ conn.profile = profileName;
386
394
  this.connections.set(composite, conn);
387
395
  await this.applyDefaultDownloadBehavior(conn, composite);
388
396
  }
389
- if (conn && !conn.bareName)
390
- conn.bareName = profileName;
397
+ if (conn && !conn.profile)
398
+ conn.profile = profileName;
391
399
  // Browsers launch with --no-startup-window (session-cookie persistence,
392
400
  // see launchBrowser), so a bare `start` with no --url would otherwise
393
401
  // leave the user staring at a process with zero windows. Recreate the
@@ -404,9 +412,9 @@ export class BrowserService {
404
412
  if (!opts.url && !conn.electron) {
405
413
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
406
414
  if (!targetInfos.some((t) => t.type === 'page')) {
407
- const created = (await conn.cdp.send('Target.createTarget', {
415
+ const created = await this.createPageTarget(conn, {
408
416
  url: 'about:blank',
409
- }));
417
+ });
410
418
  startupBlankTargetId = created.targetId;
411
419
  this.invalidateTargetCache(conn);
412
420
  }
@@ -416,7 +424,7 @@ export class BrowserService {
416
424
  id: taskId,
417
425
  name: taskName,
418
426
  label: taskLabel,
419
- profile: effectiveProfileName,
427
+ profile: effectiveKey,
420
428
  tabs: {},
421
429
  currentTabId: undefined,
422
430
  createdAt: now,
@@ -445,7 +453,7 @@ export class BrowserService {
445
453
  }
446
454
  }
447
455
  conn.tasks.set(taskName, task);
448
- await this.saveTaskState(effectiveProfileName, conn.tasks);
456
+ await this.saveTaskState(effectiveKey, conn.tasks);
449
457
  // Durable identity, written ONCE at start and never deleted (RUSH-2549).
450
458
  // tasks.json above stays live state: `stop` drops the task from that map, so
451
459
  // anything recorded only there is gone the moment the task ends -- which is
@@ -459,22 +467,23 @@ export class BrowserService {
459
467
  try {
460
468
  recordBrowserSession({
461
469
  task: taskName,
462
- profile: effectiveProfileName,
470
+ profile: effectiveKey,
463
471
  sessionId: task.sessionId,
464
472
  launchId: task.launchId,
465
473
  actor: task.owner,
466
474
  startedAt: task.createdAt,
467
- captureDir: getProfileSessionsDir(effectiveProfileName, taskName),
475
+ captureDir: getProfileSessionsDir(effectiveKey, taskName),
468
476
  });
469
477
  }
470
478
  catch {
471
479
  // Recording is best-effort; the task itself is live either way.
472
480
  }
473
- emit('browser.launch', { profile: effectiveProfileName, task: taskName, pid: conn.pid });
481
+ // Feed events and the usage rollup are read by humans: bare name only.
482
+ emit('browser.launch', { profile: profileName, task: taskName, pid: conn.pid });
474
483
  void import('../analytics/usage-db.js').then(({ recordUsage }) => {
475
484
  recordUsage({
476
485
  kind: 'browser',
477
- name: effectiveProfileName,
486
+ name: profileName,
478
487
  event: 'launch',
479
488
  source: 'browser',
480
489
  meta: { task: taskName },
@@ -485,19 +494,16 @@ export class BrowserService {
485
494
  let tabId;
486
495
  if (opts.url && !conn.electron) {
487
496
  const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
488
- const targetId = adopted ??
489
- (await conn.cdp.send('Target.createTarget', {
490
- url: opts.url,
491
- })).targetId;
497
+ const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
492
498
  const shortId = generateShortId();
493
499
  task.tabs[shortId] = targetId;
494
500
  task.currentTabId = shortId;
495
501
  this.invalidateTargetCache(conn);
496
- await this.saveTaskState(effectiveProfileName, conn.tasks);
502
+ await this.saveTaskState(effectiveKey, conn.tasks);
497
503
  tabId = shortId;
498
504
  }
499
505
  else if (opts.url && conn.electron) {
500
- const result = await this.navigate(taskName, opts.url, effectiveProfileName);
506
+ const result = await this.navigate(taskName, opts.url, effectiveKey);
501
507
  tabId = result.tabId;
502
508
  }
503
509
  // Domain-skill discovery: when a URL is supplied, look up site-specific
@@ -509,7 +515,7 @@ export class BrowserService {
509
515
  if (resolved)
510
516
  skill = resolved;
511
517
  }
512
- return { task: taskId, name: taskName, tabId, profile: effectiveProfileName, skill };
518
+ return { task: taskId, name: taskName, tabId, profile: profileName, key: effectiveKey, skill };
513
519
  }
514
520
  /**
515
521
  * Reclaim a live page already showing `url` from an ABANDONED task, instead
@@ -543,6 +549,50 @@ export class BrowserService {
543
549
  * page that has since redirected elsewhere simply does not match, and the
544
550
  * caller opens a tab as before.
545
551
  */
552
+ /** Is this CDP target already registered to any task on the connection? */
553
+ targetIsClaimed(conn, targetId) {
554
+ for (const t of conn.tasks.values()) {
555
+ if (Object.values(t.tabs).includes(targetId))
556
+ return true;
557
+ }
558
+ return false;
559
+ }
560
+ /**
561
+ * The CDP targets a task may CLOSE — its own tabs minus any it merely
562
+ * borrowed. A borrowed tab existed before the task did (see `Task.borrowedTabs`),
563
+ * so closing it would remove something the task never opened.
564
+ */
565
+ closeableTargetIds(task) {
566
+ const borrowed = new Set(task.borrowedTabs ?? []);
567
+ return Object.entries(task.tabs)
568
+ .filter(([shortId]) => !borrowed.has(shortId))
569
+ .map(([, cdpId]) => cdpId);
570
+ }
571
+ /**
572
+ * A page target that `navigate` may take over on a browser which cannot create
573
+ * tabs (Arc). Deliberately narrow: only a tab ALREADY showing the requested URL
574
+ * -- re-showing that document there is what the caller asked for -- or an empty
575
+ * new-tab page. A target any task already owns is left alone. Note the honest
576
+ * limit: reusing a matching tab still RELOADS it, discarding scroll position and
577
+ * unsaved form state. Arc cannot open a tab at all, so some intrusion is
578
+ * unavoidable to show the document; a reload is the smallest one, and the tab is
579
+ * borrowed rather than owned so it is never closed.
580
+ */
581
+ async pickReusableTargetWithoutCreate(conn, url) {
582
+ const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
583
+ const owned = new Set();
584
+ for (const t of conn.tasks.values()) {
585
+ for (const id of Object.values(t.tabs))
586
+ owned.add(id);
587
+ }
588
+ const free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
589
+ const wanted = canonicalTabUrl(url);
590
+ const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
591
+ if (sameUrl)
592
+ return sameUrl.targetId;
593
+ const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
594
+ return free.find((t) => BLANK.has(t.url))?.targetId;
595
+ }
546
596
  async adoptTabShowing(conn, url) {
547
597
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
548
598
  const wanted = canonicalTabUrl(url);
@@ -553,7 +603,15 @@ export class BrowserService {
553
603
  return undefined;
554
604
  const candidates = [];
555
605
  for (const task of conn.tasks.values()) {
606
+ const borrowed = new Set(task.borrowedTabs ?? []);
556
607
  for (const [shortId, cdpId] of Object.entries(task.tabs)) {
608
+ // A BORROWED tab is not ours to hand on. The dead task never opened it
609
+ // (Task.borrowedTabs) -- reclaiming it would register a pre-existing tab
610
+ // as a fresh task's own, and that task's `done` would close it. Exactly
611
+ // the "adopting it would make this task's `done` close the user's tab"
612
+ // case above, arriving one hop later via reclaim instead of directly.
613
+ if (borrowed.has(shortId))
614
+ continue;
557
615
  if (matching.has(cdpId))
558
616
  candidates.push({ task, shortId, targetId: cdpId });
559
617
  }
@@ -602,7 +660,7 @@ export class BrowserService {
602
660
  const out = [];
603
661
  for (const [key, conn] of this.connections) {
604
662
  for (const task of conn.tasks.values()) {
605
- out.push({ profile: conn.profileName ?? key, task });
663
+ out.push({ profile: conn.key ?? key, task });
606
664
  }
607
665
  }
608
666
  return out;
@@ -623,7 +681,7 @@ export class BrowserService {
623
681
  catch {
624
682
  // findTask throws when truly gone; fall through to the miss return.
625
683
  }
626
- for (const [profileName, conn] of this.connections) {
684
+ for (const [key, conn] of this.connections) {
627
685
  const task = this.lookupTaskOnConn(conn, taskName);
628
686
  if (task) {
629
687
  // Map key may be task.name, not the caller-supplied handle.
@@ -651,8 +709,9 @@ export class BrowserService {
651
709
  }
652
710
  // Save to history before closing
653
711
  await this.saveToHistory(task, Array.from(domains));
654
- // Close task's tabs (not the window - it's shared)
655
- await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
712
+ // Close task's tabs (not the window - it's shared, and not a tab the
713
+ // task merely borrowed - that one was open before the task existed).
714
+ await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => {
656
715
  // Tab already closed
657
716
  })));
658
717
  for (const cdpId of Object.values(task.tabs)) {
@@ -660,12 +719,14 @@ export class BrowserService {
660
719
  }
661
720
  this.invalidateTargetCache(conn);
662
721
  conn.tasks.delete(mapKey);
663
- await this.saveTaskState(profileName, conn.tasks);
664
- emit('browser.close', { profile: profileName, task: task.name });
722
+ await this.saveTaskState(key, conn.tasks);
723
+ // Feed + usage rows are read by humans: bare profile name (RUSH-2709).
724
+ const bareProfile = conn.profile ?? parseConnectionKey(key).profile;
725
+ emit('browser.close', { profile: bareProfile, task: task.name });
665
726
  void import('../analytics/usage-db.js').then(({ recordUsage }) => {
666
727
  recordUsage({
667
728
  kind: 'browser',
668
- name: profileName,
729
+ name: bareProfile,
669
730
  event: 'close',
670
731
  source: 'browser',
671
732
  meta: { task: task.name },
@@ -675,10 +736,10 @@ export class BrowserService {
675
736
  conn.cdp.close();
676
737
  killChrome(conn.pid);
677
738
  conn.cleanup?.();
678
- this.connections.delete(profileName);
679
- clearProfileRuntime(profileName);
739
+ this.connections.delete(key);
740
+ clearProfileRuntime(key);
680
741
  }
681
- return { ok: true, profile: profileName };
742
+ return { ok: true, profile: bareProfile };
682
743
  }
683
744
  }
684
745
  return { ok: false };
@@ -686,16 +747,15 @@ export class BrowserService {
686
747
  async done(taskName) {
687
748
  return this.stop(taskName);
688
749
  }
689
- async stopProfile(profileName) {
690
- // Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
691
- // but callers pass the bare profile name (or, occasionally, an exact composite).
692
- // A plain `connections.get(profileName)` therefore missed every real remote
693
- // connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
694
- // Windows, the WMI-spawned browser orphaned on the remote host after every
695
- // `browser stop --profile` (#559). Match the exact key AND every
696
- // `<profileName>@<endpoint>` composite so all of a profile's live connections
697
- // are torn down.
698
- const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
750
+ async stopProfile(profileRef) {
751
+ // Connections are keyed by the runtime key `<profile>@<endpoint>` (see
752
+ // start()) while callers pass the bare profile name (or, occasionally, an
753
+ // exact key). A plain `connections.get(profileRef)` therefore missed every
754
+ // real remote connection, so `cleanup()` never ran — leaving the SSH tunnel
755
+ // and, on Windows, the WMI-spawned browser orphaned on the remote host after
756
+ // every `browser stop --profile` (#559). Same single rule as findTask and
757
+ // status: exact key, else every key belonging to that profile.
758
+ const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
699
759
  for (const key of keys) {
700
760
  const conn = this.connections.get(key);
701
761
  if (!conn)
@@ -709,7 +769,7 @@ export class BrowserService {
709
769
  // Kill stale processes and clean runtime dirs for every composite and
710
770
  // fork entry belonging to this profile (including `.N` forks left by
711
771
  // earlier daemon sessions that the connection loop above didn't cover).
712
- for (const dir of listProfileCacheDirs(profileName)) {
772
+ for (const dir of listProfileCacheDirs(parseConnectionKey(profileRef).profile)) {
713
773
  const dirName = path.basename(dir);
714
774
  const meta = readProfileRuntimeMeta(dirName);
715
775
  if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
@@ -718,8 +778,8 @@ export class BrowserService {
718
778
  clearProfileRuntime(dirName);
719
779
  }
720
780
  }
721
- async navigate(taskId, url, profileName) {
722
- const { conn, task } = await this.findTask(taskId, profileName);
781
+ async navigate(taskId, url, profileRef) {
782
+ const { conn, task } = await this.findTask(taskId, profileRef);
723
783
  this.maybeUpdateLabelFromUrl(conn, task, url);
724
784
  // If we have a current tab, navigate in it (reuse)
725
785
  const currentShortId = task.currentTabId;
@@ -728,7 +788,7 @@ export class BrowserService {
728
788
  const sessionId = await this.getSessionId(conn, cdpTargetId);
729
789
  await conn.cdp.send('Page.navigate', { url }, sessionId);
730
790
  await this.saveTaskState(task.profile, conn.tasks);
731
- emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: currentShortId, created: false });
791
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: currentShortId, created: false });
732
792
  return { tabId: currentShortId, url, created: false };
733
793
  }
734
794
  // No current tab - create one
@@ -743,29 +803,77 @@ export class BrowserService {
743
803
  task.tabs[shortId] = cdpTargetId;
744
804
  task.currentTabId = shortId;
745
805
  await this.saveTaskState(task.profile, conn.tasks);
746
- emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: shortId, created: true });
806
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
747
807
  return { tabId: shortId, url, created: true };
748
808
  }
809
+ // Arc exposes page targets and honors Page.navigate on them; what it cannot
810
+ // survive is Target.createTarget (#2778). createPageTarget below therefore
811
+ // refuses outright, so the document never gets shown AT ALL and callers fall
812
+ // back to a raw `open` -- a new tab every call, which is the tab-spam #2779
813
+ // set out to end. Reuse in place instead, the same shape as the Electron
814
+ // branch above. Measured against a live Arc (#2786): 33 page targets,
815
+ // Page.navigate reused one, tab count unchanged, no crash.
816
+ if (conn.browserType === 'arc') {
817
+ const reusable = await this.pickReusableTargetWithoutCreate(conn, url);
818
+ if (reusable) {
819
+ const shortId = generateShortId();
820
+ const sessionId = await this.getSessionId(conn, reusable);
821
+ // Check and CLAIM in one synchronous tick, with no await between them --
822
+ // the ordering `adoptTabShowing` uses (service.ts: the `task.tabs[shortId]
823
+ // !== targetId` re-check sits immediately before its `delete`). Checking
824
+ // and then awaiting `Page.navigate` before writing leaves exactly the gap
825
+ // the check exists to close: a concurrent navigate passes its own check in
826
+ // that window and both tasks end up driving one tab.
827
+ if (this.targetIsClaimed(conn, reusable)) {
828
+ throw arcNotDrivableError(conn.profile);
829
+ }
830
+ task.tabs[shortId] = reusable;
831
+ // Borrowed, not opened: this tab was in the browser before the task and
832
+ // must survive `done`. See Task.borrowedTabs.
833
+ task.borrowedTabs = [...(task.borrowedTabs ?? []), shortId];
834
+ task.currentTabId = shortId;
835
+ try {
836
+ await conn.cdp.send('Page.navigate', { url }, sessionId);
837
+ }
838
+ catch (err) {
839
+ // Release the claim we took optimistically, so a failed navigate does
840
+ // not leave the task holding a tab it never drove.
841
+ delete task.tabs[shortId];
842
+ task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
843
+ if (task.currentTabId === shortId)
844
+ task.currentTabId = undefined;
845
+ throw err;
846
+ }
847
+ this.invalidateTargetCache(conn);
848
+ await this.saveTaskState(task.profile, conn.tasks);
849
+ emit('browser.navigate', {
850
+ profile: task.profile,
851
+ task: task.name,
852
+ url,
853
+ tabId: shortId,
854
+ created: false,
855
+ });
856
+ return { tabId: shortId, url, created: false };
857
+ }
858
+ // Nothing safe to reuse -- fall through to the actionable refusal rather
859
+ // than taking over a page the user is reading.
860
+ }
749
861
  // Chrome: create new tab
750
- const result = (await conn.cdp.send('Target.createTarget', {
751
- url,
752
- }));
862
+ const result = await this.createPageTarget(conn, { url });
753
863
  const shortId = generateShortId();
754
864
  task.tabs[shortId] = result.targetId;
755
865
  task.currentTabId = shortId;
756
866
  this.invalidateTargetCache(conn);
757
867
  await this.saveTaskState(task.profile, conn.tasks);
758
- emit('browser.navigate', { profile: task.profile, task: task.name, url, tabId: shortId, created: true });
868
+ emit('browser.navigate', { profile: parseConnectionKey(task.profile).profile, task: task.name, url, tabId: shortId, created: true });
759
869
  return { tabId: shortId, url, created: true };
760
870
  }
761
- async tabAdd(taskId, url, profileName) {
762
- const { conn, task } = await this.findTask(taskId, profileName);
871
+ async tabAdd(taskId, url, profileRef) {
872
+ const { conn, task } = await this.findTask(taskId, profileRef);
763
873
  if (conn.electron) {
764
874
  throw new Error('Electron apps do not support opening additional tabs');
765
875
  }
766
- const result = (await conn.cdp.send('Target.createTarget', {
767
- url,
768
- }));
876
+ const result = await this.createPageTarget(conn, { url });
769
877
  const shortId = generateShortId();
770
878
  task.tabs[shortId] = result.targetId;
771
879
  task.currentTabId = shortId; // new tab becomes current
@@ -837,9 +945,9 @@ export class BrowserService {
837
945
  throw new Error(`Tab ${shortId} not found`);
838
946
  return cdpId;
839
947
  }
840
- async tabs(taskId, profileName) {
948
+ async tabs(taskId, profileRef) {
841
949
  if (taskId) {
842
- const { conn, task } = await this.findTask(taskId, profileName);
950
+ const { conn, task } = await this.findTask(taskId, profileRef);
843
951
  return this.getTabsForTask(conn.cdp, task);
844
952
  }
845
953
  const allTabs = [];
@@ -857,9 +965,15 @@ export class BrowserService {
857
965
  const shortId = await this.resolveTabHint(conn, task, tabHint);
858
966
  const cdpId = task.tabs[shortId];
859
967
  if (cdpId) {
860
- await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
968
+ // A borrowed tab is released, not closed it outlives this task.
969
+ if (!(task.borrowedTabs ?? []).includes(shortId)) {
970
+ await conn.cdp.send('Target.closeTarget', { targetId: cdpId });
971
+ }
861
972
  conn.sessionCache.delete(cdpId);
862
973
  delete task.tabs[shortId];
974
+ // Drop the borrow record with the tab it names, or it outlives every tab
975
+ // it ever described and grows unbounded in the persisted task state.
976
+ task.borrowedTabs = (task.borrowedTabs ?? []).filter((id) => id !== shortId);
863
977
  // Update currentTabId if we closed the current tab
864
978
  if (task.currentTabId === shortId) {
865
979
  const remaining = Object.keys(task.tabs);
@@ -868,12 +982,13 @@ export class BrowserService {
868
982
  }
869
983
  }
870
984
  else {
871
- // Close all tabs
872
- await Promise.all(Object.values(task.tabs).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
985
+ // Close all tabs the task opened; borrowed ones are only released.
986
+ await Promise.all(this.closeableTargetIds(task).map((cdpId) => conn.cdp.send('Target.closeTarget', { targetId: cdpId }).catch(() => { })));
873
987
  for (const cdpId of Object.values(task.tabs)) {
874
988
  conn.sessionCache.delete(cdpId);
875
989
  }
876
990
  task.tabs = {};
991
+ task.borrowedTabs = [];
877
992
  task.currentTabId = undefined;
878
993
  }
879
994
  this.invalidateTargetCache(conn);
@@ -910,7 +1025,7 @@ export class BrowserService {
910
1025
  return result.result.value;
911
1026
  }
912
1027
  async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
913
- const { conn, task, profileName } = await this.findTask(taskId);
1028
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
914
1029
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
915
1030
  const cdpTargetId = this.getCdpTargetId(task, shortId);
916
1031
  const target = await this.getTarget(conn, cdpTargetId);
@@ -943,7 +1058,7 @@ export class BrowserService {
943
1058
  }
944
1059
  extension = 'jpg';
945
1060
  }
946
- const sessionsDir = getProfileSessionsDir(profileName, task.name);
1061
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
947
1062
  const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
948
1063
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
949
1064
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -951,7 +1066,7 @@ export class BrowserService {
951
1066
  const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
952
1067
  { width: 0, height: 0 };
953
1068
  emit('browser.screenshot', {
954
- profile: profileName,
1069
+ profile: parseConnectionKey(runtimeKey).profile,
955
1070
  task: task.name,
956
1071
  tabId: shortId,
957
1072
  path: finalPath,
@@ -970,7 +1085,7 @@ export class BrowserService {
970
1085
  * — without it, dark-mode pages render on a blank sheet.
971
1086
  */
972
1087
  async printToPdf(taskId, tabHint, outputPath) {
973
- const { conn, task, profileName } = await this.findTask(taskId);
1088
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
974
1089
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
975
1090
  const cdpTargetId = this.getCdpTargetId(task, shortId);
976
1091
  const target = await this.getTarget(conn, cdpTargetId);
@@ -980,7 +1095,7 @@ export class BrowserService {
980
1095
  const sessionId = await this.getSessionId(conn, target.targetId);
981
1096
  const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
982
1097
  const buffer = Buffer.from(data, 'base64');
983
- const sessionsDir = getProfileSessionsDir(profileName, task.name);
1098
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
984
1099
  const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
985
1100
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
986
1101
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -998,7 +1113,7 @@ export class BrowserService {
998
1113
  if (this.recordings.has(taskId)) {
999
1114
  throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
1000
1115
  }
1001
- const { conn, task, profileName } = await this.findTask(taskId);
1116
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1002
1117
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1003
1118
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1004
1119
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1014,7 +1129,7 @@ export class BrowserService {
1014
1129
  throw new Error('--duration must be between 1 and 3600 seconds');
1015
1130
  if (maxMb < 1 || maxMb > 500)
1016
1131
  throw new Error('--max-mb must be between 1 and 500');
1017
- const recordingsDir = path.join(getProfileSessionsDir(profileName, task.name), 'recordings');
1132
+ const recordingsDir = path.join(getProfileSessionsDir(runtimeKey, task.name), 'recordings');
1018
1133
  await fs.promises.mkdir(recordingsDir, { recursive: true });
1019
1134
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
1020
1135
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
@@ -1408,34 +1523,37 @@ export class BrowserService {
1408
1523
  stageUpload(source) {
1409
1524
  return { path: stageUploadFile(source) };
1410
1525
  }
1411
- async status(profileName) {
1526
+ /**
1527
+ * Status for one profile (bare name) or every live profile.
1528
+ *
1529
+ * `profileName` is a {@link ProfileName} — a BARE name, never a runtime key.
1530
+ * Every candidate is selected by the one rule ({@link keyBelongsToProfile}),
1531
+ * and a scoped query that finds no live connection falls through to disk, so
1532
+ * `status --profile comet-local` reports the LIVE `comet-local@endpoint-0`
1533
+ * instead of the empty list it used to return (RUSH-2709).
1534
+ */
1535
+ async status(profileRef) {
1412
1536
  // Reconnect live browsers after a daemon restart so status is not empty
1413
1537
  // while tasks.json still holds open work.
1414
1538
  await this.rehydrateAllFromDisk();
1415
- const seen = new Set();
1539
+ // A caller who pasted a runtime key out of an older listing still gets the
1540
+ // profile it belongs to, rather than an empty result.
1541
+ const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
1542
+ const seenProfiles = new Set();
1416
1543
  const statuses = [];
1417
- // Connections are keyed by composite `<profile>@<endpoint>`. A bare
1418
- // `--profile comet-local` must still list those tasks (same rule as
1419
- // stopProfile). Match exact key, bareName, and composite/fork prefixes.
1420
1544
  const candidates = profileName
1421
- ? [...this.connections.keys()].filter((k) => connectionKeyMatchesProfile(k, profileName) ||
1422
- this.connections.get(k)?.bareName === profileName)
1545
+ ? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
1423
1546
  : Array.from(this.connections.keys());
1424
- for (const name of candidates) {
1425
- const status = await this.getProfileStatus(name);
1547
+ for (const key of candidates) {
1548
+ const status = await this.getProfileStatus(key);
1426
1549
  if (status) {
1427
1550
  statuses.push(status);
1428
- seen.add(name);
1429
- if (status.name)
1430
- seen.add(status.name);
1551
+ seenProfiles.add(status.name);
1431
1552
  }
1432
1553
  }
1433
1554
  if (!profileName) {
1434
- const profiles = await listProfiles();
1435
- for (const profile of profiles) {
1436
- if (seen.has(profile.name))
1437
- continue;
1438
- if ([...seen].some((s) => connectionKeyMatchesProfile(s, profile.name)))
1555
+ for (const profile of await listProfiles()) {
1556
+ if (seenProfiles.has(profile.name))
1439
1557
  continue;
1440
1558
  const reconciled = await this.reconcileFromDisk(profile.name);
1441
1559
  if (reconciled)
@@ -1449,31 +1567,28 @@ export class BrowserService {
1449
1567
  }
1450
1568
  return statuses;
1451
1569
  }
1570
+ /**
1571
+ * Rebuild a profile's status from its runtime dirs when no live connection is
1572
+ * registered — a daemon that never connected this profile, or a browser
1573
+ * started by an earlier daemon.
1574
+ *
1575
+ * Takes a BARE profile name and walks every runtime key that belongs to it
1576
+ * ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
1577
+ * exactly the profile.
1578
+ */
1452
1579
  async reconcileFromDisk(profileName) {
1453
- // Runtime dirs live under the composite (`comet-local@endpoint-0`), not the
1454
- // bare YAML name. Walk every matching cache dir so bare `status --profile`
1455
- // sees the same tasks as unscoped `status`.
1456
1580
  const dirs = listProfileCacheDirs(profileName);
1457
- if (dirs.length === 0) {
1458
- // Legacy: non-composite dir named exactly the bare profile.
1459
- const info = getRunningChromeInfo(profileName);
1460
- if (!info)
1461
- return null;
1462
- return this.profileStatusFromDisk(profileName, profileName, info.port, info.pid);
1463
- }
1464
- // Prefer a dir that still has a live browser; fall back to the first.
1465
1581
  for (const dir of dirs) {
1466
- const dirName = path.basename(dir);
1467
- const info = getRunningChromeInfo(dirName);
1582
+ const key = asConnectionKey(path.basename(dir));
1583
+ const info = getRunningChromeInfo(key);
1468
1584
  if (!info)
1469
1585
  continue;
1470
- return this.profileStatusFromDisk(profileName, dirName, info.port, info.pid);
1586
+ return this.profileStatusFromDisk(profileName, key, info.port, info.pid);
1471
1587
  }
1472
1588
  return null;
1473
1589
  }
1474
- async profileStatusFromDisk(displayName, runtimeKey, port, pid) {
1475
- const bare = displayName.includes('@') ? displayName.split('@')[0] : displayName;
1476
- const profile = await getProfile(bare);
1590
+ async profileStatusFromDisk(profileName, runtimeKey, port, pid) {
1591
+ const profile = await getProfile(profileName);
1477
1592
  const tasks = this.loadTaskState(runtimeKey);
1478
1593
  const taskStatuses = [];
1479
1594
  for (const [, task] of tasks) {
@@ -1488,7 +1603,10 @@ export class BrowserService {
1488
1603
  }
1489
1604
  const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
1490
1605
  return {
1491
- name: runtimeKey,
1606
+ // Bare name for the user; the endpoint travels in its own field.
1607
+ name: profileName,
1608
+ endpoint: parseConnectionKey(runtimeKey).endpoint,
1609
+ key: runtimeKey,
1492
1610
  running: true,
1493
1611
  port,
1494
1612
  pid,
@@ -1800,8 +1918,8 @@ export class BrowserService {
1800
1918
  * effort: a remote CDP endpoint that doesn't expose the Browser domain must not
1801
1919
  * fail the whole connect.
1802
1920
  */
1803
- async applyDefaultDownloadBehavior(conn, profileName) {
1804
- const downloadPath = getProfileDownloadsDir(profileName);
1921
+ async applyDefaultDownloadBehavior(conn, key) {
1922
+ const downloadPath = getProfileDownloadsDir(key);
1805
1923
  try {
1806
1924
  await fs.promises.mkdir(downloadPath, { recursive: true });
1807
1925
  await conn.cdp.send('Browser.setDownloadBehavior', {
@@ -1817,7 +1935,7 @@ export class BrowserService {
1817
1935
  }
1818
1936
  }
1819
1937
  async setDownloadPath(taskId, downloadPath, tabHint) {
1820
- const { conn, task, profileName } = await this.findTask(taskId);
1938
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1821
1939
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1822
1940
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1823
1941
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1826,7 +1944,7 @@ export class BrowserService {
1826
1944
  const sessionId = await this.getSessionId(conn, target.targetId);
1827
1945
  // No explicit --path: fall back to the profile's downloads dir, the same
1828
1946
  // destination already set browser-global at connect (applyDefaultDownloadBehavior).
1829
- const resolvedPath = downloadPath ?? getProfileDownloadsDir(profileName);
1947
+ const resolvedPath = downloadPath ?? getProfileDownloadsDir(runtimeKey);
1830
1948
  await fs.promises.mkdir(resolvedPath, { recursive: true });
1831
1949
  await conn.cdp.send('Browser.setDownloadBehavior', {
1832
1950
  behavior: 'allow',
@@ -1904,20 +2022,20 @@ export class BrowserService {
1904
2022
  }
1905
2023
  this.connections.clear();
1906
2024
  }
1907
- findAvailableFork(profileName) {
2025
+ findAvailableFork(baseKey) {
1908
2026
  for (const [name, conn] of this.connections) {
1909
- if (conn.forkedFrom === profileName && conn.tasks.size === 0) {
2027
+ if (conn.forkedFrom === baseKey && conn.tasks.size === 0) {
1910
2028
  return { name, conn };
1911
2029
  }
1912
2030
  }
1913
2031
  return null;
1914
2032
  }
1915
- async forkElectronProfile(profile) {
2033
+ async forkElectronProfile(profile, baseKey) {
1916
2034
  let forkNum = 2;
1917
- while (this.connections.has(`${profile.name}.${forkNum}`)) {
2035
+ while (this.connections.has(asConnectionKey(`${baseKey}.${forkNum}`))) {
1918
2036
  forkNum++;
1919
2037
  }
1920
- const forkName = `${profile.name}.${forkNum}`;
2038
+ const forkName = asConnectionKey(`${baseKey}.${forkNum}`);
1921
2039
  const port = allocatePort();
1922
2040
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
1923
2041
  const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
@@ -1929,9 +2047,11 @@ export class BrowserService {
1929
2047
  port,
1930
2048
  pid,
1931
2049
  electron: true,
2050
+ browserType: profile.browser,
1932
2051
  targetFilter: profile.targetFilter,
1933
- profileName: forkName,
1934
- forkedFrom: profile.name,
2052
+ key: forkName,
2053
+ profile: profile.name,
2054
+ forkedFrom: baseKey,
1935
2055
  tasks: new Map(),
1936
2056
  sessionCache: new Map(),
1937
2057
  };
@@ -1944,12 +2064,13 @@ export class BrowserService {
1944
2064
  * already resolved the endpoint and built the `effectiveProfile` with
1945
2065
  * the per-endpoint binary/targetFilter overrides applied; we just use it.
1946
2066
  *
1947
- * `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
1948
- * so per-endpoint pid/port files don't collide when the same app runs
1949
- * locally and remotely at the same time.
2067
+ * `key` is the runtime key (`<profile>@<endpoint>`) and is what every on-disk
2068
+ * lookup uses, so per-endpoint pid/port files don't collide when the same app
2069
+ * runs locally and remotely at the same time. It is a SEPARATE argument
2070
+ * because `effectiveProfile.name` stays the bare, user-facing name (RUSH-2709).
1950
2071
  */
1951
- async connectProfile(effectiveProfile, target) {
1952
- const existingInfo = getRunningChromeInfo(effectiveProfile.name);
2072
+ async connectProfile(effectiveProfile, target, key) {
2073
+ const existingInfo = getRunningChromeInfo(key);
1953
2074
  if (existingInfo) {
1954
2075
  try {
1955
2076
  const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
@@ -1957,12 +2078,13 @@ export class BrowserService {
1957
2078
  const cdp = new CDPClient();
1958
2079
  await cdp.connect(wsUrl);
1959
2080
  await this.enableDomains(cdp);
1960
- const tasks = this.loadTaskState(effectiveProfile.name);
2081
+ const tasks = this.loadTaskState(key);
1961
2082
  return {
1962
2083
  cdp,
1963
2084
  port: existingInfo.port,
1964
2085
  pid: existingInfo.pid,
1965
2086
  electron: effectiveProfile.electron,
2087
+ browserType: effectiveProfile.browser,
1966
2088
  targetFilter: effectiveProfile.targetFilter,
1967
2089
  tasks,
1968
2090
  sessionCache: new Map(),
@@ -1975,38 +2097,40 @@ export class BrowserService {
1975
2097
  // or because the OS reused the pid for an unrelated process.
1976
2098
  // Wipe the stale runtime files and fall through to a fresh
1977
2099
  // connect against the profile's currently-configured endpoint.
1978
- clearProfileRuntime(effectiveProfile.name);
2100
+ clearProfileRuntime(key);
1979
2101
  }
1980
2102
  }
1981
- const conn = await this.connectEndpoint(effectiveProfile, target);
2103
+ const conn = await this.connectEndpoint(effectiveProfile, target, key);
1982
2104
  if (!conn) {
1983
2105
  throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
1984
2106
  }
1985
2107
  return conn;
1986
2108
  }
1987
- async connectEndpoint(profile, endpoint) {
2109
+ async connectEndpoint(profile, endpoint, key) {
1988
2110
  const url = new URL(endpoint);
1989
2111
  if (url.protocol === 'cdp:') {
1990
- const conn = await connectLocal(endpoint, profile);
2112
+ const conn = await connectLocal(endpoint, profile, key);
1991
2113
  await this.enableDomains(conn.cdp);
1992
2114
  return {
1993
2115
  cdp: conn.cdp,
1994
2116
  port: conn.port,
1995
2117
  pid: conn.pid,
1996
2118
  electron: profile.electron,
2119
+ browserType: profile.browser,
1997
2120
  targetFilter: profile.targetFilter,
1998
- tasks: conn.pid === 0 ? this.loadTaskState(profile.name) : new Map(),
2121
+ tasks: conn.pid === 0 ? this.loadTaskState(key) : new Map(),
1999
2122
  sessionCache: new Map(),
2000
2123
  };
2001
2124
  }
2002
2125
  if (url.protocol === 'ssh:') {
2003
- const conn = await connectSSH(endpoint, profile);
2126
+ const conn = await connectSSH(endpoint, profile, key);
2004
2127
  await this.enableDomains(conn.cdp);
2005
2128
  return {
2006
2129
  cdp: conn.cdp,
2007
2130
  port: conn.port,
2008
2131
  pid: conn.pid,
2009
2132
  electron: profile.electron,
2133
+ browserType: profile.browser,
2010
2134
  targetFilter: profile.targetFilter,
2011
2135
  tasks: new Map(),
2012
2136
  sessionCache: new Map(),
@@ -2028,8 +2152,9 @@ export class BrowserService {
2028
2152
  port: 0,
2029
2153
  pid: 0,
2030
2154
  electron: profile.electron,
2155
+ browserType: profile.browser,
2031
2156
  targetFilter: profile.targetFilter,
2032
- tasks: this.loadTaskState(profile.name),
2157
+ tasks: this.loadTaskState(key),
2033
2158
  sessionCache: new Map(),
2034
2159
  };
2035
2160
  }
@@ -2045,8 +2170,9 @@ export class BrowserService {
2045
2170
  port,
2046
2171
  pid: 0,
2047
2172
  electron: profile.electron,
2173
+ browserType: profile.browser,
2048
2174
  targetFilter: profile.targetFilter,
2049
- tasks: this.loadTaskState(profile.name),
2175
+ tasks: this.loadTaskState(key),
2050
2176
  sessionCache: new Map(),
2051
2177
  };
2052
2178
  }
@@ -2055,6 +2181,19 @@ export class BrowserService {
2055
2181
  async enableDomains(cdp) {
2056
2182
  await cdp.send('Target.setDiscoverTargets', { discover: true });
2057
2183
  }
2184
+ /**
2185
+ * The single path that opens a new CDP page/window target. It exists so the
2186
+ * Arc guard lives in exactly one place: `Target.createTarget` crashes Arc
2187
+ * (verified, PR #2778), so an Arc connection throws a clear, actionable error
2188
+ * here instead of every call site re-checking. Every drivable Chromium-family
2189
+ * browser goes straight through.
2190
+ */
2191
+ async createPageTarget(conn, params) {
2192
+ if (conn.browserType === 'arc') {
2193
+ throw arcNotDrivableError(conn.profile);
2194
+ }
2195
+ return (await conn.cdp.send('Target.createTarget', params));
2196
+ }
2058
2197
  async getOrCreateWindow(conn) {
2059
2198
  // Already have a window for this profile?
2060
2199
  if (conn.windowId) {
@@ -2086,10 +2225,10 @@ export class BrowserService {
2086
2225
  `Available page targets:\n${candidates || ' (none)'}`);
2087
2226
  }
2088
2227
  // First ever use - create window
2089
- const result = (await conn.cdp.send('Target.createTarget', {
2228
+ const result = await this.createPageTarget(conn, {
2090
2229
  url: 'about:blank',
2091
2230
  newWindow: true,
2092
- }));
2231
+ });
2093
2232
  conn.windowId = result.targetId;
2094
2233
  return result.targetId;
2095
2234
  }
@@ -2154,7 +2293,7 @@ export class BrowserService {
2154
2293
  if (matches.length === 1) {
2155
2294
  const m = matches[0];
2156
2295
  await this.touchTask(m.conn, m.task);
2157
- return { conn: m.conn, task: m.task, profileName: m.profileName, created: false };
2296
+ return { conn: m.conn, task: m.task, key: m.key, created: false };
2158
2297
  }
2159
2298
  if (matches.length > 1) {
2160
2299
  const lines = matches.map((m) => {
@@ -2194,7 +2333,7 @@ export class BrowserService {
2194
2333
  for (const task of conn.tasks.values()) {
2195
2334
  if (hasIdentity) {
2196
2335
  if (taskMatchesCaller(task, caller)) {
2197
- out.push({ conn, task, profileName: conn.profileName ?? key });
2336
+ out.push({ conn, task, key: conn.key ?? key });
2198
2337
  }
2199
2338
  }
2200
2339
  else {
@@ -2202,7 +2341,7 @@ export class BrowserService {
2202
2341
  // (a human-driven shell). If there is exactly one live task total,
2203
2342
  // the single-task path still needs that listed — fall through below.
2204
2343
  if (!task.sessionId && !task.launchId) {
2205
- out.push({ conn, task, profileName: conn.profileName ?? key });
2344
+ out.push({ conn, task, key: conn.key ?? key });
2206
2345
  }
2207
2346
  }
2208
2347
  }
@@ -2217,7 +2356,7 @@ export class BrowserService {
2217
2356
  const conn = this.connections.get(only.profile) ??
2218
2357
  [...this.connections.entries()].find(([, c]) => c.tasks.has(only.task.name) || [...c.tasks.values()].includes(only.task))?.[1];
2219
2358
  if (conn) {
2220
- out.push({ conn, task: only.task, profileName: only.profile });
2359
+ out.push({ conn, task: only.task, key: only.profile });
2221
2360
  }
2222
2361
  }
2223
2362
  }
@@ -2268,33 +2407,34 @@ export class BrowserService {
2268
2407
  // `recordBrowserSession` call in `start` uses for the same reason.
2269
2408
  }
2270
2409
  }
2271
- async findTask(taskId, profileName) {
2272
- if (profileName) {
2410
+ async findTask(taskId, profileRef) {
2411
+ if (profileRef) {
2273
2412
  // Accept bare profile names against composite connection keys.
2274
- const keys = [...this.connections.keys()].filter((k) => k === profileName ||
2275
- connectionKeyMatchesProfile(k, profileName) ||
2276
- this.connections.get(k)?.bareName === profileName);
2413
+ // One rule: an exact runtime key, else every key belonging to that bare
2414
+ // profile. Callers pass whichever they hold — the composite is theirs to
2415
+ // ignore (RUSH-2709).
2416
+ const keys = [...this.connections.keys()].filter((k) => k === profileRef || keyBelongsToProfile(k, profileRef));
2277
2417
  for (const key of keys) {
2278
2418
  const conn = this.connections.get(key);
2279
2419
  const task = this.lookupTaskOnConn(conn, taskId);
2280
2420
  if (task) {
2281
2421
  await this.touchTask(conn, task);
2282
- return { conn, task, profileName: conn.profileName ?? key };
2422
+ return { conn, task, key: conn.key ?? key };
2283
2423
  }
2284
2424
  }
2285
2425
  // RAM miss → rehydrate from disk for this profile.
2286
- const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileName);
2426
+ const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
2287
2427
  if (rehydrated) {
2288
2428
  await this.touchTask(rehydrated.conn, rehydrated.task);
2289
2429
  return rehydrated;
2290
2430
  }
2291
- throw new Error(actionable(`Task "${taskId}" not found on profile "${profileName}".`, `Next: agents browser status --profile ${profileName}`));
2431
+ throw new Error(actionable(`Task "${taskId}" not found on profile "${profileRef}".`, `Next: agents browser status --profile ${parseConnectionKey(profileRef).profile}`));
2292
2432
  }
2293
2433
  for (const [key, conn] of this.connections) {
2294
2434
  const task = this.lookupTaskOnConn(conn, taskId);
2295
2435
  if (task) {
2296
2436
  await this.touchTask(conn, task);
2297
- return { conn, task, profileName: conn.profileName ?? key };
2437
+ return { conn, task, key: conn.key ?? key };
2298
2438
  }
2299
2439
  }
2300
2440
  // RAM miss after a daemon restart: tasks.json still has the task and
@@ -2318,11 +2458,8 @@ export class BrowserService {
2318
2458
  * Returns null when nothing is speaking CDP so callers can fall through
2319
2459
  * to disk reconcile.
2320
2460
  */
2321
- async attachRunningProfile(dirName, diskTasks) {
2322
- const bare = dirName.includes('@') ? dirName.split('@')[0] : dirName;
2323
- const endpointFromKey = dirName.includes('@')
2324
- ? dirName.slice(dirName.indexOf('@') + 1).split('.')[0]
2325
- : undefined;
2461
+ async attachRunningProfile(key, diskTasks) {
2462
+ const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
2326
2463
  const profile = await getProfile(bare);
2327
2464
  if (!profile)
2328
2465
  return null;
@@ -2333,7 +2470,7 @@ export class BrowserService {
2333
2470
  catch {
2334
2471
  return null;
2335
2472
  }
2336
- const existingInfo = getRunningChromeInfo(dirName);
2473
+ const existingInfo = getRunningChromeInfo(key);
2337
2474
  const parsed = parseEndpointUrl(resolved.target);
2338
2475
  const port = existingInfo?.port ?? parsed?.port;
2339
2476
  if (port === undefined)
@@ -2343,12 +2480,12 @@ export class BrowserService {
2343
2480
  if (resolved.target.startsWith('ssh:'))
2344
2481
  return null;
2345
2482
  try {
2346
- const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, dirName);
2483
+ const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
2347
2484
  verifyBrowserIdentity(browser, profile.browser, port, host);
2348
2485
  const cdp = new CDPClient();
2349
2486
  await cdp.connect(wsUrl);
2350
2487
  await this.enableDomains(cdp);
2351
- const tasks = this.loadTaskState(dirName);
2488
+ const tasks = this.loadTaskState(key);
2352
2489
  for (const [k, t] of diskTasks) {
2353
2490
  if (!tasks.has(k))
2354
2491
  tasks.set(k, t);
@@ -2358,9 +2495,10 @@ export class BrowserService {
2358
2495
  port,
2359
2496
  pid: existingInfo?.pid ?? 0,
2360
2497
  electron: profile.electron,
2498
+ browserType: profile.browser,
2361
2499
  targetFilter: resolved.targetFilter ?? profile.targetFilter,
2362
- profileName: dirName,
2363
- bareName: bare,
2500
+ key,
2501
+ profile: bare,
2364
2502
  tasks,
2365
2503
  sessionCache: new Map(),
2366
2504
  };
@@ -2392,21 +2530,23 @@ export class BrowserService {
2392
2530
  return;
2393
2531
  }
2394
2532
  for (const dirName of dirNames) {
2395
- if (this.connections.has(dirName))
2533
+ // A runtime dir name IS a connection key — that is what wrote it.
2534
+ const key = asConnectionKey(dirName);
2535
+ if (this.connections.has(key))
2396
2536
  continue;
2397
2537
  // Skip non-runtime dirs (e.g. profiles/)
2398
2538
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
2399
2539
  continue;
2400
- const tasks = this.loadTaskState(dirName);
2540
+ const tasks = this.loadTaskState(key);
2401
2541
  if (tasks.size === 0)
2402
2542
  continue;
2403
2543
  // Soft attach only — never launch / never clear pid files.
2404
- const conn = await this.attachRunningProfile(dirName, tasks);
2544
+ const conn = await this.attachRunningProfile(key, tasks);
2405
2545
  if (!conn)
2406
2546
  continue;
2407
- this.connections.set(dirName, conn);
2547
+ this.connections.set(key, conn);
2408
2548
  try {
2409
- await this.applyDefaultDownloadBehavior(conn, dirName);
2549
+ await this.applyDefaultDownloadBehavior(conn, key);
2410
2550
  }
2411
2551
  catch {
2412
2552
  // Non-fatal for rehydrate.
@@ -2435,12 +2575,13 @@ export class BrowserService {
2435
2575
  return null;
2436
2576
  }
2437
2577
  if (profileHint) {
2438
- dirNames = dirNames.filter((d) => d === profileHint || connectionKeyMatchesProfile(d, profileHint));
2578
+ dirNames = dirNames.filter((d) => d === profileHint || keyBelongsToProfile(d, profileHint));
2439
2579
  }
2440
2580
  for (const dirName of dirNames) {
2441
2581
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
2442
2582
  continue;
2443
- const tasks = this.loadTaskState(dirName);
2583
+ const key = asConnectionKey(dirName);
2584
+ const tasks = this.loadTaskState(key);
2444
2585
  let found;
2445
2586
  for (const task of tasks.values()) {
2446
2587
  if (task.id === taskId || task.name === taskId) {
@@ -2454,17 +2595,17 @@ export class BrowserService {
2454
2595
  if (!found)
2455
2596
  continue;
2456
2597
  // Already connected under this key?
2457
- let conn = this.connections.get(dirName);
2598
+ let conn = this.connections.get(key);
2458
2599
  if (!conn) {
2459
- const attached = await this.attachRunningProfile(dirName, tasks);
2600
+ const attached = await this.attachRunningProfile(key, tasks);
2460
2601
  if (!attached) {
2461
2602
  // CDP down — cannot rehydrate a live connection; caller will error.
2462
2603
  continue;
2463
2604
  }
2464
2605
  conn = attached;
2465
- this.connections.set(dirName, conn);
2606
+ this.connections.set(key, conn);
2466
2607
  try {
2467
- await this.applyDefaultDownloadBehavior(conn, dirName);
2608
+ await this.applyDefaultDownloadBehavior(conn, key);
2468
2609
  }
2469
2610
  catch {
2470
2611
  // Non-fatal.
@@ -2475,7 +2616,7 @@ export class BrowserService {
2475
2616
  if (!this.lookupTaskOnConn(conn, task.name)) {
2476
2617
  conn.tasks.set(task.name, task);
2477
2618
  }
2478
- return { conn, task, profileName: conn.profileName ?? dirName };
2619
+ return { conn, task, key: conn.key ?? key };
2479
2620
  }
2480
2621
  return null;
2481
2622
  }
@@ -2495,20 +2636,9 @@ export class BrowserService {
2495
2636
  }
2496
2637
  return tabs;
2497
2638
  }
2498
- async getProfileStatus(profileName) {
2499
- // Exact composite key first; bare name falls through connectionKeyMatches.
2500
- let conn = this.connections.get(profileName);
2501
- let resolvedKey = profileName;
2502
- if (!conn) {
2503
- for (const [key, c] of this.connections) {
2504
- if (connectionKeyMatchesProfile(key, profileName) ||
2505
- c.bareName === profileName) {
2506
- conn = c;
2507
- resolvedKey = key;
2508
- break;
2509
- }
2510
- }
2511
- }
2639
+ /** Live status for ONE registered connection, addressed by its runtime key. */
2640
+ async getProfileStatus(key) {
2641
+ const conn = this.connections.get(key);
2512
2642
  if (!conn)
2513
2643
  return null;
2514
2644
  // Fetch all targets once for efficiency
@@ -2554,11 +2684,14 @@ export class BrowserService {
2554
2684
  domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
2555
2685
  });
2556
2686
  }
2557
- const bare = conn.bareName ?? (resolvedKey.includes('@') ? resolvedKey.split('@')[0] : resolvedKey);
2687
+ const parsed = parseConnectionKey(key);
2688
+ const bare = conn.profile ?? parsed.profile;
2558
2689
  const profile = await getProfile(bare);
2559
2690
  const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
2560
2691
  return {
2561
- name: resolvedKey,
2692
+ name: bare,
2693
+ endpoint: parsed.endpoint,
2694
+ key,
2562
2695
  running: true,
2563
2696
  port: conn.port,
2564
2697
  pid: conn.pid,
@@ -2618,14 +2751,14 @@ export class BrowserService {
2618
2751
  invalidateTargetCache(conn) {
2619
2752
  conn.targetCache = undefined;
2620
2753
  }
2621
- async saveTaskState(profileName, tasks) {
2622
- const runtimeDir = getProfileRuntimeDir(profileName);
2754
+ async saveTaskState(key, tasks) {
2755
+ const runtimeDir = getProfileRuntimeDir(key);
2623
2756
  await fs.promises.mkdir(runtimeDir, { recursive: true });
2624
2757
  const state = Object.fromEntries(tasks);
2625
2758
  await fs.promises.writeFile(path.join(runtimeDir, 'tasks.json'), JSON.stringify(state, null, 2));
2626
2759
  }
2627
- loadTaskState(profileName) {
2628
- const runtimeDir = getProfileRuntimeDir(profileName);
2760
+ loadTaskState(key) {
2761
+ const runtimeDir = getProfileRuntimeDir(key);
2629
2762
  const tasksFile = path.join(runtimeDir, 'tasks.json');
2630
2763
  if (!fs.existsSync(tasksFile)) {
2631
2764
  return new Map();
@@ -2647,7 +2780,7 @@ export class BrowserService {
2647
2780
  tasks.set(key, {
2648
2781
  id: task.id,
2649
2782
  name: task.name || key,
2650
- profile: task.profile,
2783
+ profile: asConnectionKey(task.profile),
2651
2784
  tabs,
2652
2785
  currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
2653
2786
  createdAt: task.createdAt,
@@ -2689,7 +2822,9 @@ export class BrowserService {
2689
2822
  history.unshift({
2690
2823
  id: task.id,
2691
2824
  name: task.name,
2692
- profile: task.profile,
2825
+ // `task.profile` is the runtime key; history is a user-facing table, so
2826
+ // it carries the bare name (RUSH-2709).
2827
+ profile: parseConnectionKey(task.profile).profile,
2693
2828
  createdAt: task.createdAt,
2694
2829
  endedAt: Date.now(),
2695
2830
  domains,