@phnx-labs/agents-cli 1.22.41 → 1.22.43

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 (253) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +25 -7
  5. package/dist/cli/command-registry.d.ts +2 -3
  6. package/dist/cli/command-registry.js +4 -8
  7. package/dist/commands/accounts.d.ts +29 -0
  8. package/dist/commands/accounts.js +220 -29
  9. package/dist/commands/alias.d.ts +7 -4
  10. package/dist/commands/alias.js +34 -9
  11. package/dist/commands/auth.d.ts +9 -0
  12. package/dist/commands/auth.js +108 -0
  13. package/dist/commands/browser-sessions-picker.js +42 -62
  14. package/dist/commands/browser.js +30 -38
  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 +22 -8
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/doctor.d.ts +5 -3
  22. package/dist/commands/doctor.js +23 -8
  23. package/dist/commands/exec.js +3 -2
  24. package/dist/commands/feed.js +2 -31
  25. package/dist/commands/harness.js +3 -3
  26. package/dist/commands/hooks.js +1 -1
  27. package/dist/commands/import.js +1 -1
  28. package/dist/commands/insights.js +163 -85
  29. package/dist/commands/inspect.js +2 -2
  30. package/dist/commands/mine.js +1 -1
  31. package/dist/commands/monitors.js +1 -1
  32. package/dist/commands/org.d.ts +6 -0
  33. package/dist/commands/org.js +175 -0
  34. package/dist/commands/packages.js +1 -1
  35. package/dist/commands/profiles.js +2 -2
  36. package/dist/commands/prune.js +1 -1
  37. package/dist/commands/routines.js +44 -26
  38. package/dist/commands/run-account-picker.d.ts +19 -0
  39. package/dist/commands/run-account-picker.js +81 -0
  40. package/dist/commands/sessions-picker-factory.d.ts +40 -0
  41. package/dist/commands/sessions-picker-factory.js +67 -0
  42. package/dist/commands/sessions-picker.js +124 -122
  43. package/dist/commands/setup-browser.js +4 -4
  44. package/dist/commands/setup-mine.js +1 -1
  45. package/dist/commands/setup-preferences.js +2 -2
  46. package/dist/commands/setup.js +10 -4
  47. package/dist/commands/snapshot.d.ts +1 -1
  48. package/dist/commands/snapshot.js +4 -4
  49. package/dist/commands/status.d.ts +4 -2
  50. package/dist/commands/status.js +10 -7
  51. package/dist/commands/sync.d.ts +1 -0
  52. package/dist/commands/sync.js +30 -3
  53. package/dist/commands/versions.js +1 -1
  54. package/dist/commands/view.js +7 -1
  55. package/dist/commands/webhook.js +1 -1
  56. package/dist/lib/account-capabilities.d.ts +10 -0
  57. package/dist/lib/account-capabilities.js +34 -0
  58. package/dist/lib/accounting/rotate.d.ts +5 -3
  59. package/dist/lib/accounting/rotate.js +36 -7
  60. package/dist/lib/accounting/usage.d.ts +18 -4
  61. package/dist/lib/accounting/usage.js +138 -27
  62. package/dist/lib/agent-spec/agents.d.ts +584 -0
  63. package/dist/lib/agent-spec/agents.js +3127 -0
  64. package/dist/lib/agent-spec/provider.js +1 -1
  65. package/dist/lib/agents.d.ts +2 -584
  66. package/dist/lib/agents.js +2 -3127
  67. package/dist/lib/browser/drivers/local.d.ts +8 -2
  68. package/dist/lib/browser/drivers/local.js +8 -2
  69. package/dist/lib/browser/drivers/ssh.d.ts +8 -2
  70. package/dist/lib/browser/drivers/ssh.js +10 -4
  71. package/dist/lib/browser/hygiene.d.ts +1 -1
  72. package/dist/lib/browser/hygiene.js +5 -1
  73. package/dist/lib/browser/profiles.d.ts +74 -1
  74. package/dist/lib/browser/profiles.js +114 -5
  75. package/dist/lib/browser/runtime-state.d.ts +11 -5
  76. package/dist/lib/browser/runtime-state.js +13 -9
  77. package/dist/lib/browser/service.d.ts +68 -30
  78. package/dist/lib/browser/service.js +305 -194
  79. package/dist/lib/browser/types.d.ts +95 -3
  80. package/dist/lib/browser/types.js +51 -0
  81. package/dist/lib/catchup.js +1 -1
  82. package/dist/lib/cloud/rush.d.ts +0 -15
  83. package/dist/lib/cloud/rush.js +0 -35
  84. package/dist/lib/computer/actions.d.ts +1 -1
  85. package/dist/lib/computer/actions.js +1 -1
  86. package/dist/lib/{computer-rpc.js → computer/computer-rpc.js} +5 -5
  87. package/dist/lib/computer/dispatch.d.ts +1 -1
  88. package/dist/lib/computer/download.d.ts +1 -1
  89. package/dist/lib/computer/download.js +3 -3
  90. package/dist/lib/{ssh-tunnel.d.ts → computer/ssh-tunnel.d.ts} +1 -1
  91. package/dist/lib/{ssh-tunnel.js → computer/ssh-tunnel.js} +12 -12
  92. package/dist/lib/crabbox/runtimes.d.ts +6 -2
  93. package/dist/lib/crabbox/runtimes.js +21 -5
  94. package/dist/lib/daemon/daemon.js +87 -59
  95. package/dist/lib/{runner.d.ts → daemon/runner.d.ts} +28 -11
  96. package/dist/lib/{runner.js → daemon/runner.js} +143 -123
  97. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  98. package/dist/lib/devices/doctor-findings.js +4 -4
  99. package/dist/lib/devices/fleet-inventory.js +1 -1
  100. package/dist/lib/devices/harness-inventory.js +1 -1
  101. package/dist/lib/devices/health-report.js +1 -1
  102. package/dist/lib/devices/registry.js +4 -22
  103. package/dist/lib/doctor-diff.d.ts +1 -1
  104. package/dist/lib/doctor-diff.js +1 -1
  105. package/dist/lib/drift-sync.d.ts +1 -1
  106. package/dist/lib/drift-sync.js +2 -2
  107. package/dist/lib/drift.d.ts +1 -1
  108. package/dist/lib/drift.js +1 -1
  109. package/dist/lib/entitlement.d.ts +31 -0
  110. package/dist/lib/entitlement.js +137 -0
  111. package/dist/lib/exec.js +52 -205
  112. package/dist/lib/feed/feed.js +2 -8
  113. package/dist/lib/format.d.ts +9 -0
  114. package/dist/lib/format.js +16 -0
  115. package/dist/lib/fs-atomic.d.ts +7 -0
  116. package/dist/lib/fs-atomic.js +9 -0
  117. package/dist/lib/github/pr-mergeable.d.ts +49 -0
  118. package/dist/lib/github/pr-mergeable.js +157 -0
  119. package/dist/lib/github/pr-verdict.d.ts +67 -0
  120. package/dist/lib/github/pr-verdict.js +72 -0
  121. package/dist/lib/harness/adapter.d.ts +161 -0
  122. package/dist/lib/harness/adapter.js +47 -0
  123. package/dist/lib/harness/adapters/claude.d.ts +2 -0
  124. package/dist/lib/harness/adapters/claude.js +110 -0
  125. package/dist/lib/harness/adapters/codex.d.ts +2 -0
  126. package/dist/lib/harness/adapters/codex.js +83 -0
  127. package/dist/lib/harness/adapters/copilot.d.ts +2 -0
  128. package/dist/lib/harness/adapters/copilot.js +23 -0
  129. package/dist/lib/harness/adapters/cursor.d.ts +2 -0
  130. package/dist/lib/harness/adapters/cursor.js +38 -0
  131. package/dist/lib/harness/adapters/droid.d.ts +2 -0
  132. package/dist/lib/harness/adapters/droid.js +17 -0
  133. package/dist/lib/harness/adapters/grok.d.ts +2 -0
  134. package/dist/lib/harness/adapters/grok.js +14 -0
  135. package/dist/lib/harness/adapters/kimi.d.ts +2 -0
  136. package/dist/lib/harness/adapters/kimi.js +49 -0
  137. package/dist/lib/harness/adapters/muse.d.ts +2 -0
  138. package/dist/lib/harness/adapters/muse.js +44 -0
  139. package/dist/lib/harness/adapters/opencode.d.ts +2 -0
  140. package/dist/lib/harness/adapters/opencode.js +14 -0
  141. package/dist/lib/harness/exec-config-version.d.ts +8 -0
  142. package/dist/lib/harness/exec-config-version.js +22 -0
  143. package/dist/lib/harness/index.d.ts +1 -0
  144. package/dist/lib/harness/index.js +34 -0
  145. package/dist/lib/{hooks.d.ts → hooks/install.d.ts} +1 -1
  146. package/dist/lib/{hooks.js → hooks/install.js} +11 -11
  147. package/dist/lib/hosts/passthrough.d.ts +10 -0
  148. package/dist/lib/hosts/passthrough.js +28 -6
  149. package/dist/lib/hosts/session-index.js +1 -1
  150. package/dist/lib/import.js +1 -1
  151. package/dist/lib/installations/index.d.ts +4 -4
  152. package/dist/lib/installations/index.js +3 -4
  153. package/dist/lib/installations/migrate.js +6 -6
  154. package/dist/lib/{shims.d.ts → installations/shims.d.ts} +2 -2
  155. package/dist/lib/{shims.js → installations/shims.js} +13 -115
  156. package/dist/lib/installations/store.d.ts +141 -3
  157. package/dist/lib/installations/store.js +475 -5
  158. package/dist/lib/installations/versions.d.ts +40 -125
  159. package/dist/lib/installations/versions.js +57 -462
  160. package/dist/lib/isolation-boundary-report.d.ts +1 -1
  161. package/dist/lib/isolation-boundary-report.js +1 -1
  162. package/dist/lib/mailbox-gc.js +2 -3
  163. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  164. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  165. package/dist/lib/menubar/install-menubar.js +19 -8
  166. package/dist/lib/monitors/config.d.ts +2 -2
  167. package/dist/lib/monitors/config.js +3 -2
  168. package/dist/lib/monitors/dispatch.d.ts +1 -1
  169. package/dist/lib/monitors/dispatch.js +2 -2
  170. package/dist/lib/monitors/state.d.ts +1 -1
  171. package/dist/lib/origin-machine.d.ts +18 -0
  172. package/dist/lib/origin-machine.js +34 -0
  173. package/dist/lib/perf/db.js +1 -1
  174. package/dist/lib/perf/spool.js +1 -1
  175. package/dist/lib/plugins/plugins.js +1 -1
  176. package/dist/lib/plugins/skills.js +1 -1
  177. package/dist/lib/prix-account.d.ts +158 -0
  178. package/dist/lib/prix-account.js +214 -0
  179. package/dist/lib/profiles.d.ts +7 -0
  180. package/dist/lib/profiles.js +26 -1
  181. package/dist/lib/project-key.js +2 -2
  182. package/dist/lib/project-launch.js +1 -1
  183. package/dist/lib/refresh.js +2 -2
  184. package/dist/lib/resource-inventory.d.ts +1 -1
  185. package/dist/lib/resource-inventory.js +1 -1
  186. package/dist/lib/rules/rules.js +1 -1
  187. package/dist/lib/sandbox.d.ts +24 -0
  188. package/dist/lib/sandbox.js +101 -0
  189. package/dist/lib/scheduling/routines.d.ts +18 -9
  190. package/dist/lib/scheduling/routines.js +17 -9
  191. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  192. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  193. package/dist/lib/secrets/agent.js +9 -1
  194. package/dist/lib/secrets/index.js +1 -1
  195. package/dist/lib/secrets/list-filter.js +1 -1
  196. package/dist/lib/self-heal/checks/hook-runtime.js +1 -1
  197. package/dist/lib/self-heal/checks/path.js +1 -1
  198. package/dist/lib/self-heal/checks/shadowing.js +1 -1
  199. package/dist/lib/self-heal/checks/shims.js +1 -1
  200. package/dist/lib/service-manifest.d.ts +20 -0
  201. package/dist/lib/service-manifest.js +34 -0
  202. package/dist/lib/session/cloud.js +1 -1
  203. package/dist/lib/session/db.js +1 -1
  204. package/dist/lib/session/discover.d.ts +39 -0
  205. package/dist/lib/session/discover.js +3 -3
  206. package/dist/lib/session/fork.js +1 -1
  207. package/dist/lib/session/live-metadata.js +1 -1
  208. package/dist/lib/session/origin-machine.d.ts +1 -18
  209. package/dist/lib/session/origin-machine.js +4 -34
  210. package/dist/lib/session/parse.js +24 -42
  211. package/dist/lib/session/recovery.js +1 -1
  212. package/dist/lib/session/relative-time.d.ts +1 -44
  213. package/dist/lib/session/relative-time.js +4 -120
  214. package/dist/lib/session/remote-list.js +1 -1
  215. package/dist/lib/session/remote.js +2 -2
  216. package/dist/lib/session/short-id.d.ts +1 -17
  217. package/dist/lib/session/short-id.js +4 -20
  218. package/dist/lib/session/width.d.ts +1 -29
  219. package/dist/lib/session/width.js +4 -101
  220. package/dist/lib/snapshot.d.ts +2 -2
  221. package/dist/lib/snapshot.js +1 -1
  222. package/dist/lib/staleness/prune.d.ts +1 -1
  223. package/dist/lib/staleness/prune.js +1 -1
  224. package/dist/lib/staleness/writers/hooks.js +1 -1
  225. package/dist/lib/startup/command-registry.d.ts +4 -1
  226. package/dist/lib/startup/command-registry.js +12 -5
  227. package/dist/lib/teams/agents.js +2 -19
  228. package/dist/lib/teams/parsers.js +20 -26
  229. package/dist/lib/teams/placement-probe.js +1 -1
  230. package/dist/lib/teams/registry.js +2 -21
  231. package/dist/lib/teams/scheduler.js +1 -1
  232. package/dist/lib/text/relative-time.d.ts +44 -0
  233. package/dist/lib/text/relative-time.js +120 -0
  234. package/dist/lib/text/short-id.d.ts +17 -0
  235. package/dist/lib/text/short-id.js +20 -0
  236. package/dist/lib/text/width.d.ts +29 -0
  237. package/dist/lib/text/width.js +101 -0
  238. package/dist/lib/triggers/handlers.d.ts +7 -0
  239. package/dist/lib/triggers/handlers.js +9 -1
  240. package/dist/lib/triggers/webhook.d.ts +1 -0
  241. package/dist/lib/triggers/webhook.js +3 -2
  242. package/dist/lib/uninstall.js +1 -1
  243. package/dist/lib/version.d.ts +9 -2
  244. package/dist/lib/version.js +11 -9
  245. package/dist/lib/view-types.d.ts +4 -0
  246. package/package.json +1 -1
  247. package/dist/commands/tickets.d.ts +0 -2
  248. package/dist/commands/tickets.js +0 -43
  249. package/dist/lib/observe-aliases.d.ts +0 -30
  250. package/dist/lib/observe-aliases.js +0 -38
  251. package/dist/lib/tickets/list.d.ts +0 -53
  252. package/dist/lib/tickets/list.js +0 -153
  253. /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';
@@ -272,14 +272,6 @@ export function deriveTaskLabel(opts) {
272
272
  }
273
273
  return opts.existing ?? 'untitled';
274
274
  }
275
- /**
276
- * True when `key` is the bare profile name or one of its composite endpoint
277
- * keys (`name@endpoint`). Used by status/stop so `comet-local` matches
278
- * `comet-local@endpoint-0`.
279
- */
280
- export function connectionKeyMatchesProfile(key, profileName) {
281
- return key === profileName || key.startsWith(`${profileName}@`) || key.startsWith(`${profileName}.`);
282
- }
283
275
  /**
284
276
  * Resolve the identity stamped on a task at start: WHO (`owner`) and WHICH run
285
277
  * (`launchId`). The forwarded values come from the caller's own CLI process and
@@ -301,6 +293,12 @@ export class BrowserService {
301
293
  'rush-app': 'rush-app-',
302
294
  'rush-cli': 'rush-cli-',
303
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
+ */
304
302
  connections = new Map();
305
303
  forkingProfiles = new Set();
306
304
  // Per-task storage for console, errors, network, downloads
@@ -319,19 +317,18 @@ export class BrowserService {
319
317
  throw new Error(`Profile "${profileName}" not found`);
320
318
  }
321
319
  // Pick the endpoint preset. Throws with the candidate list if the user
322
- // passed an unknown name. The composite identifier `<profile>@<endpoint>`
323
- // is what the connection map + per-profile runtime dirs are keyed on, so
324
- // 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.
325
323
  //
326
- // The YAML profile's bare `name` stays on `profile.name` for status
327
- // 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.
328
328
  const resolved = resolveEndpoint(profile, opts.endpointName);
329
- const composite = `${profileName}@${resolved.name}`;
329
+ const composite = connectionKey(profileName, resolved.name);
330
330
  const effectiveProfile = {
331
331
  ...profile,
332
- // Runtime/connect path key — NOT the bare YAML name. Connections and
333
- // tasks.json live under this composite so multi-endpoint is safe.
334
- name: composite,
335
332
  binary: resolved.binary,
336
333
  targetFilter: resolved.targetFilter,
337
334
  };
@@ -349,7 +346,7 @@ export class BrowserService {
349
346
  }
350
347
  const taskLabel = deriveTaskLabel({ title: opts.title, url: opts.url });
351
348
  let conn = this.connections.get(composite);
352
- let effectiveProfileName = composite;
349
+ let effectiveKey = composite;
353
350
  // If we have a cached connection, confirm it's still usable before any
354
351
  // caller relies on it. A browser killed externally (Cmd-Q, crash, or a
355
352
  // user clearing the profile by hand) leaves a closed WebSocket here.
@@ -372,7 +369,7 @@ export class BrowserService {
372
369
  const existingFork = this.findAvailableFork(composite);
373
370
  if (existingFork) {
374
371
  conn = existingFork.conn;
375
- effectiveProfileName = existingFork.name;
372
+ effectiveKey = existingFork.name;
376
373
  }
377
374
  else {
378
375
  throw new Error(`Fork in progress but no available fork found for "${composite}"`);
@@ -381,9 +378,9 @@ export class BrowserService {
381
378
  else {
382
379
  this.forkingProfiles.add(composite);
383
380
  try {
384
- const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
381
+ const { forkName, connection } = await this.forkElectronProfile(effectiveProfile, composite);
385
382
  conn = connection;
386
- effectiveProfileName = forkName;
383
+ effectiveKey = forkName;
387
384
  }
388
385
  finally {
389
386
  this.forkingProfiles.delete(composite);
@@ -391,14 +388,14 @@ export class BrowserService {
391
388
  }
392
389
  }
393
390
  else if (!conn) {
394
- conn = await this.connectProfile(effectiveProfile, resolved.target);
395
- conn.profileName = composite;
396
- conn.bareName = profileName;
391
+ conn = await this.connectProfile(effectiveProfile, resolved.target, composite);
392
+ conn.key = composite;
393
+ conn.profile = profileName;
397
394
  this.connections.set(composite, conn);
398
395
  await this.applyDefaultDownloadBehavior(conn, composite);
399
396
  }
400
- if (conn && !conn.bareName)
401
- conn.bareName = profileName;
397
+ if (conn && !conn.profile)
398
+ conn.profile = profileName;
402
399
  // Browsers launch with --no-startup-window (session-cookie persistence,
403
400
  // see launchBrowser), so a bare `start` with no --url would otherwise
404
401
  // leave the user staring at a process with zero windows. Recreate the
@@ -427,7 +424,7 @@ export class BrowserService {
427
424
  id: taskId,
428
425
  name: taskName,
429
426
  label: taskLabel,
430
- profile: effectiveProfileName,
427
+ profile: effectiveKey,
431
428
  tabs: {},
432
429
  currentTabId: undefined,
433
430
  createdAt: now,
@@ -456,7 +453,7 @@ export class BrowserService {
456
453
  }
457
454
  }
458
455
  conn.tasks.set(taskName, task);
459
- await this.saveTaskState(effectiveProfileName, conn.tasks);
456
+ await this.saveTaskState(effectiveKey, conn.tasks);
460
457
  // Durable identity, written ONCE at start and never deleted (RUSH-2549).
461
458
  // tasks.json above stays live state: `stop` drops the task from that map, so
462
459
  // anything recorded only there is gone the moment the task ends -- which is
@@ -470,22 +467,23 @@ export class BrowserService {
470
467
  try {
471
468
  recordBrowserSession({
472
469
  task: taskName,
473
- profile: effectiveProfileName,
470
+ profile: effectiveKey,
474
471
  sessionId: task.sessionId,
475
472
  launchId: task.launchId,
476
473
  actor: task.owner,
477
474
  startedAt: task.createdAt,
478
- captureDir: getProfileSessionsDir(effectiveProfileName, taskName),
475
+ captureDir: getProfileSessionsDir(effectiveKey, taskName),
479
476
  });
480
477
  }
481
478
  catch {
482
479
  // Recording is best-effort; the task itself is live either way.
483
480
  }
484
- 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 });
485
483
  void import('../analytics/usage-db.js').then(({ recordUsage }) => {
486
484
  recordUsage({
487
485
  kind: 'browser',
488
- name: effectiveProfileName,
486
+ name: profileName,
489
487
  event: 'launch',
490
488
  source: 'browser',
491
489
  meta: { task: taskName },
@@ -501,11 +499,11 @@ export class BrowserService {
501
499
  task.tabs[shortId] = targetId;
502
500
  task.currentTabId = shortId;
503
501
  this.invalidateTargetCache(conn);
504
- await this.saveTaskState(effectiveProfileName, conn.tasks);
502
+ await this.saveTaskState(effectiveKey, conn.tasks);
505
503
  tabId = shortId;
506
504
  }
507
505
  else if (opts.url && conn.electron) {
508
- const result = await this.navigate(taskName, opts.url, effectiveProfileName);
506
+ const result = await this.navigate(taskName, opts.url, effectiveKey);
509
507
  tabId = result.tabId;
510
508
  }
511
509
  // Domain-skill discovery: when a URL is supplied, look up site-specific
@@ -517,7 +515,7 @@ export class BrowserService {
517
515
  if (resolved)
518
516
  skill = resolved;
519
517
  }
520
- return { task: taskId, name: taskName, tabId, profile: effectiveProfileName, skill };
518
+ return { task: taskId, name: taskName, tabId, profile: profileName, key: effectiveKey, skill };
521
519
  }
522
520
  /**
523
521
  * Reclaim a live page already showing `url` from an ABANDONED task, instead
@@ -551,6 +549,50 @@ export class BrowserService {
551
549
  * page that has since redirected elsewhere simply does not match, and the
552
550
  * caller opens a tab as before.
553
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
+ }
554
596
  async adoptTabShowing(conn, url) {
555
597
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));
556
598
  const wanted = canonicalTabUrl(url);
@@ -561,7 +603,15 @@ export class BrowserService {
561
603
  return undefined;
562
604
  const candidates = [];
563
605
  for (const task of conn.tasks.values()) {
606
+ const borrowed = new Set(task.borrowedTabs ?? []);
564
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;
565
615
  if (matching.has(cdpId))
566
616
  candidates.push({ task, shortId, targetId: cdpId });
567
617
  }
@@ -610,7 +660,7 @@ export class BrowserService {
610
660
  const out = [];
611
661
  for (const [key, conn] of this.connections) {
612
662
  for (const task of conn.tasks.values()) {
613
- out.push({ profile: conn.profileName ?? key, task });
663
+ out.push({ profile: conn.key ?? key, task });
614
664
  }
615
665
  }
616
666
  return out;
@@ -631,7 +681,7 @@ export class BrowserService {
631
681
  catch {
632
682
  // findTask throws when truly gone; fall through to the miss return.
633
683
  }
634
- for (const [profileName, conn] of this.connections) {
684
+ for (const [key, conn] of this.connections) {
635
685
  const task = this.lookupTaskOnConn(conn, taskName);
636
686
  if (task) {
637
687
  // Map key may be task.name, not the caller-supplied handle.
@@ -659,8 +709,9 @@ export class BrowserService {
659
709
  }
660
710
  // Save to history before closing
661
711
  await this.saveToHistory(task, Array.from(domains));
662
- // Close task's tabs (not the window - it's shared)
663
- 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(() => {
664
715
  // Tab already closed
665
716
  })));
666
717
  for (const cdpId of Object.values(task.tabs)) {
@@ -668,12 +719,14 @@ export class BrowserService {
668
719
  }
669
720
  this.invalidateTargetCache(conn);
670
721
  conn.tasks.delete(mapKey);
671
- await this.saveTaskState(profileName, conn.tasks);
672
- 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 });
673
726
  void import('../analytics/usage-db.js').then(({ recordUsage }) => {
674
727
  recordUsage({
675
728
  kind: 'browser',
676
- name: profileName,
729
+ name: bareProfile,
677
730
  event: 'close',
678
731
  source: 'browser',
679
732
  meta: { task: task.name },
@@ -683,10 +736,10 @@ export class BrowserService {
683
736
  conn.cdp.close();
684
737
  killChrome(conn.pid);
685
738
  conn.cleanup?.();
686
- this.connections.delete(profileName);
687
- clearProfileRuntime(profileName);
739
+ this.connections.delete(key);
740
+ clearProfileRuntime(key);
688
741
  }
689
- return { ok: true, profile: profileName };
742
+ return { ok: true, profile: bareProfile };
690
743
  }
691
744
  }
692
745
  return { ok: false };
@@ -694,16 +747,15 @@ export class BrowserService {
694
747
  async done(taskName) {
695
748
  return this.stop(taskName);
696
749
  }
697
- async stopProfile(profileName) {
698
- // Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
699
- // but callers pass the bare profile name (or, occasionally, an exact composite).
700
- // A plain `connections.get(profileName)` therefore missed every real remote
701
- // connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
702
- // Windows, the WMI-spawned browser orphaned on the remote host after every
703
- // `browser stop --profile` (#559). Match the exact key AND every
704
- // `<profileName>@<endpoint>` composite so all of a profile's live connections
705
- // are torn down.
706
- 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));
707
759
  for (const key of keys) {
708
760
  const conn = this.connections.get(key);
709
761
  if (!conn)
@@ -717,7 +769,7 @@ export class BrowserService {
717
769
  // Kill stale processes and clean runtime dirs for every composite and
718
770
  // fork entry belonging to this profile (including `.N` forks left by
719
771
  // earlier daemon sessions that the connection loop above didn't cover).
720
- for (const dir of listProfileCacheDirs(profileName)) {
772
+ for (const dir of listProfileCacheDirs(parseConnectionKey(profileRef).profile)) {
721
773
  const dirName = path.basename(dir);
722
774
  const meta = readProfileRuntimeMeta(dirName);
723
775
  if (meta?.pid && meta.pid !== 0 && isProcessAlive(meta.pid, meta.command)) {
@@ -726,8 +778,8 @@ export class BrowserService {
726
778
  clearProfileRuntime(dirName);
727
779
  }
728
780
  }
729
- async navigate(taskId, url, profileName) {
730
- const { conn, task } = await this.findTask(taskId, profileName);
781
+ async navigate(taskId, url, profileRef) {
782
+ const { conn, task } = await this.findTask(taskId, profileRef);
731
783
  this.maybeUpdateLabelFromUrl(conn, task, url);
732
784
  // If we have a current tab, navigate in it (reuse)
733
785
  const currentShortId = task.currentTabId;
@@ -736,7 +788,7 @@ export class BrowserService {
736
788
  const sessionId = await this.getSessionId(conn, cdpTargetId);
737
789
  await conn.cdp.send('Page.navigate', { url }, sessionId);
738
790
  await this.saveTaskState(task.profile, conn.tasks);
739
- 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 });
740
792
  return { tabId: currentShortId, url, created: false };
741
793
  }
742
794
  // No current tab - create one
@@ -751,9 +803,61 @@ export class BrowserService {
751
803
  task.tabs[shortId] = cdpTargetId;
752
804
  task.currentTabId = shortId;
753
805
  await this.saveTaskState(task.profile, conn.tasks);
754
- 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 });
755
807
  return { tabId: shortId, url, created: true };
756
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
+ }
757
861
  // Chrome: create new tab
758
862
  const result = await this.createPageTarget(conn, { url });
759
863
  const shortId = generateShortId();
@@ -761,11 +865,11 @@ export class BrowserService {
761
865
  task.currentTabId = shortId;
762
866
  this.invalidateTargetCache(conn);
763
867
  await this.saveTaskState(task.profile, conn.tasks);
764
- 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 });
765
869
  return { tabId: shortId, url, created: true };
766
870
  }
767
- async tabAdd(taskId, url, profileName) {
768
- const { conn, task } = await this.findTask(taskId, profileName);
871
+ async tabAdd(taskId, url, profileRef) {
872
+ const { conn, task } = await this.findTask(taskId, profileRef);
769
873
  if (conn.electron) {
770
874
  throw new Error('Electron apps do not support opening additional tabs');
771
875
  }
@@ -841,9 +945,9 @@ export class BrowserService {
841
945
  throw new Error(`Tab ${shortId} not found`);
842
946
  return cdpId;
843
947
  }
844
- async tabs(taskId, profileName) {
948
+ async tabs(taskId, profileRef) {
845
949
  if (taskId) {
846
- const { conn, task } = await this.findTask(taskId, profileName);
950
+ const { conn, task } = await this.findTask(taskId, profileRef);
847
951
  return this.getTabsForTask(conn.cdp, task);
848
952
  }
849
953
  const allTabs = [];
@@ -861,9 +965,15 @@ export class BrowserService {
861
965
  const shortId = await this.resolveTabHint(conn, task, tabHint);
862
966
  const cdpId = task.tabs[shortId];
863
967
  if (cdpId) {
864
- 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
+ }
865
972
  conn.sessionCache.delete(cdpId);
866
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);
867
977
  // Update currentTabId if we closed the current tab
868
978
  if (task.currentTabId === shortId) {
869
979
  const remaining = Object.keys(task.tabs);
@@ -872,12 +982,13 @@ export class BrowserService {
872
982
  }
873
983
  }
874
984
  else {
875
- // Close all tabs
876
- 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(() => { })));
877
987
  for (const cdpId of Object.values(task.tabs)) {
878
988
  conn.sessionCache.delete(cdpId);
879
989
  }
880
990
  task.tabs = {};
991
+ task.borrowedTabs = [];
881
992
  task.currentTabId = undefined;
882
993
  }
883
994
  this.invalidateTargetCache(conn);
@@ -914,7 +1025,7 @@ export class BrowserService {
914
1025
  return result.result.value;
915
1026
  }
916
1027
  async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
917
- const { conn, task, profileName } = await this.findTask(taskId);
1028
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
918
1029
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
919
1030
  const cdpTargetId = this.getCdpTargetId(task, shortId);
920
1031
  const target = await this.getTarget(conn, cdpTargetId);
@@ -947,7 +1058,7 @@ export class BrowserService {
947
1058
  }
948
1059
  extension = 'jpg';
949
1060
  }
950
- const sessionsDir = getProfileSessionsDir(profileName, task.name);
1061
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
951
1062
  const automaticPath = path.join(sessionsDir, `${Date.now()}.${extension}`);
952
1063
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
953
1064
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -955,7 +1066,7 @@ export class BrowserService {
955
1066
  const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
956
1067
  { width: 0, height: 0 };
957
1068
  emit('browser.screenshot', {
958
- profile: profileName,
1069
+ profile: parseConnectionKey(runtimeKey).profile,
959
1070
  task: task.name,
960
1071
  tabId: shortId,
961
1072
  path: finalPath,
@@ -974,7 +1085,7 @@ export class BrowserService {
974
1085
  * — without it, dark-mode pages render on a blank sheet.
975
1086
  */
976
1087
  async printToPdf(taskId, tabHint, outputPath) {
977
- const { conn, task, profileName } = await this.findTask(taskId);
1088
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
978
1089
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
979
1090
  const cdpTargetId = this.getCdpTargetId(task, shortId);
980
1091
  const target = await this.getTarget(conn, cdpTargetId);
@@ -984,7 +1095,7 @@ export class BrowserService {
984
1095
  const sessionId = await this.getSessionId(conn, target.targetId);
985
1096
  const { data } = (await conn.cdp.send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true }, sessionId));
986
1097
  const buffer = Buffer.from(data, 'base64');
987
- const sessionsDir = getProfileSessionsDir(profileName, task.name);
1098
+ const sessionsDir = getProfileSessionsDir(runtimeKey, task.name);
988
1099
  const automaticPath = path.join(sessionsDir, `${Date.now()}.pdf`);
989
1100
  const finalPath = resolveScreenshotOutputPath(outputPath, automaticPath);
990
1101
  await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
@@ -1002,7 +1113,7 @@ export class BrowserService {
1002
1113
  if (this.recordings.has(taskId)) {
1003
1114
  throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
1004
1115
  }
1005
- const { conn, task, profileName } = await this.findTask(taskId);
1116
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1006
1117
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1007
1118
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1008
1119
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1018,7 +1129,7 @@ export class BrowserService {
1018
1129
  throw new Error('--duration must be between 1 and 3600 seconds');
1019
1130
  if (maxMb < 1 || maxMb > 500)
1020
1131
  throw new Error('--max-mb must be between 1 and 500');
1021
- const recordingsDir = path.join(getProfileSessionsDir(profileName, task.name), 'recordings');
1132
+ const recordingsDir = path.join(getProfileSessionsDir(runtimeKey, task.name), 'recordings');
1022
1133
  await fs.promises.mkdir(recordingsDir, { recursive: true });
1023
1134
  const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
1024
1135
  // Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
@@ -1412,34 +1523,37 @@ export class BrowserService {
1412
1523
  stageUpload(source) {
1413
1524
  return { path: stageUploadFile(source) };
1414
1525
  }
1415
- 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) {
1416
1536
  // Reconnect live browsers after a daemon restart so status is not empty
1417
1537
  // while tasks.json still holds open work.
1418
1538
  await this.rehydrateAllFromDisk();
1419
- 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();
1420
1543
  const statuses = [];
1421
- // Connections are keyed by composite `<profile>@<endpoint>`. A bare
1422
- // `--profile comet-local` must still list those tasks (same rule as
1423
- // stopProfile). Match exact key, bareName, and composite/fork prefixes.
1424
1544
  const candidates = profileName
1425
- ? [...this.connections.keys()].filter((k) => connectionKeyMatchesProfile(k, profileName) ||
1426
- this.connections.get(k)?.bareName === profileName)
1545
+ ? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
1427
1546
  : Array.from(this.connections.keys());
1428
- for (const name of candidates) {
1429
- const status = await this.getProfileStatus(name);
1547
+ for (const key of candidates) {
1548
+ const status = await this.getProfileStatus(key);
1430
1549
  if (status) {
1431
1550
  statuses.push(status);
1432
- seen.add(name);
1433
- if (status.name)
1434
- seen.add(status.name);
1551
+ seenProfiles.add(status.name);
1435
1552
  }
1436
1553
  }
1437
1554
  if (!profileName) {
1438
- const profiles = await listProfiles();
1439
- for (const profile of profiles) {
1440
- if (seen.has(profile.name))
1441
- continue;
1442
- if ([...seen].some((s) => connectionKeyMatchesProfile(s, profile.name)))
1555
+ for (const profile of await listProfiles()) {
1556
+ if (seenProfiles.has(profile.name))
1443
1557
  continue;
1444
1558
  const reconciled = await this.reconcileFromDisk(profile.name);
1445
1559
  if (reconciled)
@@ -1453,31 +1567,28 @@ export class BrowserService {
1453
1567
  }
1454
1568
  return statuses;
1455
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
+ */
1456
1579
  async reconcileFromDisk(profileName) {
1457
- // Runtime dirs live under the composite (`comet-local@endpoint-0`), not the
1458
- // bare YAML name. Walk every matching cache dir so bare `status --profile`
1459
- // sees the same tasks as unscoped `status`.
1460
1580
  const dirs = listProfileCacheDirs(profileName);
1461
- if (dirs.length === 0) {
1462
- // Legacy: non-composite dir named exactly the bare profile.
1463
- const info = getRunningChromeInfo(profileName);
1464
- if (!info)
1465
- return null;
1466
- return this.profileStatusFromDisk(profileName, profileName, info.port, info.pid);
1467
- }
1468
- // Prefer a dir that still has a live browser; fall back to the first.
1469
1581
  for (const dir of dirs) {
1470
- const dirName = path.basename(dir);
1471
- const info = getRunningChromeInfo(dirName);
1582
+ const key = asConnectionKey(path.basename(dir));
1583
+ const info = getRunningChromeInfo(key);
1472
1584
  if (!info)
1473
1585
  continue;
1474
- return this.profileStatusFromDisk(profileName, dirName, info.port, info.pid);
1586
+ return this.profileStatusFromDisk(profileName, key, info.port, info.pid);
1475
1587
  }
1476
1588
  return null;
1477
1589
  }
1478
- async profileStatusFromDisk(displayName, runtimeKey, port, pid) {
1479
- const bare = displayName.includes('@') ? displayName.split('@')[0] : displayName;
1480
- const profile = await getProfile(bare);
1590
+ async profileStatusFromDisk(profileName, runtimeKey, port, pid) {
1591
+ const profile = await getProfile(profileName);
1481
1592
  const tasks = this.loadTaskState(runtimeKey);
1482
1593
  const taskStatuses = [];
1483
1594
  for (const [, task] of tasks) {
@@ -1492,7 +1603,10 @@ export class BrowserService {
1492
1603
  }
1493
1604
  const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
1494
1605
  return {
1495
- 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,
1496
1610
  running: true,
1497
1611
  port,
1498
1612
  pid,
@@ -1804,8 +1918,8 @@ export class BrowserService {
1804
1918
  * effort: a remote CDP endpoint that doesn't expose the Browser domain must not
1805
1919
  * fail the whole connect.
1806
1920
  */
1807
- async applyDefaultDownloadBehavior(conn, profileName) {
1808
- const downloadPath = getProfileDownloadsDir(profileName);
1921
+ async applyDefaultDownloadBehavior(conn, key) {
1922
+ const downloadPath = getProfileDownloadsDir(key);
1809
1923
  try {
1810
1924
  await fs.promises.mkdir(downloadPath, { recursive: true });
1811
1925
  await conn.cdp.send('Browser.setDownloadBehavior', {
@@ -1821,7 +1935,7 @@ export class BrowserService {
1821
1935
  }
1822
1936
  }
1823
1937
  async setDownloadPath(taskId, downloadPath, tabHint) {
1824
- const { conn, task, profileName } = await this.findTask(taskId);
1938
+ const { conn, task, key: runtimeKey } = await this.findTask(taskId);
1825
1939
  const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
1826
1940
  const cdpTargetId = this.getCdpTargetId(task, shortId);
1827
1941
  const target = await this.getTarget(conn, cdpTargetId);
@@ -1830,7 +1944,7 @@ export class BrowserService {
1830
1944
  const sessionId = await this.getSessionId(conn, target.targetId);
1831
1945
  // No explicit --path: fall back to the profile's downloads dir, the same
1832
1946
  // destination already set browser-global at connect (applyDefaultDownloadBehavior).
1833
- const resolvedPath = downloadPath ?? getProfileDownloadsDir(profileName);
1947
+ const resolvedPath = downloadPath ?? getProfileDownloadsDir(runtimeKey);
1834
1948
  await fs.promises.mkdir(resolvedPath, { recursive: true });
1835
1949
  await conn.cdp.send('Browser.setDownloadBehavior', {
1836
1950
  behavior: 'allow',
@@ -1908,20 +2022,20 @@ export class BrowserService {
1908
2022
  }
1909
2023
  this.connections.clear();
1910
2024
  }
1911
- findAvailableFork(profileName) {
2025
+ findAvailableFork(baseKey) {
1912
2026
  for (const [name, conn] of this.connections) {
1913
- if (conn.forkedFrom === profileName && conn.tasks.size === 0) {
2027
+ if (conn.forkedFrom === baseKey && conn.tasks.size === 0) {
1914
2028
  return { name, conn };
1915
2029
  }
1916
2030
  }
1917
2031
  return null;
1918
2032
  }
1919
- async forkElectronProfile(profile) {
2033
+ async forkElectronProfile(profile, baseKey) {
1920
2034
  let forkNum = 2;
1921
- while (this.connections.has(`${profile.name}.${forkNum}`)) {
2035
+ while (this.connections.has(asConnectionKey(`${baseKey}.${forkNum}`))) {
1922
2036
  forkNum++;
1923
2037
  }
1924
- const forkName = `${profile.name}.${forkNum}`;
2038
+ const forkName = asConnectionKey(`${baseKey}.${forkNum}`);
1925
2039
  const port = allocatePort();
1926
2040
  const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
1927
2041
  const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
@@ -1935,8 +2049,9 @@ export class BrowserService {
1935
2049
  electron: true,
1936
2050
  browserType: profile.browser,
1937
2051
  targetFilter: profile.targetFilter,
1938
- profileName: forkName,
1939
- forkedFrom: profile.name,
2052
+ key: forkName,
2053
+ profile: profile.name,
2054
+ forkedFrom: baseKey,
1940
2055
  tasks: new Map(),
1941
2056
  sessionCache: new Map(),
1942
2057
  };
@@ -1949,12 +2064,13 @@ export class BrowserService {
1949
2064
  * already resolved the endpoint and built the `effectiveProfile` with
1950
2065
  * the per-endpoint binary/targetFilter overrides applied; we just use it.
1951
2066
  *
1952
- * `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
1953
- * so per-endpoint pid/port files don't collide when the same app runs
1954
- * 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).
1955
2071
  */
1956
- async connectProfile(effectiveProfile, target) {
1957
- const existingInfo = getRunningChromeInfo(effectiveProfile.name);
2072
+ async connectProfile(effectiveProfile, target, key) {
2073
+ const existingInfo = getRunningChromeInfo(key);
1958
2074
  if (existingInfo) {
1959
2075
  try {
1960
2076
  const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port, 'localhost', effectiveProfile.name);
@@ -1962,7 +2078,7 @@ export class BrowserService {
1962
2078
  const cdp = new CDPClient();
1963
2079
  await cdp.connect(wsUrl);
1964
2080
  await this.enableDomains(cdp);
1965
- const tasks = this.loadTaskState(effectiveProfile.name);
2081
+ const tasks = this.loadTaskState(key);
1966
2082
  return {
1967
2083
  cdp,
1968
2084
  port: existingInfo.port,
@@ -1981,19 +2097,19 @@ export class BrowserService {
1981
2097
  // or because the OS reused the pid for an unrelated process.
1982
2098
  // Wipe the stale runtime files and fall through to a fresh
1983
2099
  // connect against the profile's currently-configured endpoint.
1984
- clearProfileRuntime(effectiveProfile.name);
2100
+ clearProfileRuntime(key);
1985
2101
  }
1986
2102
  }
1987
- const conn = await this.connectEndpoint(effectiveProfile, target);
2103
+ const conn = await this.connectEndpoint(effectiveProfile, target, key);
1988
2104
  if (!conn) {
1989
2105
  throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
1990
2106
  }
1991
2107
  return conn;
1992
2108
  }
1993
- async connectEndpoint(profile, endpoint) {
2109
+ async connectEndpoint(profile, endpoint, key) {
1994
2110
  const url = new URL(endpoint);
1995
2111
  if (url.protocol === 'cdp:') {
1996
- const conn = await connectLocal(endpoint, profile);
2112
+ const conn = await connectLocal(endpoint, profile, key);
1997
2113
  await this.enableDomains(conn.cdp);
1998
2114
  return {
1999
2115
  cdp: conn.cdp,
@@ -2002,12 +2118,12 @@ export class BrowserService {
2002
2118
  electron: profile.electron,
2003
2119
  browserType: profile.browser,
2004
2120
  targetFilter: profile.targetFilter,
2005
- tasks: conn.pid === 0 ? this.loadTaskState(profile.name) : new Map(),
2121
+ tasks: conn.pid === 0 ? this.loadTaskState(key) : new Map(),
2006
2122
  sessionCache: new Map(),
2007
2123
  };
2008
2124
  }
2009
2125
  if (url.protocol === 'ssh:') {
2010
- const conn = await connectSSH(endpoint, profile);
2126
+ const conn = await connectSSH(endpoint, profile, key);
2011
2127
  await this.enableDomains(conn.cdp);
2012
2128
  return {
2013
2129
  cdp: conn.cdp,
@@ -2038,7 +2154,7 @@ export class BrowserService {
2038
2154
  electron: profile.electron,
2039
2155
  browserType: profile.browser,
2040
2156
  targetFilter: profile.targetFilter,
2041
- tasks: this.loadTaskState(profile.name),
2157
+ tasks: this.loadTaskState(key),
2042
2158
  sessionCache: new Map(),
2043
2159
  };
2044
2160
  }
@@ -2056,7 +2172,7 @@ export class BrowserService {
2056
2172
  electron: profile.electron,
2057
2173
  browserType: profile.browser,
2058
2174
  targetFilter: profile.targetFilter,
2059
- tasks: this.loadTaskState(profile.name),
2175
+ tasks: this.loadTaskState(key),
2060
2176
  sessionCache: new Map(),
2061
2177
  };
2062
2178
  }
@@ -2074,7 +2190,7 @@ export class BrowserService {
2074
2190
  */
2075
2191
  async createPageTarget(conn, params) {
2076
2192
  if (conn.browserType === 'arc') {
2077
- throw arcNotDrivableError(conn.bareName ?? conn.profileName);
2193
+ throw arcNotDrivableError(conn.profile);
2078
2194
  }
2079
2195
  return (await conn.cdp.send('Target.createTarget', params));
2080
2196
  }
@@ -2177,7 +2293,7 @@ export class BrowserService {
2177
2293
  if (matches.length === 1) {
2178
2294
  const m = matches[0];
2179
2295
  await this.touchTask(m.conn, m.task);
2180
- 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 };
2181
2297
  }
2182
2298
  if (matches.length > 1) {
2183
2299
  const lines = matches.map((m) => {
@@ -2217,7 +2333,7 @@ export class BrowserService {
2217
2333
  for (const task of conn.tasks.values()) {
2218
2334
  if (hasIdentity) {
2219
2335
  if (taskMatchesCaller(task, caller)) {
2220
- out.push({ conn, task, profileName: conn.profileName ?? key });
2336
+ out.push({ conn, task, key: conn.key ?? key });
2221
2337
  }
2222
2338
  }
2223
2339
  else {
@@ -2225,7 +2341,7 @@ export class BrowserService {
2225
2341
  // (a human-driven shell). If there is exactly one live task total,
2226
2342
  // the single-task path still needs that listed — fall through below.
2227
2343
  if (!task.sessionId && !task.launchId) {
2228
- out.push({ conn, task, profileName: conn.profileName ?? key });
2344
+ out.push({ conn, task, key: conn.key ?? key });
2229
2345
  }
2230
2346
  }
2231
2347
  }
@@ -2240,7 +2356,7 @@ export class BrowserService {
2240
2356
  const conn = this.connections.get(only.profile) ??
2241
2357
  [...this.connections.entries()].find(([, c]) => c.tasks.has(only.task.name) || [...c.tasks.values()].includes(only.task))?.[1];
2242
2358
  if (conn) {
2243
- out.push({ conn, task: only.task, profileName: only.profile });
2359
+ out.push({ conn, task: only.task, key: only.profile });
2244
2360
  }
2245
2361
  }
2246
2362
  }
@@ -2291,33 +2407,34 @@ export class BrowserService {
2291
2407
  // `recordBrowserSession` call in `start` uses for the same reason.
2292
2408
  }
2293
2409
  }
2294
- async findTask(taskId, profileName) {
2295
- if (profileName) {
2410
+ async findTask(taskId, profileRef) {
2411
+ if (profileRef) {
2296
2412
  // Accept bare profile names against composite connection keys.
2297
- const keys = [...this.connections.keys()].filter((k) => k === profileName ||
2298
- connectionKeyMatchesProfile(k, profileName) ||
2299
- 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));
2300
2417
  for (const key of keys) {
2301
2418
  const conn = this.connections.get(key);
2302
2419
  const task = this.lookupTaskOnConn(conn, taskId);
2303
2420
  if (task) {
2304
2421
  await this.touchTask(conn, task);
2305
- return { conn, task, profileName: conn.profileName ?? key };
2422
+ return { conn, task, key: conn.key ?? key };
2306
2423
  }
2307
2424
  }
2308
2425
  // RAM miss → rehydrate from disk for this profile.
2309
- const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileName);
2426
+ const rehydrated = await this.rehydrateTaskFromDisk(taskId, profileRef);
2310
2427
  if (rehydrated) {
2311
2428
  await this.touchTask(rehydrated.conn, rehydrated.task);
2312
2429
  return rehydrated;
2313
2430
  }
2314
- 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}`));
2315
2432
  }
2316
2433
  for (const [key, conn] of this.connections) {
2317
2434
  const task = this.lookupTaskOnConn(conn, taskId);
2318
2435
  if (task) {
2319
2436
  await this.touchTask(conn, task);
2320
- return { conn, task, profileName: conn.profileName ?? key };
2437
+ return { conn, task, key: conn.key ?? key };
2321
2438
  }
2322
2439
  }
2323
2440
  // RAM miss after a daemon restart: tasks.json still has the task and
@@ -2341,11 +2458,8 @@ export class BrowserService {
2341
2458
  * Returns null when nothing is speaking CDP so callers can fall through
2342
2459
  * to disk reconcile.
2343
2460
  */
2344
- async attachRunningProfile(dirName, diskTasks) {
2345
- const bare = dirName.includes('@') ? dirName.split('@')[0] : dirName;
2346
- const endpointFromKey = dirName.includes('@')
2347
- ? dirName.slice(dirName.indexOf('@') + 1).split('.')[0]
2348
- : undefined;
2461
+ async attachRunningProfile(key, diskTasks) {
2462
+ const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
2349
2463
  const profile = await getProfile(bare);
2350
2464
  if (!profile)
2351
2465
  return null;
@@ -2356,7 +2470,7 @@ export class BrowserService {
2356
2470
  catch {
2357
2471
  return null;
2358
2472
  }
2359
- const existingInfo = getRunningChromeInfo(dirName);
2473
+ const existingInfo = getRunningChromeInfo(key);
2360
2474
  const parsed = parseEndpointUrl(resolved.target);
2361
2475
  const port = existingInfo?.port ?? parsed?.port;
2362
2476
  if (port === undefined)
@@ -2366,12 +2480,12 @@ export class BrowserService {
2366
2480
  if (resolved.target.startsWith('ssh:'))
2367
2481
  return null;
2368
2482
  try {
2369
- const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, dirName);
2483
+ const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
2370
2484
  verifyBrowserIdentity(browser, profile.browser, port, host);
2371
2485
  const cdp = new CDPClient();
2372
2486
  await cdp.connect(wsUrl);
2373
2487
  await this.enableDomains(cdp);
2374
- const tasks = this.loadTaskState(dirName);
2488
+ const tasks = this.loadTaskState(key);
2375
2489
  for (const [k, t] of diskTasks) {
2376
2490
  if (!tasks.has(k))
2377
2491
  tasks.set(k, t);
@@ -2383,8 +2497,8 @@ export class BrowserService {
2383
2497
  electron: profile.electron,
2384
2498
  browserType: profile.browser,
2385
2499
  targetFilter: resolved.targetFilter ?? profile.targetFilter,
2386
- profileName: dirName,
2387
- bareName: bare,
2500
+ key,
2501
+ profile: bare,
2388
2502
  tasks,
2389
2503
  sessionCache: new Map(),
2390
2504
  };
@@ -2416,21 +2530,23 @@ export class BrowserService {
2416
2530
  return;
2417
2531
  }
2418
2532
  for (const dirName of dirNames) {
2419
- 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))
2420
2536
  continue;
2421
2537
  // Skip non-runtime dirs (e.g. profiles/)
2422
2538
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
2423
2539
  continue;
2424
- const tasks = this.loadTaskState(dirName);
2540
+ const tasks = this.loadTaskState(key);
2425
2541
  if (tasks.size === 0)
2426
2542
  continue;
2427
2543
  // Soft attach only — never launch / never clear pid files.
2428
- const conn = await this.attachRunningProfile(dirName, tasks);
2544
+ const conn = await this.attachRunningProfile(key, tasks);
2429
2545
  if (!conn)
2430
2546
  continue;
2431
- this.connections.set(dirName, conn);
2547
+ this.connections.set(key, conn);
2432
2548
  try {
2433
- await this.applyDefaultDownloadBehavior(conn, dirName);
2549
+ await this.applyDefaultDownloadBehavior(conn, key);
2434
2550
  }
2435
2551
  catch {
2436
2552
  // Non-fatal for rehydrate.
@@ -2459,12 +2575,13 @@ export class BrowserService {
2459
2575
  return null;
2460
2576
  }
2461
2577
  if (profileHint) {
2462
- dirNames = dirNames.filter((d) => d === profileHint || connectionKeyMatchesProfile(d, profileHint));
2578
+ dirNames = dirNames.filter((d) => d === profileHint || keyBelongsToProfile(d, profileHint));
2463
2579
  }
2464
2580
  for (const dirName of dirNames) {
2465
2581
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
2466
2582
  continue;
2467
- const tasks = this.loadTaskState(dirName);
2583
+ const key = asConnectionKey(dirName);
2584
+ const tasks = this.loadTaskState(key);
2468
2585
  let found;
2469
2586
  for (const task of tasks.values()) {
2470
2587
  if (task.id === taskId || task.name === taskId) {
@@ -2478,17 +2595,17 @@ export class BrowserService {
2478
2595
  if (!found)
2479
2596
  continue;
2480
2597
  // Already connected under this key?
2481
- let conn = this.connections.get(dirName);
2598
+ let conn = this.connections.get(key);
2482
2599
  if (!conn) {
2483
- const attached = await this.attachRunningProfile(dirName, tasks);
2600
+ const attached = await this.attachRunningProfile(key, tasks);
2484
2601
  if (!attached) {
2485
2602
  // CDP down — cannot rehydrate a live connection; caller will error.
2486
2603
  continue;
2487
2604
  }
2488
2605
  conn = attached;
2489
- this.connections.set(dirName, conn);
2606
+ this.connections.set(key, conn);
2490
2607
  try {
2491
- await this.applyDefaultDownloadBehavior(conn, dirName);
2608
+ await this.applyDefaultDownloadBehavior(conn, key);
2492
2609
  }
2493
2610
  catch {
2494
2611
  // Non-fatal.
@@ -2499,7 +2616,7 @@ export class BrowserService {
2499
2616
  if (!this.lookupTaskOnConn(conn, task.name)) {
2500
2617
  conn.tasks.set(task.name, task);
2501
2618
  }
2502
- return { conn, task, profileName: conn.profileName ?? dirName };
2619
+ return { conn, task, key: conn.key ?? key };
2503
2620
  }
2504
2621
  return null;
2505
2622
  }
@@ -2519,20 +2636,9 @@ export class BrowserService {
2519
2636
  }
2520
2637
  return tabs;
2521
2638
  }
2522
- async getProfileStatus(profileName) {
2523
- // Exact composite key first; bare name falls through connectionKeyMatches.
2524
- let conn = this.connections.get(profileName);
2525
- let resolvedKey = profileName;
2526
- if (!conn) {
2527
- for (const [key, c] of this.connections) {
2528
- if (connectionKeyMatchesProfile(key, profileName) ||
2529
- c.bareName === profileName) {
2530
- conn = c;
2531
- resolvedKey = key;
2532
- break;
2533
- }
2534
- }
2535
- }
2639
+ /** Live status for ONE registered connection, addressed by its runtime key. */
2640
+ async getProfileStatus(key) {
2641
+ const conn = this.connections.get(key);
2536
2642
  if (!conn)
2537
2643
  return null;
2538
2644
  // Fetch all targets once for efficiency
@@ -2578,11 +2684,14 @@ export class BrowserService {
2578
2684
  domains: domainSet.size > 0 ? Array.from(domainSet) : undefined,
2579
2685
  });
2580
2686
  }
2581
- const bare = conn.bareName ?? (resolvedKey.includes('@') ? resolvedKey.split('@')[0] : resolvedKey);
2687
+ const parsed = parseConnectionKey(key);
2688
+ const bare = conn.profile ?? parsed.profile;
2582
2689
  const profile = await getProfile(bare);
2583
2690
  const configuredPort = profile ? extractConfiguredPort(profile) : undefined;
2584
2691
  return {
2585
- name: resolvedKey,
2692
+ name: bare,
2693
+ endpoint: parsed.endpoint,
2694
+ key,
2586
2695
  running: true,
2587
2696
  port: conn.port,
2588
2697
  pid: conn.pid,
@@ -2642,14 +2751,14 @@ export class BrowserService {
2642
2751
  invalidateTargetCache(conn) {
2643
2752
  conn.targetCache = undefined;
2644
2753
  }
2645
- async saveTaskState(profileName, tasks) {
2646
- const runtimeDir = getProfileRuntimeDir(profileName);
2754
+ async saveTaskState(key, tasks) {
2755
+ const runtimeDir = getProfileRuntimeDir(key);
2647
2756
  await fs.promises.mkdir(runtimeDir, { recursive: true });
2648
2757
  const state = Object.fromEntries(tasks);
2649
2758
  await fs.promises.writeFile(path.join(runtimeDir, 'tasks.json'), JSON.stringify(state, null, 2));
2650
2759
  }
2651
- loadTaskState(profileName) {
2652
- const runtimeDir = getProfileRuntimeDir(profileName);
2760
+ loadTaskState(key) {
2761
+ const runtimeDir = getProfileRuntimeDir(key);
2653
2762
  const tasksFile = path.join(runtimeDir, 'tasks.json');
2654
2763
  if (!fs.existsSync(tasksFile)) {
2655
2764
  return new Map();
@@ -2671,7 +2780,7 @@ export class BrowserService {
2671
2780
  tasks.set(key, {
2672
2781
  id: task.id,
2673
2782
  name: task.name || key,
2674
- profile: task.profile,
2783
+ profile: asConnectionKey(task.profile),
2675
2784
  tabs,
2676
2785
  currentTabId: tabIds.length > 0 ? tabIds[tabIds.length - 1] : undefined,
2677
2786
  createdAt: task.createdAt,
@@ -2713,7 +2822,9 @@ export class BrowserService {
2713
2822
  history.unshift({
2714
2823
  id: task.id,
2715
2824
  name: task.name,
2716
- 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,
2717
2828
  createdAt: task.createdAt,
2718
2829
  endedAt: Date.now(),
2719
2830
  domains,