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.
Files changed (119) hide show
  1. package/README.md +4 -1
  2. package/dist/browser/browser.d.ts +98 -5
  3. package/dist/browser/browser.js +255 -19
  4. package/dist/browser/browser.js.map +1 -1
  5. package/dist/browser/tab-control.d.ts +386 -0
  6. package/dist/browser/tab-control.js +1013 -0
  7. package/dist/browser/tab-control.js.map +1 -0
  8. package/dist/browser/tab-reservations.d.ts +59 -0
  9. package/dist/browser/tab-reservations.js +101 -0
  10. package/dist/browser/tab-reservations.js.map +1 -0
  11. package/dist/chats/voice-call.d.ts +43 -11
  12. package/dist/chats/voice-call.js +96 -40
  13. package/dist/chats/voice-call.js.map +1 -1
  14. package/dist/cli/commands/init.d.ts +4 -0
  15. package/dist/cli/commands/init.js +14 -5
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli/sources.js +2 -0
  18. package/dist/cli/sources.js.map +1 -1
  19. package/dist/common/websocket.d.ts +60 -0
  20. package/dist/common/websocket.js +179 -0
  21. package/dist/common/websocket.js.map +1 -0
  22. package/dist/engine/agenda.d.ts +19 -5
  23. package/dist/engine/agenda.js +20 -8
  24. package/dist/engine/agenda.js.map +1 -1
  25. package/dist/engine/engine-update.js +2 -0
  26. package/dist/engine/engine-update.js.map +1 -1
  27. package/dist/engine/snapshot.d.ts +3 -0
  28. package/dist/engine/voice-calls.d.ts +41 -4
  29. package/dist/engine/voice-calls.js +40 -8
  30. package/dist/engine/voice-calls.js.map +1 -1
  31. package/dist/expert/_main.md +8 -4
  32. package/dist/expert/cli/acceptance.md +3 -1
  33. package/dist/expert/cli/browser-tab-control.md +38 -0
  34. package/dist/expert/cli/browser.md +7 -5
  35. package/dist/expert/cli/chat-ui.md +1 -1
  36. package/dist/expert/cli/dashboard-details.md +3 -1
  37. package/dist/expert/cli/dashboard.md +2 -0
  38. package/dist/expert/cli/desktop.md +21 -4
  39. package/dist/expert/cli/harness-voice.md +1 -1
  40. package/dist/expert/cli/installation.md +2 -2
  41. package/dist/expert/cli/interactive-feature-bridges.md +35 -0
  42. package/dist/expert/cli/notifications.md +2 -2
  43. package/dist/expert/cli/releasing.md +8 -2
  44. package/dist/expert/cli/task-control.md +1 -1
  45. package/dist/expert/cli/terminal.md +2 -0
  46. package/dist/expert/cli/traces.md +1 -1
  47. package/dist/expert/cli/user-interactions.md +1 -1
  48. package/dist/expert/cli/voice-chat-ui.md +11 -9
  49. package/dist/expert/cli/voice-chats.md +10 -10
  50. package/dist/expert/cli/wallet-testing.md +2 -0
  51. package/dist/expert/cli/wallet.md +16 -4
  52. package/dist/expert/cli/windows-installer.md +15 -41
  53. package/dist/expert/concepts/engine-and-project.md +2 -0
  54. package/dist/expert/concepts/minimal-interruption/_index.md +1 -0
  55. package/dist/expert/concepts/minimal-interruption/authentication-examples.md +1 -0
  56. package/dist/expert/concepts/product-presentation.md +1 -1
  57. package/dist/expert/concepts/user-control.md +15 -0
  58. package/dist/expert/dictionary.md +4 -2
  59. package/dist/service/auth.d.ts +5 -0
  60. package/dist/service/auth.js +2 -1
  61. package/dist/service/auth.js.map +1 -1
  62. package/dist/service/discovery.d.ts +3 -0
  63. package/dist/service/discovery.js +1 -0
  64. package/dist/service/discovery.js.map +1 -1
  65. package/dist/service/host.d.ts +8 -0
  66. package/dist/service/host.js +125 -4
  67. package/dist/service/host.js.map +1 -1
  68. package/dist/service/http.d.ts +2 -0
  69. package/dist/service/http.js +27 -4
  70. package/dist/service/http.js.map +1 -1
  71. package/dist/service/spa/app.css +143 -2
  72. package/dist/service/spa/app.js +31 -4
  73. package/dist/service/spa/fonts/fonts.css +35 -0
  74. package/dist/service/spa/fonts/inter-OFL.txt +93 -0
  75. package/dist/service/spa/fonts/inter-latin-ext.woff2 +0 -0
  76. package/dist/service/spa/fonts/inter-latin.woff2 +0 -0
  77. package/dist/service/spa/fonts/outfit-OFL.txt +93 -0
  78. package/dist/service/spa/fonts/outfit-latin-ext.woff2 +0 -0
  79. package/dist/service/spa/fonts/outfit-latin.woff2 +0 -0
  80. package/dist/service/spa/index.html +1 -0
  81. package/dist/ui/chat-client.d.ts +7 -2
  82. package/dist/ui/chat-client.js +41 -6
  83. package/dist/ui/chat-client.js.map +1 -1
  84. package/dist/ui/dashboard-pages.d.ts +7 -0
  85. package/dist/ui/dashboard-pages.js +64 -3
  86. package/dist/ui/dashboard-pages.js.map +1 -1
  87. package/dist/ui/dashboard.d.ts +5 -0
  88. package/dist/ui/dashboard.js +160 -2
  89. package/dist/ui/dashboard.js.map +1 -1
  90. package/dist/ui/feature-server.d.ts +169 -0
  91. package/dist/ui/feature-server.js +443 -0
  92. package/dist/ui/feature-server.js.map +1 -0
  93. package/dist/ui/global-dashboard.d.ts +2 -0
  94. package/dist/ui/global-dashboard.js +1 -1
  95. package/dist/ui/global-dashboard.js.map +1 -1
  96. package/dist/ui/port-surface.d.ts +92 -0
  97. package/dist/ui/port-surface.js +172 -0
  98. package/dist/ui/port-surface.js.map +1 -0
  99. package/dist/ui/tab-page.d.ts +13 -0
  100. package/dist/ui/tab-page.js +194 -0
  101. package/dist/ui/tab-page.js.map +1 -0
  102. package/dist/ui/tab-surface.d.ts +67 -0
  103. package/dist/ui/tab-surface.js +218 -0
  104. package/dist/ui/tab-surface.js.map +1 -0
  105. package/dist/ui/voice-page.d.ts +15 -0
  106. package/dist/{chats → ui}/voice-page.js +38 -39
  107. package/dist/ui/voice-page.js.map +1 -0
  108. package/dist/ui/voice-surface.d.ts +88 -0
  109. package/dist/ui/voice-surface.js +162 -0
  110. package/dist/ui/voice-surface.js.map +1 -0
  111. package/dist/wallet/wallet.d.ts +62 -0
  112. package/dist/wallet/wallet.js +120 -1
  113. package/dist/wallet/wallet.js.map +1 -1
  114. package/package.json +1 -1
  115. package/dist/chats/voice-bridge.d.ts +0 -49
  116. package/dist/chats/voice-bridge.js +0 -282
  117. package/dist/chats/voice-bridge.js.map +0 -1
  118. package/dist/chats/voice-page.d.ts +0 -14
  119. 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), Codex and Claude Code. 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
+ **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
- private readonly instanceId;
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
- /** Ensures the connection and this task's working tab, with recording following that tab. */
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;
@@ -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
- /** Ensures the connection and this task's working tab, with recording following that tab. */
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 opened = (await listPages(connection)).filter((page) => !before.has(page.targetId));
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 trimmed = url.trim();
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 mark = (page) => (page.targetId === this.targetId ? '*' : this.ownTabs.has(page.targetId) ? '-' : others.has(page.targetId) ? 'x' : 'o');
907
- return `Open tabs (* = the tab you work in, - = your other tabs, x = another task's tab (not yours to use), o = other tabs; a unique prefix of an id is enough):\n${pages.map((page) => `${mark(page)} ${page.targetId} ${page.title || '(untitled)'} ${page.url}`).join('\n')}`;
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 tabs = [...this.ownTabs];
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;