cdp-toolkit 1.6.0 → 1.7.0
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 +155 -0
- package/CONTRACT.md +7 -2
- package/README.md +57 -13
- package/dist/cli.js +184 -62
- package/dist/index.js +184 -62
- package/dist/mcp.js +245 -112
- package/package.json +1 -1
- package/src/leases-tools.ts +146 -25
- package/src/leases.ts +83 -3
- package/src/manifest.ts +59 -49
- package/src/mcp.ts +5 -2
- package/src/reap.ts +114 -0
- package/src/shared-tools.ts +39 -7
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,16 @@ function leaseTtlMs() {
|
|
|
13
13
|
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
14
14
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
15
15
|
}
|
|
16
|
+
var longLived = false;
|
|
17
|
+
function markLongLivedProcess(value = true) {
|
|
18
|
+
longLived = value;
|
|
19
|
+
}
|
|
20
|
+
function requireLease() {
|
|
21
|
+
if (!longLived)
|
|
22
|
+
return false;
|
|
23
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
24
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
25
|
+
}
|
|
16
26
|
function defaultLabel() {
|
|
17
27
|
return `pid-${process.pid}`;
|
|
18
28
|
}
|
|
@@ -99,7 +109,8 @@ async function claimLease(backend, targetId, opts) {
|
|
|
99
109
|
label: opts.label,
|
|
100
110
|
createdAt: now,
|
|
101
111
|
lastUsedAt: now,
|
|
102
|
-
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
112
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
113
|
+
auto: opts.auto === true
|
|
103
114
|
};
|
|
104
115
|
try {
|
|
105
116
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -175,8 +186,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
175
186
|
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
176
187
|
const where = describeTarget(targetId, ctx);
|
|
177
188
|
if (token === undefined) {
|
|
178
|
-
if (!held)
|
|
189
|
+
if (!held) {
|
|
190
|
+
if (requireLease()) {
|
|
191
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
192
|
+
}
|
|
179
193
|
return;
|
|
194
|
+
}
|
|
195
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
196
|
+
await touchLease(held, now);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
180
199
|
throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
|
|
181
200
|
}
|
|
182
201
|
const parts = tokenParts(token);
|
|
@@ -184,8 +203,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
184
203
|
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
185
204
|
}
|
|
186
205
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
187
|
-
if (!held)
|
|
206
|
+
if (!held) {
|
|
207
|
+
if (requireLease()) {
|
|
208
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
209
|
+
}
|
|
188
210
|
return;
|
|
211
|
+
}
|
|
189
212
|
throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
|
|
190
213
|
}
|
|
191
214
|
if (!held) {
|
|
@@ -1429,6 +1452,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
|
|
|
1429
1452
|
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1430
1453
|
} catch {}
|
|
1431
1454
|
}
|
|
1455
|
+
async function readOrigin(backend, targetId) {
|
|
1456
|
+
let raw;
|
|
1457
|
+
try {
|
|
1458
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1459
|
+
} catch {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
const rec = JSON.parse(raw);
|
|
1464
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1465
|
+
return rec;
|
|
1466
|
+
} catch {}
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1432
1469
|
async function listOrigins(opts = {}) {
|
|
1433
1470
|
let names = [];
|
|
1434
1471
|
try {
|
|
@@ -1486,59 +1523,43 @@ async function newTrackedPage(driver, backend, opts = {}) {
|
|
|
1486
1523
|
return page;
|
|
1487
1524
|
}
|
|
1488
1525
|
|
|
1489
|
-
// src/
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
1507
|
-
if (!existing)
|
|
1508
|
-
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1509
|
-
url = existing.url;
|
|
1526
|
+
// src/reap.ts
|
|
1527
|
+
function staleAgentTabs(input) {
|
|
1528
|
+
const live = new Set(input.livePageIds);
|
|
1529
|
+
const out = [];
|
|
1530
|
+
for (const row of input.leases) {
|
|
1531
|
+
if (row.backend !== input.backend)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (row.unreadable !== undefined)
|
|
1534
|
+
continue;
|
|
1535
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1536
|
+
continue;
|
|
1537
|
+
if (!live.has(row.targetId))
|
|
1538
|
+
continue;
|
|
1539
|
+
const origin = input.origins.get(row.targetId);
|
|
1540
|
+
if (origin === undefined || origin.unreadable !== undefined)
|
|
1541
|
+
continue;
|
|
1542
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
1510
1543
|
}
|
|
1511
|
-
|
|
1512
|
-
const { record, token } = await claimLease(backend, targetId, {
|
|
1513
|
-
label,
|
|
1514
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1515
|
-
liveIds
|
|
1516
|
-
});
|
|
1517
|
-
return {
|
|
1518
|
-
lease: token,
|
|
1519
|
-
targetId,
|
|
1520
|
-
url,
|
|
1521
|
-
label: record.label,
|
|
1522
|
-
ttlMs: record.ttlMs,
|
|
1523
|
-
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1524
|
-
};
|
|
1544
|
+
return out;
|
|
1525
1545
|
}
|
|
1526
|
-
async function
|
|
1527
|
-
|
|
1528
|
-
|
|
1546
|
+
async function reapStaleAgentTabs(driver, backend) {
|
|
1547
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1548
|
+
const [origins, leases] = await Promise.all([
|
|
1549
|
+
originIndex(backend, undefined),
|
|
1550
|
+
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1551
|
+
]);
|
|
1552
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
1553
|
+
const closed = [];
|
|
1554
|
+
for (const c of candidates) {
|
|
1555
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
1556
|
+
if (res.success !== true)
|
|
1557
|
+
continue;
|
|
1558
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
1559
|
+
closed.push(c);
|
|
1529
1560
|
}
|
|
1530
|
-
return
|
|
1561
|
+
return closed;
|
|
1531
1562
|
}
|
|
1532
|
-
async function listLeasesTool(driver, _args = {}) {
|
|
1533
|
-
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1534
|
-
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1535
|
-
return { leases, count: leases.length };
|
|
1536
|
-
}
|
|
1537
|
-
var LEASE_TOOLS = {
|
|
1538
|
-
claim_page: claimPage,
|
|
1539
|
-
release_page: releasePage,
|
|
1540
|
-
list_leases: listLeasesTool
|
|
1541
|
-
};
|
|
1542
1563
|
|
|
1543
1564
|
// src/shared-tools.ts
|
|
1544
1565
|
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -1551,7 +1572,7 @@ class SharedToolError extends Error {
|
|
|
1551
1572
|
function target3(p) {
|
|
1552
1573
|
return { id: p.id, url: p.url, title: p.title };
|
|
1553
1574
|
}
|
|
1554
|
-
function
|
|
1575
|
+
function backendOf(driver) {
|
|
1555
1576
|
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1556
1577
|
}
|
|
1557
1578
|
async function withPage2(driver, target, fn) {
|
|
@@ -1565,7 +1586,7 @@ async function withPage2(driver, target, fn) {
|
|
|
1565
1586
|
async function resolvePage(driver, selector) {
|
|
1566
1587
|
const pages = await driver.listPages();
|
|
1567
1588
|
const hit = await pickPage(driver, pages, selector);
|
|
1568
|
-
await assertLeaseOk(
|
|
1589
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1569
1590
|
return hit;
|
|
1570
1591
|
}
|
|
1571
1592
|
async function pickPage(driver, pages, selector) {
|
|
@@ -1620,8 +1641,10 @@ async function reapSet(driver, pages, all) {
|
|
|
1620
1641
|
}
|
|
1621
1642
|
}
|
|
1622
1643
|
async function listPages(driver, args = {}) {
|
|
1623
|
-
const
|
|
1624
|
-
const
|
|
1644
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
|
|
1645
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1646
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1647
|
+
const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
|
|
1625
1648
|
const annotated = pages.map((p) => {
|
|
1626
1649
|
const rec = ledger.get(p.id);
|
|
1627
1650
|
if (!rec)
|
|
@@ -1630,16 +1653,18 @@ async function listPages(driver, args = {}) {
|
|
|
1630
1653
|
return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
1631
1654
|
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
1632
1655
|
});
|
|
1633
|
-
return { pages: annotated, count: annotated.length };
|
|
1656
|
+
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1634
1657
|
}
|
|
1635
1658
|
async function newPage(driver, args = {}) {
|
|
1636
1659
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1637
|
-
const p = await newTrackedPage(driver,
|
|
1638
|
-
|
|
1660
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
1661
|
+
const explicit = args.claim === true;
|
|
1662
|
+
if (!explicit && !requireLease())
|
|
1639
1663
|
return { targetId: p.id, url: p.url };
|
|
1640
|
-
const { record, token } = await claimLease(
|
|
1664
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
1641
1665
|
label,
|
|
1642
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
1666
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1667
|
+
auto: !explicit
|
|
1643
1668
|
});
|
|
1644
1669
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1645
1670
|
}
|
|
@@ -1650,7 +1675,7 @@ async function closePage(driver, args) {
|
|
|
1650
1675
|
const res = await driver.closePage(p.id);
|
|
1651
1676
|
if (!res.success)
|
|
1652
1677
|
return { closed: p.id, success: false, leaseReleased: false };
|
|
1653
|
-
const released = await releaseLeaseFor(
|
|
1678
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
1654
1679
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1655
1680
|
}
|
|
1656
1681
|
async function writeSelectedFile(id) {
|
|
@@ -1975,6 +2000,103 @@ var SHARED_TOOLS = {
|
|
|
1975
2000
|
handle_dialog: handleDialog
|
|
1976
2001
|
};
|
|
1977
2002
|
|
|
2003
|
+
// src/leases-tools.ts
|
|
2004
|
+
class LeaseToolError extends Error {
|
|
2005
|
+
}
|
|
2006
|
+
function backendOf2(driver) {
|
|
2007
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
2008
|
+
}
|
|
2009
|
+
async function claimPage(driver, args = {}) {
|
|
2010
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2011
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2012
|
+
if (hasTarget && hasTargetId) {
|
|
2013
|
+
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
|
|
2014
|
+
}
|
|
2015
|
+
const backend = backendOf2(driver);
|
|
2016
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
2017
|
+
const pages = await driver.listPages();
|
|
2018
|
+
let targetId;
|
|
2019
|
+
let url;
|
|
2020
|
+
let opened;
|
|
2021
|
+
if (hasTarget) {
|
|
2022
|
+
const hit = await pickPage(driver, pages, args.target).catch((err) => {
|
|
2023
|
+
throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
|
|
2024
|
+
});
|
|
2025
|
+
targetId = hit.id;
|
|
2026
|
+
url = hit.url;
|
|
2027
|
+
opened = false;
|
|
2028
|
+
} else if (hasTargetId) {
|
|
2029
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
2030
|
+
if (!existing)
|
|
2031
|
+
throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
2032
|
+
targetId = existing.id;
|
|
2033
|
+
url = existing.url;
|
|
2034
|
+
opened = false;
|
|
2035
|
+
} else {
|
|
2036
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
2037
|
+
targetId = page.id;
|
|
2038
|
+
url = page.url;
|
|
2039
|
+
opened = true;
|
|
2040
|
+
}
|
|
2041
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2042
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
2043
|
+
label,
|
|
2044
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2045
|
+
liveIds
|
|
2046
|
+
});
|
|
2047
|
+
return {
|
|
2048
|
+
lease: token,
|
|
2049
|
+
targetId,
|
|
2050
|
+
url,
|
|
2051
|
+
label: record.label,
|
|
2052
|
+
ttlMs: record.ttlMs,
|
|
2053
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2054
|
+
opened
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
async function releasePage(driver, args = {}) {
|
|
2058
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
2059
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2060
|
+
if (hasLease === hasTarget) {
|
|
2061
|
+
throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
|
|
2062
|
+
}
|
|
2063
|
+
const backend = backendOf2(driver);
|
|
2064
|
+
let targetId;
|
|
2065
|
+
let released;
|
|
2066
|
+
if (hasLease) {
|
|
2067
|
+
const parts = tokenParts(args.lease);
|
|
2068
|
+
if (!parts)
|
|
2069
|
+
return { released: false, closed: false };
|
|
2070
|
+
targetId = parts.targetId;
|
|
2071
|
+
released = (await releaseLease(args.lease)).released;
|
|
2072
|
+
} else {
|
|
2073
|
+
const page = await resolvePage(driver, args.target);
|
|
2074
|
+
targetId = page.id;
|
|
2075
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
2076
|
+
}
|
|
2077
|
+
if (!released)
|
|
2078
|
+
return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
|
|
2079
|
+
const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
|
|
2080
|
+
if (!shouldClose)
|
|
2081
|
+
return { released: true, closed: false, targetId };
|
|
2082
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
2083
|
+
return { released: true, closed: res.success === true, targetId };
|
|
2084
|
+
}
|
|
2085
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
2086
|
+
const backend = backendOf2(driver);
|
|
2087
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2088
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2089
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
2090
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2091
|
+
const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2092
|
+
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2093
|
+
}
|
|
2094
|
+
var LEASE_TOOLS = {
|
|
2095
|
+
claim_page: claimPage,
|
|
2096
|
+
release_page: releasePage,
|
|
2097
|
+
list_leases: listLeasesTool
|
|
2098
|
+
};
|
|
2099
|
+
|
|
1978
2100
|
// src/tools/console.ts
|
|
1979
2101
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1980
2102
|
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,16 @@ function leaseTtlMs() {
|
|
|
11
11
|
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
12
12
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
13
13
|
}
|
|
14
|
+
var longLived = false;
|
|
15
|
+
function markLongLivedProcess(value = true) {
|
|
16
|
+
longLived = value;
|
|
17
|
+
}
|
|
18
|
+
function requireLease() {
|
|
19
|
+
if (!longLived)
|
|
20
|
+
return false;
|
|
21
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
22
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
23
|
+
}
|
|
14
24
|
function defaultLabel() {
|
|
15
25
|
return `pid-${process.pid}`;
|
|
16
26
|
}
|
|
@@ -97,7 +107,8 @@ async function claimLease(backend, targetId, opts) {
|
|
|
97
107
|
label: opts.label,
|
|
98
108
|
createdAt: now,
|
|
99
109
|
lastUsedAt: now,
|
|
100
|
-
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
110
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
111
|
+
auto: opts.auto === true
|
|
101
112
|
};
|
|
102
113
|
try {
|
|
103
114
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -173,8 +184,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
173
184
|
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
174
185
|
const where = describeTarget(targetId, ctx);
|
|
175
186
|
if (token === undefined) {
|
|
176
|
-
if (!held)
|
|
187
|
+
if (!held) {
|
|
188
|
+
if (requireLease()) {
|
|
189
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
190
|
+
}
|
|
177
191
|
return;
|
|
192
|
+
}
|
|
193
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
194
|
+
await touchLease(held, now);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
178
197
|
throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
|
|
179
198
|
}
|
|
180
199
|
const parts = tokenParts(token);
|
|
@@ -182,8 +201,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
182
201
|
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
183
202
|
}
|
|
184
203
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
185
|
-
if (!held)
|
|
204
|
+
if (!held) {
|
|
205
|
+
if (requireLease()) {
|
|
206
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
207
|
+
}
|
|
186
208
|
return;
|
|
209
|
+
}
|
|
187
210
|
throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
|
|
188
211
|
}
|
|
189
212
|
if (!held) {
|
|
@@ -1427,6 +1450,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
|
|
|
1427
1450
|
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1428
1451
|
} catch {}
|
|
1429
1452
|
}
|
|
1453
|
+
async function readOrigin(backend, targetId) {
|
|
1454
|
+
let raw;
|
|
1455
|
+
try {
|
|
1456
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1457
|
+
} catch {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
const rec = JSON.parse(raw);
|
|
1462
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1463
|
+
return rec;
|
|
1464
|
+
} catch {}
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1430
1467
|
async function listOrigins(opts = {}) {
|
|
1431
1468
|
let names = [];
|
|
1432
1469
|
try {
|
|
@@ -1484,59 +1521,43 @@ async function newTrackedPage(driver, backend, opts = {}) {
|
|
|
1484
1521
|
return page;
|
|
1485
1522
|
}
|
|
1486
1523
|
|
|
1487
|
-
// src/
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
1505
|
-
if (!existing)
|
|
1506
|
-
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1507
|
-
url = existing.url;
|
|
1524
|
+
// src/reap.ts
|
|
1525
|
+
function staleAgentTabs(input) {
|
|
1526
|
+
const live = new Set(input.livePageIds);
|
|
1527
|
+
const out = [];
|
|
1528
|
+
for (const row of input.leases) {
|
|
1529
|
+
if (row.backend !== input.backend)
|
|
1530
|
+
continue;
|
|
1531
|
+
if (row.unreadable !== undefined)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1534
|
+
continue;
|
|
1535
|
+
if (!live.has(row.targetId))
|
|
1536
|
+
continue;
|
|
1537
|
+
const origin = input.origins.get(row.targetId);
|
|
1538
|
+
if (origin === undefined || origin.unreadable !== undefined)
|
|
1539
|
+
continue;
|
|
1540
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
1508
1541
|
}
|
|
1509
|
-
|
|
1510
|
-
const { record, token } = await claimLease(backend, targetId, {
|
|
1511
|
-
label,
|
|
1512
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1513
|
-
liveIds
|
|
1514
|
-
});
|
|
1515
|
-
return {
|
|
1516
|
-
lease: token,
|
|
1517
|
-
targetId,
|
|
1518
|
-
url,
|
|
1519
|
-
label: record.label,
|
|
1520
|
-
ttlMs: record.ttlMs,
|
|
1521
|
-
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1522
|
-
};
|
|
1542
|
+
return out;
|
|
1523
1543
|
}
|
|
1524
|
-
async function
|
|
1525
|
-
|
|
1526
|
-
|
|
1544
|
+
async function reapStaleAgentTabs(driver, backend) {
|
|
1545
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1546
|
+
const [origins, leases] = await Promise.all([
|
|
1547
|
+
originIndex(backend, undefined),
|
|
1548
|
+
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1549
|
+
]);
|
|
1550
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
1551
|
+
const closed = [];
|
|
1552
|
+
for (const c of candidates) {
|
|
1553
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
1554
|
+
if (res.success !== true)
|
|
1555
|
+
continue;
|
|
1556
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
1557
|
+
closed.push(c);
|
|
1527
1558
|
}
|
|
1528
|
-
return
|
|
1559
|
+
return closed;
|
|
1529
1560
|
}
|
|
1530
|
-
async function listLeasesTool(driver, _args = {}) {
|
|
1531
|
-
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1532
|
-
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1533
|
-
return { leases, count: leases.length };
|
|
1534
|
-
}
|
|
1535
|
-
var LEASE_TOOLS = {
|
|
1536
|
-
claim_page: claimPage,
|
|
1537
|
-
release_page: releasePage,
|
|
1538
|
-
list_leases: listLeasesTool
|
|
1539
|
-
};
|
|
1540
1561
|
|
|
1541
1562
|
// src/shared-tools.ts
|
|
1542
1563
|
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -1549,7 +1570,7 @@ class SharedToolError extends Error {
|
|
|
1549
1570
|
function target3(p) {
|
|
1550
1571
|
return { id: p.id, url: p.url, title: p.title };
|
|
1551
1572
|
}
|
|
1552
|
-
function
|
|
1573
|
+
function backendOf(driver) {
|
|
1553
1574
|
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1554
1575
|
}
|
|
1555
1576
|
async function withPage2(driver, target, fn) {
|
|
@@ -1563,7 +1584,7 @@ async function withPage2(driver, target, fn) {
|
|
|
1563
1584
|
async function resolvePage(driver, selector) {
|
|
1564
1585
|
const pages = await driver.listPages();
|
|
1565
1586
|
const hit = await pickPage(driver, pages, selector);
|
|
1566
|
-
await assertLeaseOk(
|
|
1587
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1567
1588
|
return hit;
|
|
1568
1589
|
}
|
|
1569
1590
|
async function pickPage(driver, pages, selector) {
|
|
@@ -1618,8 +1639,10 @@ async function reapSet(driver, pages, all) {
|
|
|
1618
1639
|
}
|
|
1619
1640
|
}
|
|
1620
1641
|
async function listPages(driver, args = {}) {
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
1642
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
|
|
1643
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1644
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1645
|
+
const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
|
|
1623
1646
|
const annotated = pages.map((p) => {
|
|
1624
1647
|
const rec = ledger.get(p.id);
|
|
1625
1648
|
if (!rec)
|
|
@@ -1628,16 +1651,18 @@ async function listPages(driver, args = {}) {
|
|
|
1628
1651
|
return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
1629
1652
|
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
1630
1653
|
});
|
|
1631
|
-
return { pages: annotated, count: annotated.length };
|
|
1654
|
+
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1632
1655
|
}
|
|
1633
1656
|
async function newPage(driver, args = {}) {
|
|
1634
1657
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1635
|
-
const p = await newTrackedPage(driver,
|
|
1636
|
-
|
|
1658
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
1659
|
+
const explicit = args.claim === true;
|
|
1660
|
+
if (!explicit && !requireLease())
|
|
1637
1661
|
return { targetId: p.id, url: p.url };
|
|
1638
|
-
const { record, token } = await claimLease(
|
|
1662
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
1639
1663
|
label,
|
|
1640
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
1664
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1665
|
+
auto: !explicit
|
|
1641
1666
|
});
|
|
1642
1667
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1643
1668
|
}
|
|
@@ -1648,7 +1673,7 @@ async function closePage(driver, args) {
|
|
|
1648
1673
|
const res = await driver.closePage(p.id);
|
|
1649
1674
|
if (!res.success)
|
|
1650
1675
|
return { closed: p.id, success: false, leaseReleased: false };
|
|
1651
|
-
const released = await releaseLeaseFor(
|
|
1676
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
1652
1677
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1653
1678
|
}
|
|
1654
1679
|
async function writeSelectedFile(id) {
|
|
@@ -1973,6 +1998,103 @@ var SHARED_TOOLS = {
|
|
|
1973
1998
|
handle_dialog: handleDialog
|
|
1974
1999
|
};
|
|
1975
2000
|
|
|
2001
|
+
// src/leases-tools.ts
|
|
2002
|
+
class LeaseToolError extends Error {
|
|
2003
|
+
}
|
|
2004
|
+
function backendOf2(driver) {
|
|
2005
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
2006
|
+
}
|
|
2007
|
+
async function claimPage(driver, args = {}) {
|
|
2008
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2009
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2010
|
+
if (hasTarget && hasTargetId) {
|
|
2011
|
+
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
|
|
2012
|
+
}
|
|
2013
|
+
const backend = backendOf2(driver);
|
|
2014
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
2015
|
+
const pages = await driver.listPages();
|
|
2016
|
+
let targetId;
|
|
2017
|
+
let url;
|
|
2018
|
+
let opened;
|
|
2019
|
+
if (hasTarget) {
|
|
2020
|
+
const hit = await pickPage(driver, pages, args.target).catch((err) => {
|
|
2021
|
+
throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
|
|
2022
|
+
});
|
|
2023
|
+
targetId = hit.id;
|
|
2024
|
+
url = hit.url;
|
|
2025
|
+
opened = false;
|
|
2026
|
+
} else if (hasTargetId) {
|
|
2027
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
2028
|
+
if (!existing)
|
|
2029
|
+
throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
2030
|
+
targetId = existing.id;
|
|
2031
|
+
url = existing.url;
|
|
2032
|
+
opened = false;
|
|
2033
|
+
} else {
|
|
2034
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
2035
|
+
targetId = page.id;
|
|
2036
|
+
url = page.url;
|
|
2037
|
+
opened = true;
|
|
2038
|
+
}
|
|
2039
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2040
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
2041
|
+
label,
|
|
2042
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2043
|
+
liveIds
|
|
2044
|
+
});
|
|
2045
|
+
return {
|
|
2046
|
+
lease: token,
|
|
2047
|
+
targetId,
|
|
2048
|
+
url,
|
|
2049
|
+
label: record.label,
|
|
2050
|
+
ttlMs: record.ttlMs,
|
|
2051
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2052
|
+
opened
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
async function releasePage(driver, args = {}) {
|
|
2056
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
2057
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2058
|
+
if (hasLease === hasTarget) {
|
|
2059
|
+
throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
|
|
2060
|
+
}
|
|
2061
|
+
const backend = backendOf2(driver);
|
|
2062
|
+
let targetId;
|
|
2063
|
+
let released;
|
|
2064
|
+
if (hasLease) {
|
|
2065
|
+
const parts = tokenParts(args.lease);
|
|
2066
|
+
if (!parts)
|
|
2067
|
+
return { released: false, closed: false };
|
|
2068
|
+
targetId = parts.targetId;
|
|
2069
|
+
released = (await releaseLease(args.lease)).released;
|
|
2070
|
+
} else {
|
|
2071
|
+
const page = await resolvePage(driver, args.target);
|
|
2072
|
+
targetId = page.id;
|
|
2073
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
2074
|
+
}
|
|
2075
|
+
if (!released)
|
|
2076
|
+
return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
|
|
2077
|
+
const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
|
|
2078
|
+
if (!shouldClose)
|
|
2079
|
+
return { released: true, closed: false, targetId };
|
|
2080
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
2081
|
+
return { released: true, closed: res.success === true, targetId };
|
|
2082
|
+
}
|
|
2083
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
2084
|
+
const backend = backendOf2(driver);
|
|
2085
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2086
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2087
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
2088
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2089
|
+
const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2090
|
+
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2091
|
+
}
|
|
2092
|
+
var LEASE_TOOLS = {
|
|
2093
|
+
claim_page: claimPage,
|
|
2094
|
+
release_page: releasePage,
|
|
2095
|
+
list_leases: listLeasesTool
|
|
2096
|
+
};
|
|
2097
|
+
|
|
1976
2098
|
// src/tools/console.ts
|
|
1977
2099
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1978
2100
|
|