@phnx-labs/agents-cli 1.22.79 → 1.22.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.81
4
+
5
+ - **Same-task page reopen refreshes the SAME tab instead of duplicating it (PHNX-2399).** When an agent reopens a URL that is already live in one of its browser task's OWN tabs, `agents browser navigate` / `tab add` now issue a real `Page.reload` on that same tab — keeping its tab id and CDP target, marking it current without stealing window focus — and report `refreshed: true` with the note `Tab already open—refreshed`, rather than opening a second copy. Borrowed Arc/user tabs are excluded, a stale registered tab or a failed reload never reports a phantom refresh, and the lookup→reload/create→persist section is serialized per task (and first-use creation per caller) so concurrent requests converge on one tab and the same id. The implicit first-open at the create boundary is reported as a genuine open, not disguised as a refresh. `navigate` / `tab add` gain `--json` (`{ ok, task, tabId, url, created, refreshed, message }`), and `agents browser tabs` again marks the current tab. Source: `cli/src/lib/browser/service.ts`, `cli/src/lib/browser/ipc.ts`, `cli/src/lib/browser/types.ts`, `cli/src/commands/browser.ts`.
6
+
7
+ ## 1.22.80
8
+
9
+ - **`agents prune cleanup` collapses duplicate account homes to one per account — and keeps the RIGHT one (PHNX-3940).** After the automatic-update pass moves every home to the latest release in place, an account can be left with two homes: the original identity-captured login and a freshly re-logged-in home that only carries an email ("usage unavailable"). Consolidation now groups those together and keeps the home that best represents the account — captured identity first, then a signed-in credential, then the newest running release — instead of the blind highest dir-name semver, which was the just-created duplicate and whose selection trashed the working login. When the retired duplicate held the global default, the default is repointed onto the keeper first, so the duplicate actually collapses instead of being skipped. Retirement stays a reversible soft-delete (`agents restore`). Source: `cli/src/commands/view.ts` (`planDuplicatePrune`).
10
+
3
11
  ## 1.22.79
4
12
 
5
13
  - **Managed harnesses follow the latest release without replacing account homes (PHNX-3940).** Automatic updates are on by default and configurable with `updates.auto` and per-harness `updates.<harness>.auto`. Updates preserve existing home paths and identities, defer active installations, and stage and verify the executable before committing it. A concrete `--to` release pins an installation; `--to latest` resumes automatic updates. `agents update --check` previews without changing local state. Source: `cli/src/lib/installations/update.ts`, `cli/src/lib/daemon/harness-update-service.ts`, `cli/src/commands/update.ts`.
@@ -1507,6 +1507,7 @@ function registerTaskCommands(browser) {
1507
1507
  .option('--fps <n>', 'Recording frames per second (with --record; 1–30, default 5)', (v) => parseInt(v, 10))
1508
1508
  .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
1509
1509
  .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
1510
+ .option('--json', 'Output machine-readable JSON (task handle + tabId + reused/message)')
1510
1511
  .action(async (opts) => {
1511
1512
  // Fast-fail copy of the consent gate, so a refused start never resolves or
1512
1513
  // auto-creates a profile. The AUTHORITATIVE gate is in the daemon
@@ -1736,12 +1737,33 @@ function registerTaskCommands(browser) {
1736
1737
  createdAt: Date.now(),
1737
1738
  });
1738
1739
  }
1740
+ // --json: machine-readable, still task-handle-first for callers that parse it.
1741
+ if (opts.json) {
1742
+ console.log(JSON.stringify({
1743
+ ok: true,
1744
+ task: response.task,
1745
+ tabId: response.tabId,
1746
+ device: boundDevice,
1747
+ profile: profileName,
1748
+ // `reused` is task-level; `created`/`refreshed` describe the page op.
1749
+ reused: response.reused === true,
1750
+ created: response.created,
1751
+ refreshed: response.refreshed,
1752
+ message: response.message,
1753
+ }, null, 2));
1754
+ return;
1755
+ }
1739
1756
  // stdout: just the resolved name, one line, no decoration. Lets callers do:
1740
1757
  // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
1741
1758
  console.log(response.task);
1742
1759
  // stderr: human-friendly commentary so a TTY user still sees what happened.
1743
- // Shell substitution captures stdout only, so $(...) stays clean.
1744
- console.error(`Task "${response.task}" started on ${boundDevice} (profile: ${profileName}).`);
1760
+ // Shell substitution captures stdout only, so $(...) stays clean. A same-name
1761
+ // retry reused the TASK — key on `reused`, not the page-level `refreshed`
1762
+ // (a no-URL retry reuses the task without reloading anything).
1763
+ const startVerb = response.reused ? 'reused' : 'started';
1764
+ console.error(`Task "${response.task}" ${startVerb} on ${boundDevice} (profile: ${profileName}).`);
1765
+ if (response.message)
1766
+ console.error(response.message);
1745
1767
  if (opts.url && response.tabId) {
1746
1768
  console.error(`Tab ${response.tabId}`);
1747
1769
  }
@@ -1938,6 +1960,7 @@ function registerTaskCommands(browser) {
1938
1960
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1939
1961
  .option('-u, --url <url>', 'URL to navigate to (or pass it positionally)')
1940
1962
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
1963
+ .option('--json', 'Output machine-readable JSON')
1941
1964
  .action(async (urlPos, opts) => {
1942
1965
  const url = urlPos || opts.url;
1943
1966
  if (!url) {
@@ -1952,13 +1975,34 @@ function registerTaskCommands(browser) {
1952
1975
  profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
1953
1976
  });
1954
1977
  if (!response.ok) {
1955
- console.error(response.error);
1978
+ if (opts.json)
1979
+ console.log(JSON.stringify({ ok: false, error: response.error }));
1980
+ else
1981
+ console.error(response.error);
1956
1982
  process.exit(1);
1957
1983
  }
1984
+ if (opts.json) {
1985
+ console.log(JSON.stringify({
1986
+ ok: true,
1987
+ task: response.task,
1988
+ tabId: response.tabId,
1989
+ url,
1990
+ created: response.created ?? false,
1991
+ refreshed: response.refreshed ?? false,
1992
+ message: response.message,
1993
+ }, null, 2));
1994
+ return;
1995
+ }
1958
1996
  if (response.task) {
1959
1997
  console.error(`task ${response.task}`);
1960
1998
  }
1961
- console.log(`Navigated ${response.tabId} to ${url}`);
1999
+ // Honest human output: a same-task reopen reloaded the SAME tab rather than
2000
+ // navigating a fresh one (PHNX-2399). Any picked-device / reopen note rides
2001
+ // stderr so stdout stays the one-line result.
2002
+ if (response.message)
2003
+ console.error(response.message);
2004
+ const verb = response.refreshed ? 'Refreshed' : 'Navigated';
2005
+ console.log(`${verb} ${response.tabId} to ${url}`);
1962
2006
  });
1963
2007
  // Tab subcommand group
1964
2008
  const tab = browser.command('tab').description('Manage tabs');
@@ -1969,6 +2013,7 @@ function registerTaskCommands(browser) {
1969
2013
  .option(DEVICE_ON_PAGE_VERB_FLAG, DEVICE_ON_PAGE_VERB_DESC)
1970
2014
  .requiredOption('--url <url>', 'URL to open in the new tab')
1971
2015
  .option('-p, --profile <name>', 'Browser profile')
2016
+ .option('--json', 'Output machine-readable JSON')
1972
2017
  .action(async (opts) => {
1973
2018
  const task = resolveTaskName(opts);
1974
2019
  const response = await sendIPCRequest({
@@ -1978,10 +2023,34 @@ function registerTaskCommands(browser) {
1978
2023
  profile: opts.profile ? await resolveProfileRef(opts.profile) : undefined,
1979
2024
  });
1980
2025
  if (!response.ok) {
1981
- console.error(response.error);
2026
+ if (opts.json)
2027
+ console.log(JSON.stringify({ ok: false, error: response.error }));
2028
+ else
2029
+ console.error(response.error);
1982
2030
  process.exit(1);
1983
2031
  }
1984
- console.log(`Opened tab ${response.tabId}: ${opts.url}`);
2032
+ if (opts.json) {
2033
+ console.log(JSON.stringify({
2034
+ ok: true,
2035
+ task: response.task,
2036
+ tabId: response.tabId,
2037
+ url: opts.url,
2038
+ created: response.created ?? false,
2039
+ refreshed: response.refreshed ?? false,
2040
+ message: response.message,
2041
+ }, null, 2));
2042
+ return;
2043
+ }
2044
+ // A reopen of a URL already in an owned tab refreshes that tab in place
2045
+ // rather than opening a duplicate (PHNX-2399).
2046
+ if (response.refreshed) {
2047
+ if (response.message)
2048
+ console.error(response.message);
2049
+ console.log(`Refreshed tab ${response.tabId}: ${opts.url}`);
2050
+ }
2051
+ else {
2052
+ console.log(`Opened tab ${response.tabId}: ${opts.url}`);
2053
+ }
1985
2054
  });
1986
2055
  tab
1987
2056
  .command('focus <tabId>')
@@ -95,6 +95,23 @@ export declare function parseResourceSections(options: {
95
95
  export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>, opts?: {
96
96
  forceRefresh?: boolean;
97
97
  }): Promise<ViewJsonAgent[]>;
98
+ interface PrunePlanEntry {
99
+ agentId: AgentId;
100
+ version: string;
101
+ email: string;
102
+ keeper: string;
103
+ isDefault: boolean;
104
+ /**
105
+ * 'duplicate' — older version sharing an email with a newer install.
106
+ * 'home-leftover' — home-only dir left over after a previous removeVersion;
107
+ * no binary, but transcripts may still live here.
108
+ */
109
+ reason: 'duplicate' | 'home-leftover';
110
+ }
111
+ interface AgentPrunePlan {
112
+ agentId: AgentId;
113
+ toPrune: PrunePlanEntry[];
114
+ }
98
115
  /**
99
116
  * Identity key for duplicate-install detection. Prefers accountKey — which
100
117
  * encodes account AND org — over the bare email: two installs can share an
@@ -104,10 +121,55 @@ export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSecti
104
121
  * identity key. Null when there is no usable identity.
105
122
  */
106
123
  export declare function pruneGroupKey(info: Pick<AccountInfo, 'accountKey' | 'email'>): string | null;
124
+ /** One installed home, reduced to the fields duplicate detection needs. */
125
+ export interface PruneCandidate {
126
+ /** The version-dir label (opaque slot id). */
127
+ version: string;
128
+ /** The RUNNING release inside the home (installation.releaseVersion), or the label. */
129
+ release: string;
130
+ email: string | null;
131
+ /** Present only once the home's native identity is captured (claude: account+org). */
132
+ accountKey: string | null;
133
+ signedIn: boolean;
134
+ hasBinary: boolean;
135
+ }
136
+ /** A duplicate home to retire, and the keeper it collapses into. */
137
+ export interface DuplicatePruneEntry {
138
+ version: string;
139
+ email: string;
140
+ keeper: string;
141
+ }
142
+ /**
143
+ * PURE duplicate-home detection: given every installed home for one agent, decide
144
+ * which are redundant duplicates of the SAME logical account and which single home
145
+ * to keep per account. Extracted from buildAgentPrunePlan so the keeper/merge rules
146
+ * are unit-tested without touching disk.
147
+ *
148
+ * Two rules, both conservative:
149
+ * - GROUPING. A home with a captured `accountKey` groups by it (account+org, so a
150
+ * personal Max and a Team seat on the same email stay separate). A home with NO
151
+ * accountKey folds into a same-email account ONLY when a sibling home for that
152
+ * email IS identified — that email-only home is an incompletely-captured re-login
153
+ * of the known account, not a distinct seat (PHNX-3887). Otherwise it groups by
154
+ * its bare email and only ever collapses against another equally-bare home.
155
+ * - KEEPER. Within a group the keeper is the home that best represents the account:
156
+ * captured identity first, then a signed-in credential, then the newest RUNNING
157
+ * release, then the highest dir label as a stable tiebreak. This is deliberately
158
+ * NOT "highest dir-name semver" — in the wild that is the freshly re-logged-in
159
+ * duplicate that never captured its identity or usage, and keeping it while
160
+ * trashing the identified home is the exact bug this replaces.
161
+ */
162
+ export declare function planDuplicatePrune(candidates: PruneCandidate[]): DuplicatePruneEntry[];
163
+ export declare function executePrunePlan(plan: AgentPrunePlan): Promise<Array<{
164
+ agent: AgentId;
165
+ version: string;
166
+ }>>;
107
167
  /**
108
- * Prune older installed versions that share an email with a newer installed
109
- * version. Keeps the highest semver per email, skips the global default (with
110
- * a warning so the user can switch first).
168
+ * Consolidate to one home per logical account: retire the redundant duplicate
169
+ * homes an account accumulated, keeping the single home that best represents it
170
+ * (see {@link planDuplicatePrune} for the keeper/merge rules). When the retired
171
+ * duplicate holds the global default, the default is first repointed onto the
172
+ * keeper, so consolidation collapses it instead of leaving it pinned.
111
173
  *
112
174
  * When filterAgentId is set, prunes that agent first, then cascades: after
113
175
  * each agent, offers the next agent with duplicates. User answering "no"
@@ -11,7 +11,7 @@ import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
11
11
  import { agentReportsUsage, classifyUsageErrorKind, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageBenignState, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, usageErrorForDisplay, } from '../lib/accounting/usage.js';
12
12
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../lib/device-config.js';
13
13
  import { readManifest } from '../lib/manifest.js';
14
- import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
14
+ import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
15
15
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/installations/shims.js';
16
16
  import { getAgentResources } from '../lib/resources.js';
17
17
  import { renderMergedResources } from '../lib/merged-resources.js';
@@ -1455,50 +1455,116 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1455
1455
  export function pruneGroupKey(info) {
1456
1456
  return info.accountKey ?? info.email?.toLowerCase() ?? null;
1457
1457
  }
1458
+ /**
1459
+ * PURE duplicate-home detection: given every installed home for one agent, decide
1460
+ * which are redundant duplicates of the SAME logical account and which single home
1461
+ * to keep per account. Extracted from buildAgentPrunePlan so the keeper/merge rules
1462
+ * are unit-tested without touching disk.
1463
+ *
1464
+ * Two rules, both conservative:
1465
+ * - GROUPING. A home with a captured `accountKey` groups by it (account+org, so a
1466
+ * personal Max and a Team seat on the same email stay separate). A home with NO
1467
+ * accountKey folds into a same-email account ONLY when a sibling home for that
1468
+ * email IS identified — that email-only home is an incompletely-captured re-login
1469
+ * of the known account, not a distinct seat (PHNX-3887). Otherwise it groups by
1470
+ * its bare email and only ever collapses against another equally-bare home.
1471
+ * - KEEPER. Within a group the keeper is the home that best represents the account:
1472
+ * captured identity first, then a signed-in credential, then the newest RUNNING
1473
+ * release, then the highest dir label as a stable tiebreak. This is deliberately
1474
+ * NOT "highest dir-name semver" — in the wild that is the freshly re-logged-in
1475
+ * duplicate that never captured its identity or usage, and keeping it while
1476
+ * trashing the identified home is the exact bug this replaces.
1477
+ */
1478
+ export function planDuplicatePrune(candidates) {
1479
+ // Only installs with a working binary compete for "the live install for this account".
1480
+ const installed = candidates.filter((c) => c.hasBinary && c.email);
1481
+ // email -> every DISTINCT captured accountKey seen for it. An email-only home
1482
+ // folds into an identified account only when that email maps to EXACTLY ONE
1483
+ // account; when two distinct orgs share the email (the Personal+Team case) the
1484
+ // fold is ambiguous, so the identity-less home is left ungrouped rather than
1485
+ // guessed into one — guessing could retire the actually-working re-login of the
1486
+ // OTHER org (the exact bug this function exists to prevent, one level up).
1487
+ const emailToAccountKeys = new Map();
1488
+ for (const c of installed) {
1489
+ if (!c.accountKey)
1490
+ continue;
1491
+ const email = c.email.toLowerCase();
1492
+ (emailToAccountKeys.get(email) ?? emailToAccountKeys.set(email, new Set()).get(email)).add(c.accountKey);
1493
+ }
1494
+ const groupKeyFor = (c) => {
1495
+ if (c.accountKey)
1496
+ return c.accountKey;
1497
+ const email = c.email?.toLowerCase();
1498
+ const keys = email ? emailToAccountKeys.get(email) : undefined;
1499
+ if (keys && keys.size === 1)
1500
+ return [...keys][0];
1501
+ // ≥2 identified orgs on this email: never merge the ambiguous home. A unique
1502
+ // key makes it its own singleton group (length 1 → never pruned).
1503
+ if (keys && keys.size >= 2)
1504
+ return `ambiguous:${c.email.toLowerCase()}:${c.version}`;
1505
+ // No identified sibling at all: group equally-bare homes by email as before.
1506
+ return pruneGroupKey(c);
1507
+ };
1508
+ const byAccount = new Map();
1509
+ for (const c of installed) {
1510
+ const key = groupKeyFor(c);
1511
+ if (!key)
1512
+ continue;
1513
+ (byAccount.get(key) ?? byAccount.set(key, []).get(key)).push(c);
1514
+ }
1515
+ const rank = (c) => [c.accountKey ? 1 : 0, c.signedIn ? 1 : 0];
1516
+ const better = (a, b) => {
1517
+ const [aa, ab] = rank(a);
1518
+ const [ba, bb] = rank(b);
1519
+ if (aa !== ba)
1520
+ return ba - aa;
1521
+ if (ab !== bb)
1522
+ return bb - ab;
1523
+ if (a.release !== b.release)
1524
+ return compareVersions(b.release, a.release);
1525
+ return compareVersions(b.version, a.version);
1526
+ };
1527
+ const out = [];
1528
+ for (const group of byAccount.values()) {
1529
+ if (group.length < 2)
1530
+ continue;
1531
+ const sorted = [...group].sort(better);
1532
+ const keeper = sorted[0].version;
1533
+ for (const dup of sorted.slice(1)) {
1534
+ out.push({ version: dup.version, email: dup.email, keeper });
1535
+ }
1536
+ }
1537
+ return out;
1538
+ }
1458
1539
  async function buildAgentPrunePlan(agentId) {
1459
1540
  const dirInfos = listInstalledVersionDirs(agentId);
1460
1541
  const entries = await Promise.all(dirInfos.map(async ({ version, hasBinary }) => {
1461
1542
  const home = getVersionHomePath(agentId, version);
1462
1543
  const info = await getAccountInfo(agentId, home);
1463
- return { version, info, hasBinary };
1544
+ const release = readInstallation(agentId, version)?.releaseVersion ?? version;
1545
+ return { version, info, hasBinary, release };
1464
1546
  }));
1465
1547
  const globalDefault = getGlobalDefault(agentId);
1466
1548
  const toPrune = [];
1467
- const skippedDefaults = [];
1468
- // Duplicate-account detection runs only over installs that actually have a
1469
- // working binary — those are the things that compete for "the live install
1470
- // for this account."
1471
- const installed = entries.filter((e) => e.hasBinary);
1472
- const byAccount = new Map();
1473
- for (const e of installed) {
1474
- if (!e.info.email)
1475
- continue;
1476
- const key = pruneGroupKey(e.info);
1477
- if (!key)
1478
- continue;
1479
- const list = byAccount.get(key) ?? [];
1480
- list.push(e);
1481
- byAccount.set(key, list);
1482
- }
1483
- for (const [, group] of byAccount) {
1484
- if (group.length < 2)
1485
- continue;
1486
- const sorted = [...group].sort((a, b) => compareVersions(b.version, a.version));
1487
- const keeper = sorted[0].version;
1488
- for (const older of sorted.slice(1)) {
1489
- const plan = {
1490
- agentId,
1491
- version: older.version,
1492
- email: older.info.email,
1493
- keeper,
1494
- isDefault: older.version === globalDefault,
1495
- reason: 'duplicate',
1496
- };
1497
- if (plan.isDefault)
1498
- skippedDefaults.push(plan);
1499
- else
1500
- toPrune.push(plan);
1501
- }
1549
+ for (const d of planDuplicatePrune(entries.map((e) => ({
1550
+ version: e.version,
1551
+ release: e.release,
1552
+ email: e.info.email,
1553
+ accountKey: e.info.accountKey,
1554
+ signedIn: e.info.signedIn,
1555
+ hasBinary: e.hasBinary,
1556
+ })))) {
1557
+ toPrune.push({
1558
+ agentId,
1559
+ version: d.version,
1560
+ email: d.email,
1561
+ keeper: d.keeper,
1562
+ // The default may itself be a to-be-retired duplicate (a freshly created
1563
+ // home often becomes the global default). We do NOT skip it: executePrune
1564
+ // repoints the default onto the keeper first, then retires the duplicate.
1565
+ isDefault: d.version === globalDefault,
1566
+ reason: 'duplicate',
1567
+ });
1502
1568
  }
1503
1569
  // Home-only leftovers: dirs without a binary. These are residue from a
1504
1570
  // prior removeVersion before the soft-delete migration, plus any hand-edited
@@ -1517,10 +1583,20 @@ async function buildAgentPrunePlan(agentId) {
1517
1583
  reason: 'home-leftover',
1518
1584
  });
1519
1585
  }
1520
- return { agentId, toPrune, skippedDefaults };
1586
+ return { agentId, toPrune };
1521
1587
  }
1522
- async function executePrunePlan(plan) {
1588
+ export async function executePrunePlan(plan) {
1523
1589
  const moved = [];
1590
+ // Repoint the global default onto the keeper BEFORE retiring a duplicate that
1591
+ // currently holds it, so consolidation collapses the duplicate instead of
1592
+ // leaving it pinned as the default (and so removeVersion's own fallback never
1593
+ // has to guess a replacement).
1594
+ for (const p of plan.toPrune) {
1595
+ if (p.reason === 'duplicate' && p.isDefault && p.keeper && p.keeper !== p.version) {
1596
+ setGlobalDefault(p.agentId, p.keeper);
1597
+ console.log(chalk.gray(`Default ${agentLabel(p.agentId)} moved ${p.version} → ${p.keeper} (keeper) before pruning the duplicate.`));
1598
+ }
1599
+ }
1524
1600
  for (const p of plan.toPrune) {
1525
1601
  const ok = removeVersion(p.agentId, p.version);
1526
1602
  if (ok) {
@@ -1537,14 +1613,6 @@ async function executePrunePlan(plan) {
1537
1613
  return moved;
1538
1614
  }
1539
1615
  function printPrunePlan(plan, isFirst) {
1540
- if (plan.skippedDefaults.length > 0) {
1541
- console.log(chalk.yellow(`Skipping default versions for ${agentLabel(plan.agentId)} (switch default first):`));
1542
- for (const s of plan.skippedDefaults) {
1543
- console.log(` ${agentLabel(s.agentId)}@${s.version} ${chalk.cyan(s.email)} ` +
1544
- chalk.gray(`— duplicate of ${s.agentId}@${s.keeper}. Run: agents use ${s.agentId}@${s.keeper}`));
1545
- }
1546
- console.log();
1547
- }
1548
1616
  if (plan.toPrune.length === 0)
1549
1617
  return;
1550
1618
  const heading = isFirst ? `Will move to trash for ${agentLabel(plan.agentId)}:` : `Also found candidates for ${agentLabel(plan.agentId)}:`;
@@ -1562,9 +1630,11 @@ function printPrunePlan(plan, isFirst) {
1562
1630
  console.log();
1563
1631
  }
1564
1632
  /**
1565
- * Prune older installed versions that share an email with a newer installed
1566
- * version. Keeps the highest semver per email, skips the global default (with
1567
- * a warning so the user can switch first).
1633
+ * Consolidate to one home per logical account: retire the redundant duplicate
1634
+ * homes an account accumulated, keeping the single home that best represents it
1635
+ * (see {@link planDuplicatePrune} for the keeper/merge rules). When the retired
1636
+ * duplicate holds the global default, the default is first repointed onto the
1637
+ * keeper, so consolidation collapses it instead of leaving it pinned.
1568
1638
  *
1569
1639
  * When filterAgentId is set, prunes that agent first, then cascades: after
1570
1640
  * each agent, offers the next agent with duplicates. User answering "no"
@@ -1577,7 +1647,7 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1577
1647
  const spinner = ora({ text: 'Scanning installed versions...', isSilent: !process.stdout.isTTY }).start();
1578
1648
  const plans = await Promise.all(ordered.map((a) => buildAgentPrunePlan(a)));
1579
1649
  spinner.stop();
1580
- const actionable = plans.filter((p) => p.toPrune.length > 0 || p.skippedDefaults.length > 0);
1650
+ const actionable = plans.filter((p) => p.toPrune.length > 0);
1581
1651
  if (actionable.length === 0) {
1582
1652
  console.log(chalk.gray('Nothing to prune — no duplicate-account installs and no home-only leftovers.'));
1583
1653
  return;
@@ -1588,11 +1658,6 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1588
1658
  let processedAny = false;
1589
1659
  for (const plan of actionable) {
1590
1660
  printPrunePlan(plan, isFirst);
1591
- if (plan.toPrune.length === 0) {
1592
- // Only skippedDefaults for this agent; move on.
1593
- isFirst = false;
1594
- continue;
1595
- }
1596
1661
  if (dryRun) {
1597
1662
  processedAny = true;
1598
1663
  isFirst = false;
@@ -105,7 +105,14 @@ export declare class BrowserIPCServer {
105
105
  private connections;
106
106
  /** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
107
107
  private stopping;
108
- constructor(service: BrowserService);
108
+ /**
109
+ * Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
110
+ * server binds (and unlinks a stale copy of) THIS path instead of the shared
111
+ * production `browser.sock`, so a test never unlinks the user's live daemon
112
+ * socket. Default (undefined) is unchanged. Not for production use.
113
+ */
114
+ private readonly socketPathOverride?;
115
+ constructor(service: BrowserService, socketPathOverride?: string);
109
116
  start(): Promise<void>;
110
117
  /**
111
118
  * Stop accepting, release the binding, and only then resolve.
@@ -443,12 +443,23 @@ export class BrowserIPCServer {
443
443
  connections = new Set();
444
444
  /** In-flight stop, so a second SIGTERM awaits the same close, never skips it. */
445
445
  stopping = null;
446
- constructor(service) {
446
+ /**
447
+ * Optional explicit socket path for TEST ISOLATION only (POSIX). When set, the
448
+ * server binds (and unlinks a stale copy of) THIS path instead of the shared
449
+ * production `browser.sock`, so a test never unlinks the user's live daemon
450
+ * socket. Default (undefined) is unchanged. Not for production use.
451
+ */
452
+ socketPathOverride;
453
+ constructor(service, socketPathOverride) {
447
454
  this.service = service;
455
+ this.socketPathOverride = socketPathOverride;
448
456
  }
449
457
  async start() {
450
- const socketPath = getSocketPath();
451
- const endpoint = getIpcEndpoint();
458
+ const socketPath = this.socketPathOverride ?? getSocketPath();
459
+ // On POSIX the endpoint IS the socket path; an override addresses the same
460
+ // isolated file. (Windows keeps its derived pipe name; the override is
461
+ // POSIX-only test machinery.)
462
+ const endpoint = this.socketPathOverride ?? getIpcEndpoint();
452
463
  const socketDir = path.dirname(socketPath);
453
464
  fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
454
465
  if (!IS_WINDOWS) {
@@ -615,6 +626,13 @@ export class BrowserIPCServer {
615
626
  async bindTask(request) {
616
627
  const createIfMissing = PAGE_CREATE_VERBS.has(request.action);
617
628
  const isClose = CLOSE_VERBS.has(request.action);
629
+ // NEVER trust these off the wire — they are server-derived bind state written
630
+ // BELOW from the resolver's own result. A client that pre-set `firstOpen` in
631
+ // its request JSON could otherwise steer navigate/tab-add to short-circuit
632
+ // onto an arbitrary tab id / forged created flag (or forge a picked-device
633
+ // hint). Clear before binding so only this handler's values survive (PHNX-2399).
634
+ delete request.firstOpen;
635
+ delete request.picked;
618
636
  // stop with --profile only (no task) is handled by the stop case itself.
619
637
  if (request.action === 'stop' && request.profile && !request.task)
620
638
  return undefined;
@@ -643,6 +661,13 @@ export class BrowserIPCServer {
643
661
  if (resolved.created && resolved.picked) {
644
662
  request.picked = resolved.picked;
645
663
  }
664
+ // The implicit create already opened request.url on this tab. Carry the
665
+ // ACTUAL page result so the navigate/tab-add handler reports that first
666
+ // open honestly (created/refreshed as it happened) instead of executing
667
+ // the URL a second time (PHNX-2399).
668
+ if (resolved.created && resolved.firstOpen) {
669
+ request.firstOpen = resolved.firstOpen;
670
+ }
646
671
  return undefined;
647
672
  }
648
673
  catch (err) {
@@ -738,13 +763,26 @@ export class BrowserIPCServer {
738
763
  fleetRemote: request.fleetRemote,
739
764
  title: request.title,
740
765
  });
766
+ // `reused` is task-level (a same-name retry matched an existing task);
767
+ // `created`/`refreshed` describe the actual PAGE operation, carried from
768
+ // `firstOpen`. A no-URL retry is `reused: true` with created/refreshed
769
+ // both false — no reload happened, so it must NOT read as refreshed. A
770
+ // pure task reuse with no page refresh gets the "Reused existing task"
771
+ // note; a same-URL reopen gets its own "Tab already open—refreshed" note
772
+ // from `firstOpen.message` (PHNX-2399).
773
+ const startReuseNote = result.reused && !result.firstOpen?.refreshed && !result.firstOpen?.created
774
+ ? 'Reused existing task'
775
+ : undefined;
741
776
  return {
742
777
  ok: true,
743
778
  task: result.name,
744
779
  tabId: result.tabId,
745
780
  windowTargetId: result.windowId,
746
781
  skill: result.skill,
747
- message: result.picked,
782
+ reused: result.reused,
783
+ created: result.firstOpen?.created,
784
+ refreshed: result.firstOpen?.refreshed,
785
+ message: [result.picked, result.firstOpen?.message, startReuseNote].filter(Boolean).join(' · ') || undefined,
748
786
  };
749
787
  }
750
788
  // The out-of-process seam onto the abandoned-task reaper: the daemon
@@ -824,16 +862,60 @@ export class BrowserIPCServer {
824
862
  error: actionable('No browser task for this session.', 'Next: agents browser navigate <url> | agents browser start'),
825
863
  };
826
864
  }
827
- const result = await this.service.navigate(request.task, request.url, request.profile);
865
+ // The implicit create already opened this URL on this tab. Report the
866
+ // ACTUAL first-open result (adopt → created:false, fresh → created:true)
867
+ // instead of re-navigating it or assuming a create (PHNX-2399).
868
+ const firstOpen = request.firstOpen;
828
869
  const picked = request.picked;
829
- return { ok: true, tabId: result.tabId, task: request.task, message: picked };
870
+ if (firstOpen) {
871
+ return {
872
+ ok: true,
873
+ tabId: firstOpen.tabId,
874
+ task: request.task,
875
+ created: firstOpen.created,
876
+ refreshed: firstOpen.refreshed,
877
+ message: [picked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
878
+ };
879
+ }
880
+ const result = await this.service.navigate(request.task, request.url, request.profile);
881
+ return {
882
+ ok: true,
883
+ tabId: result.tabId,
884
+ task: request.task,
885
+ created: result.created,
886
+ refreshed: result.refreshed,
887
+ // A picked-device hint and a reopen note are distinct signals; combine
888
+ // them so a first navigate on a remote-picked device still surfaces the
889
+ // pick, and a plain reopen surfaces the refresh note.
890
+ message: [picked, result.message].filter(Boolean).join(' · ') || undefined,
891
+ };
830
892
  }
831
893
  case 'tab-add': {
832
894
  if (!request.task || !request.url) {
833
895
  return { ok: false, error: 'Task and URL required' };
834
896
  }
897
+ const tabAddPicked = request.picked;
898
+ const firstOpen = request.firstOpen;
899
+ if (firstOpen) {
900
+ return {
901
+ ok: true,
902
+ tabId: firstOpen.tabId,
903
+ task: request.task,
904
+ created: firstOpen.created,
905
+ refreshed: firstOpen.refreshed,
906
+ message: [tabAddPicked, firstOpen.message].filter(Boolean).join(' · ') || undefined,
907
+ };
908
+ }
835
909
  const result = await this.service.tabAdd(request.task, request.url, request.profile);
836
- return { ok: true, tabId: result.tabId };
910
+ return {
911
+ ok: true,
912
+ tabId: result.tabId,
913
+ task: request.task,
914
+ created: result.created,
915
+ refreshed: result.refreshed,
916
+ // Keep the picked-device hint AND any reopen note (same shape as navigate).
917
+ message: [tabAddPicked, result.message].filter(Boolean).join(' · ') || undefined,
918
+ };
837
919
  }
838
920
  case 'tab-focus': {
839
921
  if (!request.task || !request.tabId) {
@@ -854,7 +936,10 @@ export class BrowserIPCServer {
854
936
  return { ok: false, error: 'Task required' };
855
937
  }
856
938
  const tabs = await this.service.tabList(request.task);
857
- return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, task: request.task })) };
939
+ // Carry `current` through — it is the one field a caller needs to show
940
+ // which tab URL-less verbs act on, and dropping it here left `agents
941
+ // browser tabs` unable to mark the selection (PHNX-2399).
942
+ return { ok: true, tabs: tabs.map(t => ({ id: t.id, url: t.url, title: t.title, current: t.current, task: request.task })) };
858
943
  }
859
944
  case 'evaluate': {
860
945
  if (!request.task || !request.expr) {