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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 41 tools including built-in network mocking, a cookie group, tab leases, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/leases-tools.ts
CHANGED
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
* Firefox, exactly as the 23 unified tools in that file do.
|
|
5
5
|
*/
|
|
6
6
|
import type { BrowserDriver } from "./driver.ts";
|
|
7
|
-
import { newTrackedPage } from "./origins.ts";
|
|
7
|
+
import { newTrackedPage, readOrigin } from "./origins.ts";
|
|
8
|
+
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
9
|
+
import { pickPage, resolvePage } from "./shared-tools.ts";
|
|
10
|
+
import type { TargetSelector } from "./types.ts";
|
|
8
11
|
import {
|
|
9
12
|
claimLease,
|
|
10
13
|
defaultLabel,
|
|
11
14
|
leaseTtlMs,
|
|
12
15
|
listLeases,
|
|
13
16
|
releaseLease,
|
|
17
|
+
releaseLeaseFor,
|
|
18
|
+
requireLease,
|
|
19
|
+
tokenParts,
|
|
14
20
|
type LeaseBackend,
|
|
15
21
|
type LeaseSummary,
|
|
16
22
|
type LeaseToken,
|
|
@@ -31,34 +37,87 @@ export interface ClaimPageResult {
|
|
|
31
37
|
label: string;
|
|
32
38
|
ttlMs: number;
|
|
33
39
|
expiresAt: number;
|
|
40
|
+
/** true when this call CREATED the tab, false when it claimed one that was
|
|
41
|
+
* already open. The same distinction the creation ledger records, surfaced
|
|
42
|
+
* in the answer so a caller knows without a second lookup whether
|
|
43
|
+
* release_page will close the tab. */
|
|
44
|
+
opened: boolean;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
|
-
* Claim a tab
|
|
38
|
-
*
|
|
39
|
-
*
|
|
48
|
+
* Claim a tab, in one of two modes.
|
|
49
|
+
*
|
|
50
|
+
* OPEN: with neither `target` nor `targetId`, opens a fresh tab (optionally at
|
|
51
|
+
* `url`) and claims that, so "give me my own tab" is one call.
|
|
52
|
+
*
|
|
53
|
+
* TAKE OVER: with `target`, claims a tab that is ALREADY OPEN — the first-class
|
|
54
|
+
* path for "work in the tab I have open", which previously had none, because
|
|
55
|
+
* `targetId` demands an exact id a human never has to hand and no id at all
|
|
56
|
+
* meant a brand new tab. `target` takes the toolkit's whole selector grammar
|
|
57
|
+
* (active | index:N | url:<substr> | title:<substr> | <targetId>).
|
|
58
|
+
*
|
|
59
|
+
* `targetId` is unchanged and still accepted; `target` is the same thing with a
|
|
60
|
+
* grammar, so exactly one of the two may be given.
|
|
61
|
+
*
|
|
62
|
+
* WHY RESOLUTION IS GATE-FREE HERE. `target` resolves through pickPage, NOT
|
|
63
|
+
* resolvePage. resolvePage runs assertLeaseOk, which under CDP_REQUIRE_LEASE
|
|
64
|
+
* auto-acquires a lease on an unleased tab: routing a claim through it would
|
|
65
|
+
* claim the tab twice and collide with itself. The protection is not lost, only
|
|
66
|
+
* moved — claimLease below still refuses a tab a live process holds.
|
|
67
|
+
*
|
|
68
|
+
* TAKEOVER IS OF UNLEASED TABS ONLY. There is deliberately no force/steal
|
|
69
|
+
* option: a tab another live agent holds stays refused with the same conflict
|
|
70
|
+
* error as before. The tab this feature exists to take over is a human's, and a
|
|
71
|
+
* human's tab carries no lease.
|
|
72
|
+
*
|
|
73
|
+
* RESOLUTION NEVER CREATES A TAB. With `target` given, the open branch is
|
|
74
|
+
* unreachable: an unmatched selector is an error, never a silently substituted
|
|
75
|
+
* new tab, because "the tab I asked for is not there" and "here is an empty one
|
|
76
|
+
* instead" are answers a caller must be able to tell apart.
|
|
40
77
|
*/
|
|
41
78
|
export async function claimPage(
|
|
42
79
|
driver: BrowserDriver,
|
|
43
|
-
args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
|
|
80
|
+
args: { target?: TargetSelector; targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
|
|
44
81
|
): Promise<ClaimPageResult> {
|
|
82
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
83
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
84
|
+
if (hasTarget && hasTargetId) {
|
|
85
|
+
throw new LeaseToolError(
|
|
86
|
+
"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.",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
45
89
|
const backend = backendOf(driver);
|
|
46
90
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
47
91
|
const pages = await driver.listPages();
|
|
48
|
-
let targetId
|
|
49
|
-
let url
|
|
50
|
-
|
|
92
|
+
let targetId: string;
|
|
93
|
+
let url: string;
|
|
94
|
+
let opened: boolean;
|
|
95
|
+
if (hasTarget) {
|
|
96
|
+
// Gate-free on purpose: see the header. No tab is created on this path,
|
|
97
|
+
// including when nothing matches.
|
|
98
|
+
const hit = await pickPage(driver, pages, args.target).catch((err: unknown) => {
|
|
99
|
+
throw new LeaseToolError(
|
|
100
|
+
`claim_page: no open tab matches target '${args.target}' (${(err as Error).message}). No tab was opened: claim_page never creates one when 'target' is given.`,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
targetId = hit.id;
|
|
104
|
+
url = hit.url;
|
|
105
|
+
opened = false;
|
|
106
|
+
} else if (hasTargetId) {
|
|
107
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
108
|
+
if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
109
|
+
targetId = existing.id;
|
|
110
|
+
url = existing.url;
|
|
111
|
+
opened = false;
|
|
112
|
+
} else {
|
|
51
113
|
// Creation ledger, not the lease: this record survives release_page and
|
|
52
114
|
// expiry, so the tab stays attributable to `label` after the lease is gone.
|
|
53
|
-
// Claiming an EXISTING tab (the
|
|
54
|
-
// toolkit did not create it and provenance is not ownership.
|
|
115
|
+
// Claiming an EXISTING tab (the branches above) records nothing, because
|
|
116
|
+
// this toolkit did not create it and provenance is not ownership.
|
|
55
117
|
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
56
118
|
targetId = page.id;
|
|
57
119
|
url = page.url;
|
|
58
|
-
|
|
59
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
60
|
-
if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
61
|
-
url = existing.url;
|
|
120
|
+
opened = true;
|
|
62
121
|
}
|
|
63
122
|
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
64
123
|
const { record, token } = await claimLease(backend, targetId, {
|
|
@@ -73,31 +132,93 @@ export async function claimPage(
|
|
|
73
132
|
label: record.label,
|
|
74
133
|
ttlMs: record.ttlMs,
|
|
75
134
|
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
135
|
+
opened,
|
|
76
136
|
};
|
|
77
137
|
}
|
|
78
138
|
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
139
|
+
/**
|
|
140
|
+
* Give a lease back, and close the tab if this toolkit opened it.
|
|
141
|
+
*
|
|
142
|
+
* TWO WAYS IN, because an auto-acquired lease never handed the caller a token:
|
|
143
|
+
* `lease` is the 1.4.0 path, `target` resolves through shared-tools' resolvePage
|
|
144
|
+
* and is authorized by the same gate as any other call. Exactly one is required;
|
|
145
|
+
* accepting both would leave it ambiguous which one authorizes the close.
|
|
146
|
+
*
|
|
147
|
+
* WHY PROVENANCE DECIDES THE CLOSE. A lease says who is driving a tab, never who
|
|
148
|
+
* opened it. Closing on release is right for a tab this toolkit opened for an
|
|
149
|
+
* agent and wrong for a tab the human already had open and an agent merely
|
|
150
|
+
* claimed. origins.ts records exactly that distinction and its records outlive
|
|
151
|
+
* the lease, so the answer is still there at release time.
|
|
152
|
+
*
|
|
153
|
+
* A RELEASE THAT DID NOT HAPPEN NEVER CLOSES. An already-released or reclaimed
|
|
154
|
+
* lease reports released:false, and the tab may well belong to another agent by
|
|
155
|
+
* then; closing it would destroy a tab someone else is driving. This is the one
|
|
156
|
+
* rule in this function that is a safety property rather than a convenience.
|
|
157
|
+
*/
|
|
81
158
|
export async function releasePage(
|
|
82
|
-
|
|
83
|
-
args: { lease?: LeaseToken },
|
|
84
|
-
): Promise<{ released: boolean }> {
|
|
85
|
-
|
|
86
|
-
|
|
159
|
+
driver: BrowserDriver,
|
|
160
|
+
args: { lease?: LeaseToken; target?: TargetSelector; close?: boolean } = {},
|
|
161
|
+
): Promise<{ released: boolean; closed: boolean; targetId?: string }> {
|
|
162
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
163
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
164
|
+
if (hasLease === hasTarget) {
|
|
165
|
+
throw new LeaseToolError(
|
|
166
|
+
"release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const backend = backendOf(driver);
|
|
171
|
+
let targetId: string;
|
|
172
|
+
let released: boolean;
|
|
173
|
+
|
|
174
|
+
if (hasLease) {
|
|
175
|
+
const parts = tokenParts(args.lease as LeaseToken);
|
|
176
|
+
// A malformed token names no tab, so there is nothing to release and
|
|
177
|
+
// nothing to close. Idempotent rather than throwing, as in 1.4.0.
|
|
178
|
+
if (!parts) return { released: false, closed: false };
|
|
179
|
+
targetId = parts.targetId;
|
|
180
|
+
released = (await releaseLease(args.lease as LeaseToken)).released;
|
|
181
|
+
} else {
|
|
182
|
+
// resolvePage runs assertLeaseOk, which IS the authorization: a tab held by
|
|
183
|
+
// another process throws here, and under strict mode an unleased tab is
|
|
184
|
+
// acquired first, so the caller holds it by the time we release it.
|
|
185
|
+
const page = await resolvePage(driver, args.target);
|
|
186
|
+
targetId = page.id;
|
|
187
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
87
188
|
}
|
|
88
|
-
|
|
189
|
+
|
|
190
|
+
if (!released) return { released: false, closed: false, ...(hasTarget ? { targetId } : {}) };
|
|
191
|
+
|
|
192
|
+
const shouldClose =
|
|
193
|
+
args.close === false ? false
|
|
194
|
+
: args.close === true ? true
|
|
195
|
+
: (await readOrigin(backend, targetId)) !== undefined;
|
|
196
|
+
if (!shouldClose) return { released: true, closed: false, targetId };
|
|
197
|
+
|
|
198
|
+
// The release already succeeded and is not undone by a failed close: the
|
|
199
|
+
// caller has genuinely given the lease back either way, and throwing here
|
|
200
|
+
// would report a release that did happen as an error.
|
|
201
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
202
|
+
return { released: true, closed: res.success === true, targetId };
|
|
89
203
|
}
|
|
90
204
|
|
|
91
205
|
/** Enumerate active leases for diagnosis. Requires no token by design. */
|
|
92
206
|
export async function listLeasesTool(
|
|
93
207
|
driver: BrowserDriver,
|
|
94
208
|
_args: Record<string, never> = {} as Record<string, never>,
|
|
95
|
-
): Promise<{ leases: LeaseSummary[]; count: number }> {
|
|
209
|
+
): Promise<{ leases: LeaseSummary[]; count: number; reaped?: ReapedTab[] }> {
|
|
210
|
+
const backend = backendOf(driver);
|
|
211
|
+
// Same reap as list_pages, and for the same reason: this is the other tool an
|
|
212
|
+
// operator runs when tabs look wrong, so it must not report a lease it is
|
|
213
|
+
// about to delete. Strict mode only.
|
|
214
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
215
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
96
216
|
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
97
217
|
// liveBackend scopes the target-gone test to the browser these ids came from,
|
|
98
218
|
// so leases held on the OTHER backend are not mislabeled as reclaimable.
|
|
99
|
-
const
|
|
100
|
-
|
|
219
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
220
|
+
const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
221
|
+
return { leases, count: leases.length, ...(reaped.length ? { reaped } : {}) };
|
|
101
222
|
}
|
|
102
223
|
|
|
103
224
|
export const LEASE_TOOLS = {
|
package/src/leases.ts
CHANGED
|
@@ -49,6 +49,15 @@ export interface LeaseRecord {
|
|
|
49
49
|
createdAt: number;
|
|
50
50
|
lastUsedAt: number;
|
|
51
51
|
ttlMs: number;
|
|
52
|
+
/**
|
|
53
|
+
* How this lease was taken, and therefore who may use it without a token.
|
|
54
|
+
* true - the gate acquired it implicitly; passes for any call from `pid`.
|
|
55
|
+
* false - claim_page or new_page{claim:true}; the token is required, even
|
|
56
|
+
* from the owning process.
|
|
57
|
+
* Absent on records written by <=1.4.0 and read as false, so an upgrade never
|
|
58
|
+
* downgrades a held lease from token-required to pid-only.
|
|
59
|
+
*/
|
|
60
|
+
auto?: boolean;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/** 15 minutes. Refresh-on-use means an active agent never expires, so this only
|
|
@@ -65,6 +74,43 @@ export function leaseTtlMs(): number {
|
|
|
65
74
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Whether THIS process is the long-lived MCP server rather than a one-shot CLI
|
|
79
|
+
* invocation. Module state, which this file's header otherwise forbids: that
|
|
80
|
+
* prohibition is about state describing OTHER processes' ownership, which must
|
|
81
|
+
* live on disk because two MCP servers drive one browser. This describes this
|
|
82
|
+
* process's own role, which no other process can observe or contend for, so a
|
|
83
|
+
* module flag is the correct scope rather than a violation of the rule.
|
|
84
|
+
*/
|
|
85
|
+
let longLived = false;
|
|
86
|
+
|
|
87
|
+
/** Called once by mcp.ts at startup. cli.ts never calls it. The parameter
|
|
88
|
+
* exists so a test can put the module back into CLI state. */
|
|
89
|
+
export function markLongLivedProcess(value = true): void {
|
|
90
|
+
longLived = value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Strict mode: every tool call acquires a lease for the tab it resolves, and
|
|
95
|
+
* stale agent tabs are reaped on read. Off by default, so 1.4.0 consumers are
|
|
96
|
+
* unaffected.
|
|
97
|
+
*
|
|
98
|
+
* ALWAYS FALSE UNDER THE CLI, regardless of the env var, and that is not a
|
|
99
|
+
* convenience. cli.ts already refuses claim_page because a CLI invocation is
|
|
100
|
+
* one process per call, so its lease is reclaimable by the dead-pid rule the
|
|
101
|
+
* moment the process exits, and "a lease that is reclaimable on arrival is
|
|
102
|
+
* worse than no lease". Auto-acquire would mint exactly such a lease on every
|
|
103
|
+
* call. The sharper danger is reap: those dead-pid records would read as
|
|
104
|
+
* orphaned agent tabs, so a CLI user running list_pages twice could watch the
|
|
105
|
+
* second run close the tabs the first run leased. Read per call, like
|
|
106
|
+
* leaseDir(), so a test can flip it.
|
|
107
|
+
*/
|
|
108
|
+
export function requireLease(): boolean {
|
|
109
|
+
if (!longLived) return false;
|
|
110
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
111
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
112
|
+
}
|
|
113
|
+
|
|
68
114
|
/** The label recorded when a caller supplies none. ONE definition, because it
|
|
69
115
|
* is now written by three call sites (claim_page, new_page, and the origin
|
|
70
116
|
* ledger) and a second rule would make the same tab describe itself two
|
|
@@ -158,6 +204,9 @@ export interface ClaimOptions {
|
|
|
158
204
|
now?: number;
|
|
159
205
|
/** Live target ids from the browser, enabling the "target-gone" reclamation. */
|
|
160
206
|
liveIds?: readonly string[];
|
|
207
|
+
/** Marks the lease gate-acquired. See LeaseRecord.auto. Defaults to false:
|
|
208
|
+
* an unmarked claim is an explicit one, which is the stricter reading. */
|
|
209
|
+
auto?: boolean;
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
/** Why an existing lease may be taken over. All three are sufficient alone. */
|
|
@@ -214,6 +263,7 @@ export async function claimLease(
|
|
|
214
263
|
createdAt: now,
|
|
215
264
|
lastUsedAt: now,
|
|
216
265
|
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
266
|
+
auto: opts.auto === true,
|
|
217
267
|
};
|
|
218
268
|
try {
|
|
219
269
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -370,7 +420,29 @@ export async function assertLeaseOk(
|
|
|
370
420
|
const where = describeTarget(targetId, ctx);
|
|
371
421
|
|
|
372
422
|
if (token === undefined) {
|
|
373
|
-
if (!held)
|
|
423
|
+
if (!held) {
|
|
424
|
+
// STRICT MODE: acquire rather than wave through. This is the change that
|
|
425
|
+
// makes "you cannot drive a tab you do not hold" true, and it is done
|
|
426
|
+
// here rather than by refusing because refusing would force every caller
|
|
427
|
+
// to learn a claim/release protocol to do what used to just work.
|
|
428
|
+
// Marked auto:true, which is what lets a later call from this same
|
|
429
|
+
// process pass with no token. A LeaseConflictError from claimLease (two
|
|
430
|
+
// processes racing to acquire the same tab, resolved by the "wx"
|
|
431
|
+
// exclusive create) propagates: the loser is genuinely refused.
|
|
432
|
+
if (requireLease()) {
|
|
433
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
434
|
+
}
|
|
435
|
+
return; // unleased, or leased-but-reclaimable: today's behavior
|
|
436
|
+
}
|
|
437
|
+
// An auto lease is owned by a PROCESS, not by a token, so any call from the
|
|
438
|
+
// owning pid passes. NOT gated on requireLease(): an auto:true record can
|
|
439
|
+
// only exist because strict mode created it, and gating this too would mean
|
|
440
|
+
// turning the flag off locks this process out of leases it still holds. It
|
|
441
|
+
// cannot regress 1.4.0, where no auto:true record can exist at all.
|
|
442
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
443
|
+
await touchLease(held, now);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
374
446
|
throw new LeaseConflictError(
|
|
375
447
|
`${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.`,
|
|
376
448
|
targetId,
|
|
@@ -396,8 +468,16 @@ export async function assertLeaseOk(
|
|
|
396
468
|
// call arrives here, select_page via activatePage included.
|
|
397
469
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
398
470
|
// The token is about a different tab, so it says nothing about this one.
|
|
399
|
-
//
|
|
400
|
-
|
|
471
|
+
// Under strict mode this tab still has to be acquired: an agent holding
|
|
472
|
+
// tab A that reaches into unleased tab C is exactly the case the feature
|
|
473
|
+
// exists to cover, and it is the COMMON case under the ambient dispatch
|
|
474
|
+
// scope, not an edge one.
|
|
475
|
+
if (!held) {
|
|
476
|
+
if (requireLease()) {
|
|
477
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
401
481
|
throw new LeaseConflictError(
|
|
402
482
|
`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.`,
|
|
403
483
|
targetId,
|