@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 +8 -0
- package/dist/commands/browser.js +75 -6
- package/dist/commands/view.d.ts +65 -3
- package/dist/commands/view.js +121 -56
- package/dist/lib/browser/ipc.d.ts +8 -1
- package/dist/lib/browser/ipc.js +93 -8
- package/dist/lib/browser/service.d.ts +122 -34
- package/dist/lib/browser/service.js +251 -13
- package/dist/lib/browser/types.d.ts +47 -0
- package/package.json +1 -1
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`.
|
package/dist/commands/browser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>')
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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"
|
package/dist/commands/view.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
|
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
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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) {
|