pi-browser-harness 0.4.0 → 0.6.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 +27 -0
- package/README.md +150 -56
- package/package.json +2 -1
- package/skills/pi-browser-harness/SKILL.md +24 -1
- package/src/cdp/console-buffer.ts +263 -0
- package/src/cdp/daemon-transport.ts +277 -0
- package/src/cdp/session.ts +124 -23
- package/src/client.ts +29 -10
- package/src/daemon/bridge.ts +411 -0
- package/src/daemon/index.ts +114 -0
- package/src/daemon/protocol.ts +162 -0
- package/src/daemon/server.ts +245 -0
- package/src/daemon/spawn.ts +85 -0
- package/src/domains/console.ts +173 -0
- package/src/domains/navigate.ts +17 -3
- package/src/domains/setup.ts +41 -0
- package/src/index.ts +46 -25
- package/src/prompt.ts +3 -0
- package/src/registry.ts +5 -0
- package/src/setup.ts +45 -39
- package/src/util/tool.ts +19 -0
package/src/cdp/session.ts
CHANGED
|
@@ -4,6 +4,20 @@ import type { DialogInfo } from "./types";
|
|
|
4
4
|
import type { OwnershipRegistry } from "./ownership";
|
|
5
5
|
import type { CdpTransport } from "./transport";
|
|
6
6
|
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
|
|
7
|
+
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
|
|
8
|
+
|
|
9
|
+
// Per-tab state. One TabSession exists per known targetId.
|
|
10
|
+
// Dialog, page-info dirty flag, and CDP buffers are per-tab — switching
|
|
11
|
+
// tabs preserves the previous tab's data in its TabSession instead of
|
|
12
|
+
// clearing it.
|
|
13
|
+
type TabSession = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
targetId: string;
|
|
16
|
+
dialog: DialogInfo | null;
|
|
17
|
+
pageInfoDirty: boolean;
|
|
18
|
+
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
19
|
+
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
20
|
+
};
|
|
7
21
|
|
|
8
22
|
export type CdpSession = {
|
|
9
23
|
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
@@ -15,6 +29,7 @@ export type CdpSession = {
|
|
|
15
29
|
takeDialog(): DialogInfo | null;
|
|
16
30
|
drainPageInfoInvalidations(): boolean;
|
|
17
31
|
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
|
|
32
|
+
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
18
33
|
};
|
|
19
34
|
|
|
20
35
|
export const createCdpSession = (
|
|
@@ -23,17 +38,33 @@ export const createCdpSession = (
|
|
|
23
38
|
): CdpSession => {
|
|
24
39
|
let sessionId: string | null = null;
|
|
25
40
|
let targetId: string | null = null;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
|
|
42
|
+
// Per-tab tracking: maps targetId → TabSession and sessionId → targetId for
|
|
43
|
+
// event routing. Lazy: a TabSession is created on the first visit to a tab.
|
|
44
|
+
const tabs = new Map<string, TabSession>();
|
|
45
|
+
const sessionIdToTargetId = new Map<string, string>();
|
|
29
46
|
|
|
30
47
|
let activeConsumer: Promise<void> = Promise.resolve();
|
|
31
48
|
|
|
49
|
+
// Event → TabSession resolver. Uses sessionId from CDP events to find the
|
|
50
|
+
// correct TabSession, falling back to the current targetId when no sessionId.
|
|
51
|
+
const resolveTab = (evSessionId?: string): TabSession | undefined => {
|
|
52
|
+
const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
|
|
53
|
+
return tid ? tabs.get(tid) : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
32
56
|
const consumeEvents = async (): Promise<void> => {
|
|
33
57
|
for await (const ev of transport.events()) {
|
|
58
|
+
// Filter: skip events from sessions we're not currently tracking.
|
|
59
|
+
// Target.targetDestroyed is browser-level (no sessionId) — always process.
|
|
60
|
+
if (ev.method !== "Target.targetDestroyed") {
|
|
61
|
+
if (ev.sessionId && ev.sessionId !== sessionId) continue;
|
|
62
|
+
}
|
|
34
63
|
if (ev.method === "Page.javascriptDialogOpening") {
|
|
64
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
65
|
+
if (!tab) continue;
|
|
35
66
|
const params = ev.params as Partial<DialogInfo> | undefined;
|
|
36
|
-
dialog = {
|
|
67
|
+
tab.dialog = {
|
|
37
68
|
type: (params?.type as DialogInfo["type"]) ?? "alert",
|
|
38
69
|
message: params?.message ?? "",
|
|
39
70
|
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
|
|
@@ -45,16 +76,40 @@ export const createCdpSession = (
|
|
|
45
76
|
// This prevents fast dismiss flows from dropping a dialog the agent
|
|
46
77
|
// was about to read. (Fix for spec §7 predictability bug #2.)
|
|
47
78
|
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
|
|
48
|
-
|
|
79
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
80
|
+
if (tab) tab.pageInfoDirty = true;
|
|
49
81
|
}
|
|
50
82
|
if (ev.method === "Target.targetDestroyed" && ownership) {
|
|
51
83
|
const params = ev.params as { targetId?: string } | undefined;
|
|
52
|
-
if (params?.targetId)
|
|
84
|
+
if (params?.targetId) {
|
|
85
|
+
ownership.remove(params.targetId);
|
|
86
|
+
// Prune per-tab state for the destroyed target
|
|
87
|
+
const tab = tabs.get(params.targetId);
|
|
88
|
+
if (tab) {
|
|
89
|
+
sessionIdToTargetId.delete(tab.sessionId);
|
|
90
|
+
tabs.delete(params.targetId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (ev.method === "Network.requestWillBeSent") {
|
|
95
|
+
const tab = resolveTab(ev.sessionId);
|
|
96
|
+
if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
97
|
+
} else if (ev.method === "Network.responseReceived") {
|
|
98
|
+
const tab = resolveTab(ev.sessionId);
|
|
99
|
+
if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
|
|
100
|
+
} else if (ev.method === "Network.loadingFinished") {
|
|
101
|
+
const tab = resolveTab(ev.sessionId);
|
|
102
|
+
if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
|
|
103
|
+
} else if (ev.method === "Network.loadingFailed") {
|
|
104
|
+
const tab = resolveTab(ev.sessionId);
|
|
105
|
+
if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
|
|
106
|
+
} else if (ev.method === "Runtime.consoleAPICalled") {
|
|
107
|
+
const tab = resolveTab(ev.sessionId);
|
|
108
|
+
if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
|
|
109
|
+
} else if (ev.method === "Log.entryAdded") {
|
|
110
|
+
const tab = resolveTab(ev.sessionId);
|
|
111
|
+
if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
|
|
53
112
|
}
|
|
54
|
-
if (ev.method === "Network.requestWillBeSent") networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
55
|
-
else if (ev.method === "Network.responseReceived") networkBuffer.ingestResponseReceived(ev.params);
|
|
56
|
-
else if (ev.method === "Network.loadingFinished") networkBuffer.ingestLoadingFinished(ev.params);
|
|
57
|
-
else if (ev.method === "Network.loadingFailed") networkBuffer.ingestLoadingFailed(ev.params);
|
|
58
113
|
}
|
|
59
114
|
};
|
|
60
115
|
|
|
@@ -72,7 +127,8 @@ export const createCdpSession = (
|
|
|
72
127
|
transport.onClose(() => {
|
|
73
128
|
sessionId = null;
|
|
74
129
|
targetId = null;
|
|
75
|
-
|
|
130
|
+
tabs.clear();
|
|
131
|
+
sessionIdToTargetId.clear();
|
|
76
132
|
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
|
|
77
133
|
// the agent may have a pending takeDialog() call that should still see it.
|
|
78
134
|
restartConsumer();
|
|
@@ -82,7 +138,7 @@ export const createCdpSession = (
|
|
|
82
138
|
// Switching to Promise.all over a single WS pipelines the round-trips and
|
|
83
139
|
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
|
|
84
140
|
const enableDomains = async (sid: string): Promise<void> => {
|
|
85
|
-
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility"]) {
|
|
141
|
+
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
|
|
86
142
|
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
87
143
|
}
|
|
88
144
|
};
|
|
@@ -111,6 +167,15 @@ export const createCdpSession = (
|
|
|
111
167
|
const hw = ownership.harnessWindow();
|
|
112
168
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
113
169
|
}
|
|
170
|
+
// Prune per-tab state for pages that no longer exist
|
|
171
|
+
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
172
|
+
for (const tid of tabs.keys()) {
|
|
173
|
+
if (!liveTargetIds.has(tid)) {
|
|
174
|
+
const tab = tabs.get(tid);
|
|
175
|
+
if (tab) sessionIdToTargetId.delete(tab.sessionId);
|
|
176
|
+
tabs.delete(tid);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
114
179
|
|
|
115
180
|
// Prefer attaching to a tab this session already owns. Falls back to
|
|
116
181
|
// creating a fresh harness-owned tab in a dedicated window — never
|
|
@@ -139,6 +204,15 @@ export const createCdpSession = (
|
|
|
139
204
|
sessionId = a.sessionId;
|
|
140
205
|
targetId = pickTargetId;
|
|
141
206
|
await enableDomains(a.sessionId);
|
|
207
|
+
tabs.set(pickTargetId, {
|
|
208
|
+
sessionId: a.sessionId,
|
|
209
|
+
targetId: pickTargetId,
|
|
210
|
+
dialog: null,
|
|
211
|
+
pageInfoDirty: false,
|
|
212
|
+
networkBuffer: createNetworkBuffer(),
|
|
213
|
+
consoleBuffer: createConsoleBuffer(),
|
|
214
|
+
});
|
|
215
|
+
sessionIdToTargetId.set(a.sessionId, pickTargetId);
|
|
142
216
|
return ok({ targetId: pickTargetId, sessionId: a.sessionId });
|
|
143
217
|
},
|
|
144
218
|
async switchTo(tid) {
|
|
@@ -147,13 +221,29 @@ export const createCdpSession = (
|
|
|
147
221
|
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
|
|
148
222
|
if (!attached.success) return attached;
|
|
149
223
|
const a = attached.data as { sessionId: string };
|
|
150
|
-
|
|
224
|
+
// Reuse existing TabSession or create one on first visit
|
|
225
|
+
const existing = tabs.get(tid);
|
|
226
|
+
const tab: TabSession = existing ?? {
|
|
227
|
+
sessionId: a.sessionId,
|
|
228
|
+
targetId: tid,
|
|
229
|
+
dialog: null,
|
|
230
|
+
pageInfoDirty: true,
|
|
231
|
+
networkBuffer: createNetworkBuffer(),
|
|
232
|
+
consoleBuffer: createConsoleBuffer(),
|
|
233
|
+
};
|
|
234
|
+
if (existing) {
|
|
235
|
+
// Each Target.attachToTarget produces a new sessionId — update it.
|
|
236
|
+
sessionIdToTargetId.delete(existing.sessionId);
|
|
237
|
+
existing.sessionId = a.sessionId;
|
|
238
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
239
|
+
} else {
|
|
240
|
+
tabs.set(tid, tab);
|
|
241
|
+
await enableDomains(a.sessionId);
|
|
242
|
+
sessionIdToTargetId.set(a.sessionId, tid);
|
|
243
|
+
}
|
|
244
|
+
// Update global pointers to point at the new active tab
|
|
245
|
+
sessionId = tab.sessionId;
|
|
151
246
|
targetId = tid;
|
|
152
|
-
pageInfoDirty = true;
|
|
153
|
-
// Buffer is page-scoped: on switch, the network history of the previous
|
|
154
|
-
// tab is no longer relevant to what the agent is now looking at.
|
|
155
|
-
networkBuffer.clear();
|
|
156
|
-
await enableDomains(a.sessionId);
|
|
157
247
|
return ok(undefined);
|
|
158
248
|
},
|
|
159
249
|
current() {
|
|
@@ -169,17 +259,28 @@ export const createCdpSession = (
|
|
|
169
259
|
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
170
260
|
},
|
|
171
261
|
takeDialog() {
|
|
172
|
-
const
|
|
173
|
-
|
|
262
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
263
|
+
if (!tab) return null;
|
|
264
|
+
const d = tab.dialog;
|
|
265
|
+
tab.dialog = null;
|
|
174
266
|
return d;
|
|
175
267
|
},
|
|
176
268
|
drainPageInfoInvalidations() {
|
|
177
|
-
const
|
|
178
|
-
|
|
269
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
270
|
+
if (!tab) return false;
|
|
271
|
+
const dirty = tab.pageInfoDirty;
|
|
272
|
+
tab.pageInfoDirty = false;
|
|
179
273
|
return dirty;
|
|
180
274
|
},
|
|
181
275
|
drainNetworkBuffer(filter) {
|
|
182
|
-
|
|
276
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
277
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
278
|
+
return tab.networkBuffer.drain(filter);
|
|
279
|
+
},
|
|
280
|
+
drainConsoleBuffer(filter) {
|
|
281
|
+
const tab = targetId ? tabs.get(targetId) : undefined;
|
|
282
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
283
|
+
return tab.consoleBuffer.drain(filter);
|
|
183
284
|
},
|
|
184
285
|
};
|
|
185
286
|
};
|
package/src/client.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types";
|
|
|
11
11
|
|
|
12
12
|
export type BrowserClientOptions = {
|
|
13
13
|
readonly namespace: string;
|
|
14
|
+
readonly transport?: CdpTransport;
|
|
14
15
|
readonly remote?: { readonly cdpUrl: string; readonly browserId: string };
|
|
15
16
|
readonly initialOwnership?: {
|
|
16
17
|
readonly ownedTargetIds?: ReadonlyArray<string>;
|
|
@@ -28,6 +29,9 @@ export type BrowserClient = {
|
|
|
28
29
|
status(): DaemonStatus;
|
|
29
30
|
start(): Promise<Result<void, CdpError>>;
|
|
30
31
|
stop(): Promise<void>;
|
|
32
|
+
/** Detach from the current page target (removes the "Chrome is being controlled" banner)
|
|
33
|
+
* while keeping the transport connection alive. Call on session shutdown. */
|
|
34
|
+
detach(): Promise<void>;
|
|
31
35
|
evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>;
|
|
32
36
|
pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>;
|
|
33
37
|
takeDialog(): DialogInfo | null;
|
|
@@ -77,7 +81,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => {
|
|
|
77
81
|
};
|
|
78
82
|
|
|
79
83
|
export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => {
|
|
80
|
-
const transport = createCdpTransport();
|
|
84
|
+
const transport = opts.transport ?? createCdpTransport();
|
|
81
85
|
const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {};
|
|
82
86
|
if (opts.initialOwnership?.ownedTargetIds !== undefined) {
|
|
83
87
|
ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds;
|
|
@@ -98,7 +102,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
98
102
|
const session = createCdpSession(transport, ownership);
|
|
99
103
|
const mutationMutex = createMutex();
|
|
100
104
|
let lastHealth = 0;
|
|
101
|
-
let
|
|
105
|
+
let pageCaches = new Map<string, { readonly info: PageInfo; readonly at: number }>();
|
|
102
106
|
let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null;
|
|
103
107
|
|
|
104
108
|
const start = async (): Promise<Result<void, CdpError>> => {
|
|
@@ -125,13 +129,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
125
129
|
return attached;
|
|
126
130
|
}
|
|
127
131
|
lastHealth = Date.now();
|
|
128
|
-
|
|
132
|
+
pageCaches.clear();
|
|
129
133
|
return ok(undefined);
|
|
130
134
|
};
|
|
131
135
|
|
|
132
136
|
const stop = async (): Promise<void> => {
|
|
133
137
|
await transport.close();
|
|
134
|
-
|
|
138
|
+
pageCaches.clear();
|
|
135
139
|
lastHealth = 0;
|
|
136
140
|
};
|
|
137
141
|
|
|
@@ -155,7 +159,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
155
159
|
if (!jsProbe.success && jsProbe.error.kind === "session_not_found") {
|
|
156
160
|
const reattached = await session.attachFirstPage();
|
|
157
161
|
if (reattached.success) {
|
|
158
|
-
|
|
162
|
+
pageCaches.clear();
|
|
159
163
|
return ok(undefined);
|
|
160
164
|
}
|
|
161
165
|
await stop();
|
|
@@ -185,7 +189,9 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
185
189
|
|
|
186
190
|
const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => {
|
|
187
191
|
const dirty = session.drainPageInfoInvalidations();
|
|
188
|
-
|
|
192
|
+
const currentTid = session.current()?.targetId;
|
|
193
|
+
const cached = currentTid ? pageCaches.get(currentTid) : undefined;
|
|
194
|
+
if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
|
|
189
195
|
const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
|
|
190
196
|
const raw = await evaluateJs(expr);
|
|
191
197
|
if (!raw.success) return raw;
|
|
@@ -198,7 +204,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
198
204
|
}
|
|
199
205
|
const info = parsePageInfoPayload(parsedRaw);
|
|
200
206
|
if (!info.success) return info;
|
|
201
|
-
|
|
207
|
+
if (currentTid) pageCaches.set(currentTid, { info: info.data, at: Date.now() });
|
|
202
208
|
return ok(info.data);
|
|
203
209
|
};
|
|
204
210
|
|
|
@@ -228,13 +234,17 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
228
234
|
if (survivors.length !== owned.length) ownership.replaceAll(survivors);
|
|
229
235
|
const hw = ownership.harnessWindow();
|
|
230
236
|
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
237
|
+
// Prune per-tab page caches for tabs that no longer exist
|
|
238
|
+
for (const tid of pageCaches.keys()) {
|
|
239
|
+
if (!live.has(tid)) pageCaches.delete(tid);
|
|
240
|
+
}
|
|
231
241
|
return ok(tabs);
|
|
232
242
|
};
|
|
233
243
|
|
|
234
244
|
const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => {
|
|
235
245
|
const r = await session.switchTo(targetId);
|
|
236
246
|
if (!r.success) return r;
|
|
237
|
-
pageCache
|
|
247
|
+
// pageCache per-tab: no longer cleared — each tab retains its cache
|
|
238
248
|
// Best-effort: mark the tab title with a green circle so the user can
|
|
239
249
|
// see which tab the agent attached to. CSP or detached frames may
|
|
240
250
|
// block the eval; we don't surface that as a switchTab failure.
|
|
@@ -274,7 +284,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
274
284
|
if (url && url !== "about:blank") {
|
|
275
285
|
const nav = await session.call("Page.navigate", { url });
|
|
276
286
|
if (!nav.success) return nav;
|
|
277
|
-
pageCache = null;
|
|
278
287
|
}
|
|
279
288
|
return ok(c.targetId);
|
|
280
289
|
};
|
|
@@ -293,9 +302,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
|
|
|
293
302
|
...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}),
|
|
294
303
|
});
|
|
295
304
|
|
|
305
|
+
const detach = async (): Promise<void> => {
|
|
306
|
+
const cur = session.current();
|
|
307
|
+
if (!cur) return;
|
|
308
|
+
// Target.detachFromTarget removes the "Chrome is being controlled" banner
|
|
309
|
+
// and releases the page session. The transport (WebSocket/Unix socket)
|
|
310
|
+
// stays alive for reuse by the next session.
|
|
311
|
+
// Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget
|
|
312
|
+
await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null });
|
|
313
|
+
};
|
|
314
|
+
|
|
296
315
|
return {
|
|
297
316
|
namespace: opts.namespace,
|
|
298
|
-
ensureAlive, status, start, stop,
|
|
317
|
+
ensureAlive, status, start, stop, detach,
|
|
299
318
|
evaluateJs, pageInfo,
|
|
300
319
|
takeDialog: () => session.takeDialog(),
|
|
301
320
|
listTabs, switchTab, newTab, closeTab,
|