@mind-harness/mh-browser 0.1.6 → 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.
package/lib/index.js CHANGED
@@ -3,12 +3,12 @@ import z from "@deepseek-ai/schemastery";
3
3
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
6
- import { z as z$1 } from "zod";
6
+ import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
+ import { parse } from "yaml";
8
9
  import { constants, readFileSync } from "node:fs";
9
10
  import { lstat, mkdir, open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
10
- import { parse } from "yaml";
11
- import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
11
+ import { z as z$1 } from "zod";
12
12
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
13
13
  import { chromium } from "playwright";
14
14
  import { fileURLToPath } from "node:url";
@@ -25,493 +25,12 @@ const browserConfig = z.object({
25
25
  headless: z.boolean().default(false).description("Run the browser without a window.")
26
26
  });
27
27
  /**
28
- * Browser profile directory when the configuration names none. Each dsh profile gets its own:
29
- * a browser profile directory admits one running browser, so two dsh profiles sharing one
30
- * cannot both start.
31
- */
32
- function defaultProfileDir(profile, home = resolveDshHome()) {
33
- return profile === void 0 ? join(home, "mh", "browser") : join(home, "mh", "profiles", profile.name, "browser");
34
- }
35
- //#endregion
36
- //#region lib/types/presence/record.js
37
- /** Structured presence record carried on a message source. The model sees the rendered text, not this. */
38
- const PRESENCE_SOURCES = [
39
- "person via sidebar",
40
- "cwd reference",
41
- "model"
42
- ];
43
- //#endregion
44
- //#region lib/types/presence/fold.js
45
- function initPresenceLog() {
46
- return {
47
- committed: [],
48
- inbox: {
49
- "next-step": [],
50
- "next-turn": []
51
- },
52
- discarded: [],
53
- visible: false,
54
- hidden: false,
55
- shown: [],
56
- ops: []
57
- };
58
- }
59
- function applyPresenceLog(state, event) {
60
- if (event.type === "compaction/summary") {
61
- if (!state.visible && state.shown.length === 0) return state;
62
- return {
63
- ...state,
64
- visible: false,
65
- hidden: true,
66
- shown: []
67
- };
68
- }
69
- if (event.type === "user/message") {
70
- const carried = carriedOf(event.data, `history:${String(event.seq)}`);
71
- if (carried === void 0) return state;
72
- return {
73
- ...state,
74
- committed: applyRecord(state.committed, carried.record, "committed"),
75
- inbox: {
76
- "next-step": state.inbox["next-step"].map((item) => item?.id === carried.id ? null : item),
77
- "next-turn": state.inbox["next-turn"].map((item) => item?.id === carried.id ? null : item)
78
- },
79
- discarded: [],
80
- visible: true,
81
- hidden: false,
82
- shown: [...state.shown, textDigest(event.data)],
83
- ops: []
84
- };
85
- }
86
- if (event.type === "agent/inbox/discarded") {
87
- const carried = carriedOf(event.data, `discarded:${String(event.seq)}`);
88
- if (carried === void 0) return state;
89
- return {
90
- ...state,
91
- discarded: [...state.discarded, carried]
92
- };
93
- }
94
- if (event.type === "agent/inbox/spliced") return applySplice(state, event);
95
- if (event.type === "tool/result") return applyReceipts(state, event);
96
- return state;
97
- }
98
- /** The folded set, with pending reminders and unannounced receipts on top. */
99
- function presenceSet(state) {
100
- let pages = state.committed;
101
- for (const carried of [
102
- ...state.inbox["next-turn"],
103
- ...state.inbox["next-step"],
104
- ...state.discarded
105
- ]) if (carried !== null) pages = applyRecord(pages, carried.record, "pending");
106
- const removals = [];
107
- for (const op of state.ops) {
108
- const index = pages.findIndex((page) => page.url === op.url);
109
- if (op.op === "remove") {
110
- if (index < 0) continue;
111
- pages = pages.toSpliced(index, 1);
112
- removals.push(op);
113
- continue;
114
- }
115
- if (index >= 0) continue;
116
- pages = [...pages, {
117
- url: op.url,
118
- source: "model",
119
- state: "pending",
120
- order: pages.length
121
- }];
122
- }
123
- return {
124
- pages,
125
- removals
126
- };
127
- }
128
- /** The text of a reminder, as `shown` records it. */
129
- function textDigest(message) {
130
- return createHash("sha256").update(textOf$3(message)).digest("hex");
131
- }
132
- function applySplice(state, event) {
133
- const data = record$3(event.data);
134
- const target = data.target === "next-turn" ? "next-turn" : "next-step";
135
- const start = typeof data.start === "number" ? data.start : 0;
136
- const removedCount = typeof data.removedCount === "number" ? data.removedCount : 0;
137
- const slots = (Array.isArray(data.inserted) ? data.inserted : []).map((item, index) => carriedOf(item, `splice:${String(event.seq)}:${String(index)}`));
138
- const list = state.inbox[target];
139
- const removed = list.slice(start, start + removedCount).filter((item) => item !== null);
140
- if (removedCount === 0 && slots.length === 0) return state;
141
- const next = [
142
- ...list.slice(0, start),
143
- ...slots.map((slot) => slot ?? null),
144
- ...list.slice(start + removedCount)
145
- ];
146
- const announced = slots.some((slot) => slot !== void 0);
147
- return {
148
- ...state,
149
- inbox: {
150
- ...state.inbox,
151
- [target]: next
152
- },
153
- discarded: data.outcome === "canceled" ? [...state.discarded, ...removed] : state.discarded,
154
- ops: announced ? [] : state.ops
155
- };
156
- }
157
- function applyReceipts(state, event) {
158
- const data = record$3(event.data);
159
- if (record$3(data.message).isError === true) return state;
160
- const meta = record$3(data.meta);
161
- if (meta.by !== "mh-browser" || meta.action !== "presence" || !Array.isArray(meta.receipts)) return state;
162
- const ops = [];
163
- for (const item of meta.receipts) {
164
- const row = record$3(item);
165
- if (typeof row.url !== "string" || row.url === "") continue;
166
- if (row.receipt === "added (pending)") ops.push({
167
- op: "put",
168
- url: row.url
169
- });
170
- if (row.receipt === "removed (pending)") ops.push({
171
- op: "remove",
172
- url: row.url
173
- });
174
- }
175
- return ops.length === 0 ? state : {
176
- ...state,
177
- ops: [...state.ops, ...ops]
178
- };
179
- }
180
- function applyRecord(pages, record, state) {
181
- if (record.kind === "baseline") {
182
- const next = [];
183
- for (const entry of record.entries) {
184
- if (entry.op === "removed") continue;
185
- next.push(heldFrom(entry, state, next.length));
186
- }
187
- return next;
188
- }
189
- const next = pages.map((page) => ({ ...page }));
190
- for (const entry of record.entries) {
191
- if (entry.op === "removed") {
192
- const index = next.findIndex((page) => page.url === entry.url);
193
- if (index >= 0) next.splice(index, 1);
194
- continue;
195
- }
196
- const index = next.findIndex((page) => page.url === entry.url);
197
- const held = heldFrom(entry, state, index >= 0 ? next[index]?.order ?? index : next.length);
198
- if (index >= 0) next[index] = held;
199
- else next.push(held);
200
- }
201
- return next;
202
- }
203
- function heldFrom(entry, state, order) {
204
- const absent = entry.op === "absent";
205
- return {
206
- url: entry.url,
207
- source: entry.source,
208
- state,
209
- order,
210
- seen: absent ? { absent: entry.reason ?? "" } : {
211
- digest: entry.digest,
212
- observedAt: entry.observedAt,
213
- ...entry.version === void 0 ? {} : { version: entry.version },
214
- ...entry.id === void 0 ? {} : { id: entry.id }
215
- }
216
- };
217
- }
218
- /** A `user/message` event's data is the message itself. */
219
- function carriedOf(data, fallbackId) {
220
- const message = record$3(data);
221
- const source = record$3(message.source);
222
- if (source.kind !== "mh-browser") return void 0;
223
- const presence = record$3(source.presence);
224
- const kind = presence.kind;
225
- if (kind !== "baseline" && kind !== "delta") return void 0;
226
- const entries = Array.isArray(presence.entries) ? presence.entries.flatMap((item) => entryOf$1(item)) : [];
227
- return {
228
- id: typeof message.id === "string" && message.id !== "" ? message.id : fallbackId,
229
- record: {
230
- kind,
231
- entries,
232
- ...presence.replaces === true ? { replaces: true } : {}
233
- }
234
- };
235
- }
236
- function entryOf$1(value) {
237
- const row = record$3(value);
238
- const op = row.op;
239
- if (op !== "present" && op !== "added" && op !== "changed" && op !== "absent" && op !== "removed") return [];
240
- const url = typeof row.url === "string" ? row.url : "";
241
- if (url === "") return [];
242
- const source = isSource(row.source) ? row.source : "model";
243
- const version = typeof row.version === "string" ? row.version : void 0;
244
- const id = typeof row.id === "string" && row.id !== "" ? row.id : void 0;
245
- const reason = typeof row.reason === "string" ? row.reason : void 0;
246
- return [{
247
- op,
248
- url,
249
- source,
250
- observedAt: typeof row.observedAt === "string" ? row.observedAt : "",
251
- digest: typeof row.digest === "string" ? row.digest : "",
252
- ...version === void 0 ? {} : { version },
253
- ...id === void 0 ? {} : { id },
254
- ...reason === void 0 ? {} : { reason }
255
- }];
256
- }
257
- function textOf$3(value) {
258
- const content = record$3(value).content;
259
- if (!Array.isArray(content)) return "";
260
- return content.map((block) => record$3(block).text).filter((text) => typeof text === "string").join("\n");
261
- }
262
- function isSource(value) {
263
- return typeof value === "string" && PRESENCE_SOURCES.includes(value);
264
- }
265
- function record$3(value) {
266
- if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
267
- return {};
268
- }
269
- //#endregion
270
- //#region lib/types/types.js
271
- /** Public browser types. Playwright types stay inside the service. */
272
- var MhBrowserError = class extends Error {
273
- code;
274
- page;
275
- constructor(code, message, page) {
276
- super(message);
277
- this.name = "MhBrowserError";
278
- this.code = code;
279
- if (page !== void 0) this.page = page;
280
- }
281
- };
282
- /** Owner of the person's own window. It belongs to no Session. */
283
- const PERSON_OWNER = "person";
284
- const UNKNOWN_TAB = "UNKNOWN_TAB";
285
- const UNKNOWN_TAB_REASON = "no such tab in this session";
286
- /**
287
- * Internal: a wait ended because its signal aborted. A tool never reports it:
288
- * a stopped call ends as the tool runtime's abort, a takeover as `TAKEN_OVER`.
289
- */
290
- const INTERRUPTED = "INTERRUPTED";
291
- /** The browser did not start or could not be restarted. */
292
- const BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
293
- function unknownTab() {
294
- return {
295
- url: "",
296
- reason: UNKNOWN_TAB_REASON
297
- };
298
- }
299
- //#endregion
300
- //#region lib/types/tools/session.js
301
- /** dsh shows the model only the message, so the message starts with the code. */
302
- var BrowserToolError = class extends HarnessError {
303
- reason;
304
- constructor(code, reason) {
305
- super(`${code}: ${reason}`, code);
306
- this.name = "BrowserToolError";
307
- this.reason = reason;
308
- }
309
- };
310
- const NO_WORKSPACE_REASON$1 = "this session has no working directory";
311
- /**
312
- * The session of this tool call. Also records its title for the tab group.
313
- * No title service, or no title yet, records an empty one.
314
- */
315
- function sessionFor(ctx, exec) {
316
- const session = sessionOf(exec);
317
- const titles = ctx.get("sessionTitle");
318
- ctx.mhBrowser.noteSessionTitle(session.id, titles?.get(session.session)?.title ?? "");
319
- return session;
320
- }
321
- /** A title written after the last tool call still reaches the tab group. */
322
- function followSessionTitles(ctx) {
323
- ctx.on("session/event", (session, event) => {
324
- const { type, data } = event;
325
- if (type !== "session/title" || typeof data !== "object" || data === null) return;
326
- const title = data.title;
327
- if (typeof title === "string") ctx.mhBrowser.noteSessionTitle(session.id, title);
328
- });
329
- }
330
- function sessionOf(exec) {
331
- const session = exec.agent?.session;
332
- if (session === void 0) throw new BrowserToolError("NO_SESSION", "browser tools run only inside an agent session");
333
- const cwd = session.header.cwd;
334
- return cwd === void 0 ? {
335
- id: session.id,
336
- session
337
- } : {
338
- id: session.id,
339
- cwd,
340
- session
341
- };
342
- }
343
- /**
344
- * Where this session may write a download. With dsh's sandbox policy loaded,
345
- * its mode decides: `read-only` writes nothing, otherwise the policy's
346
- * workspace root. Without it, the session cwd.
347
- */
348
- function writeTarget(ctx, session) {
349
- const policy = ctx.get("sandboxPolicy");
350
- if (policy === void 0) return session.cwd === void 0 ? {
351
- denied: NO_WORKSPACE_REASON$1,
352
- code: "NO_WORKSPACE"
353
- } : { root: session.cwd };
354
- const resolved = policy.resolve({ session: session.session });
355
- if (resolved.mode === "read-only") return {
356
- denied: "[sandbox: file access denied under read-only mode]",
357
- code: "SANDBOX_DENIED"
358
- };
359
- return { root: resolved.workspaceRoot };
360
- }
361
- function rethrow(error, detail) {
362
- if (error instanceof MhBrowserError || error instanceof BrowserToolError) {
363
- const reason = error instanceof BrowserToolError ? error.reason : error.message;
364
- throw new BrowserToolError(error.code, detail === void 0 ? reason : `${reason}\n${detail}`);
365
- }
366
- throw error;
367
- }
368
- //#endregion
369
- //#region lib/types/tools/paths.js
370
- /** Resolve upload paths and reject anything outside the session's cwd. */
371
- async function filesInWorkspace(cwd, files) {
372
- if (cwd === void 0) throw new BrowserToolError("NO_WORKSPACE", NO_WORKSPACE_REASON$1);
373
- const root = await realpath(cwd);
374
- const resolved = [];
375
- for (const file of files) {
376
- const abs = isAbsolute(file) ? file : resolve(cwd, file);
377
- let real;
378
- try {
379
- real = await realpath(abs);
380
- } catch {
381
- throw new BrowserToolError("INVALID_ARGS", `no such file: ${file}`);
382
- }
383
- const fromRoot = relative(root, real);
384
- if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${file} is outside the session workspace`);
385
- resolved.push(real);
386
- }
387
- return resolved;
388
- }
389
- /** Refuse a filename that leaves the session's write root, without creating it. */
390
- async function assertInside(target, filename) {
391
- await place(target, filename);
392
- }
393
- /**
394
- * Write `filename` under the session's write root. A path that leaves the
395
- * root is refused, through a symbolic link as well: the deepest existing
396
- * directory is checked before any is created, and the file itself is not
397
- * followed if it is a link.
398
- */
399
- async function writeInside(target, filename, data) {
400
- const file = await place(target, filename);
401
- assertUnder(file.root, await realpath(await deepestExisting(dirname(file.file))), filename);
402
- await mkdir(dirname(file.file), { recursive: true });
403
- const parent = await realpath(dirname(file.file));
404
- assertUnder(file.root, parent, filename);
405
- const placed = resolve(parent, basename(file.file));
406
- let handle;
407
- try {
408
- handle = await open(placed, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
409
- } catch (error) {
410
- if (error.code === "ELOOP") throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
411
- throw error;
412
- }
413
- try {
414
- await handle.writeFile(data);
415
- } finally {
416
- await handle.close();
417
- }
418
- const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
419
- return {
420
- path: relative(file.root, placed),
421
- bytes
422
- };
423
- }
424
- function assertUnder(root, path, filename) {
425
- const fromRoot = relative(root, path);
426
- if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
427
- }
428
- async function deepestExisting(path) {
429
- for (let current = path;; current = dirname(current)) try {
430
- await lstat(current);
431
- return current;
432
- } catch {
433
- if (dirname(current) === current) return current;
434
- }
435
- }
436
- async function place(target, filename) {
437
- if ("denied" in target) throw new BrowserToolError(target.code, target.denied);
438
- if (filename.trim() === "") throw new BrowserToolError("INVALID_ARGS", "filename: give a path inside the workspace");
439
- const root = await realpath(target.root).catch(() => target.root);
440
- const abs = isAbsolute(filename) ? filename : resolve(root, filename);
441
- const direct = relative(root, abs);
442
- if (direct.startsWith("..") || isAbsolute(direct)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
443
- return {
444
- root,
445
- file: abs
446
- };
447
- }
448
- //#endregion
449
- //#region lib/types/tools/attachments.js
450
- function initAttachments() {
451
- return { refs: {} };
452
- }
453
- /** Attachments reach the model in user messages and tool results. */
454
- function applyAttachments(state, event) {
455
- if (event.type !== "user/message" && event.type !== "tool/result") return state;
456
- const found = {};
457
- visit(event.data, 0, found);
458
- const ids = Object.keys(found).filter((id) => state.refs[id] === void 0);
459
- if (ids.length === 0) return state;
460
- const refs = { ...state.refs };
461
- for (const id of ids) refs[id] = found[id];
462
- return { refs };
463
- }
464
- /**
465
- * Host paths for upload values. A value naming an attachment that appeared in
466
- * this session resolves through the attachment provider; anything else is a
467
- * path that must resolve inside the session cwd.
28
+ * Browser profile directory when the configuration names none. Each dsh profile gets its own:
29
+ * a browser profile directory admits one running browser, so two dsh profiles sharing one
30
+ * cannot both start.
468
31
  */
469
- async function uploadPaths(ctx, seen, cwd, values) {
470
- const paths = [];
471
- for (const value of values) {
472
- const id = attachmentIdOf(value);
473
- const found = id === void 0 ? void 0 : seen.refs[id];
474
- if (found !== void 0) {
475
- paths.push(hostPath(ctx, found));
476
- continue;
477
- }
478
- if (typeof value !== "string") throw new BrowserToolError("INVALID_INPUT", "attachment reference is not in this session");
479
- paths.push(...await filesInWorkspace(cwd, [value]));
480
- }
481
- return paths;
482
- }
483
- function hostPath(ctx, found) {
484
- let host;
485
- try {
486
- host = found.kind === "image" ? ctx.attachments.imageHostPath(found.ref) : ctx.attachments.fileHostPath(found.ref);
487
- } catch (error) {
488
- throw new BrowserToolError("INVALID_INPUT", error instanceof Error ? error.message : String(error));
489
- }
490
- if (host === void 0) throw new BrowserToolError("INVALID_INPUT", "attachment has no host path");
491
- return host;
492
- }
493
- function attachmentIdOf(value) {
494
- if (typeof value === "string") return value;
495
- if (value !== null && typeof value === "object") {
496
- const id = value.attachmentId;
497
- if (typeof id === "string") return id;
498
- }
499
- }
500
- function visit(value, depth, found) {
501
- if (depth > 12 || value === null || typeof value !== "object") return;
502
- if (Array.isArray(value)) {
503
- for (const item of value) visit(item, depth + 1, found);
504
- return;
505
- }
506
- const row = value;
507
- if (typeof row.attachmentId === "string" && typeof row.bytes === "number") {
508
- const kind = typeof row.width === "number" && typeof row.mediaType === "string" ? "image" : "file";
509
- if (kind === "image" || typeof row.name === "string") found[row.attachmentId] = {
510
- kind,
511
- ref: row
512
- };
513
- }
514
- for (const item of Object.values(row)) visit(item, depth + 1, found);
32
+ function defaultProfileDir(profile, home = resolveDshHome()) {
33
+ return profile === void 0 ? join(home, "mh", "browser") : join(home, "mh", "profiles", profile.name, "browser");
515
34
  }
516
35
  //#endregion
517
36
  //#region lib/types/representation/agent-view.js
@@ -911,20 +430,268 @@ function versionOf$1(view) {
911
430
  if (representation.kind === "agent-view") return representation.version;
912
431
  return representation.agentView?.version;
913
432
  }
914
- function digestOf$1(view) {
915
- if (view.representation.kind !== "agent-view") return void 0;
916
- return representationDigest(view.representation.text);
433
+ function digestOf$1(view) {
434
+ if (view.representation.kind !== "agent-view") return void 0;
435
+ return representationDigest(view.representation.text);
436
+ }
437
+ function idOf(view) {
438
+ if (view.representation.kind !== "agent-view") return void 0;
439
+ return agentViewId(view.representation.text, view.url);
440
+ }
441
+ function attr$1(name, value) {
442
+ return ` ${name}="${escapeAttr(value)}"`;
443
+ }
444
+ function formatSeconds(seconds) {
445
+ const rounded = Math.round(seconds * 10) / 10;
446
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
447
+ }
448
+ //#endregion
449
+ //#region lib/types/types.js
450
+ var MhBrowserError = class extends Error {
451
+ code;
452
+ page;
453
+ constructor(code, message, page) {
454
+ super(message);
455
+ this.name = "MhBrowserError";
456
+ this.code = code;
457
+ if (page !== void 0) this.page = page;
458
+ }
459
+ };
460
+ /** Owner of the person's own window. It belongs to no Session. */
461
+ const PERSON_OWNER = "person";
462
+ const UNKNOWN_TAB = "UNKNOWN_TAB";
463
+ const UNKNOWN_TAB_REASON = "no such tab in this session";
464
+ /**
465
+ * Internal: a wait ended because its signal aborted. A tool never reports it:
466
+ * a stopped call ends as the tool runtime's abort, a takeover as `TAKEN_OVER`.
467
+ */
468
+ const INTERRUPTED = "INTERRUPTED";
469
+ /** The browser did not start or could not be restarted. */
470
+ const BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
471
+ function unknownTab() {
472
+ return {
473
+ url: "",
474
+ reason: UNKNOWN_TAB_REASON
475
+ };
476
+ }
477
+ //#endregion
478
+ //#region lib/types/tools/session.js
479
+ /** dsh shows the model only the message, so the message starts with the code. */
480
+ var BrowserToolError = class extends HarnessError {
481
+ code;
482
+ reason;
483
+ constructor(code, reason) {
484
+ super(`${code}: ${reason}`, code);
485
+ this.name = "BrowserToolError";
486
+ this.code = code;
487
+ this.reason = reason;
488
+ }
489
+ };
490
+ const NO_WORKSPACE_REASON$1 = "this session has no working directory";
491
+ /**
492
+ * The session of this tool call. Also records its title for the tab group.
493
+ * No title service, or no title yet, records an empty one.
494
+ */
495
+ function sessionFor(ctx, exec) {
496
+ const session = sessionOf(exec);
497
+ const titles = ctx.get("sessionTitle");
498
+ ctx.mhBrowser.noteSessionTitle(session.id, titles?.get(session.session)?.title ?? "");
499
+ return session;
500
+ }
501
+ /** A title written after the last tool call still reaches the tab group. */
502
+ function followSessionTitles(ctx) {
503
+ ctx.on("session/event", (session, event) => {
504
+ const { type, data } = event;
505
+ if (type !== "session/title" || typeof data !== "object" || data === null) return;
506
+ const title = data.title;
507
+ if (typeof title === "string") ctx.mhBrowser.noteSessionTitle(session.id, title);
508
+ });
509
+ }
510
+ function sessionOf(exec) {
511
+ const session = exec.agent?.session;
512
+ if (session === void 0) throw new BrowserToolError("NO_SESSION", "browser tools run only inside an agent session");
513
+ const cwd = session.header.cwd;
514
+ return cwd === void 0 ? {
515
+ id: session.id,
516
+ session
517
+ } : {
518
+ id: session.id,
519
+ cwd,
520
+ session
521
+ };
522
+ }
523
+ /**
524
+ * Where this session may write a download. With dsh's sandbox policy loaded,
525
+ * its mode decides: `read-only` writes nothing, otherwise the policy's
526
+ * workspace root. Without it, the session cwd.
527
+ */
528
+ function writeTarget(ctx, session) {
529
+ const policy = ctx.get("sandboxPolicy");
530
+ if (policy === void 0) return session.cwd === void 0 ? {
531
+ denied: NO_WORKSPACE_REASON$1,
532
+ code: "NO_WORKSPACE"
533
+ } : { root: session.cwd };
534
+ const resolved = policy.resolve({ session: session.session });
535
+ if (resolved.mode === "read-only") return {
536
+ denied: "[sandbox: file access denied under read-only mode]",
537
+ code: "SANDBOX_DENIED"
538
+ };
539
+ return { root: resolved.workspaceRoot };
540
+ }
541
+ function rethrow(error, detail) {
542
+ if (error instanceof MhBrowserError || error instanceof BrowserToolError) {
543
+ const reason = error instanceof BrowserToolError ? error.reason : error.message;
544
+ throw new BrowserToolError(error.code, detail === void 0 ? reason : `${reason}\n${detail}`);
545
+ }
546
+ throw error;
547
+ }
548
+ //#endregion
549
+ //#region lib/types/tools/paths.js
550
+ /** Resolve upload paths and reject anything outside the session's cwd. */
551
+ async function filesInWorkspace(cwd, files) {
552
+ if (cwd === void 0) throw new BrowserToolError("NO_WORKSPACE", NO_WORKSPACE_REASON$1);
553
+ const root = await realpath(cwd);
554
+ const resolved = [];
555
+ for (const file of files) {
556
+ const abs = isAbsolute(file) ? file : resolve(cwd, file);
557
+ let real;
558
+ try {
559
+ real = await realpath(abs);
560
+ } catch {
561
+ throw new BrowserToolError("INVALID_ARGS", `no such file: ${file}`);
562
+ }
563
+ const fromRoot = relative(root, real);
564
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${file} is outside the session workspace`);
565
+ resolved.push(real);
566
+ }
567
+ return resolved;
568
+ }
569
+ /** Refuse a filename that leaves the session's write root, without creating it. */
570
+ async function assertInside(target, filename) {
571
+ await place(target, filename);
572
+ }
573
+ /**
574
+ * Write `filename` under the session's write root. A path that leaves the
575
+ * root is refused, through a symbolic link as well: the deepest existing
576
+ * directory is checked before any is created, and the file itself is not
577
+ * followed if it is a link.
578
+ */
579
+ async function writeInside(target, filename, data) {
580
+ const file = await place(target, filename);
581
+ assertUnder(file.root, await realpath(await deepestExisting(dirname(file.file))), filename);
582
+ await mkdir(dirname(file.file), { recursive: true });
583
+ const parent = await realpath(dirname(file.file));
584
+ assertUnder(file.root, parent, filename);
585
+ const placed = resolve(parent, basename(file.file));
586
+ let handle;
587
+ try {
588
+ handle = await open(placed, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
589
+ } catch (error) {
590
+ if (error.code === "ELOOP") throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
591
+ throw error;
592
+ }
593
+ try {
594
+ await handle.writeFile(data);
595
+ } finally {
596
+ await handle.close();
597
+ }
598
+ const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
599
+ return {
600
+ path: relative(file.root, placed),
601
+ bytes
602
+ };
603
+ }
604
+ function assertUnder(root, path, filename) {
605
+ const fromRoot = relative(root, path);
606
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
607
+ }
608
+ async function deepestExisting(path) {
609
+ for (let current = path;; current = dirname(current)) try {
610
+ await lstat(current);
611
+ return current;
612
+ } catch {
613
+ if (dirname(current) === current) return current;
614
+ }
615
+ }
616
+ async function place(target, filename) {
617
+ if ("denied" in target) throw new BrowserToolError(target.code, target.denied);
618
+ if (filename.trim() === "") throw new BrowserToolError("INVALID_ARGS", "filename: give a path inside the workspace");
619
+ const root = await realpath(target.root).catch(() => target.root);
620
+ const abs = isAbsolute(filename) ? filename : resolve(root, filename);
621
+ const direct = relative(root, abs);
622
+ if (direct.startsWith("..") || isAbsolute(direct)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
623
+ return {
624
+ root,
625
+ file: abs
626
+ };
627
+ }
628
+ //#endregion
629
+ //#region lib/types/tools/attachments.js
630
+ function initAttachments() {
631
+ return { refs: {} };
632
+ }
633
+ /** Attachments reach the model in user messages and tool results. */
634
+ function applyAttachments(state, event) {
635
+ if (event.type !== "user/message" && event.type !== "tool/result") return state;
636
+ const found = {};
637
+ visit(event.data, 0, found);
638
+ const ids = Object.keys(found).filter((id) => state.refs[id] === void 0);
639
+ if (ids.length === 0) return state;
640
+ const refs = { ...state.refs };
641
+ for (const id of ids) refs[id] = found[id];
642
+ return { refs };
643
+ }
644
+ /**
645
+ * Host paths for upload values. A value naming an attachment that appeared in
646
+ * this session resolves through the attachment provider; anything else is a
647
+ * path that must resolve inside the session cwd.
648
+ */
649
+ async function uploadPaths(ctx, seen, cwd, values) {
650
+ const paths = [];
651
+ for (const value of values) {
652
+ const id = attachmentIdOf(value);
653
+ const found = id === void 0 ? void 0 : seen.refs[id];
654
+ if (found !== void 0) {
655
+ paths.push(hostPath(ctx, found));
656
+ continue;
657
+ }
658
+ if (typeof value !== "string") throw new BrowserToolError("INVALID_INPUT", "attachment reference is not in this session");
659
+ paths.push(...await filesInWorkspace(cwd, [value]));
660
+ }
661
+ return paths;
917
662
  }
918
- function idOf(view) {
919
- if (view.representation.kind !== "agent-view") return void 0;
920
- return agentViewId(view.representation.text, view.url);
663
+ function hostPath(ctx, found) {
664
+ let host;
665
+ try {
666
+ host = found.kind === "image" ? ctx.attachments.imageHostPath(found.ref) : ctx.attachments.fileHostPath(found.ref);
667
+ } catch (error) {
668
+ throw new BrowserToolError("INVALID_INPUT", error instanceof Error ? error.message : String(error));
669
+ }
670
+ if (host === void 0) throw new BrowserToolError("INVALID_INPUT", "attachment has no host path");
671
+ return host;
921
672
  }
922
- function attr$1(name, value) {
923
- return ` ${name}="${escapeAttr(value)}"`;
673
+ function attachmentIdOf(value) {
674
+ if (typeof value === "string") return value;
675
+ if (value !== null && typeof value === "object") {
676
+ const id = value.attachmentId;
677
+ if (typeof id === "string") return id;
678
+ }
924
679
  }
925
- function formatSeconds(seconds) {
926
- const rounded = Math.round(seconds * 10) / 10;
927
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
680
+ function visit(value, depth, found) {
681
+ if (depth > 12 || value === null || typeof value !== "object") return;
682
+ if (Array.isArray(value)) {
683
+ for (const item of value) visit(item, depth + 1, found);
684
+ return;
685
+ }
686
+ const row = value;
687
+ if (typeof row.attachmentId === "string" && typeof row.bytes === "number") {
688
+ const kind = typeof row.width === "number" && typeof row.mediaType === "string" ? "image" : "file";
689
+ if (kind === "image" || typeof row.name === "string") found[row.attachmentId] = {
690
+ kind,
691
+ ref: row
692
+ };
693
+ }
694
+ for (const item of Object.values(row)) visit(item, depth + 1, found);
928
695
  }
929
696
  //#endregion
930
697
  //#region lib/types/tools/delivery.js
@@ -1027,18 +794,18 @@ function urlsIn(text) {
1027
794
  return [...new Set(found)];
1028
795
  }
1029
796
  function fromTool(event) {
1030
- const data = record$2(event.data);
1031
- const message = record$2(data.message);
797
+ const data = record$3(event.data);
798
+ const message = record$3(data.message);
1032
799
  const callId = readString(message.toolCallId);
1033
- const text = textOf$2(message.content);
800
+ const text = textOf$3(message.content);
1034
801
  if (message.isError === true) return void 0;
1035
- const meta = record$2(data.meta);
802
+ const meta = record$3(data.meta);
1036
803
  if (meta.by !== "mh-browser") return void 0;
1037
804
  const pages = meta.pages;
1038
805
  if (!Array.isArray(pages)) return void 0;
1039
806
  const deliveries = [];
1040
807
  for (const page of pages) {
1041
- const row = record$2(page);
808
+ const row = record$3(page);
1042
809
  if (row.representation === "header" || readString(row.reason) !== void 0) continue;
1043
810
  const url = readString(row.url);
1044
811
  if (url === void 0) continue;
@@ -1064,21 +831,21 @@ function fromTool(event) {
1064
831
  };
1065
832
  }
1066
833
  function fromPresence(event) {
1067
- const message = record$2(event.data);
1068
- const source = record$2(message.source);
834
+ const message = record$3(event.data);
835
+ const source = record$3(message.source);
1069
836
  if (source.kind !== "mh-browser") return void 0;
1070
- const entries = record$2(source.presence).entries;
837
+ const entries = record$3(source.presence).entries;
1071
838
  if (!Array.isArray(entries)) return void 0;
1072
839
  const deliveries = [];
1073
840
  for (const entry of entries) {
1074
- const row = record$2(entry);
841
+ const row = record$3(entry);
1075
842
  if (row.op === "removed" || row.op === "absent") continue;
1076
843
  const url = readString(row.url);
1077
844
  if (url === void 0) continue;
1078
845
  const digest = readString(row.digest);
1079
846
  const version = readString(row.version);
1080
847
  const id = readString(row.id);
1081
- const body = digest === void 0 ? void 0 : shownBody(textOf$2(message.content), url);
848
+ const body = digest === void 0 ? void 0 : shownBody(textOf$3(message.content), url);
1082
849
  deliveries.push({
1083
850
  url,
1084
851
  seq: event.seq,
@@ -1091,7 +858,7 @@ function fromPresence(event) {
1091
858
  }
1092
859
  return {
1093
860
  deliveries,
1094
- text: textOf$2(message.content)
861
+ text: textOf$3(message.content)
1095
862
  };
1096
863
  }
1097
864
  const OMITTED_BODY = /* @__PURE__ */ new Set([
@@ -1136,11 +903,11 @@ function attrOf(header, name) {
1136
903
  if (value === void 0 || value === "") return void 0;
1137
904
  return value.replace(/&quot;/g, "\"").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#10;/g, "\n").replace(/&#13;/g, "\r").replace(/&#9;/g, " ").replace(/&amp;/g, "&");
1138
905
  }
1139
- function textOf$2(content) {
906
+ function textOf$3(content) {
1140
907
  if (!Array.isArray(content)) return "";
1141
908
  const parts = [];
1142
909
  for (const block of content) {
1143
- const text = readString(record$2(block).text);
910
+ const text = readString(record$3(block).text);
1144
911
  if (text !== void 0) parts.push(text);
1145
912
  }
1146
913
  return parts.join("\n");
@@ -1157,7 +924,7 @@ function sameUrl(left, right) {
1157
924
  return false;
1158
925
  }
1159
926
  }
1160
- function record$2(value) {
927
+ function record$3(value) {
1161
928
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
1162
929
  return {};
1163
930
  }
@@ -1172,9 +939,9 @@ function initTabLog() {
1172
939
  function applyTabLog(state, event) {
1173
940
  const cleared = event.type === "compaction/summary" || event.type === "compaction/prune" || event.surfaceOp === "replace";
1174
941
  if (event.type !== "tool/result") return cleared ? withoutSnap(state) : state;
1175
- const meta = record$1(record$1(event.data).meta);
942
+ const meta = record$2(record$2(event.data).meta);
1176
943
  if (meta.by !== "mh-browser") return cleared ? withoutSnap(state) : state;
1177
- const named = Array.isArray(meta.pages) ? meta.pages.map((page) => record$1(page).tab).filter(isTabId) : [];
944
+ const named = Array.isArray(meta.pages) ? meta.pages.map((page) => record$2(page).tab).filter(isTabId) : [];
1178
945
  const closed = Array.isArray(meta.closed) ? meta.closed.filter((note) => typeof note === "string").map(tabOfNote) : [];
1179
946
  const next = new Set(state.open);
1180
947
  for (const tab of named) next.add(tab);
@@ -1225,11 +992,11 @@ function withoutSnap(state) {
1225
992
  return rest;
1226
993
  }
1227
994
  function snapOf(value) {
1228
- const snap = record$1(value);
995
+ const snap = record$2(value);
1229
996
  if (!Array.isArray(snap.tabs)) return void 0;
1230
997
  return {
1231
998
  tabs: snap.tabs.flatMap((item) => {
1232
- const row = record$1(item);
999
+ const row = record$2(item);
1233
1000
  if (typeof row.id !== "string" || typeof row.url !== "string") return [];
1234
1001
  return [{
1235
1002
  id: row.id,
@@ -1242,71 +1009,10 @@ function snapOf(value) {
1242
1009
  closed: Array.isArray(snap.closed) ? snap.closed.filter((note) => typeof note === "string") : []
1243
1010
  };
1244
1011
  }
1245
- function record$1(value) {
1012
+ function record$2(value) {
1246
1013
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1247
1014
  }
1248
1015
  //#endregion
1249
- //#region lib/types/projections.js
1250
- /** Host-only folds over the session log; every read of what the log says goes through these. */
1251
- function registerProjections(ctx) {
1252
- ctx.sessionProjections.register({
1253
- key: "mhBrowserDeliveries",
1254
- stateVersion: 2,
1255
- stateSchema: plainObject(["deliveries", "tokens"]),
1256
- init: initDeliveries,
1257
- apply: (state, event) => applyDelivery(state, {
1258
- seq: event.seq,
1259
- type: event.type,
1260
- data: event.data,
1261
- ..."surfaceOp" in event && event.surfaceOp !== void 0 ? { surfaceOp: event.surfaceOp } : {}
1262
- })
1263
- });
1264
- ctx.sessionProjections.register({
1265
- key: "mhBrowserPresence",
1266
- stateVersion: 1,
1267
- stateSchema: plainObject([
1268
- "committed",
1269
- "inbox",
1270
- "discarded",
1271
- "shown",
1272
- "ops"
1273
- ]),
1274
- init: initPresenceLog,
1275
- apply: (state, event) => applyPresenceLog(state, event)
1276
- });
1277
- ctx.sessionProjections.register({
1278
- key: "mhBrowserAttachments",
1279
- stateVersion: 1,
1280
- stateSchema: plainObject(["refs"]),
1281
- init: initAttachments,
1282
- apply: (state, event) => applyAttachments(state, event)
1283
- });
1284
- ctx.sessionProjections.register({
1285
- key: "mhBrowserTabs",
1286
- stateVersion: 2,
1287
- stateSchema: plainObject(["open"]),
1288
- init: initTabLog,
1289
- apply: (state, event) => applyTabLog(state, event)
1290
- });
1291
- }
1292
- /** Fails closed when the registry or a unit is not active. */
1293
- function logReader(ctx) {
1294
- const read = (session, key) => {
1295
- const state = ctx.sessionProjections.stateOf(session, key);
1296
- if (state === void 0) throw new Error(`mh-browser cannot read ${key}: its session projection is not registered`);
1297
- return state;
1298
- };
1299
- return {
1300
- deliveries: (session) => read(session, "mhBrowserDeliveries"),
1301
- presence: (session) => read(session, "mhBrowserPresence"),
1302
- attachments: (session) => read(session, "mhBrowserAttachments"),
1303
- tabs: (session) => read(session, "mhBrowserTabs")
1304
- };
1305
- }
1306
- function plainObject(keys) {
1307
- return z$1.custom((value) => value !== null && typeof value === "object" && keys.every((key) => key in value));
1308
- }
1309
- //#endregion
1310
1016
  //#region lib/types/tools/page-ops.js
1311
1017
  const IMAGE_VALUE = {
1312
1018
  type: "object",
@@ -1358,7 +1064,7 @@ const IMAGE_VALUE = {
1358
1064
  const TAB$1 = "Tab id copied from the latest result in this session: a tabs list, or a page element's tab attribute. Omit it to use the tab of that latest result; if it has no tab, the call fails with INVALID_ARGS. An idle tab may be closed, and a closed id fails with UNKNOWN_TAB: open the address again with browser_open. A tab with unsubmitted input is marked in use and is not closed, and opening or a declared action does not reload it. Tabs are gone after a restart. A page with an Agent View has no tab until you open it with snapshot.";
1359
1065
  const TARGET = "A ref copied from this tab's latest snapshot, such as e3, or a selector that matches one element. A ref that is not in the current snapshot fails with STALE_REF.";
1360
1066
  const ELEMENT = "What the person sees in the log. It does not locate the element.";
1361
- const FILENAME = "A path inside the workspace. The result is saved there and the reply is the path and the byte count. Omit it and the result stays in the reply.";
1067
+ const FILENAME = "A path inside the workspace. The result is saved there and the reply is the path and the byte count. A file already at that path is replaced. Omit it and the result stays in the reply.";
1362
1068
  const OUTPUT = {
1363
1069
  schema: {
1364
1070
  type: "object",
@@ -1805,8 +1511,7 @@ const OPS = [
1805
1511
  })
1806
1512
  }
1807
1513
  ];
1808
- function pageOperationTools(ctx) {
1809
- const reader = logReader(ctx);
1514
+ function pageOperationTools(ctx, reader) {
1810
1515
  return OPS.map((spec) => defineOperation(ctx, reader, spec));
1811
1516
  }
1812
1517
  function defineOperation(ctx, reader, spec) {
@@ -1877,9 +1582,9 @@ function defineOperation(ctx, reader, spec) {
1877
1582
  }
1878
1583
  /** Probe the path before the action, then write the result the model would have been shown. */
1879
1584
  async function saveResult(target, filename, view) {
1880
- return writeInside(target, filename, view.shot?.bytes ?? textOf$1(view));
1585
+ return writeInside(target, filename, view.shot?.bytes ?? textOf$2(view));
1881
1586
  }
1882
- function textOf$1(view) {
1587
+ function textOf$2(view) {
1883
1588
  if (view.summary !== void 0 && view.summary !== "") return view.detail === void 0 || view.detail === "" ? view.summary : `${view.summary}\n${view.detail}`;
1884
1589
  return view.representation.text;
1885
1590
  }
@@ -1895,69 +1600,303 @@ function string(value) {
1895
1600
  function stringList$1(value) {
1896
1601
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1897
1602
  }
1898
- function optionalString(args, key) {
1899
- return typeof args[key] === "string" ? { element: args[key] } : {};
1603
+ function optionalString(args, key) {
1604
+ return typeof args[key] === "string" ? { element: args[key] } : {};
1605
+ }
1606
+ function optional(key, value) {
1607
+ return typeof value === "string" ? { [key]: value } : {};
1608
+ }
1609
+ function fieldsOf(value) {
1610
+ if (!Array.isArray(value)) return [];
1611
+ return value.flatMap((item) => {
1612
+ if (item === null || typeof item !== "object") return [];
1613
+ const row = item;
1614
+ const type = row.type;
1615
+ if (type !== "textbox" && type !== "checkbox" && type !== "radio" && type !== "combobox" && type !== "slider") return [];
1616
+ return [{
1617
+ target: string(row.target),
1618
+ ...typeof row.element === "string" ? { element: row.element } : {},
1619
+ name: string(row.name),
1620
+ type,
1621
+ value: string(row.value)
1622
+ }];
1623
+ });
1624
+ }
1625
+ function callIdOf(exec) {
1626
+ return String(exec.callId);
1627
+ }
1628
+ function imageValue(ref) {
1629
+ return {
1630
+ attachmentId: ref.attachmentId,
1631
+ mediaType: ref.mediaType,
1632
+ bytes: ref.bytes,
1633
+ width: ref.width,
1634
+ height: ref.height,
1635
+ ...ref.name === void 0 ? {} : { name: ref.name },
1636
+ ...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
1637
+ };
1638
+ }
1639
+ function imageRef(value) {
1640
+ return {
1641
+ attachmentId: value.attachmentId,
1642
+ mediaType: value.mediaType,
1643
+ bytes: value.bytes,
1644
+ width: value.width,
1645
+ height: value.height,
1646
+ ...value.name === void 0 ? {} : { name: value.name },
1647
+ ...value.originalDimensions === void 0 ? {} : { originalDimensions: { ...value.originalDimensions } }
1648
+ };
1649
+ }
1650
+ async function saveShot(ctx, view) {
1651
+ const shot = view.shot;
1652
+ if (shot === void 0) return void 0;
1653
+ try {
1654
+ return await ctx.attachments.saveImage({
1655
+ data: shot.bytes,
1656
+ mediaType: shot.mediaType,
1657
+ name: `${view.tab ?? "page"}.${shot.mediaType.slice(6)}`
1658
+ });
1659
+ } catch (error) {
1660
+ ctx.logger.warn("saving the screenshot failed: %o", error);
1661
+ return;
1662
+ }
1663
+ }
1664
+ function isModifier(value) {
1665
+ return value === "Alt" || value === "Control" || value === "ControlOrMeta" || value === "Meta" || value === "Shift";
1666
+ }
1667
+ //#endregion
1668
+ //#region lib/types/presence/record.js
1669
+ /** Structured presence record carried on a message source. The model sees the rendered text, not this. */
1670
+ const PRESENCE_SOURCES = [
1671
+ "person via sidebar",
1672
+ "cwd reference",
1673
+ "model"
1674
+ ];
1675
+ //#endregion
1676
+ //#region lib/types/presence/fold.js
1677
+ function initPresenceLog() {
1678
+ return {
1679
+ committed: [],
1680
+ inbox: {
1681
+ "next-step": [],
1682
+ "next-turn": []
1683
+ },
1684
+ discarded: [],
1685
+ visible: false,
1686
+ hidden: false,
1687
+ shown: [],
1688
+ ops: []
1689
+ };
1690
+ }
1691
+ function applyPresenceLog(state, event) {
1692
+ if (event.type === "compaction/summary") {
1693
+ if (!state.visible && state.shown.length === 0) return state;
1694
+ return {
1695
+ ...state,
1696
+ visible: false,
1697
+ hidden: true,
1698
+ shown: []
1699
+ };
1700
+ }
1701
+ if (event.type === "user/message") {
1702
+ const carried = carriedOf(event.data, `history:${String(event.seq)}`);
1703
+ if (carried === void 0) return state;
1704
+ return {
1705
+ ...state,
1706
+ committed: applyRecord(state.committed, carried.record, "committed"),
1707
+ inbox: {
1708
+ "next-step": state.inbox["next-step"].map((item) => item?.id === carried.id ? null : item),
1709
+ "next-turn": state.inbox["next-turn"].map((item) => item?.id === carried.id ? null : item)
1710
+ },
1711
+ discarded: [],
1712
+ visible: true,
1713
+ hidden: false,
1714
+ shown: [...state.shown, textDigest(event.data)],
1715
+ ops: []
1716
+ };
1717
+ }
1718
+ if (event.type === "agent/inbox/discarded") {
1719
+ const carried = carriedOf(event.data, `discarded:${String(event.seq)}`);
1720
+ if (carried === void 0) return state;
1721
+ return {
1722
+ ...state,
1723
+ discarded: [...state.discarded, carried]
1724
+ };
1725
+ }
1726
+ if (event.type === "agent/inbox/spliced") return applySplice(state, event);
1727
+ if (event.type === "tool/result") return applyReceipts(state, event);
1728
+ return state;
1729
+ }
1730
+ /** The folded set, with pending reminders and unannounced receipts on top. */
1731
+ function presenceSet(state) {
1732
+ let pages = state.committed;
1733
+ for (const carried of [
1734
+ ...state.inbox["next-turn"],
1735
+ ...state.inbox["next-step"],
1736
+ ...state.discarded
1737
+ ]) if (carried !== null) pages = applyRecord(pages, carried.record, "pending");
1738
+ const removals = [];
1739
+ for (const op of state.ops) {
1740
+ const index = pages.findIndex((page) => page.url === op.url);
1741
+ if (op.op === "remove") {
1742
+ if (index < 0) continue;
1743
+ pages = pages.toSpliced(index, 1);
1744
+ removals.push(op);
1745
+ continue;
1746
+ }
1747
+ if (index >= 0) continue;
1748
+ pages = [...pages, {
1749
+ url: op.url,
1750
+ source: "model",
1751
+ state: "pending",
1752
+ order: pages.length
1753
+ }];
1754
+ }
1755
+ return {
1756
+ pages,
1757
+ removals
1758
+ };
1759
+ }
1760
+ /** The text of a reminder, as `shown` records it. */
1761
+ function textDigest(message) {
1762
+ return createHash("sha256").update(textOf$1(message)).digest("hex");
1900
1763
  }
1901
- function optional(key, value) {
1902
- return typeof value === "string" ? { [key]: value } : {};
1764
+ function applySplice(state, event) {
1765
+ const data = record$1(event.data);
1766
+ const target = data.target === "next-turn" ? "next-turn" : "next-step";
1767
+ const start = typeof data.start === "number" ? data.start : 0;
1768
+ const removedCount = typeof data.removedCount === "number" ? data.removedCount : 0;
1769
+ const slots = (Array.isArray(data.inserted) ? data.inserted : []).map((item, index) => carriedOf(item, `splice:${String(event.seq)}:${String(index)}`));
1770
+ const list = state.inbox[target];
1771
+ const removed = list.slice(start, start + removedCount).filter((item) => item !== null);
1772
+ if (removedCount === 0 && slots.length === 0) return state;
1773
+ const next = [
1774
+ ...list.slice(0, start),
1775
+ ...slots.map((slot) => slot ?? null),
1776
+ ...list.slice(start + removedCount)
1777
+ ];
1778
+ const announced = slots.some((slot) => slot !== void 0);
1779
+ return {
1780
+ ...state,
1781
+ inbox: {
1782
+ ...state.inbox,
1783
+ [target]: next
1784
+ },
1785
+ discarded: data.outcome === "canceled" ? [...state.discarded, ...removed] : state.discarded,
1786
+ ops: announced ? [] : state.ops
1787
+ };
1903
1788
  }
1904
- function fieldsOf(value) {
1905
- if (!Array.isArray(value)) return [];
1906
- return value.flatMap((item) => {
1907
- if (item === null || typeof item !== "object") return [];
1908
- const row = item;
1909
- const type = row.type;
1910
- if (type !== "textbox" && type !== "checkbox" && type !== "radio" && type !== "combobox" && type !== "slider") return [];
1911
- return [{
1912
- target: string(row.target),
1913
- ...typeof row.element === "string" ? { element: row.element } : {},
1914
- name: string(row.name),
1915
- type,
1916
- value: string(row.value)
1917
- }];
1918
- });
1789
+ function applyReceipts(state, event) {
1790
+ const data = record$1(event.data);
1791
+ if (record$1(data.message).isError === true) return state;
1792
+ const meta = record$1(data.meta);
1793
+ if (meta.by !== "mh-browser" || meta.action !== "presence" || !Array.isArray(meta.receipts)) return state;
1794
+ const ops = [];
1795
+ for (const item of meta.receipts) {
1796
+ const row = record$1(item);
1797
+ if (typeof row.url !== "string" || row.url === "") continue;
1798
+ if (row.receipt === "added (pending)") ops.push({
1799
+ op: "put",
1800
+ url: row.url
1801
+ });
1802
+ if (row.receipt === "removed (pending)") ops.push({
1803
+ op: "remove",
1804
+ url: row.url
1805
+ });
1806
+ }
1807
+ return ops.length === 0 ? state : {
1808
+ ...state,
1809
+ ops: [...state.ops, ...ops]
1810
+ };
1919
1811
  }
1920
- function callIdOf(exec) {
1921
- return String(exec.callId);
1812
+ function applyRecord(pages, record, state) {
1813
+ if (record.kind === "baseline") {
1814
+ const next = [];
1815
+ for (const entry of record.entries) {
1816
+ if (entry.op === "removed") continue;
1817
+ next.push(heldFrom(entry, state, next.length));
1818
+ }
1819
+ return next;
1820
+ }
1821
+ const next = pages.map((page) => ({ ...page }));
1822
+ for (const entry of record.entries) {
1823
+ if (entry.op === "removed") {
1824
+ const index = next.findIndex((page) => page.url === entry.url);
1825
+ if (index >= 0) next.splice(index, 1);
1826
+ continue;
1827
+ }
1828
+ const index = next.findIndex((page) => page.url === entry.url);
1829
+ const held = heldFrom(entry, state, index >= 0 ? next[index]?.order ?? index : next.length);
1830
+ if (index >= 0) next[index] = held;
1831
+ else next.push(held);
1832
+ }
1833
+ return next;
1922
1834
  }
1923
- function imageValue(ref) {
1835
+ function heldFrom(entry, state, order) {
1836
+ const absent = entry.op === "absent";
1924
1837
  return {
1925
- attachmentId: ref.attachmentId,
1926
- mediaType: ref.mediaType,
1927
- bytes: ref.bytes,
1928
- width: ref.width,
1929
- height: ref.height,
1930
- ...ref.name === void 0 ? {} : { name: ref.name },
1931
- ...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
1838
+ url: entry.url,
1839
+ source: entry.source,
1840
+ state,
1841
+ order,
1842
+ seen: absent ? { absent: entry.reason ?? "" } : {
1843
+ digest: entry.digest,
1844
+ observedAt: entry.observedAt,
1845
+ ...entry.version === void 0 ? {} : { version: entry.version },
1846
+ ...entry.id === void 0 ? {} : { id: entry.id }
1847
+ }
1932
1848
  };
1933
1849
  }
1934
- function imageRef(value) {
1850
+ /** A `user/message` event's data is the message itself. */
1851
+ function carriedOf(data, fallbackId) {
1852
+ const message = record$1(data);
1853
+ const source = record$1(message.source);
1854
+ if (source.kind !== "mh-browser") return void 0;
1855
+ const presence = record$1(source.presence);
1856
+ const kind = presence.kind;
1857
+ if (kind !== "baseline" && kind !== "delta") return void 0;
1858
+ const entries = Array.isArray(presence.entries) ? presence.entries.flatMap((item) => entryOf$1(item)) : [];
1935
1859
  return {
1936
- attachmentId: value.attachmentId,
1937
- mediaType: value.mediaType,
1938
- bytes: value.bytes,
1939
- width: value.width,
1940
- height: value.height,
1941
- ...value.name === void 0 ? {} : { name: value.name },
1942
- ...value.originalDimensions === void 0 ? {} : { originalDimensions: { ...value.originalDimensions } }
1860
+ id: typeof message.id === "string" && message.id !== "" ? message.id : fallbackId,
1861
+ record: {
1862
+ kind,
1863
+ entries,
1864
+ ...presence.replaces === true ? { replaces: true } : {}
1865
+ }
1943
1866
  };
1944
1867
  }
1945
- async function saveShot(ctx, view) {
1946
- const shot = view.shot;
1947
- if (shot === void 0) return void 0;
1948
- try {
1949
- return await ctx.attachments.saveImage({
1950
- data: shot.bytes,
1951
- mediaType: shot.mediaType,
1952
- name: `${view.tab ?? "page"}.${shot.mediaType.slice(6)}`
1953
- });
1954
- } catch (error) {
1955
- ctx.logger.warn("saving the screenshot failed: %o", error);
1956
- return;
1957
- }
1868
+ function entryOf$1(value) {
1869
+ const row = record$1(value);
1870
+ const op = row.op;
1871
+ if (op !== "present" && op !== "added" && op !== "changed" && op !== "absent" && op !== "removed") return [];
1872
+ const url = typeof row.url === "string" ? row.url : "";
1873
+ if (url === "") return [];
1874
+ const source = isSource(row.source) ? row.source : "model";
1875
+ const version = typeof row.version === "string" ? row.version : void 0;
1876
+ const id = typeof row.id === "string" && row.id !== "" ? row.id : void 0;
1877
+ const reason = typeof row.reason === "string" ? row.reason : void 0;
1878
+ return [{
1879
+ op,
1880
+ url,
1881
+ source,
1882
+ observedAt: typeof row.observedAt === "string" ? row.observedAt : "",
1883
+ digest: typeof row.digest === "string" ? row.digest : "",
1884
+ ...version === void 0 ? {} : { version },
1885
+ ...id === void 0 ? {} : { id },
1886
+ ...reason === void 0 ? {} : { reason }
1887
+ }];
1958
1888
  }
1959
- function isModifier(value) {
1960
- return value === "Alt" || value === "Control" || value === "ControlOrMeta" || value === "Meta" || value === "Shift";
1889
+ function textOf$1(value) {
1890
+ const content = record$1(value).content;
1891
+ if (!Array.isArray(content)) return "";
1892
+ return content.map((block) => record$1(block).text).filter((text) => typeof text === "string").join("\n");
1893
+ }
1894
+ function isSource(value) {
1895
+ return typeof value === "string" && PRESENCE_SOURCES.includes(value);
1896
+ }
1897
+ function record$1(value) {
1898
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
1899
+ return {};
1961
1900
  }
1962
1901
  //#endregion
1963
1902
  //#region lib/types/presence/notice.js
@@ -1983,8 +1922,7 @@ const BROWSER_TOOLS = /* @__PURE__ */ new Set([
1983
1922
  * the reminder after the claimed input. `await next()` runs first, so a
1984
1923
  * caller that puts pages and then returns `next()` is folded into this step.
1985
1924
  */
1986
- function registerPresence(ctx) {
1987
- const reader = logReader(ctx);
1925
+ function registerPresence(ctx, reader) {
1988
1926
  const tails = /* @__PURE__ */ new WeakMap();
1989
1927
  const stepTouches = /* @__PURE__ */ new WeakMap();
1990
1928
  const executionTouches = /* @__PURE__ */ new Map();
@@ -2012,8 +1950,7 @@ function registerPresence(ctx) {
2012
1950
  });
2013
1951
  };
2014
1952
  const stepIsOpen = (session) => {
2015
- const boundary = ctx.sessionProjections.stateOf(session, "turnBoundary");
2016
- if (boundary === void 0) throw new Error("mh-browser requires the turnBoundary session projection");
1953
+ const boundary = ctx.agents.turnBoundaryOf(session);
2017
1954
  return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === "start" && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq;
2018
1955
  };
2019
1956
  const project = (touch) => {
@@ -2239,6 +2176,58 @@ function canonical$1(url) {
2239
2176
  }
2240
2177
  }
2241
2178
  //#endregion
2179
+ //#region lib/types/projections.js
2180
+ /** Host-only folds over the session log; every read of what the log says goes through these. */
2181
+ async function registerProjections(ctx) {
2182
+ return readerFrom(await ctx.sessionProjections.register({
2183
+ key: "mhBrowserDeliveries",
2184
+ stateVersion: 2,
2185
+ stateSchema: plainObject(["deliveries", "tokens"]),
2186
+ init: initDeliveries,
2187
+ apply: (state, event) => applyDelivery(state, {
2188
+ seq: event.seq,
2189
+ type: event.type,
2190
+ data: event.data,
2191
+ ..."surfaceOp" in event && event.surfaceOp !== void 0 ? { surfaceOp: event.surfaceOp } : {}
2192
+ })
2193
+ }), await ctx.sessionProjections.register({
2194
+ key: "mhBrowserPresence",
2195
+ stateVersion: 1,
2196
+ stateSchema: plainObject([
2197
+ "committed",
2198
+ "inbox",
2199
+ "discarded",
2200
+ "shown",
2201
+ "ops"
2202
+ ]),
2203
+ init: initPresenceLog,
2204
+ apply: (state, event) => applyPresenceLog(state, event)
2205
+ }), await ctx.sessionProjections.register({
2206
+ key: "mhBrowserAttachments",
2207
+ stateVersion: 1,
2208
+ stateSchema: plainObject(["refs"]),
2209
+ init: initAttachments,
2210
+ apply: (state, event) => applyAttachments(state, event)
2211
+ }), await ctx.sessionProjections.register({
2212
+ key: "mhBrowserTabs",
2213
+ stateVersion: 2,
2214
+ stateSchema: plainObject(["open"]),
2215
+ init: initTabLog,
2216
+ apply: (state, event) => applyTabLog(state, event)
2217
+ }));
2218
+ }
2219
+ function readerFrom(deliveries, presence, attachments, tabs) {
2220
+ return {
2221
+ deliveries: (session) => deliveries.stateOf(session),
2222
+ presence: (session) => presence.stateOf(session),
2223
+ attachments: (session) => attachments.stateOf(session),
2224
+ tabs: (session) => tabs.stateOf(session)
2225
+ };
2226
+ }
2227
+ function plainObject(keys) {
2228
+ return z$1.custom((value) => value !== null && typeof value === "object" && keys.every((key) => key in value));
2229
+ }
2230
+ //#endregion
2242
2231
  //#region lib/types/presence/book.js
2243
2232
  /** In-memory presence set for one process. The log is the source of a set that outlives the process. */
2244
2233
  var PresenceBook = class {
@@ -2973,6 +2962,7 @@ async function launchBrowser(options) {
2973
2962
  acceptDownloads: true,
2974
2963
  chromiumSandbox: chromiumSandboxEnabled(options.browser),
2975
2964
  ...options.headless ? {} : {
2965
+ viewport: null,
2976
2966
  ignoreDefaultArgs: ["--disable-extensions"],
2977
2967
  args: ["--enable-unsafe-extension-debugging", "--keep-alive-for-test"]
2978
2968
  },
@@ -4108,6 +4098,16 @@ function watchDialog(page) {
4108
4098
  };
4109
4099
  }
4110
4100
  //#endregion
4101
+ //#region lib/types/words.js
4102
+ /** Shown on a session's tab group while a handover is waiting. */
4103
+ const WAITING_TITLE = "等你:";
4104
+ /** The question on the handover card. `reason` is the sentence the model gave. */
4105
+ function handoverQuestion(reason) {
4106
+ return `浏览器交给你了:${reason}`;
4107
+ }
4108
+ /** The option that hands the browser back. */
4109
+ const HAND_BACK = "交回";
4110
+ //#endregion
4111
4111
  //#region lib/types/service/extension.js
4112
4112
  const PACKAGE = "@mind-harness/mh-browser";
4113
4113
  const WORKER_MS = 5e3;
@@ -4772,7 +4772,7 @@ var Windows = class {
4772
4772
  if (extension === void 0 || groupId === void 0) return;
4773
4773
  const title = this.titles.get(owner) ?? "";
4774
4774
  const waiting = this.waiting.has(owner);
4775
- await extension.styleGroup(groupId, waiting ? `等你:${title}` : title, waiting ? "red" : "blue");
4775
+ await extension.styleGroup(groupId, waiting ? `${WAITING_TITLE}${title}` : title, waiting ? "red" : "blue");
4776
4776
  }
4777
4777
  async ingest(page) {
4778
4778
  if (this.byPage.has(page)) return;
@@ -5009,6 +5009,9 @@ const DOWNLOAD_EVENT_MS = 3e3;
5009
5009
  /** More exits than this within the window and the browser is not started again until one ages out. */
5010
5010
  const RELAUNCH_LIMIT = 3;
5011
5011
  const RELAUNCH_WINDOW_MS = 6e4;
5012
+ /** Agent Views remembered for a later conditional read. Older ones are read again in full. */
5013
+ const DOCUMENT_CAP = 64;
5014
+ const OPEN_ADDRESS_REASON = "only http and https addresses are opened";
5012
5015
  const STOP_WAIT_MS = 5e3;
5013
5016
  const WAITING_STOPPED = "stopped while waiting";
5014
5017
  const STOPPED = Symbol("stopped");
@@ -5052,10 +5055,10 @@ let MhBrowserService = (() => {
5052
5055
  windows = (__runInitializers(this, _instanceExtraInitializers), new Windows());
5053
5056
  launchOptions;
5054
5057
  context;
5055
- ready;
5058
+ ready = Promise.resolve();
5056
5059
  closing = false;
5057
- /** The browser exited. The next call that needs a page starts it again. */
5058
- exited = false;
5060
+ /** The browser is not running. The next call that needs a page starts it. */
5061
+ exited = true;
5059
5062
  exits = [];
5060
5063
  /** One queue per owner: a slow wait in one Session does not hold up another. */
5061
5064
  queues = /* @__PURE__ */ new Map();
@@ -5085,7 +5088,7 @@ let MhBrowserService = (() => {
5085
5088
  this.windows.onTabClosed((tab) => {
5086
5089
  this.shown.delete(tab.id);
5087
5090
  });
5088
- this.ready = this.launch();
5091
+ this.up().catch(() => void 0);
5089
5092
  ctx.on("session/disposed", (session) => {
5090
5093
  const owner = session.id;
5091
5094
  if (owner === void 0) return;
@@ -5490,6 +5493,10 @@ let MhBrowserService = (() => {
5490
5493
  * the tab, and that tab is not listed as closed.
5491
5494
  */
5492
5495
  async openAddress(owner, item, signal) {
5496
+ if (!openableAddress(item.url)) return {
5497
+ url: item.url,
5498
+ reason: OPEN_ADDRESS_REASON
5499
+ };
5493
5500
  const wantsTab = owner === "person" || item.snapshot === true || item.waitFor !== void 0;
5494
5501
  if (!wantsTab && this.knownOrigin(item.url)) {
5495
5502
  const via = await this.readKnown(owner, item.url, signal);
@@ -5532,7 +5539,7 @@ let MhBrowserService = (() => {
5532
5539
  if (!(representation.kind === "agent-view" || representation.kind === "snapshot" && representation.agentView !== void 0)) return;
5533
5540
  this.rememberOrigin(opened.view.url);
5534
5541
  const shown = this.shown.get(opened.live.id);
5535
- if (shown?.kind === "agent-view") this.documents.set(canonical(shown.pageUrl), shown);
5542
+ if (shown?.kind === "agent-view") this.rememberDocument(shown.pageUrl, shown);
5536
5543
  }
5537
5544
  /**
5538
5545
  * Read a known origin without a tab. `undefined` means this page has to be
@@ -5576,10 +5583,21 @@ let MhBrowserService = (() => {
5576
5583
  });
5577
5584
  if (stopped(signal)) return STOPPED;
5578
5585
  if (shown.kind !== "agent-view") return void 0;
5579
- this.documents.set(pageUrl, shown);
5586
+ this.rememberDocument(pageUrl, shown);
5580
5587
  this.rememberOrigin(pageUrl);
5581
5588
  return this.pageOfShown(owner, shown);
5582
5589
  }
5590
+ /** Keep the newest Agent Views. A dropped one is read in full the next time. */
5591
+ rememberDocument(url, shown) {
5592
+ const key = canonical(url);
5593
+ this.documents.delete(key);
5594
+ this.documents.set(key, shown);
5595
+ while (this.documents.size > DOCUMENT_CAP) {
5596
+ const oldest = this.documents.keys().next().value;
5597
+ if (oldest === void 0) return;
5598
+ this.documents.delete(oldest);
5599
+ }
5600
+ }
5583
5601
  /** One page from a document, with no tab. */
5584
5602
  pageOfShown(owner, shown) {
5585
5603
  const view = {
@@ -6052,7 +6070,10 @@ let MhBrowserService = (() => {
6052
6070
  });
6053
6071
  }
6054
6072
  async aria(tab) {
6055
- return tab.page.ariaSnapshot({ mode: "ai" }).catch(() => "");
6073
+ return tab.page.ariaSnapshot({ mode: "ai" }).catch((error) => {
6074
+ this.ctx.logger.debug("aria snapshot failed: %s", errorText(error));
6075
+ return "";
6076
+ });
6056
6077
  }
6057
6078
  async pageFromShown(tab, shown, extra) {
6058
6079
  const current = shown ?? {
@@ -6062,7 +6083,10 @@ let MhBrowserService = (() => {
6062
6083
  text: "",
6063
6084
  snapshot: ""
6064
6085
  };
6065
- const title = tab.dialogHeld ? "" : await tab.page.title().catch(() => "");
6086
+ const title = tab.dialogHeld ? "" : await tab.page.title().catch((error) => {
6087
+ this.ctx.logger.debug("page title failed: %s", errorText(error));
6088
+ return "";
6089
+ });
6066
6090
  this.windows.noteTitle(tab, title);
6067
6091
  const view = {
6068
6092
  tab: tab.id,
@@ -6227,8 +6251,9 @@ let MhBrowserService = (() => {
6227
6251
  ...location === void 0 || location === "" ? {} : { location },
6228
6252
  ...mediaType === void 0 ? {} : { mediaType }
6229
6253
  };
6230
- } catch {
6254
+ } catch (error) {
6231
6255
  if (signal?.aborted === true) throw new MhBrowserError(INTERRUPTED, "stopped after sending; it may have taken effect");
6256
+ this.ctx.logger.debug("declared action was not answered: %s", errorText(error));
6232
6257
  return { kind: "lost" };
6233
6258
  }
6234
6259
  }
@@ -6330,11 +6355,18 @@ let MhBrowserService = (() => {
6330
6355
  async start() {
6331
6356
  const context = await launchBrowser(this.launchOptions);
6332
6357
  if (this.closing) {
6333
- await context.close().catch(() => void 0);
6358
+ await this.closeContext(context);
6334
6359
  return;
6335
6360
  }
6336
6361
  this.context = context;
6337
- await this.windows.attach(context, this.launchOptions.profileDir, { headed: !this.launchOptions.headless });
6362
+ try {
6363
+ await this.windows.attach(context, this.launchOptions.profileDir, { headed: !this.launchOptions.headless });
6364
+ } catch (error) {
6365
+ this.context = void 0;
6366
+ this.windows.dropPages();
6367
+ await this.closeContext(context);
6368
+ throw error;
6369
+ }
6338
6370
  context.browser()?.on("disconnected", () => {
6339
6371
  if (this.closing || this.context !== context) return;
6340
6372
  this.onExit();
@@ -6350,22 +6382,23 @@ let MhBrowserService = (() => {
6350
6382
  this.ready = Promise.resolve();
6351
6383
  }
6352
6384
  /**
6353
- * The browser for a call that needs a page. One that exited starts again
6354
- * here. The exit limit stops unattended restarts; a person's request is
6355
- * one start per click and skips it.
6385
+ * The browser for a call that needs a page. One that is not running starts
6386
+ * again here. The exit limit stops unattended restarts; a person's request
6387
+ * is one start per click and skips it.
6356
6388
  */
6357
6389
  up(byPerson = false) {
6358
6390
  if (!this.exited || this.closing) return this.ready;
6359
6391
  const now = Date.now();
6360
6392
  while (this.exits.length > 0 && now - (this.exits[0] ?? now) > RELAUNCH_WINDOW_MS) this.exits.shift();
6361
6393
  if (!byPerson && this.exits.length > RELAUNCH_LIMIT) {
6362
- this.ctx.logger.warn("browser keeps exiting; not starting it again yet");
6363
- return Promise.reject(new MhBrowserError(BROWSER_UNAVAILABLE, `the browser exited ${String(this.exits.length)} times within ${String(RELAUNCH_WINDOW_MS / 1e3)} s; try again in a minute`));
6394
+ this.ctx.logger.warn("browser keeps stopping; not starting it again yet");
6395
+ return Promise.reject(new MhBrowserError(BROWSER_UNAVAILABLE, `the browser stopped or did not start ${String(this.exits.length)} times within ${String(RELAUNCH_WINDOW_MS / 1e3)} s; try again in a minute`));
6364
6396
  }
6365
6397
  this.exited = false;
6366
6398
  const started = this.launch();
6367
6399
  started.then(() => this.windows.resumePersonInput(), () => {
6368
6400
  this.exited = true;
6401
+ if (!this.closing) this.exits.push(Date.now());
6369
6402
  });
6370
6403
  this.ready = started;
6371
6404
  return started;
@@ -6373,10 +6406,15 @@ let MhBrowserService = (() => {
6373
6406
  async stop() {
6374
6407
  this.windows.suppressPersonInput();
6375
6408
  await Promise.race([this.ready.catch(() => void 0), new Promise((resolve) => setTimeout(resolve, STOP_WAIT_MS))]);
6376
- const browser = this.context?.browser();
6377
- await Promise.race([this.context?.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 1e3))]);
6378
- (browser?.process?.())?.kill("SIGKILL");
6409
+ const context = this.context;
6379
6410
  this.context = void 0;
6411
+ if (context !== void 0) await this.closeContext(context);
6412
+ }
6413
+ /** Close a context the service is done with, and kill it if close stalls. */
6414
+ async closeContext(context) {
6415
+ const browser = context.browser();
6416
+ await Promise.race([context.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 1e3))]);
6417
+ (browser?.process?.())?.kill("SIGKILL");
6380
6418
  }
6381
6419
  refuseIfPerson(owner, tab) {
6382
6420
  if (this.control.ofTab(owner, tab) !== "person") return;
@@ -6533,6 +6571,18 @@ function requiresOf(text, pageUrl) {
6533
6571
  const read = readAgentView(text, pageUrl);
6534
6572
  return read.ok ? read.doc.requires : [];
6535
6573
  }
6574
+ function openableAddress(url) {
6575
+ if (url === "about:blank") return true;
6576
+ try {
6577
+ const protocol = new URL(url).protocol;
6578
+ return protocol === "http:" || protocol === "https:";
6579
+ } catch {
6580
+ return false;
6581
+ }
6582
+ }
6583
+ function errorText(error) {
6584
+ return error instanceof Error ? error.message : String(error);
6585
+ }
6536
6586
  function canonical(url) {
6537
6587
  try {
6538
6588
  const parsed = new URL(url);
@@ -6599,8 +6649,7 @@ function stopped(signal) {
6599
6649
  //#region lib/types/tools/download.js
6600
6650
  const DOWNLOAD = ["Download one file into the session workspace's downloads/ directory, then read it with bash or fs.", "Use it for a file that requires login: the browser sends the session's cookies, bash does not."].join(" ");
6601
6651
  const URL$2 = "The file address: from a page shown to you, from the principal, or one you composed. An address that cannot be fetched returns download-error and writes nothing. An existing name under downloads/ gets a numeric suffix.";
6602
- function browserDownloadTool(ctx) {
6603
- const reader = logReader(ctx);
6652
+ function browserDownloadTool(ctx, reader) {
6604
6653
  return defineTool({
6605
6654
  name: "browser_download",
6606
6655
  description: DOWNLOAD,
@@ -6682,8 +6731,7 @@ const HANDOVER = [
6682
6731
  const REASON = "The sentence the person sees on the handover card, unchanged.";
6683
6732
  const TAB = "Tab id of the human page to show the person, copied from a page element's tab attribute. Omit it to hand over without naming a tab; that does not pick the latest result. A page with an Agent View has no tab until you open it with snapshot.";
6684
6733
  const ABORT = Symbol("abort");
6685
- function browserHandoverTool(ctx) {
6686
- const reader = logReader(ctx);
6734
+ function browserHandoverTool(ctx, reader) {
6687
6735
  return defineTool({
6688
6736
  name: "browser_handover",
6689
6737
  description: HANDOVER,
@@ -6774,8 +6822,8 @@ async function askUntilAnswered(ctx, reason, exec) {
6774
6822
  return await Promise.race([ctx.userQuestions.ask({
6775
6823
  questions: [{
6776
6824
  id: "handback",
6777
- question: `浏览器交给你了:${reason}`,
6778
- options: [{ label: "交回" }]
6825
+ question: handoverQuestion(reason),
6826
+ options: [{ label: HAND_BACK }]
6779
6827
  }],
6780
6828
  ...exec.agent !== void 0 ? { agent: exec.agent } : {},
6781
6829
  signal: exec.signal
@@ -6819,8 +6867,7 @@ const PAGE = "The url attribute of a page element in a tool result or a presence
6819
6867
  const NAME = "Action name copied from that page's Agent View actions.";
6820
6868
  const ITEM = "Item id copied from an item block, as a string. Required when the action's scope is item; omit it for a resource action.";
6821
6869
  const INPUT = "Only fields declared by that action's inputSchema; omit it when the action declares none. A field with contentEncoding binary takes a path inside the session cwd or an attachment reference.";
6822
- function browserInvokeTool(ctx) {
6823
- const reader = logReader(ctx);
6870
+ function browserInvokeTool(ctx, reader) {
6824
6871
  return defineTool({
6825
6872
  name: "browser_invoke",
6826
6873
  description: INVOKE,
@@ -6936,11 +6983,10 @@ const OPEN = [
6936
6983
  "A page lists documents in requires; those are not fetched with it, so open their addresses to read them or to call their actions."
6937
6984
  ].join(" ");
6938
6985
  const PAGES = "The pages to read, 1 to 8, each returned in order as its own element.";
6939
- const URL$1 = "The address to read: from a page shown to you, from the principal, or one you composed; opening a composed address is how it is checked before you use it elsewhere.";
6986
+ const URL$1 = "The address to read: from a page shown to you, from the principal, or one you composed; opening a composed address is how it is checked before you use it elsewhere. Only an http or https address is opened.";
6940
6987
  const WAIT = "Optional wait after the document loads for text to appear or disappear, or for time seconds; at least one must be set. If not met, the page is still returned and waited says so.";
6941
6988
  const SNAPSHOT = "Ask for the human page: open a tab and return its snapshot, for page operations and handover. A page without an Agent View already does.";
6942
- function browserOpenTool(ctx) {
6943
- const reader = logReader(ctx);
6989
+ function browserOpenTool(ctx, reader) {
6944
6990
  return defineTool({
6945
6991
  name: "browser_open",
6946
6992
  description: OPEN,
@@ -7064,8 +7110,7 @@ const PRESENCE = [
7064
7110
  ].join(" ");
7065
7111
  const PUT = "Addresses to keep: from a page shown to you, a presence reminder, or the principal; at most 8. A page that cannot be read shows as absent in the next presence reminder. At least one of put and remove is required.";
7066
7112
  const REMOVE = "Addresses to stop keeping, copied from a presence reminder; at most 8.";
7067
- function browserPresenceTool(ctx) {
7068
- const reader = logReader(ctx);
7113
+ function browserPresenceTool(ctx, reader) {
7069
7114
  return defineTool({
7070
7115
  name: "browser_presence",
7071
7116
  description: PRESENCE,
@@ -7142,14 +7187,14 @@ function line(receipt) {
7142
7187
  }
7143
7188
  //#endregion
7144
7189
  //#region lib/types/tools/register.js
7145
- function registerBrowserTools(ctx) {
7190
+ function registerBrowserTools(ctx, reader) {
7146
7191
  followSessionTitles(ctx);
7147
- ctx.tools.register(browserOpenTool(ctx));
7148
- for (const tool of pageOperationTools(ctx)) ctx.tools.register(tool);
7149
- ctx.tools.register(browserInvokeTool(ctx));
7150
- ctx.tools.register(browserDownloadTool(ctx));
7151
- ctx.tools.register(browserPresenceTool(ctx));
7152
- ctx.tools.register(browserHandoverTool(ctx));
7192
+ ctx.tools.register(browserOpenTool(ctx, reader));
7193
+ for (const tool of pageOperationTools(ctx, reader)) ctx.tools.register(tool);
7194
+ ctx.tools.register(browserInvokeTool(ctx, reader));
7195
+ ctx.tools.register(browserDownloadTool(ctx, reader));
7196
+ ctx.tools.register(browserPresenceTool(ctx, reader));
7197
+ ctx.tools.register(browserHandoverTool(ctx, reader));
7153
7198
  }
7154
7199
  //#endregion
7155
7200
  //#region lib/types/index.js
@@ -7158,10 +7203,11 @@ const inject = [
7158
7203
  "tools",
7159
7204
  "userQuestions",
7160
7205
  "attachments",
7161
- "sessionProjections"
7206
+ "sessionProjections",
7207
+ "agents"
7162
7208
  ];
7163
7209
  const Config = browserConfig;
7164
- function apply(ctx, config) {
7210
+ async function apply(ctx, config) {
7165
7211
  ctx.plugin({
7166
7212
  name: "mh-browser-slot",
7167
7213
  inject: ["browserUse", "mhBrowser"],
@@ -7169,14 +7215,14 @@ function apply(ctx, config) {
7169
7215
  inner.browserUse.register("mh-browser");
7170
7216
  }
7171
7217
  });
7172
- ctx.plugin(MhBrowserService, config);
7173
- ctx.plugin({
7218
+ await ctx.plugin(MhBrowserService, config);
7219
+ await ctx.plugin({
7174
7220
  name: "mh-browser-runtime",
7175
7221
  inject: ["mhBrowser", ...inject],
7176
- apply(inner) {
7177
- registerProjections(inner);
7178
- registerBrowserTools(inner);
7179
- registerPresence(inner);
7222
+ async apply(inner) {
7223
+ const reader = await registerProjections(inner);
7224
+ registerBrowserTools(inner, reader);
7225
+ registerPresence(inner, reader);
7180
7226
  }
7181
7227
  });
7182
7228
  }