ptbk 0.119.0-70 → 0.119.0-71
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/README.md +4 -1
- package/dist/browser/browser.d.ts +98 -5
- package/dist/browser/browser.js +255 -19
- package/dist/browser/browser.js.map +1 -1
- package/dist/browser/tab-control.d.ts +386 -0
- package/dist/browser/tab-control.js +1013 -0
- package/dist/browser/tab-control.js.map +1 -0
- package/dist/browser/tab-reservations.d.ts +59 -0
- package/dist/browser/tab-reservations.js +101 -0
- package/dist/browser/tab-reservations.js.map +1 -0
- package/dist/chats/voice-call.d.ts +43 -11
- package/dist/chats/voice-call.js +96 -40
- package/dist/chats/voice-call.js.map +1 -1
- package/dist/cli/commands/init.d.ts +4 -0
- package/dist/cli/commands/init.js +14 -5
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/sources.js +2 -0
- package/dist/cli/sources.js.map +1 -1
- package/dist/common/websocket.d.ts +60 -0
- package/dist/common/websocket.js +179 -0
- package/dist/common/websocket.js.map +1 -0
- package/dist/engine/agenda.d.ts +19 -5
- package/dist/engine/agenda.js +20 -8
- package/dist/engine/agenda.js.map +1 -1
- package/dist/engine/engine-update.js +2 -0
- package/dist/engine/engine-update.js.map +1 -1
- package/dist/engine/snapshot.d.ts +3 -0
- package/dist/engine/voice-calls.d.ts +41 -4
- package/dist/engine/voice-calls.js +40 -8
- package/dist/engine/voice-calls.js.map +1 -1
- package/dist/expert/_main.md +8 -4
- package/dist/expert/cli/acceptance.md +3 -1
- package/dist/expert/cli/browser-tab-control.md +38 -0
- package/dist/expert/cli/browser.md +7 -5
- package/dist/expert/cli/chat-ui.md +1 -1
- package/dist/expert/cli/dashboard-details.md +3 -1
- package/dist/expert/cli/dashboard.md +2 -0
- package/dist/expert/cli/desktop.md +21 -4
- package/dist/expert/cli/harness-voice.md +1 -1
- package/dist/expert/cli/installation.md +2 -2
- package/dist/expert/cli/interactive-feature-bridges.md +35 -0
- package/dist/expert/cli/notifications.md +2 -2
- package/dist/expert/cli/releasing.md +8 -2
- package/dist/expert/cli/task-control.md +1 -1
- package/dist/expert/cli/terminal.md +2 -0
- package/dist/expert/cli/traces.md +1 -1
- package/dist/expert/cli/user-interactions.md +1 -1
- package/dist/expert/cli/voice-chat-ui.md +11 -9
- package/dist/expert/cli/voice-chats.md +10 -10
- package/dist/expert/cli/wallet-testing.md +2 -0
- package/dist/expert/cli/wallet.md +16 -4
- package/dist/expert/cli/windows-installer.md +15 -41
- package/dist/expert/concepts/engine-and-project.md +2 -0
- package/dist/expert/concepts/minimal-interruption/_index.md +1 -0
- package/dist/expert/concepts/minimal-interruption/authentication-examples.md +1 -0
- package/dist/expert/concepts/product-presentation.md +1 -1
- package/dist/expert/concepts/user-control.md +15 -0
- package/dist/expert/dictionary.md +4 -2
- package/dist/service/auth.d.ts +5 -0
- package/dist/service/auth.js +2 -1
- package/dist/service/auth.js.map +1 -1
- package/dist/service/discovery.d.ts +3 -0
- package/dist/service/discovery.js +1 -0
- package/dist/service/discovery.js.map +1 -1
- package/dist/service/host.d.ts +8 -0
- package/dist/service/host.js +125 -4
- package/dist/service/host.js.map +1 -1
- package/dist/service/http.d.ts +2 -0
- package/dist/service/http.js +27 -4
- package/dist/service/http.js.map +1 -1
- package/dist/service/spa/app.css +143 -2
- package/dist/service/spa/app.js +31 -4
- package/dist/service/spa/fonts/fonts.css +35 -0
- package/dist/service/spa/fonts/inter-OFL.txt +93 -0
- package/dist/service/spa/fonts/inter-latin-ext.woff2 +0 -0
- package/dist/service/spa/fonts/inter-latin.woff2 +0 -0
- package/dist/service/spa/fonts/outfit-OFL.txt +93 -0
- package/dist/service/spa/fonts/outfit-latin-ext.woff2 +0 -0
- package/dist/service/spa/fonts/outfit-latin.woff2 +0 -0
- package/dist/service/spa/index.html +1 -0
- package/dist/ui/chat-client.d.ts +7 -2
- package/dist/ui/chat-client.js +41 -6
- package/dist/ui/chat-client.js.map +1 -1
- package/dist/ui/dashboard-pages.d.ts +7 -0
- package/dist/ui/dashboard-pages.js +64 -3
- package/dist/ui/dashboard-pages.js.map +1 -1
- package/dist/ui/dashboard.d.ts +5 -0
- package/dist/ui/dashboard.js +160 -2
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/feature-server.d.ts +169 -0
- package/dist/ui/feature-server.js +443 -0
- package/dist/ui/feature-server.js.map +1 -0
- package/dist/ui/global-dashboard.d.ts +2 -0
- package/dist/ui/global-dashboard.js +1 -1
- package/dist/ui/global-dashboard.js.map +1 -1
- package/dist/ui/port-surface.d.ts +92 -0
- package/dist/ui/port-surface.js +172 -0
- package/dist/ui/port-surface.js.map +1 -0
- package/dist/ui/tab-page.d.ts +13 -0
- package/dist/ui/tab-page.js +194 -0
- package/dist/ui/tab-page.js.map +1 -0
- package/dist/ui/tab-surface.d.ts +67 -0
- package/dist/ui/tab-surface.js +218 -0
- package/dist/ui/tab-surface.js.map +1 -0
- package/dist/ui/voice-page.d.ts +15 -0
- package/dist/{chats → ui}/voice-page.js +38 -39
- package/dist/ui/voice-page.js.map +1 -0
- package/dist/ui/voice-surface.d.ts +88 -0
- package/dist/ui/voice-surface.js +162 -0
- package/dist/ui/voice-surface.js.map +1 -0
- package/dist/wallet/wallet.d.ts +62 -0
- package/dist/wallet/wallet.js +120 -1
- package/dist/wallet/wallet.js.map +1 -1
- package/package.json +1 -1
- package/dist/chats/voice-bridge.d.ts +0 -49
- package/dist/chats/voice-bridge.js +0 -282
- package/dist/chats/voice-bridge.js.map +0 -1
- package/dist/chats/voice-page.d.ts +0 -14
- package/dist/chats/voice-page.js.map +0 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
|
12
12
|
<a href="#deploy-to-your-own-server"><strong>Deploy</strong></a> ·
|
|
13
13
|
<a href="https://www.ptbk.io/en"><strong>Website</strong></a> ·
|
|
14
|
+
<a href="design/README.md"><strong>Brand</strong></a> ·
|
|
14
15
|
<a href="specs/_main.md"><strong>Specifications</strong></a> ·
|
|
15
16
|
<a href="https://www.ptbk.io/en/partnership"><strong>For partners</strong></a>
|
|
16
17
|
</p>
|
|
@@ -63,10 +64,12 @@ Agents explicitly call you with `+@User` when they need a response. Viewing that
|
|
|
63
64
|
|
|
64
65
|
A background engine [updates itself](specs/cli/updates.md) when it has no other work: stable minor and patch releases of its major version, or a running prerelease to its stable release. It never installs a version your `ptbk.json` `updates` pin or range excludes, or one that needs a newer Node.js than your host runs, and you can switch it off on the dashboard's System page. Release signing and rollout delays are still being agreed with the platform, so your host access and backups still matter.
|
|
65
66
|
|
|
66
|
-
**Windows:** download the EXE from [GitHub Releases](https://github.com/webgptorg/promptbook/releases), or follow the [installation guide](https://www.ptbk.io/en/open-source). The installer offers the local desktop client (selected by default)
|
|
67
|
+
**Windows:** download the EXE from [GitHub Releases](https://github.com/webgptorg/promptbook/releases), or follow the [installation guide](https://www.ptbk.io/en/open-source). The installer offers the local desktop client (selected by default). Open the app to set up your project and choose AI tools. The desktop uses the same engine and initializer as the CLI, with an embedded CLI terminal; closing a window leaves your engines running. CLI-only installation remains available.
|
|
67
68
|
|
|
68
69
|
**macOS:** download the matching DMG from [GitHub Releases](https://github.com/webgptorg/promptbook/releases). Choose `arm64` for Apple Silicon or `x64` for Intel, then drag Promptbook into Applications. Node.js, npm and the matching engine are included. Project setup uses Git from your Mac, such as Git from the Xcode Command Line Tools. See the [desktop installation guide](apps/desktop/README.md#macos-installation) for signing and login details.
|
|
69
70
|
|
|
71
|
+
**Android preview:** download the ARM64 APK from [GitHub Releases](https://github.com/webgptorg/promptbook/releases). It includes the local engine, Node.js, Git and Gemini CLI. The app runs the engine in an Android foreground service and opens the project dashboard. See the [Android installation guide](apps/android/README.md) for setup and signing details.
|
|
72
|
+
|
|
70
73
|
## Deploy to your own server
|
|
71
74
|
|
|
72
75
|
**A clean Ubuntu 24.04 LTS x86-64 server** with systemd, outbound HTTPS and root or sudo access over SSH needs no preinstalled Node.js, npm, Git, Chrome or AI harness: use the [one-command installation](#clean-ubuntu-vps). It prepares the server through the [shared installer](deploy/ubuntu/install.sh) and opens `ptbk init` as a dedicated non-sudo `promptbook` user. Sign-in to Codex or Claude Code is finished on your phone or computer; see the [server guide](deploy/README.md#finish-setup-over-ssh).
|
|
@@ -5,6 +5,7 @@ import type { ControlOutcome } from '../service/actions.js';
|
|
|
5
5
|
import { promptbookAppData } from '../common/app-data.js';
|
|
6
6
|
import { BrowserRecorder } from './recording.js';
|
|
7
7
|
import { BrowserPools, profileOwnerPid } from './pool.js';
|
|
8
|
+
import { TabReservations } from './tab-reservations.js';
|
|
8
9
|
import { type SignInPage } from '../wallet/wallet.js';
|
|
9
10
|
/**
|
|
10
11
|
* Pressed-button bit of each mouse button in DevTools mouse events.
|
|
@@ -96,11 +97,26 @@ declare class CdpConnection {
|
|
|
96
97
|
waitFor(method: string | readonly string[], timeoutMs: number, sessionId?: string): Promise<Record<string, unknown> | undefined>;
|
|
97
98
|
/** Subscribes to protocol events; returns the unsubscribe function. */
|
|
98
99
|
on(listener: (message: Record<string, unknown>) => void): () => void;
|
|
99
|
-
/** Notifies when the connection closes (browser exit or crash). */
|
|
100
|
-
onClose(listener: () => void): void;
|
|
100
|
+
/** Notifies when the connection closes (browser exit or crash); returns the unsubscribe function. */
|
|
101
|
+
onClose(listener: () => void): () => void;
|
|
102
|
+
/** Commands of one session still waiting for their result (an operation in flight on that tab). */
|
|
103
|
+
pendingFor(sessionId: string): number;
|
|
101
104
|
/** Closes the socket (an intentional close does not notify close listeners). */
|
|
102
105
|
close(): void;
|
|
103
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The DevTools protocol connection of an engine's shared browser as the tab-control session uses it.
|
|
109
|
+
*
|
|
110
|
+
* @private project browser
|
|
111
|
+
*/
|
|
112
|
+
export type CdpLink = Pick<CdpConnection, 'send' | 'on' | 'waitFor' | 'onClose' | 'pendingFor'>;
|
|
113
|
+
/**
|
|
114
|
+
* How long the set of user-controlled tabs of a profile is reused before it is read again (every engine sharing the
|
|
115
|
+
* profile notices a new reservation within this bound; control starts only after it).
|
|
116
|
+
*
|
|
117
|
+
* @private project browser
|
|
118
|
+
*/
|
|
119
|
+
export declare const RESERVATION_CACHE_MS = 300;
|
|
104
120
|
/**
|
|
105
121
|
* Observed health of one named browser's connection (engine-owned diagnosis and recovery; never inferred from
|
|
106
122
|
* configuration).
|
|
@@ -149,7 +165,7 @@ export declare class ProjectBrowser {
|
|
|
149
165
|
private readonly recordingEnabled;
|
|
150
166
|
private readonly pools;
|
|
151
167
|
/** This engine's identity in the pool. */
|
|
152
|
-
|
|
168
|
+
readonly instanceId: string;
|
|
153
169
|
private connection?;
|
|
154
170
|
/** The pool record this connection was made through (process, endpoint, actual mode). */
|
|
155
171
|
private record?;
|
|
@@ -164,6 +180,14 @@ export declare class ProjectBrowser {
|
|
|
164
180
|
private health;
|
|
165
181
|
/** Tabs of this engine's active tasks, by task scope. */
|
|
166
182
|
private readonly sessions;
|
|
183
|
+
/** The shared browser-control boundary of the profile (user-controlled tabs of every connected engine). */
|
|
184
|
+
readonly reservations: TabReservations;
|
|
185
|
+
/** The user-controlled tabs as last read (briefly reused). */
|
|
186
|
+
private reservedCache?;
|
|
187
|
+
/** Called before this engine disconnects (user-controlled tabs are closed rather than left to another engine). */
|
|
188
|
+
beforeDisconnect?: () => Promise<void>;
|
|
189
|
+
/** Checks the shared reservations while connected. */
|
|
190
|
+
private reservationWatch?;
|
|
167
191
|
constructor(project: Project,
|
|
168
192
|
/** Name of the browser tool this instance serves. */
|
|
169
193
|
name: string, target: () => BrowserTarget | undefined, env: NodeJS.ProcessEnv,
|
|
@@ -171,6 +195,33 @@ export declare class ProjectBrowser {
|
|
|
171
195
|
onChange?: () => void, recordingEnabled?: () => boolean, pools?: BrowserPools,
|
|
172
196
|
/** This engine's identity in the pool. */
|
|
173
197
|
instanceId?: string);
|
|
198
|
+
/** The project directory of this engine. */
|
|
199
|
+
get projectRoot(): string;
|
|
200
|
+
/** The actual profile directory of the current connection. */
|
|
201
|
+
get profile(): string | undefined;
|
|
202
|
+
/**
|
|
203
|
+
* Tabs of this profile a user controls now, across every engine connected to it (reused for a short bound, so
|
|
204
|
+
* every engine notices a new reservation before the user's control starts).
|
|
205
|
+
*/
|
|
206
|
+
reservedTabs(): Promise<ReadonlySet<string>>;
|
|
207
|
+
/** Forgets the cached reservations (this engine just changed one). */
|
|
208
|
+
invalidateReservations(): void;
|
|
209
|
+
/** The open tabs of the shared browser with the task of this engine working in each (for choosing a tab to control). */
|
|
210
|
+
pages(): Promise<Array<BrowserTab & {
|
|
211
|
+
readonly task?: string;
|
|
212
|
+
readonly reserved: boolean;
|
|
213
|
+
}>>;
|
|
214
|
+
/** The task of this engine working in a tab. */
|
|
215
|
+
ownerOf(targetId: string): TaskBrowser | undefined;
|
|
216
|
+
/** The connection the tab-control session uses. */
|
|
217
|
+
link(): Promise<CdpLink>;
|
|
218
|
+
/** A user handed a tab back: tasks that gave it up are told to revalidate it before acting on it. */
|
|
219
|
+
handedBack(targetId: string, url: string | undefined): void;
|
|
220
|
+
/**
|
|
221
|
+
* The agent operation in flight on a tab, if any: its task's session still waits for DevTools results there (the
|
|
222
|
+
* user's control starts at the next safe boundary).
|
|
223
|
+
*/
|
|
224
|
+
busyOn(targetId: string): boolean;
|
|
174
225
|
/** Configuration of the next use: the retained one while tasks use the connection, otherwise the saved one. */
|
|
175
226
|
private config;
|
|
176
227
|
/** Whether active tasks of this engine use this browser. */
|
|
@@ -221,6 +272,11 @@ export declare class ProjectBrowser {
|
|
|
221
272
|
* is never killed.
|
|
222
273
|
*/
|
|
223
274
|
kill(): Promise<ControlOutcome>;
|
|
275
|
+
/**
|
|
276
|
+
* A profile-wide operation conflicts with a tab a user controls: it is deferred (not applied) until that tab is
|
|
277
|
+
* handed back or its use ended, so the user's session is never cut off by automation.
|
|
278
|
+
*/
|
|
279
|
+
private controlledElsewhere;
|
|
224
280
|
/** Opens https://github.com/ in the actual shared browser to show that it works (a visible window keeps the tab). */
|
|
225
281
|
testTab(): Promise<ControlOutcome>;
|
|
226
282
|
/**
|
|
@@ -232,6 +288,10 @@ export declare class ProjectBrowser {
|
|
|
232
288
|
private connectWithRecovery;
|
|
233
289
|
/** One connection attempt to the configured profile's shared browser. */
|
|
234
290
|
private connectOnce;
|
|
291
|
+
/** Tasks working in a tab a user now controls give it up (once no operation of theirs is in flight on it). */
|
|
292
|
+
private enforceReservations;
|
|
293
|
+
/** A user takes control of a tab from this engine: the task working in it gives it up now. */
|
|
294
|
+
yieldToUser(targetId: string): Promise<void>;
|
|
235
295
|
/** Forgets the connection and every task tab of it (the shared process itself is not touched). */
|
|
236
296
|
private dropConnection;
|
|
237
297
|
/** Handles a lost connection: task tabs end, the next use reconnects (relaunching the process if needed). */
|
|
@@ -248,6 +308,12 @@ export declare class ProjectBrowser {
|
|
|
248
308
|
/** Disconnects this engine from the shared browser; the process closes when it was the last managed connection. */
|
|
249
309
|
disconnect(): Promise<void>;
|
|
250
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* The address an address-bar entry opens: an entry without a scheme is a website (plain HTTP for local addresses).
|
|
313
|
+
*
|
|
314
|
+
* @private project browser
|
|
315
|
+
*/
|
|
316
|
+
export declare function browserAddress(input: string): string;
|
|
251
317
|
/**
|
|
252
318
|
* One task's work in a named browser: its own tabs in the shared process (independent tasks work concurrently, never in
|
|
253
319
|
* each other's tabs), its recording and every page action an agent performs. Tabs are separate, cookies and accounts
|
|
@@ -272,6 +338,12 @@ export declare class TaskBrowser {
|
|
|
272
338
|
readonly recorder: BrowserRecorder;
|
|
273
339
|
/** URL the task works on (diagnostics). */
|
|
274
340
|
url?: string;
|
|
341
|
+
/** What the agent learns with its next page result (a tab the user took or returned). */
|
|
342
|
+
private notice?;
|
|
343
|
+
/** The recording is paused because the user took the tab this task worked in. */
|
|
344
|
+
private yieldedRecording;
|
|
345
|
+
/** This task received the raw DevTools endpoint (tab control states that it cannot be held back). */
|
|
346
|
+
private rawEndpoint;
|
|
275
347
|
constructor(browser: ProjectBrowser,
|
|
276
348
|
/** The task scope (one per active task run). */
|
|
277
349
|
scope: string, project: Project, recordingEnabled: () => boolean, env: NodeJS.ProcessEnv);
|
|
@@ -281,7 +353,28 @@ export declare class TaskBrowser {
|
|
|
281
353
|
reset(_reason: string): void;
|
|
282
354
|
/** A target session was detached (its tab closed). */
|
|
283
355
|
detached(sessionId: string | undefined): void;
|
|
284
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* Gives up the tab a user took over: the agent never acts on, inspects or records it again until it is handed back
|
|
358
|
+
* (the user-controlled interval is a labelled gap of the recording).
|
|
359
|
+
*/
|
|
360
|
+
private yieldTab;
|
|
361
|
+
/**
|
|
362
|
+
* Gives up a tab a user now controls between actions: the one it works in (unless an operation of it is still in
|
|
363
|
+
* flight there and `now` is not set) or another of its own tabs.
|
|
364
|
+
*/
|
|
365
|
+
giveUp(connection: CdpConnection, reserved: ReadonlySet<string>, now?: boolean): Promise<void>;
|
|
366
|
+
/** Tabs this task gave up to a user (returned ones may be selected again). */
|
|
367
|
+
private readonly yielded;
|
|
368
|
+
/** The DevTools session this task has on a tab (when it currently works in it). */
|
|
369
|
+
currentSession(targetId: string): string | undefined;
|
|
370
|
+
/** A tab this task gave up was handed back by the user: the agent revalidates it before acting on it. */
|
|
371
|
+
returned(targetId: string, url: string | undefined): void;
|
|
372
|
+
/** The note for the agent's next page result (given once). */
|
|
373
|
+
private takeNotice;
|
|
374
|
+
/**
|
|
375
|
+
* Ensures the connection and this task's working tab, with recording following that tab. A tab a user controls
|
|
376
|
+
* is never used: the current one is given up (with a note for the agent), and only tabs no user controls are chosen.
|
|
377
|
+
*/
|
|
285
378
|
private active;
|
|
286
379
|
/** Opens a new tab of this task. */
|
|
287
380
|
private newTab;
|
|
@@ -291,7 +384,7 @@ export declare class TaskBrowser {
|
|
|
291
384
|
private attachPage;
|
|
292
385
|
/** Resolves a tab id or a unique prefix of it. */
|
|
293
386
|
private resolveTab;
|
|
294
|
-
/** Refuses to take over or close a tab another active task works in. */
|
|
387
|
+
/** Refuses to take over or close a tab another active task works in or a user controls. */
|
|
295
388
|
private guard;
|
|
296
389
|
/** Waits (bounded) until the current page finished loading. */
|
|
297
390
|
private settle;
|
package/dist/browser/browser.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createRedactor } from '../common/redact.js';
|
|
|
11
11
|
import { promptbookAppData } from '../common/app-data.js';
|
|
12
12
|
import { BrowserRecorder } from './recording.js';
|
|
13
13
|
import { BrowserPools, profileOwnerPid } from './pool.js';
|
|
14
|
+
import { TabReservations } from './tab-reservations.js';
|
|
14
15
|
import { parseKeyPress } from './input.js';
|
|
15
16
|
import { progressiveDelay, WAITING_POLICIES } from '../engine/progressive-waiting.js';
|
|
16
17
|
import { MAX_PAGE_TEXT, clearLoginFieldsScript, elementObjectScript, elementPointScript, entryContentScript, entryTargetScript, focusForEntryScript, focusLoginFieldScript, followSignInLinkScript, isFileInputScript, loginFormScript, programmaticClickScript, readPageScript, setValueScript, staySignedInScript, submitFormScript, submitLoginScript, viewportScript, newPasswordFormScript, } from './page-scripts.js';
|
|
@@ -230,7 +231,7 @@ class CdpConnection {
|
|
|
230
231
|
send(method, params = {}, sessionId) {
|
|
231
232
|
const id = this.nextId++;
|
|
232
233
|
return new Promise((resolve, reject) => {
|
|
233
|
-
this.pending.set(id, { resolve, reject });
|
|
234
|
+
this.pending.set(id, { resolve, reject, ...(sessionId ? { sessionId } : {}) });
|
|
234
235
|
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
235
236
|
});
|
|
236
237
|
}
|
|
@@ -257,9 +258,18 @@ class CdpConnection {
|
|
|
257
258
|
this.listeners.add(listener);
|
|
258
259
|
return () => this.listeners.delete(listener);
|
|
259
260
|
}
|
|
260
|
-
/** Notifies when the connection closes (browser exit or crash). */
|
|
261
|
+
/** Notifies when the connection closes (browser exit or crash); returns the unsubscribe function. */
|
|
261
262
|
onClose(listener) {
|
|
262
263
|
this.closeListeners.add(listener);
|
|
264
|
+
return () => this.closeListeners.delete(listener);
|
|
265
|
+
}
|
|
266
|
+
/** Commands of one session still waiting for their result (an operation in flight on that tab). */
|
|
267
|
+
pendingFor(sessionId) {
|
|
268
|
+
let count = 0;
|
|
269
|
+
for (const entry of this.pending.values())
|
|
270
|
+
if (entry.sessionId === sessionId)
|
|
271
|
+
count++;
|
|
272
|
+
return count;
|
|
263
273
|
}
|
|
264
274
|
/** Closes the socket (an intentional close does not notify close listeners). */
|
|
265
275
|
close() {
|
|
@@ -267,6 +277,17 @@ class CdpConnection {
|
|
|
267
277
|
this.socket.close();
|
|
268
278
|
}
|
|
269
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* How long the set of user-controlled tabs of a profile is reused before it is read again (every engine sharing the
|
|
282
|
+
* profile notices a new reservation within this bound; control starts only after it).
|
|
283
|
+
*
|
|
284
|
+
* @private project browser
|
|
285
|
+
*/
|
|
286
|
+
export const RESERVATION_CACHE_MS = 300;
|
|
287
|
+
/**
|
|
288
|
+
* How often a connected engine checks the profile's tab reservations between its tasks' actions.
|
|
289
|
+
*/
|
|
290
|
+
const RESERVATION_WATCH_MS = 1_000;
|
|
270
291
|
/**
|
|
271
292
|
* One named browser tool of this engine: its connection to the shared browser process of the tool's actual profile
|
|
272
293
|
* (a [browser pool](pool.ts) that other project engines may use too) and the tabs of this engine's active tasks. The
|
|
@@ -299,6 +320,14 @@ export class ProjectBrowser {
|
|
|
299
320
|
health = { state: 'not-started', ownership: { state: 'unknown' }, recovery: { attempts: 0, state: 'idle' } };
|
|
300
321
|
/** Tabs of this engine's active tasks, by task scope. */
|
|
301
322
|
sessions = new Map();
|
|
323
|
+
/** The shared browser-control boundary of the profile (user-controlled tabs of every connected engine). */
|
|
324
|
+
reservations;
|
|
325
|
+
/** The user-controlled tabs as last read (briefly reused). */
|
|
326
|
+
reservedCache;
|
|
327
|
+
/** Called before this engine disconnects (user-controlled tabs are closed rather than left to another engine). */
|
|
328
|
+
beforeDisconnect;
|
|
329
|
+
/** Checks the shared reservations while connected. */
|
|
330
|
+
reservationWatch;
|
|
302
331
|
constructor(project,
|
|
303
332
|
/** Name of the browser tool this instance serves. */
|
|
304
333
|
name, target, env,
|
|
@@ -314,6 +343,63 @@ export class ProjectBrowser {
|
|
|
314
343
|
this.recordingEnabled = recordingEnabled;
|
|
315
344
|
this.pools = pools;
|
|
316
345
|
this.instanceId = instanceId;
|
|
346
|
+
this.reservations = new TabReservations(env);
|
|
347
|
+
}
|
|
348
|
+
/** The project directory of this engine. */
|
|
349
|
+
get projectRoot() {
|
|
350
|
+
return this.project.root;
|
|
351
|
+
}
|
|
352
|
+
/** The actual profile directory of the current connection. */
|
|
353
|
+
get profile() {
|
|
354
|
+
return this.connectedProfile;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Tabs of this profile a user controls now, across every engine connected to it (reused for a short bound, so
|
|
358
|
+
* every engine notices a new reservation before the user's control starts).
|
|
359
|
+
*/
|
|
360
|
+
async reservedTabs() {
|
|
361
|
+
const profile = this.connectedProfile;
|
|
362
|
+
if (!profile)
|
|
363
|
+
return new Set();
|
|
364
|
+
const cached = this.reservedCache;
|
|
365
|
+
if (cached && cached.profile === profile && Date.now() - cached.at < RESERVATION_CACHE_MS)
|
|
366
|
+
return cached.tabs;
|
|
367
|
+
const tabs = new Set((await this.reservations.list(profile).catch(() => [])).map((entry) => entry.targetId));
|
|
368
|
+
this.reservedCache = { at: Date.now(), profile, tabs };
|
|
369
|
+
return tabs;
|
|
370
|
+
}
|
|
371
|
+
/** Forgets the cached reservations (this engine just changed one). */
|
|
372
|
+
invalidateReservations() {
|
|
373
|
+
this.reservedCache = undefined;
|
|
374
|
+
}
|
|
375
|
+
/** The open tabs of the shared browser with the task of this engine working in each (for choosing a tab to control). */
|
|
376
|
+
async pages() {
|
|
377
|
+
const connection = await this.connect();
|
|
378
|
+
const reserved = await this.reservedTabs();
|
|
379
|
+
return (await listPages(connection)).map((page) => ({ ...page, ...(this.ownerOf(page.targetId) ? { task: this.ownerOf(page.targetId).scope } : {}), reserved: reserved.has(page.targetId) }));
|
|
380
|
+
}
|
|
381
|
+
/** The task of this engine working in a tab. */
|
|
382
|
+
ownerOf(targetId) {
|
|
383
|
+
return [...this.sessions.values()].find((session) => session.ownTabs.has(targetId));
|
|
384
|
+
}
|
|
385
|
+
/** The connection the tab-control session uses. */
|
|
386
|
+
async link() {
|
|
387
|
+
return await this.connect();
|
|
388
|
+
}
|
|
389
|
+
/** A user handed a tab back: tasks that gave it up are told to revalidate it before acting on it. */
|
|
390
|
+
handedBack(targetId, url) {
|
|
391
|
+
this.invalidateReservations();
|
|
392
|
+
for (const session of this.sessions.values())
|
|
393
|
+
session.returned(targetId, url);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The agent operation in flight on a tab, if any: its task's session still waits for DevTools results there (the
|
|
397
|
+
* user's control starts at the next safe boundary).
|
|
398
|
+
*/
|
|
399
|
+
busyOn(targetId) {
|
|
400
|
+
const owner = this.ownerOf(targetId);
|
|
401
|
+
const sessionId = owner?.currentSession(targetId);
|
|
402
|
+
return Boolean(sessionId && this.connection?.pendingFor(sessionId));
|
|
317
403
|
}
|
|
318
404
|
/** Configuration of the next use: the retained one while tasks use the connection, otherwise the saved one. */
|
|
319
405
|
config() {
|
|
@@ -478,6 +564,9 @@ export class ProjectBrowser {
|
|
|
478
564
|
const tool = this.config();
|
|
479
565
|
if (!tool)
|
|
480
566
|
return this.acted('restart', 'failed', `The browser tool "${this.name}" is no longer configured.`);
|
|
567
|
+
const controlled = await this.controlledElsewhere('restart');
|
|
568
|
+
if (controlled)
|
|
569
|
+
return controlled;
|
|
481
570
|
const profile = browserProfileDirectory(this.project, tool, this.env);
|
|
482
571
|
const tabs = this.sessions.size;
|
|
483
572
|
try {
|
|
@@ -505,6 +594,9 @@ export class ProjectBrowser {
|
|
|
505
594
|
const tool = this.config();
|
|
506
595
|
if (!tool)
|
|
507
596
|
return this.acted('kill', 'failed', `The browser tool "${this.name}" is no longer configured.`);
|
|
597
|
+
const controlled = await this.controlledElsewhere('kill');
|
|
598
|
+
if (controlled)
|
|
599
|
+
return controlled;
|
|
508
600
|
const profile = browserProfileDirectory(this.project, tool, this.env);
|
|
509
601
|
const killed = await this.pools.kill(profile);
|
|
510
602
|
this.dropConnection('The browser was killed.');
|
|
@@ -514,6 +606,17 @@ export class ProjectBrowser {
|
|
|
514
606
|
return this.acted('kill', 'succeeded', `No Promptbook-started browser process was running for "${this.name}".`);
|
|
515
607
|
return this.acted('kill', 'succeeded', `Killed the browser "${this.name}" (its profile is kept)${others.length ? `; also affected: ${others.join(', ')}` : ''}. The next use starts it again.`);
|
|
516
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* A profile-wide operation conflicts with a tab a user controls: it is deferred (not applied) until that tab is
|
|
611
|
+
* handed back or its use ended, so the user's session is never cut off by automation.
|
|
612
|
+
*/
|
|
613
|
+
async controlledElsewhere(action) {
|
|
614
|
+
this.invalidateReservations();
|
|
615
|
+
const reserved = await this.reservedTabs();
|
|
616
|
+
if (!reserved.size)
|
|
617
|
+
return undefined;
|
|
618
|
+
return this.acted(action, 'deferred', `Deferred: you control ${reserved.size === 1 ? 'a tab' : `${reserved.size} tabs`} of the browser "${this.name}". Return control to the agent or end your use of the tab first; nothing was ${action === 'kill' ? 'killed' : 'restarted'}.`);
|
|
619
|
+
}
|
|
517
620
|
/** Opens https://github.com/ in the actual shared browser to show that it works (a visible window keeps the tab). */
|
|
518
621
|
async testTab() {
|
|
519
622
|
try {
|
|
@@ -622,13 +725,38 @@ export class ProjectBrowser {
|
|
|
622
725
|
this.record = record;
|
|
623
726
|
this.connectedProfile = profile;
|
|
624
727
|
this.connectedWith = tool;
|
|
728
|
+
// A user may take a tab from any engine of the profile: its tasks here give it up promptly, also between actions.
|
|
729
|
+
clearInterval(this.reservationWatch);
|
|
730
|
+
this.reservationWatch = setInterval(() => void this.enforceReservations(), RESERVATION_WATCH_MS);
|
|
731
|
+
this.reservationWatch.unref();
|
|
625
732
|
this.health = { ...this.health, actualMode: record.headless ? 'headless' : 'headful' };
|
|
626
733
|
if (launched)
|
|
627
734
|
this.note ??= undefined;
|
|
628
735
|
return connection;
|
|
629
736
|
}
|
|
737
|
+
/** Tasks working in a tab a user now controls give it up (once no operation of theirs is in flight on it). */
|
|
738
|
+
async enforceReservations() {
|
|
739
|
+
const connection = this.connection;
|
|
740
|
+
if (!connection || !this.sessions.size)
|
|
741
|
+
return;
|
|
742
|
+
const reserved = await this.reservedTabs();
|
|
743
|
+
if (!reserved.size)
|
|
744
|
+
return;
|
|
745
|
+
for (const session of this.sessions.values())
|
|
746
|
+
await session.giveUp(connection, reserved).catch(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
/** A user takes control of a tab from this engine: the task working in it gives it up now. */
|
|
749
|
+
async yieldToUser(targetId) {
|
|
750
|
+
const connection = this.connection;
|
|
751
|
+
if (!connection)
|
|
752
|
+
return;
|
|
753
|
+
for (const session of this.sessions.values())
|
|
754
|
+
await session.giveUp(connection, new Set([targetId]), true).catch(() => undefined);
|
|
755
|
+
}
|
|
630
756
|
/** Forgets the connection and every task tab of it (the shared process itself is not touched). */
|
|
631
757
|
dropConnection(reason) {
|
|
758
|
+
clearInterval(this.reservationWatch);
|
|
759
|
+
this.reservationWatch = undefined;
|
|
632
760
|
this.connection?.close();
|
|
633
761
|
this.connection = undefined;
|
|
634
762
|
this.record = undefined;
|
|
@@ -637,6 +765,8 @@ export class ProjectBrowser {
|
|
|
637
765
|
}
|
|
638
766
|
/** Handles a lost connection: task tabs end, the next use reconnects (relaunching the process if needed). */
|
|
639
767
|
connectionLost(reason) {
|
|
768
|
+
clearInterval(this.reservationWatch);
|
|
769
|
+
this.reservationWatch = undefined;
|
|
640
770
|
this.connection = undefined;
|
|
641
771
|
this.record = undefined;
|
|
642
772
|
for (const session of this.sessions.values())
|
|
@@ -679,6 +809,7 @@ export class ProjectBrowser {
|
|
|
679
809
|
}
|
|
680
810
|
/** Disconnects this engine from the shared browser; the process closes when it was the last managed connection. */
|
|
681
811
|
async disconnect() {
|
|
812
|
+
await this.beforeDisconnect?.().catch(() => undefined);
|
|
682
813
|
const profile = this.connectedProfile;
|
|
683
814
|
const lastKnown = this.record;
|
|
684
815
|
const wasConnected = Boolean(this.connection);
|
|
@@ -691,6 +822,18 @@ export class ProjectBrowser {
|
|
|
691
822
|
this.setHealth({ state: 'stopped', ownership: { state: 'free' } });
|
|
692
823
|
}
|
|
693
824
|
}
|
|
825
|
+
/**
|
|
826
|
+
* The address an address-bar entry opens: an entry without a scheme is a website (plain HTTP for local addresses).
|
|
827
|
+
*
|
|
828
|
+
* @private project browser
|
|
829
|
+
*/
|
|
830
|
+
export function browserAddress(input) {
|
|
831
|
+
const trimmed = input.trim();
|
|
832
|
+
if (!trimmed)
|
|
833
|
+
throw new PtbkError('Name the URL to open.', { kind: 'usage' });
|
|
834
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || /^(about|blob|data|javascript|mailto|tel|view-source):/i.test(trimmed);
|
|
835
|
+
return hasScheme ? trimmed : `${/^(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(trimmed) ? 'http' : 'https'}://${trimmed}`;
|
|
836
|
+
}
|
|
694
837
|
/**
|
|
695
838
|
* The open pages (tabs) of a browser connection.
|
|
696
839
|
*/
|
|
@@ -721,6 +864,12 @@ export class TaskBrowser {
|
|
|
721
864
|
recorder;
|
|
722
865
|
/** URL the task works on (diagnostics). */
|
|
723
866
|
url;
|
|
867
|
+
/** What the agent learns with its next page result (a tab the user took or returned). */
|
|
868
|
+
notice;
|
|
869
|
+
/** The recording is paused because the user took the tab this task worked in. */
|
|
870
|
+
yieldedRecording = false;
|
|
871
|
+
/** This task received the raw DevTools endpoint (tab control states that it cannot be held back). */
|
|
872
|
+
rawEndpoint = false;
|
|
724
873
|
constructor(browser,
|
|
725
874
|
/** The task scope (one per active task run). */
|
|
726
875
|
scope, project, recordingEnabled, env) {
|
|
@@ -750,14 +899,77 @@ export class TaskBrowser {
|
|
|
750
899
|
this.sessionId = undefined;
|
|
751
900
|
this.targetId = undefined;
|
|
752
901
|
}
|
|
753
|
-
/**
|
|
902
|
+
/**
|
|
903
|
+
* Gives up the tab a user took over: the agent never acts on, inspects or records it again until it is handed back
|
|
904
|
+
* (the user-controlled interval is a labelled gap of the recording).
|
|
905
|
+
*/
|
|
906
|
+
async yieldTab(connection, targetId) {
|
|
907
|
+
if (!this.yieldedRecording) {
|
|
908
|
+
await this.recorder.pause('The user took control of this tab; the user-controlled interval is not recorded.');
|
|
909
|
+
this.yieldedRecording = true;
|
|
910
|
+
}
|
|
911
|
+
const previous = this.sessionId;
|
|
912
|
+
this.navigationListener?.();
|
|
913
|
+
this.navigationListener = undefined;
|
|
914
|
+
this.sessionId = undefined;
|
|
915
|
+
this.targetId = undefined;
|
|
916
|
+
this.ownTabs.delete(targetId);
|
|
917
|
+
this.yielded.add(targetId);
|
|
918
|
+
if (previous)
|
|
919
|
+
await connection.send('Target.detachFromTarget', { sessionId: previous }).catch(() => undefined);
|
|
920
|
+
this.notice = `Note: the user took control of the tab ${targetId} you worked in. Do not act on it or wait for it here; it is handed back only when they choose. You now work in another tab of your own.`;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Gives up a tab a user now controls between actions: the one it works in (unless an operation of it is still in
|
|
924
|
+
* flight there and `now` is not set) or another of its own tabs.
|
|
925
|
+
*/
|
|
926
|
+
async giveUp(connection, reserved, now = false) {
|
|
927
|
+
if (this.targetId && reserved.has(this.targetId) && (now || !this.sessionId || !connection.pendingFor(this.sessionId)))
|
|
928
|
+
await this.yieldTab(connection, this.targetId);
|
|
929
|
+
for (const targetId of [...this.ownTabs]) {
|
|
930
|
+
if (!reserved.has(targetId) || targetId === this.targetId)
|
|
931
|
+
continue;
|
|
932
|
+
this.ownTabs.delete(targetId);
|
|
933
|
+
this.yielded.add(targetId);
|
|
934
|
+
this.notice ??= `Note: the user took control of your tab ${targetId}. Do not act on it or wait for it here; it is handed back only when they choose.`;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/** Tabs this task gave up to a user (returned ones may be selected again). */
|
|
938
|
+
yielded = new Set();
|
|
939
|
+
/** The DevTools session this task has on a tab (when it currently works in it). */
|
|
940
|
+
currentSession(targetId) {
|
|
941
|
+
return this.targetId === targetId ? this.sessionId : undefined;
|
|
942
|
+
}
|
|
943
|
+
/** A tab this task gave up was handed back by the user: the agent revalidates it before acting on it. */
|
|
944
|
+
returned(targetId, url) {
|
|
945
|
+
if (!this.yielded.delete(targetId))
|
|
946
|
+
return;
|
|
947
|
+
this.notice = `Note: the user handed back the tab ${targetId}${url ? ` (now at ${url})` : ''}. It may have changed while they used it: select it with browser_tabs and read it again before acting on it, and recheck the latest instructions.`;
|
|
948
|
+
}
|
|
949
|
+
/** The note for the agent's next page result (given once). */
|
|
950
|
+
takeNotice() {
|
|
951
|
+
const notice = this.notice;
|
|
952
|
+
this.notice = undefined;
|
|
953
|
+
return notice ? `${notice}\n\n` : '';
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Ensures the connection and this task's working tab, with recording following that tab. A tab a user controls
|
|
957
|
+
* is never used: the current one is given up (with a note for the agent), and only tabs no user controls are chosen.
|
|
958
|
+
*/
|
|
754
959
|
async active() {
|
|
755
960
|
const connection = await this.browser.connect();
|
|
961
|
+
const reserved = await this.browser.reservedTabs();
|
|
962
|
+
if (this.targetId && reserved.has(this.targetId))
|
|
963
|
+
await this.yieldTab(connection, this.targetId);
|
|
756
964
|
if (!this.sessionId) {
|
|
757
|
-
// Another of this task's tabs, else a new tab of its own (never another task's).
|
|
758
|
-
const own = (await listPages(connection)).find((page) => this.ownTabs.has(page.targetId));
|
|
965
|
+
// Another of this task's tabs, else a new tab of its own (never another task's or a user-controlled one).
|
|
966
|
+
const own = (await listPages(connection)).find((page) => this.ownTabs.has(page.targetId) && !reserved.has(page.targetId));
|
|
759
967
|
await this.attachPage(connection, own?.targetId ?? (await this.newTab(connection)));
|
|
760
968
|
}
|
|
969
|
+
if (this.yieldedRecording) {
|
|
970
|
+
this.yieldedRecording = false;
|
|
971
|
+
await this.recorder.resume();
|
|
972
|
+
}
|
|
761
973
|
this.navigationListener ??= connection.on((message) => {
|
|
762
974
|
if (message.method !== 'Page.frameNavigated' || message.sessionId !== this.sessionId)
|
|
763
975
|
return;
|
|
@@ -820,8 +1032,10 @@ export class TaskBrowser {
|
|
|
820
1032
|
throw new PtbkError(matches.length ? `The tab id "${id}" matches several tabs; give more of it.` : `No open tab has the id "${id}"; browser_tabs lists them.`, { kind: 'usage' });
|
|
821
1033
|
return matches[0].targetId;
|
|
822
1034
|
}
|
|
823
|
-
/** Refuses to take over or close a tab another active task works in. */
|
|
824
|
-
guard(targetId) {
|
|
1035
|
+
/** Refuses to take over or close a tab another active task works in or a user controls. */
|
|
1036
|
+
async guard(targetId) {
|
|
1037
|
+
if ((await this.browser.reservedTabs()).has(targetId))
|
|
1038
|
+
throw new PtbkError(`The tab ${targetId} is controlled by the user now; it is not yours to use until they hand it back. Work in your own tabs (open one with browser_tabs action new).`, { kind: 'conflict' });
|
|
825
1039
|
if (this.browser.tabsOfOthers(this.scope).has(targetId))
|
|
826
1040
|
throw new PtbkError(`The tab ${targetId} belongs to another task working in this browser now; work in your own tabs (open one with browser_tabs action new).`, { kind: 'usage' });
|
|
827
1041
|
}
|
|
@@ -833,7 +1047,8 @@ export class TaskBrowser {
|
|
|
833
1047
|
}
|
|
834
1048
|
/** After an action: continues in a tab the action opened (a link to a new window, `window.open`), as a person would. */
|
|
835
1049
|
async followNewTab(connection, before) {
|
|
836
|
-
const
|
|
1050
|
+
const reserved = await this.browser.reservedTabs();
|
|
1051
|
+
const opened = (await listPages(connection)).filter((page) => !before.has(page.targetId) && !reserved.has(page.targetId));
|
|
837
1052
|
const tab = opened[opened.length - 1];
|
|
838
1053
|
if (!tab)
|
|
839
1054
|
return '';
|
|
@@ -853,16 +1068,11 @@ export class TaskBrowser {
|
|
|
853
1068
|
const tabs = (await listPages(connection)).filter((candidate) => this.ownTabs.has(candidate.targetId)).length;
|
|
854
1069
|
const omitted = page.omitted ? `\n… ${page.omitted} more elements are not listed (scroll to them, or use browser_evaluate).` : '';
|
|
855
1070
|
const note = this.browser.modeNote;
|
|
856
|
-
return `${page.title}\n${page.url}${tabs > 1 ? `\n(${tabs} of your tabs are open; browser_tabs lists and switches them)` : ''}\n\n${page.text}\n\nInteractive elements (their numbers are the ref of other browser tools):\n${page.elements.join('\n') || '(none)'}${omitted}${note ? `\n\nNote: ${note}` : ''}`;
|
|
1071
|
+
return `${this.takeNotice()}${page.title}\n${page.url}${tabs > 1 ? `\n(${tabs} of your tabs are open; browser_tabs lists and switches them)` : ''}\n\n${page.text}\n\nInteractive elements (their numbers are the ref of other browser tools):\n${page.elements.join('\n') || '(none)'}${omitted}${note ? `\n\nNote: ${note}` : ''}`;
|
|
857
1072
|
}
|
|
858
1073
|
/** Navigates the current tab (or a new one) to any URL and waits for it to load (bounded). */
|
|
859
1074
|
async open(url, newTab = false) {
|
|
860
|
-
const
|
|
861
|
-
if (!trimmed)
|
|
862
|
-
throw new PtbkError('Name the URL to open.', { kind: 'usage' });
|
|
863
|
-
// An address without a scheme is a website, as in the address bar (plain HTTP for local addresses).
|
|
864
|
-
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || /^(about|blob|data|javascript|mailto|tel|view-source):/i.test(trimmed);
|
|
865
|
-
const address = hasScheme ? trimmed : `${/^(localhost|127\.|0\.0\.0\.0|\[::1\])/i.test(trimmed) ? 'http' : 'https'}://${trimmed}`;
|
|
1075
|
+
const address = browserAddress(url);
|
|
866
1076
|
const { connection } = await this.active();
|
|
867
1077
|
if (newTab)
|
|
868
1078
|
await this.attachPage(connection, await this.newTab(connection));
|
|
@@ -903,8 +1113,10 @@ export class TaskBrowser {
|
|
|
903
1113
|
const { connection } = await this.active();
|
|
904
1114
|
const pages = await listPages(connection);
|
|
905
1115
|
const others = this.browser.tabsOfOthers(this.scope);
|
|
906
|
-
const
|
|
907
|
-
|
|
1116
|
+
const reserved = await this.browser.reservedTabs();
|
|
1117
|
+
const mark = (page) => (reserved.has(page.targetId) ? 'u' : page.targetId === this.targetId ? '*' : this.ownTabs.has(page.targetId) ? '-' : others.has(page.targetId) ? 'x' : 'o');
|
|
1118
|
+
// A user-controlled tab is listed without its address or title: it is not observed while the user has it.
|
|
1119
|
+
return `${this.takeNotice()}Open tabs (* = the tab you work in, - = your other tabs, x = another task's tab (not yours to use), u = controlled by the user (not yours to use), o = other tabs; a unique prefix of an id is enough):\n${pages.map((page) => (reserved.has(page.targetId) ? `u ${page.targetId} (controlled by the user)` : `${mark(page)} ${page.targetId} ${page.title || '(untitled)'} ${page.url}`)).join('\n')}`;
|
|
908
1120
|
}
|
|
909
1121
|
/** Opens a new tab (optionally at a URL), switches to a tab or closes one (never another task's). */
|
|
910
1122
|
async tab(action, id, url) {
|
|
@@ -918,7 +1130,7 @@ export class TaskBrowser {
|
|
|
918
1130
|
const targetId = id?.trim() ? await this.resolveTab(connection, id) : this.targetId;
|
|
919
1131
|
if (!targetId)
|
|
920
1132
|
throw new PtbkError('Name the tab id (browser_tabs lists them).', { kind: 'usage' });
|
|
921
|
-
this.guard(targetId);
|
|
1133
|
+
await this.guard(targetId);
|
|
922
1134
|
if (action === 'select') {
|
|
923
1135
|
await this.attachPage(connection, targetId);
|
|
924
1136
|
return await this.read();
|
|
@@ -1145,15 +1357,33 @@ export class TaskBrowser {
|
|
|
1145
1357
|
if (!/^[A-Z][A-Za-z]*\.[a-z][A-Za-z]*$/.test(method.trim()))
|
|
1146
1358
|
throw new PtbkError('Name a DevTools protocol method such as Page.printToPDF or Emulation.setGeolocationOverride.', { kind: 'usage' });
|
|
1147
1359
|
const { connection, sessionId } = await this.active();
|
|
1360
|
+
if (scope === 'browser') {
|
|
1361
|
+
// While a user controls a tab of this browser, browser-wide commands cannot reach it: none naming it, none
|
|
1362
|
+
// attaching to every target and none ending the browser under the user.
|
|
1363
|
+
const reserved = await this.browser.reservedTabs();
|
|
1364
|
+
const text = JSON.stringify(params);
|
|
1365
|
+
const reaching = [...reserved].find((targetId) => text.includes(targetId));
|
|
1366
|
+
const sweeping = reserved.size > 0 && /^(Target\.(setAutoAttach|attachToBrowserTarget|exposeDevToolsProtocol|setDiscoverTargets|getTargets)|Browser\.(close|crash|crashGpuProcess)|Storage\.clear)/.test(method.trim());
|
|
1367
|
+
if (reaching || sweeping)
|
|
1368
|
+
throw new PtbkError(`${method.trim()} would reach a tab the user controls now; it is not available until they hand it back. Use your own tabs.`, { kind: 'conflict' });
|
|
1369
|
+
}
|
|
1148
1370
|
const result = await withTimeout(connection.send(method.trim(), { ...params }, scope === 'browser' ? undefined : sessionId), SCRIPT_TIMEOUT_MS, method);
|
|
1149
1371
|
return truncate(JSON.stringify(result, null, 2), MAX_PAGE_TEXT);
|
|
1150
1372
|
}
|
|
1151
1373
|
/** The local DevTools endpoint of the shared browser for the agent's own automation scripts. */
|
|
1152
1374
|
async devtoolsEndpoint() {
|
|
1153
1375
|
await this.active();
|
|
1376
|
+
// A raw endpoint bypasses the tab boundary, so it is not handed out while a user controls a tab here.
|
|
1377
|
+
if ((await this.browser.reservedTabs()).size)
|
|
1378
|
+
throw new PtbkError(`The raw DevTools endpoint of "${this.name}" is unavailable while the user controls one of its tabs; use the browser tools (they keep to your own tabs).`, { kind: 'conflict' });
|
|
1154
1379
|
const devtools = this.browser.devtools;
|
|
1155
1380
|
if (!devtools)
|
|
1156
1381
|
throw new PtbkError(`The browser "${this.name}" exposes no DevTools endpoint right now; try again.`, { kind: 'technical' });
|
|
1382
|
+
const profile = this.browser.profile;
|
|
1383
|
+
if (profile && !this.rawEndpoint) {
|
|
1384
|
+
this.rawEndpoint = true;
|
|
1385
|
+
await this.browser.reservations.grantRawEndpoint(profile, `${this.browser.instanceId}/${this.scope}`, this.browser.projectRoot).catch(() => undefined);
|
|
1386
|
+
}
|
|
1157
1387
|
const http = `http://127.0.0.1:${devtools.port}`;
|
|
1158
1388
|
return [
|
|
1159
1389
|
`HTTP endpoint: ${http}`,
|
|
@@ -1448,7 +1678,13 @@ export class TaskBrowser {
|
|
|
1448
1678
|
async release() {
|
|
1449
1679
|
this.navigationListener?.();
|
|
1450
1680
|
this.navigationListener = undefined;
|
|
1451
|
-
const
|
|
1681
|
+
const profile = this.browser.profile;
|
|
1682
|
+
if (this.rawEndpoint && profile)
|
|
1683
|
+
await this.browser.reservations.revokeRawEndpoint(profile, `${this.browser.instanceId}/${this.scope}`);
|
|
1684
|
+
this.rawEndpoint = false;
|
|
1685
|
+
// A tab the user controls stays open and theirs; it is never closed with the task.
|
|
1686
|
+
const reserved = await this.browser.reservedTabs().catch(() => new Set());
|
|
1687
|
+
const tabs = [...this.ownTabs].filter((targetId) => !reserved.has(targetId));
|
|
1452
1688
|
this.ownTabs.clear();
|
|
1453
1689
|
this.sessionId = undefined;
|
|
1454
1690
|
this.targetId = undefined;
|