@otterware/openclaw-otter 0.2.0 → 0.2.1

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 (3) hide show
  1. package/control.js +413 -42
  2. package/index.js +63 -13
  3. package/package.json +1 -1
package/control.js CHANGED
@@ -1,12 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+
1
6
  /** A held tab is released this long after Otter last sent input or a heartbeat (it quit, say). */
2
7
  export const CONTROL_TTL_MS = 30_000;
3
8
  /** How long a batch waits for one sent before it, before giving up on that one. */
4
9
  const GAP_MS = 150;
10
+ /** A tab nobody watches or holds for this long loses its DevTools connection. */
11
+ const IDLE_MS = 60_000;
12
+ const EVENTS_KEPT = 200;
13
+ const CALL_TIMEOUT_MS = 5_000;
14
+ /** Files travel base64 in one gateway message (16 MB at most). */
15
+ export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
16
+ const UPLOADS_KEPT_MS = 10 * 60_000;
17
+
18
+ /** What the agent's browser tool may still do while you hold a tab: read pages, not act on them. */
19
+ export const READ_ACTIONS = new Set([
20
+ "doctor",
21
+ "status",
22
+ "tabs",
23
+ "snapshot",
24
+ "screenshot",
25
+ "requests",
26
+ "errors",
27
+ "text",
28
+ ]);
29
+
30
+ const WORLD = "otter-watch";
31
+ const BINDING = "__otterSaw";
32
+ /**
33
+ * Runs in a world of its own, where the page can't see or stop it: where clicks land, the field
34
+ * being typed in, and what you copy (Chromium's clipboard isn't yours, so the copy is read here).
35
+ */
36
+ const WATCH_SCRIPT = `(() => {
37
+ if (globalThis.${BINDING}Ready || window !== top) return;
38
+ globalThis.${BINDING}Ready = true;
39
+ const say = (event) => { try { ${BINDING}(JSON.stringify(event)); } catch {} };
40
+ let move = null;
41
+ addEventListener("pointermove", (event) => {
42
+ if (!event.isTrusted) return;
43
+ if (!move) setTimeout(() => { say(move); move = null; }, 33);
44
+ move = { type: "pointer", phase: "move", x: event.clientX, y: event.clientY };
45
+ }, true);
46
+ addEventListener("pointerdown", (event) => {
47
+ if (event.isTrusted) say({ type: "pointer", phase: "click", x: event.clientX, y: event.clientY });
48
+ }, true);
49
+ const field = () => {
50
+ let found = document.activeElement;
51
+ while (found?.shadowRoot?.activeElement) found = found.shadowRoot.activeElement;
52
+ return found;
53
+ };
54
+ let copying = false;
55
+ addEventListener("keydown", (event) => {
56
+ if (!event.isTrusted) return;
57
+ const key = event.key.toLowerCase();
58
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && (key === "c" || key === "x")) {
59
+ copying = true;
60
+ setTimeout(() => { copying = false; });
61
+ }
62
+ const typed = field();
63
+ if (!typed || typed === document.body || typed === document.documentElement) return;
64
+ const box = typed.getBoundingClientRect();
65
+ say({ type: "typing", x: box.x, y: box.y, width: box.width, height: box.height });
66
+ }, true);
67
+ const selection = () => {
68
+ const typed = field();
69
+ if ((typed instanceof HTMLInputElement || typed instanceof HTMLTextAreaElement) && typed.selectionStart !== null)
70
+ return typed.value.slice(typed.selectionStart, typed.selectionEnd ?? typed.selectionStart);
71
+ return String(document.getSelection() ?? "");
72
+ };
73
+ for (const type of ["copy", "cut"]) {
74
+ addEventListener(type, (event) => {
75
+ if (copying && event.isTrusted) say({ type: "clipboard", text: selection().slice(0, 1048576) });
76
+ }, true);
77
+ addEventListener(type, (event) => {
78
+ if (!copying || !event.isTrusted || !event.defaultPrevented) return;
79
+ const text = event.clipboardData?.getData("text/plain");
80
+ if (text) say({ type: "clipboard", text: text.slice(0, 1048576) });
81
+ });
82
+ }
83
+ })();`;
5
84
 
6
85
  const MOUSE_TYPES = { move: "mouseMoved", down: "mousePressed", up: "mouseReleased" };
7
86
  const BUTTONS = new Set(["none", "left", "middle", "right"]);
8
87
  const num = (value) => (Number.isFinite(value) ? value : 0);
9
88
 
89
+ /** Headless Linux Chromium only copies, cuts (and with ⌘, selects all or undoes) when told to. */
90
+ export function editingCommand(key, modifiers) {
91
+ const modifier = modifiers & 0b0111;
92
+ if (modifier !== 2 && modifier !== 4) return null;
93
+ const lower = key.toLowerCase();
94
+ const command =
95
+ lower === "c"
96
+ ? "copy"
97
+ : lower === "x"
98
+ ? "cut"
99
+ : modifier !== 4
100
+ ? null
101
+ : lower === "a"
102
+ ? "selectAll"
103
+ : lower === "z"
104
+ ? modifiers & 8
105
+ ? "redo"
106
+ : "undo"
107
+ : null;
108
+ return command ? { commands: [command] } : null;
109
+ }
110
+
10
111
  /** The DevTools call that replays one of Otter's input events, as a person's input. */
11
112
  export function cdpInput(event) {
12
113
  const modifiers = num(event?.modifiers);
@@ -39,16 +140,22 @@ export function cdpInput(event) {
39
140
  ];
40
141
  case "key": {
41
142
  if (typeof event.key !== "string" || typeof event.code !== "string") return null;
143
+ if (event.action === "up")
144
+ return [
145
+ "Input.dispatchKeyEvent",
146
+ { type: "keyUp", key: event.key, code: event.code, modifiers },
147
+ ];
42
148
  const text = typeof event.text === "string" ? event.text : undefined;
43
149
  return [
44
150
  "Input.dispatchKeyEvent",
45
151
  {
46
- type: event.action === "up" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
152
+ type: text ? "keyDown" : "rawKeyDown",
47
153
  key: event.key,
48
154
  code: event.code,
49
155
  windowsVirtualKeyCode: num(event.keyCode),
50
156
  nativeVirtualKeyCode: num(event.keyCode),
51
157
  ...(text ? { text, unmodifiedText: text } : {}),
158
+ ...editingCommand(event.key, modifiers),
52
159
  modifiers,
53
160
  },
54
161
  ];
@@ -57,12 +164,26 @@ export function cdpInput(event) {
57
164
  return typeof event.text === "string"
58
165
  ? ["Input.insertText", { text: event.text.slice(0, 10_000) }]
59
166
  : null;
167
+ case "dialog":
168
+ return [
169
+ "Page.handleJavaScriptDialog",
170
+ {
171
+ accept: event.accept === true,
172
+ ...(typeof event.promptText === "string" ? { promptText: event.promptText } : {}),
173
+ },
174
+ ];
175
+ case "navigate":
176
+ return typeof event.url === "string" && /^https?:\/\//i.test(event.url)
177
+ ? ["Page.navigate", { url: event.url }]
178
+ : null;
179
+ case "reload":
180
+ return ["Page.reload", {}];
60
181
  default:
61
182
  return null;
62
183
  }
63
184
  }
64
185
 
65
- /** A page's own DevTools connection: input is sent without waiting for Chromium's replies. */
186
+ /** A page's own DevTools connection. Input goes without waiting for Chromium's replies. */
66
187
  export async function connectTarget(cdpUrl, targetId) {
67
188
  const list = await fetch(new URL("/json/list", cdpUrl), { signal: AbortSignal.timeout(5000) })
68
189
  .then((response) => response.json())
@@ -83,79 +204,329 @@ export async function connectTarget(cdpUrl, targetId) {
83
204
  );
84
205
  });
85
206
  let id = 0;
207
+ const replies = new Map();
208
+ const listeners = new Set();
209
+ socket.addEventListener("message", ({ data }) => {
210
+ const message = JSON.parse(String(data));
211
+ if (message.id === undefined) {
212
+ for (const listener of listeners) listener(message.method, message.params ?? {});
213
+ return;
214
+ }
215
+ const reply = replies.get(message.id);
216
+ if (!reply) return;
217
+ replies.delete(message.id);
218
+ if (message.error) reply.reject(new Error(message.error.message));
219
+ else reply.resolve(message.result ?? {});
220
+ });
221
+ socket.addEventListener("close", () => {
222
+ for (const reply of replies.values()) reply.reject(new Error("That tab has closed."));
223
+ replies.clear();
224
+ });
86
225
  return {
87
226
  open: () => socket.readyState === WebSocket.OPEN,
88
227
  send: (method, params) => socket.send(JSON.stringify({ id: ++id, method, params })),
228
+ // Bounded: a page showing a pop-up holds back what runs script in it, and a request left
229
+ // waiting keeps the plugin from being updated.
230
+ call: (method, params = {}) =>
231
+ new Promise((resolve, reject) => {
232
+ const n = ++id;
233
+ const timer = setTimeout(() => {
234
+ replies.delete(n);
235
+ reject(new Error("The agent's browser didn't answer."));
236
+ }, CALL_TIMEOUT_MS);
237
+ replies.set(n, {
238
+ resolve: (value) => (clearTimeout(timer), resolve(value)),
239
+ reject: (error) => (clearTimeout(timer), reject(error)),
240
+ });
241
+ socket.send(JSON.stringify({ id: n, method, params }));
242
+ }),
243
+ on: (listener) => listeners.add(listener),
89
244
  close: () => socket.close(),
90
245
  };
91
246
  }
92
247
 
93
248
  /**
94
- * Who holds which of the agent's tabs, and their input. Batches carry a sequence number, so
95
- * they're replayed in the order Otter sent them however the gateway runs its requests.
249
+ * The agent's tabs as Otter sees them: who holds which (and their input, replayed in the order
250
+ * Otter numbered it however the gateway runs requests), and what happens on them (where the
251
+ * pointer lands, what's typed in, pop-ups, file pickers, copies) for whoever watches.
96
252
  */
97
- export function createBrowserControl({ cdpUrlFor, connect = connectTarget, now = Date.now }) {
98
- const held = new Map();
253
+ export function createBrowserControl({
254
+ cdpUrlFor,
255
+ answerDialog,
256
+ openDialog,
257
+ connect = connectTarget,
258
+ now = Date.now,
259
+ uploads = path.join(tmpdir(), "otter-uploads"),
260
+ }) {
261
+ const tabs = new Map();
262
+ // Event numbers start over when the plugin does (an update, a gateway restart): say which run.
263
+ const run = randomUUID();
99
264
 
265
+ let sweeper;
266
+ const tabOf = (targetId) => {
267
+ let tab = tabs.get(targetId);
268
+ if (!tab) {
269
+ tab = {
270
+ targetId,
271
+ lock: null,
272
+ events: [],
273
+ seq: 0,
274
+ waiters: new Set(),
275
+ seen: now(),
276
+ chain: Promise.resolve(),
277
+ };
278
+ tabs.set(targetId, tab);
279
+ sweeper ??= setInterval(() => sweep(), IDLE_MS);
280
+ sweeper.unref?.();
281
+ }
282
+ return tab;
283
+ };
100
284
  const holding = (targetId) => {
101
- const tab = held.get(targetId);
102
- if (tab && tab.until > now()) return tab;
103
- if (tab) drop(targetId);
285
+ const tab = tabs.get(targetId);
286
+ if (tab?.lock && tab.lock.until > now()) return tab;
287
+ if (tab?.lock) unlock(tab);
104
288
  return undefined;
105
289
  };
106
- const drop = (targetId) => {
107
- const tab = held.get(targetId);
108
- held.delete(targetId);
109
- clearTimeout(tab?.gap);
110
- void tab?.page?.then((page) => page.close()).catch(() => {});
290
+ const unlock = (tab) => {
291
+ clearTimeout(tab.lock?.gap);
292
+ for (const batch of tab.lock?.waiting.values() ?? []) batch.done();
293
+ tab.lock = null;
294
+ tab.chooser = null;
295
+ void tab.page
296
+ ?.then(
297
+ (page) =>
298
+ page.open() && page.call("Page.setInterceptFileChooserDialog", { enabled: false }),
299
+ )
300
+ .catch(() => {});
301
+ };
302
+ /** Lets go of tabs nobody has held or watched for a while, and their DevTools connections. */
303
+ const sweep = (all = false) => {
304
+ for (const [targetId, tab] of tabs) {
305
+ if (!all && (holding(targetId) || now() - tab.seen < IDLE_MS)) continue;
306
+ if (tab.lock) unlock(tab);
307
+ for (const wake of tab.waiters) wake();
308
+ tabs.delete(targetId);
309
+ void tab.page?.then((page) => page.close()).catch(() => {});
310
+ }
311
+ if (tabs.size === 0 && sweeper) {
312
+ clearInterval(sweeper);
313
+ sweeper = undefined;
314
+ }
315
+ };
316
+ const tell = (tab, event) => {
317
+ tab.seq += 1;
318
+ tab.events.push({ ...event, seq: tab.seq });
319
+ if (tab.events.length > EVENTS_KEPT) tab.events.shift();
320
+ for (const wake of tab.waiters) wake();
321
+ };
322
+ const watch = (tab, page) => {
323
+ page.on((method, params) => {
324
+ if (method === "Runtime.bindingCalled" && params.name === BINDING) {
325
+ try {
326
+ tell(tab, JSON.parse(params.payload));
327
+ } catch {}
328
+ } else if (method === "Page.javascriptDialogOpening") {
329
+ tab.dialog = {
330
+ dialogType: params.type,
331
+ message: String(params.message ?? "").slice(0, 4000),
332
+ defaultPrompt: String(params.defaultPrompt ?? "").slice(0, 4000),
333
+ };
334
+ tell(tab, { type: "dialog", ...tab.dialog });
335
+ } else if (method === "Page.javascriptDialogClosed") {
336
+ tab.dialog = null;
337
+ tell(tab, { type: "dialogClosed" });
338
+ } else if (method === "Page.fileChooserOpened" && tab.lock) {
339
+ tab.chooser = { backendNodeId: params.backendNodeId };
340
+ tell(tab, { type: "fileChooser", multiple: params.mode === "selectMultiple" });
341
+ }
342
+ });
343
+ // Not waited for: a page showing a pop-up holds every command until it closes, and runs them
344
+ // in order then. Binding calls only arrive with Runtime on (its other events are ignored).
345
+ for (const [method, params] of [
346
+ ["Runtime.enable", {}],
347
+ ["Runtime.addBinding", { name: BINDING, executionContextName: WORLD }],
348
+ ["Page.enable", {}],
349
+ [
350
+ "Page.addScriptToEvaluateOnNewDocument",
351
+ { source: WATCH_SCRIPT, worldName: WORLD, runImmediately: true },
352
+ ],
353
+ ])
354
+ void page.call(method, params).catch(() => {});
355
+ };
356
+ const open = async (tab, targetId, profile) => {
357
+ const page = await connect(await cdpUrlFor(profile), targetId);
358
+ watch(tab, page);
359
+ // A pop-up already open before we connected isn't announced again.
360
+ const already = await openDialog?.(targetId, profile).catch(() => null);
361
+ if (already && !tab.dialog) {
362
+ tab.dialog = already;
363
+ tell(tab, { type: "dialog", ...already });
364
+ }
365
+ return page;
111
366
  };
112
- const open = (targetId, profile) => cdpUrlFor(profile).then((url) => connect(url, targetId));
113
367
  const pageOf = async (tab, targetId, profile) => {
114
- tab.page ??= open(targetId, profile);
368
+ tab.profile = profile;
369
+ tab.page ??= open(tab, targetId, profile);
115
370
  const page = await tab.page.catch(() => null);
116
371
  if (page?.open()) return page;
117
- tab.page = open(targetId, profile);
372
+ tab.page = open(tab, targetId, profile);
118
373
  return tab.page;
119
374
  };
375
+
376
+ const replay = async (tab, page, event) => {
377
+ // Answered through OpenClaw when it can, so it knows the pop-up is gone and the agent can go on.
378
+ if (event?.type === "dialog" && answerDialog) {
379
+ const answer = { accept: event.accept === true, promptText: event.promptText };
380
+ if (
381
+ await answerDialog(tab.targetId, tab.profile, answer).then(
382
+ () => true,
383
+ () => false,
384
+ )
385
+ )
386
+ return;
387
+ }
388
+ if (event?.type === "history") {
389
+ const { currentIndex, entries } = await page.call("Page.getNavigationHistory");
390
+ const entry = entries?.[currentIndex + (event.delta === -1 ? -1 : 1)];
391
+ if (entry) page.send("Page.navigateToHistoryEntry", { entryId: entry.id });
392
+ return;
393
+ }
394
+ const call = cdpInput(event);
395
+ if (call) page.send(...call);
396
+ };
397
+ /**
398
+ * Replays the batches now in order, one after another for the tab: a pop-up's answer or a
399
+ * history move waits on a reply, and what follows must not overtake it.
400
+ */
120
401
  const flush = (tab, page) => {
121
- const missing = tab.next;
122
- while (tab.waiting.has(tab.next)) {
123
- for (const event of tab.waiting.get(tab.next)) {
124
- const call = cdpInput(event);
125
- if (call) page.send(...call);
126
- }
127
- tab.waiting.delete(tab.next);
128
- tab.next += 1;
402
+ const lock = tab.lock;
403
+ const missing = lock.next;
404
+ while (lock.waiting.has(lock.next)) {
405
+ const batch = lock.waiting.get(lock.next);
406
+ lock.waiting.delete(lock.next);
407
+ lock.next += 1;
408
+ tab.chain = tab.chain
409
+ .then(async () => {
410
+ // Handed back meanwhile: what's still queued is dropped, not sent over the agent.
411
+ for (const event of batch.events) {
412
+ if (tab.lock !== lock) return;
413
+ await replay(tab, page, event).catch(() => {});
414
+ }
415
+ })
416
+ .finally(batch.done);
129
417
  }
130
418
  // A batch is missing: let it arrive, then go on without it. Batches arriving behind it
131
419
  // don't push that back.
132
- if (tab.gap && tab.next === missing && tab.waiting.size > 0) return;
133
- clearTimeout(tab.gap);
134
- tab.gap = undefined;
135
- if (tab.waiting.size === 0) return;
136
- tab.gap = setTimeout(() => {
137
- tab.gap = undefined;
138
- tab.next = Math.min(...tab.waiting.keys());
420
+ if (lock.gap && lock.next === missing && lock.waiting.size > 0) return;
421
+ clearTimeout(lock.gap);
422
+ lock.gap = undefined;
423
+ if (lock.waiting.size === 0) return;
424
+ lock.gap = setTimeout(() => {
425
+ lock.gap = undefined;
426
+ if (tab.lock !== lock) return;
427
+ lock.next = Math.min(...lock.waiting.keys());
139
428
  flush(tab, page);
140
429
  }, GAP_MS);
141
430
  };
142
431
 
143
432
  return {
144
- anyHeld: () => [...held.keys()].some((targetId) => holding(targetId)),
145
- take(targetId) {
146
- const tab = holding(targetId);
147
- if (tab) tab.until = now() + CONTROL_TTL_MS;
148
- else held.set(targetId, { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() });
433
+ /** The plugin is being replaced: let go of everything. */
434
+ dispose: () => sweep(true),
435
+ /** Whether the agent's browser tool may run this call now: reads always, actions unless held. */
436
+ allows: (params) =>
437
+ READ_ACTIONS.has(params?.action) || ![...tabs.keys()].some((id) => holding(id)),
438
+ async take(targetId, profile) {
439
+ sweep();
440
+ const held = holding(targetId);
441
+ if (held) {
442
+ held.lock.until = now() + CONTROL_TTL_MS;
443
+ held.seen = now();
444
+ return;
445
+ }
446
+ const tab = tabOf(targetId);
447
+ tab.seen = now();
448
+ tab.lock = { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() };
449
+ // The page's own file picker is offered to you while you hold it.
450
+ const page = await pageOf(tab, targetId, profile);
451
+ void page.call("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => {});
452
+ },
453
+ release(targetId) {
454
+ const tab = tabs.get(targetId);
455
+ if (tab?.lock) unlock(tab);
149
456
  },
150
- release: drop,
151
457
  async input(targetId, profile, seq, events) {
152
458
  const tab = holding(targetId);
153
459
  if (!tab) throw new Error("Take control of this tab first.");
154
- tab.until = now() + CONTROL_TTL_MS;
155
- if (!Number.isInteger(seq) || seq < tab.next || !Array.isArray(events)) return;
156
- tab.waiting.set(seq, events.slice(0, 200));
460
+ tab.lock.until = now() + CONTROL_TTL_MS;
461
+ tab.seen = now();
462
+ if (!Number.isInteger(seq) || seq < tab.lock.next || !Array.isArray(events)) return;
463
+ const lock = tab.lock;
464
+ // The request stays open until its batch is replayed: answering a pop-up goes through
465
+ // OpenClaw, which drops the call once the request that made it returns.
466
+ const replayed = new Promise((done) =>
467
+ lock.waiting.set(seq, { events: events.slice(0, 200), done }),
468
+ );
469
+ const page = await pageOf(tab, targetId, profile);
470
+ if (tab.lock === lock) flush(tab, page);
471
+ await replayed;
472
+ },
473
+ /**
474
+ * What happened on the tab since `since`, waiting up to `waitMs` for something to. Without
475
+ * `since`: no events, just where they're up to and any open pop-up.
476
+ */
477
+ async events(targetId, profile, since, waitMs, from) {
478
+ if (from !== run) since = undefined;
479
+ sweep();
480
+ const tab = tabOf(targetId);
481
+ tab.seen = now();
482
+ await pageOf(tab, targetId, profile);
483
+ const state = () => ({
484
+ run,
485
+ seq: tab.seq,
486
+ dialog: tab.dialog ?? null,
487
+ events: Number.isInteger(since) ? tab.events.filter((e) => e.seq > since) : [],
488
+ });
489
+ if (!Number.isInteger(since) || since < tab.seq || !(waitMs > 0)) return state();
490
+ await new Promise((resolve) => {
491
+ const done = () => {
492
+ clearTimeout(timer);
493
+ tab.waiters.delete(done);
494
+ resolve();
495
+ };
496
+ const timer = setTimeout(done, Math.min(waitMs, 25_000));
497
+ tab.waiters.add(done);
498
+ });
499
+ tab.seen = now();
500
+ return state();
501
+ },
502
+ /** The files you picked for the page's own picker (none cancels it). */
503
+ async upload(targetId, profile, files) {
504
+ const tab = holding(targetId);
505
+ if (!tab) throw new Error("Take control of this tab first.");
506
+ const chooser = tab.chooser;
507
+ if (!chooser) throw new Error("The page isn't asking for a file.");
508
+ tab.chooser = null;
509
+ const picked = Array.isArray(files) ? files : [];
510
+ const total = picked.reduce((sum, f) => sum + String(f?.data ?? "").length * 0.75, 0);
511
+ if (total > MAX_UPLOAD_BYTES) throw new Error("Those files are too large (10 MB at most).");
512
+ const dir = path.join(uploads, randomUUID());
513
+ const paths = [];
514
+ if (picked.length > 0) {
515
+ for (const [i, file] of picked.entries()) {
516
+ // A folder each, so two files of the same name both reach the page as named.
517
+ const at = path.join(dir, String(i));
518
+ await mkdir(at, { recursive: true });
519
+ const name = path.basename(String(file?.name ?? "")) || `file-${i + 1}`;
520
+ await writeFile(path.join(at, name), Buffer.from(String(file?.data ?? ""), "base64"));
521
+ paths.push(path.join(at, name));
522
+ }
523
+ setTimeout(() => void rm(dir, { recursive: true, force: true }), UPLOADS_KEPT_MS).unref?.();
524
+ }
157
525
  const page = await pageOf(tab, targetId, profile);
158
- if (held.get(targetId) === tab) flush(tab, page);
526
+ await page.call("DOM.setFileInputFiles", {
527
+ files: paths,
528
+ backendNodeId: chooser.backendNodeId,
529
+ });
159
530
  },
160
531
  };
161
532
  }
package/index.js CHANGED
@@ -195,15 +195,43 @@ export default defineChannelPluginEntry({
195
195
 
196
196
  // Take control of the agent's browser from Otter: your input goes to the tab as a person's
197
197
  // would, and the agent's browser waits until you hand it back.
198
+ const browser = async (method, path, query, body) => {
199
+ const result = await dispatchGatewayMethod("browser.request", {
200
+ method,
201
+ path,
202
+ ...(query ? { query } : {}),
203
+ ...(body ? { body } : {}),
204
+ });
205
+ if (!result?.ok) throw new Error(result?.error?.message ?? "The browser didn't answer.");
206
+ return result.payload;
207
+ };
208
+ const pendingDialog = async (targetId, profile) => {
209
+ const query = { ...(profile ? { profile } : {}), targetId };
210
+ return (await browser("GET", "/dialogs", query))?.browserState?.dialogs?.pending?.[0];
211
+ };
198
212
  const control = createBrowserControl({
199
- async cdpUrlFor(profile) {
200
- const result = await dispatchGatewayMethod("browser.request", {
201
- method: "GET",
202
- path: "/profiles",
213
+ async openDialog(targetId, profile) {
214
+ const open = await pendingDialog(targetId, profile);
215
+ return open
216
+ ? {
217
+ dialogType: String(open.type),
218
+ message: String(open.message ?? ""),
219
+ defaultPrompt: String(open.defaultValue ?? ""),
220
+ }
221
+ : null;
222
+ },
223
+ async answerDialog(targetId, profile, { accept, promptText }) {
224
+ const pending = await pendingDialog(targetId, profile);
225
+ if (!pending) throw new Error("No pop-up is open.");
226
+ await browser("POST", "/hooks/dialog", profile ? { profile } : undefined, {
227
+ targetId,
228
+ dialogId: pending.id,
229
+ accept,
230
+ ...(typeof promptText === "string" ? { promptText } : {}),
203
231
  });
204
- if (!result?.ok)
205
- throw new Error(result?.error?.message ?? "Couldn't find the agent's browser.");
206
- const profiles = result.payload?.profiles ?? [];
232
+ },
233
+ async cdpUrlFor(profile) {
234
+ const profiles = (await browser("GET", "/profiles"))?.profiles ?? [];
207
235
  const found = profiles.find((p) => (profile ? p.name === profile : p.isDefault));
208
236
  const url = typeof found?.cdpUrl === "string" ? URL.parse(found.cdpUrl) : null;
209
237
  if (!url || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
@@ -211,15 +239,18 @@ export default defineChannelPluginEntry({
211
239
  return url.href;
212
240
  },
213
241
  });
242
+ api.lifecycle?.onDispose?.(() => control.dispose());
214
243
  const targetOf = (params) => {
215
244
  if (typeof params?.targetId !== "string" || !params.targetId)
216
245
  throw new Error("targetId is required");
217
246
  return params.targetId;
218
247
  };
248
+ const profileOf = (params) =>
249
+ typeof params?.profile === "string" ? params.profile : undefined;
219
250
  api.registerGatewayMethod(
220
251
  "otter.browser.control",
221
- ({ params }) => {
222
- if (params?.take === true) control.take(targetOf(params));
252
+ async ({ params }) => {
253
+ if (params?.take === true) await control.take(targetOf(params), profileOf(params));
223
254
  else control.release(targetOf(params));
224
255
  return { controlled: params?.take === true };
225
256
  },
@@ -228,18 +259,37 @@ export default defineChannelPluginEntry({
228
259
  api.registerGatewayMethod(
229
260
  "otter.browser.input",
230
261
  async ({ params }) => {
231
- const profile = typeof params?.profile === "string" ? params.profile : undefined;
232
- await control.input(targetOf(params), profile, params?.seq, params?.events);
262
+ await control.input(targetOf(params), profileOf(params), params?.seq, params?.events);
263
+ return {};
264
+ },
265
+ { scope: "operator.admin" },
266
+ );
267
+ api.registerGatewayMethod(
268
+ "otter.browser.events",
269
+ ({ params }) =>
270
+ control.events(
271
+ targetOf(params),
272
+ profileOf(params),
273
+ params?.since,
274
+ params?.waitMs,
275
+ params?.run,
276
+ ),
277
+ { scope: "operator.admin" },
278
+ );
279
+ api.registerGatewayMethod(
280
+ "otter.browser.upload",
281
+ async ({ params }) => {
282
+ await control.upload(targetOf(params), profileOf(params), params?.files);
233
283
  return {};
234
284
  },
235
285
  { scope: "operator.admin" },
236
286
  );
237
287
  api.on("before_tool_call", (event) =>
238
- /(^|__)browser$/.test(event.toolName) && control.anyHeld()
288
+ /(^|__)browser$/.test(event.toolName) && !control.allows(event.params)
239
289
  ? {
240
290
  block: true,
241
291
  blockReason:
242
- "The user has taken control of the browser. Wait until they hand it back, then try again.",
292
+ "The user has taken control of the browser. Reading pages still works; wait until they hand it back before acting on one.",
243
293
  }
244
294
  : undefined,
245
295
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otterware/openclaw-otter",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Otter as an OpenClaw channel: what your agents send you waits in Otter, and Otter tells your devices.",
5
5
  "license": "MIT",
6
6
  "repository": {