@mind-harness/mh-browser 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
@@ -16,502 +16,21 @@ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
16
16
  //#region lib/types/config.js
17
17
  const browserConfig = z.object({
18
18
  presence: z.object({ maxBytes: z.number().step(1).min(1).required().description("Most bytes of page text one presence reminder may carry.") }).required().description("Pages kept in context across steps."),
19
- browser: z.union([
20
- "chrome",
21
- "msedge",
22
- "chromium"
23
- ]).default("chrome").description("Which installed browser to drive. chromium is the one Playwright downloads."),
24
- profileDir: z.string().description("Browser profile directory, kept between runs. Defaults to mh/profiles/<dsh profile>/browser under the dsh home."),
25
- headless: z.boolean().default(false).description("Run the browser without a window.")
26
- });
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
- }
19
+ browser: z.union([
20
+ "chrome",
21
+ "msedge",
22
+ "chromium"
23
+ ]).default("chrome").description("Which installed browser to drive. chromium is the one Playwright downloads."),
24
+ profileDir: z.string().description("Browser profile directory, kept between runs. Defaults to mh/profiles/<dsh profile>/browser under the dsh home."),
25
+ headless: z.boolean().default(false).description("Run the browser without a window.")
26
+ });
464
27
  /**
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
@@ -906,25 +425,272 @@ function pageBody(view) {
906
425
  if (view.status !== void 0) return `The site answered HTTP ${String(view.status)}; the ${view.mediaType} body is not shown.`;
907
426
  return "Not an HTML page; use browser_download to save it into the workspace.";
908
427
  }
909
- function versionOf$1(view) {
910
- const representation = view.representation;
911
- if (representation.kind === "agent-view") return representation.version;
912
- return representation.agentView?.version;
428
+ function versionOf$1(view) {
429
+ const representation = view.representation;
430
+ if (representation.kind === "agent-view") return representation.version;
431
+ return representation.agentView?.version;
432
+ }
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
+ /** Public browser types. Playwright types stay inside the service. */
451
+ var MhBrowserError = class extends Error {
452
+ code;
453
+ page;
454
+ constructor(code, message, page) {
455
+ super(message);
456
+ this.name = "MhBrowserError";
457
+ this.code = code;
458
+ if (page !== void 0) this.page = page;
459
+ }
460
+ };
461
+ /** Owner of the person's own window. It belongs to no Session. */
462
+ const PERSON_OWNER = "person";
463
+ const UNKNOWN_TAB = "UNKNOWN_TAB";
464
+ const UNKNOWN_TAB_REASON = "no such tab in this session";
465
+ /**
466
+ * Internal: a wait ended because its signal aborted. A tool never reports it:
467
+ * a stopped call ends as the tool runtime's abort, a takeover as `TAKEN_OVER`.
468
+ */
469
+ const INTERRUPTED = "INTERRUPTED";
470
+ /** The browser did not start or could not be restarted. */
471
+ const BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
472
+ function unknownTab() {
473
+ return {
474
+ url: "",
475
+ reason: UNKNOWN_TAB_REASON
476
+ };
477
+ }
478
+ //#endregion
479
+ //#region lib/types/tools/session.js
480
+ /** dsh shows the model only the message, so the message starts with the code. */
481
+ var BrowserToolError = class extends HarnessError {
482
+ reason;
483
+ constructor(code, reason) {
484
+ super(`${code}: ${reason}`, code);
485
+ this.name = "BrowserToolError";
486
+ this.reason = reason;
487
+ }
488
+ };
489
+ const NO_WORKSPACE_REASON$1 = "this session has no working directory";
490
+ /**
491
+ * The session of this tool call. Also records its title for the tab group.
492
+ * No title service, or no title yet, records an empty one.
493
+ */
494
+ function sessionFor(ctx, exec) {
495
+ const session = sessionOf(exec);
496
+ const titles = ctx.get("sessionTitle");
497
+ ctx.mhBrowser.noteSessionTitle(session.id, titles?.get(session.session)?.title ?? "");
498
+ return session;
499
+ }
500
+ /** A title written after the last tool call still reaches the tab group. */
501
+ function followSessionTitles(ctx) {
502
+ ctx.on("session/event", (session, event) => {
503
+ const { type, data } = event;
504
+ if (type !== "session/title" || typeof data !== "object" || data === null) return;
505
+ const title = data.title;
506
+ if (typeof title === "string") ctx.mhBrowser.noteSessionTitle(session.id, title);
507
+ });
508
+ }
509
+ function sessionOf(exec) {
510
+ const session = exec.agent?.session;
511
+ if (session === void 0) throw new BrowserToolError("NO_SESSION", "browser tools run only inside an agent session");
512
+ const cwd = session.header.cwd;
513
+ return cwd === void 0 ? {
514
+ id: session.id,
515
+ session
516
+ } : {
517
+ id: session.id,
518
+ cwd,
519
+ session
520
+ };
521
+ }
522
+ /**
523
+ * Where this session may write a download. With dsh's sandbox policy loaded,
524
+ * its mode decides: `read-only` writes nothing, otherwise the policy's
525
+ * workspace root. Without it, the session cwd.
526
+ */
527
+ function writeTarget(ctx, session) {
528
+ const policy = ctx.get("sandboxPolicy");
529
+ if (policy === void 0) return session.cwd === void 0 ? {
530
+ denied: NO_WORKSPACE_REASON$1,
531
+ code: "NO_WORKSPACE"
532
+ } : { root: session.cwd };
533
+ const resolved = policy.resolve({ session: session.session });
534
+ if (resolved.mode === "read-only") return {
535
+ denied: "[sandbox: file access denied under read-only mode]",
536
+ code: "SANDBOX_DENIED"
537
+ };
538
+ return { root: resolved.workspaceRoot };
539
+ }
540
+ function rethrow(error, detail) {
541
+ if (error instanceof MhBrowserError || error instanceof BrowserToolError) {
542
+ const reason = error instanceof BrowserToolError ? error.reason : error.message;
543
+ throw new BrowserToolError(error.code, detail === void 0 ? reason : `${reason}\n${detail}`);
544
+ }
545
+ throw error;
546
+ }
547
+ //#endregion
548
+ //#region lib/types/tools/paths.js
549
+ /** Resolve upload paths and reject anything outside the session's cwd. */
550
+ async function filesInWorkspace(cwd, files) {
551
+ if (cwd === void 0) throw new BrowserToolError("NO_WORKSPACE", NO_WORKSPACE_REASON$1);
552
+ const root = await realpath(cwd);
553
+ const resolved = [];
554
+ for (const file of files) {
555
+ const abs = isAbsolute(file) ? file : resolve(cwd, file);
556
+ let real;
557
+ try {
558
+ real = await realpath(abs);
559
+ } catch {
560
+ throw new BrowserToolError("INVALID_ARGS", `no such file: ${file}`);
561
+ }
562
+ const fromRoot = relative(root, real);
563
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${file} is outside the session workspace`);
564
+ resolved.push(real);
565
+ }
566
+ return resolved;
567
+ }
568
+ /** Refuse a filename that leaves the session's write root, without creating it. */
569
+ async function assertInside(target, filename) {
570
+ await place(target, filename);
571
+ }
572
+ /**
573
+ * Write `filename` under the session's write root. A path that leaves the
574
+ * root is refused, through a symbolic link as well: the deepest existing
575
+ * directory is checked before any is created, and the file itself is not
576
+ * followed if it is a link.
577
+ */
578
+ async function writeInside(target, filename, data) {
579
+ const file = await place(target, filename);
580
+ assertUnder(file.root, await realpath(await deepestExisting(dirname(file.file))), filename);
581
+ await mkdir(dirname(file.file), { recursive: true });
582
+ const parent = await realpath(dirname(file.file));
583
+ assertUnder(file.root, parent, filename);
584
+ const placed = resolve(parent, basename(file.file));
585
+ let handle;
586
+ try {
587
+ handle = await open(placed, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
588
+ } catch (error) {
589
+ if (error.code === "ELOOP") throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
590
+ throw error;
591
+ }
592
+ try {
593
+ await handle.writeFile(data);
594
+ } finally {
595
+ await handle.close();
596
+ }
597
+ const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
598
+ return {
599
+ path: relative(file.root, placed),
600
+ bytes
601
+ };
602
+ }
603
+ function assertUnder(root, path, filename) {
604
+ const fromRoot = relative(root, path);
605
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
606
+ }
607
+ async function deepestExisting(path) {
608
+ for (let current = path;; current = dirname(current)) try {
609
+ await lstat(current);
610
+ return current;
611
+ } catch {
612
+ if (dirname(current) === current) return current;
613
+ }
614
+ }
615
+ async function place(target, filename) {
616
+ if ("denied" in target) throw new BrowserToolError(target.code, target.denied);
617
+ if (filename.trim() === "") throw new BrowserToolError("INVALID_ARGS", "filename: give a path inside the workspace");
618
+ const root = await realpath(target.root).catch(() => target.root);
619
+ const abs = isAbsolute(filename) ? filename : resolve(root, filename);
620
+ const direct = relative(root, abs);
621
+ if (direct.startsWith("..") || isAbsolute(direct)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
622
+ return {
623
+ root,
624
+ file: abs
625
+ };
626
+ }
627
+ //#endregion
628
+ //#region lib/types/tools/attachments.js
629
+ function initAttachments() {
630
+ return { refs: {} };
913
631
  }
914
- function digestOf$1(view) {
915
- if (view.representation.kind !== "agent-view") return void 0;
916
- return representationDigest(view.representation.text);
632
+ /** Attachments reach the model in user messages and tool results. */
633
+ function applyAttachments(state, event) {
634
+ if (event.type !== "user/message" && event.type !== "tool/result") return state;
635
+ const found = {};
636
+ visit(event.data, 0, found);
637
+ const ids = Object.keys(found).filter((id) => state.refs[id] === void 0);
638
+ if (ids.length === 0) return state;
639
+ const refs = { ...state.refs };
640
+ for (const id of ids) refs[id] = found[id];
641
+ return { refs };
917
642
  }
918
- function idOf(view) {
919
- if (view.representation.kind !== "agent-view") return void 0;
920
- return agentViewId(view.representation.text, view.url);
643
+ /**
644
+ * Host paths for upload values. A value naming an attachment that appeared in
645
+ * this session resolves through the attachment provider; anything else is a
646
+ * path that must resolve inside the session cwd.
647
+ */
648
+ async function uploadPaths(ctx, seen, cwd, values) {
649
+ const paths = [];
650
+ for (const value of values) {
651
+ const id = attachmentIdOf(value);
652
+ const found = id === void 0 ? void 0 : seen.refs[id];
653
+ if (found !== void 0) {
654
+ paths.push(hostPath(ctx, found));
655
+ continue;
656
+ }
657
+ if (typeof value !== "string") throw new BrowserToolError("INVALID_INPUT", "attachment reference is not in this session");
658
+ paths.push(...await filesInWorkspace(cwd, [value]));
659
+ }
660
+ return paths;
921
661
  }
922
- function attr$1(name, value) {
923
- return ` ${name}="${escapeAttr(value)}"`;
662
+ function hostPath(ctx, found) {
663
+ let host;
664
+ try {
665
+ host = found.kind === "image" ? ctx.attachments.imageHostPath(found.ref) : ctx.attachments.fileHostPath(found.ref);
666
+ } catch (error) {
667
+ throw new BrowserToolError("INVALID_INPUT", error instanceof Error ? error.message : String(error));
668
+ }
669
+ if (host === void 0) throw new BrowserToolError("INVALID_INPUT", "attachment has no host path");
670
+ return host;
924
671
  }
925
- function formatSeconds(seconds) {
926
- const rounded = Math.round(seconds * 10) / 10;
927
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
672
+ function attachmentIdOf(value) {
673
+ if (typeof value === "string") return value;
674
+ if (value !== null && typeof value === "object") {
675
+ const id = value.attachmentId;
676
+ if (typeof id === "string") return id;
677
+ }
678
+ }
679
+ function visit(value, depth, found) {
680
+ if (depth > 12 || value === null || typeof value !== "object") return;
681
+ if (Array.isArray(value)) {
682
+ for (const item of value) visit(item, depth + 1, found);
683
+ return;
684
+ }
685
+ const row = value;
686
+ if (typeof row.attachmentId === "string" && typeof row.bytes === "number") {
687
+ const kind = typeof row.width === "number" && typeof row.mediaType === "string" ? "image" : "file";
688
+ if (kind === "image" || typeof row.name === "string") found[row.attachmentId] = {
689
+ kind,
690
+ ref: row
691
+ };
692
+ }
693
+ for (const item of Object.values(row)) visit(item, depth + 1, found);
928
694
  }
929
695
  //#endregion
930
696
  //#region lib/types/tools/delivery.js
@@ -1027,18 +793,18 @@ function urlsIn(text) {
1027
793
  return [...new Set(found)];
1028
794
  }
1029
795
  function fromTool(event) {
1030
- const data = record$2(event.data);
1031
- const message = record$2(data.message);
796
+ const data = record$3(event.data);
797
+ const message = record$3(data.message);
1032
798
  const callId = readString(message.toolCallId);
1033
- const text = textOf$2(message.content);
799
+ const text = textOf$3(message.content);
1034
800
  if (message.isError === true) return void 0;
1035
- const meta = record$2(data.meta);
801
+ const meta = record$3(data.meta);
1036
802
  if (meta.by !== "mh-browser") return void 0;
1037
803
  const pages = meta.pages;
1038
804
  if (!Array.isArray(pages)) return void 0;
1039
805
  const deliveries = [];
1040
806
  for (const page of pages) {
1041
- const row = record$2(page);
807
+ const row = record$3(page);
1042
808
  if (row.representation === "header" || readString(row.reason) !== void 0) continue;
1043
809
  const url = readString(row.url);
1044
810
  if (url === void 0) continue;
@@ -1064,21 +830,21 @@ function fromTool(event) {
1064
830
  };
1065
831
  }
1066
832
  function fromPresence(event) {
1067
- const message = record$2(event.data);
1068
- const source = record$2(message.source);
833
+ const message = record$3(event.data);
834
+ const source = record$3(message.source);
1069
835
  if (source.kind !== "mh-browser") return void 0;
1070
- const entries = record$2(source.presence).entries;
836
+ const entries = record$3(source.presence).entries;
1071
837
  if (!Array.isArray(entries)) return void 0;
1072
838
  const deliveries = [];
1073
839
  for (const entry of entries) {
1074
- const row = record$2(entry);
840
+ const row = record$3(entry);
1075
841
  if (row.op === "removed" || row.op === "absent") continue;
1076
842
  const url = readString(row.url);
1077
843
  if (url === void 0) continue;
1078
844
  const digest = readString(row.digest);
1079
845
  const version = readString(row.version);
1080
846
  const id = readString(row.id);
1081
- const body = digest === void 0 ? void 0 : shownBody(textOf$2(message.content), url);
847
+ const body = digest === void 0 ? void 0 : shownBody(textOf$3(message.content), url);
1082
848
  deliveries.push({
1083
849
  url,
1084
850
  seq: event.seq,
@@ -1091,7 +857,7 @@ function fromPresence(event) {
1091
857
  }
1092
858
  return {
1093
859
  deliveries,
1094
- text: textOf$2(message.content)
860
+ text: textOf$3(message.content)
1095
861
  };
1096
862
  }
1097
863
  const OMITTED_BODY = /* @__PURE__ */ new Set([
@@ -1136,11 +902,11 @@ function attrOf(header, name) {
1136
902
  if (value === void 0 || value === "") return void 0;
1137
903
  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
904
  }
1139
- function textOf$2(content) {
905
+ function textOf$3(content) {
1140
906
  if (!Array.isArray(content)) return "";
1141
907
  const parts = [];
1142
908
  for (const block of content) {
1143
- const text = readString(record$2(block).text);
909
+ const text = readString(record$3(block).text);
1144
910
  if (text !== void 0) parts.push(text);
1145
911
  }
1146
912
  return parts.join("\n");
@@ -1157,7 +923,7 @@ function sameUrl(left, right) {
1157
923
  return false;
1158
924
  }
1159
925
  }
1160
- function record$2(value) {
926
+ function record$3(value) {
1161
927
  if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
1162
928
  return {};
1163
929
  }
@@ -1172,9 +938,9 @@ function initTabLog() {
1172
938
  function applyTabLog(state, event) {
1173
939
  const cleared = event.type === "compaction/summary" || event.type === "compaction/prune" || event.surfaceOp === "replace";
1174
940
  if (event.type !== "tool/result") return cleared ? withoutSnap(state) : state;
1175
- const meta = record$1(record$1(event.data).meta);
941
+ const meta = record$2(record$2(event.data).meta);
1176
942
  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) : [];
943
+ const named = Array.isArray(meta.pages) ? meta.pages.map((page) => record$2(page).tab).filter(isTabId) : [];
1178
944
  const closed = Array.isArray(meta.closed) ? meta.closed.filter((note) => typeof note === "string").map(tabOfNote) : [];
1179
945
  const next = new Set(state.open);
1180
946
  for (const tab of named) next.add(tab);
@@ -1225,11 +991,11 @@ function withoutSnap(state) {
1225
991
  return rest;
1226
992
  }
1227
993
  function snapOf(value) {
1228
- const snap = record$1(value);
994
+ const snap = record$2(value);
1229
995
  if (!Array.isArray(snap.tabs)) return void 0;
1230
996
  return {
1231
997
  tabs: snap.tabs.flatMap((item) => {
1232
- const row = record$1(item);
998
+ const row = record$2(item);
1233
999
  if (typeof row.id !== "string" || typeof row.url !== "string") return [];
1234
1000
  return [{
1235
1001
  id: row.id,
@@ -1242,71 +1008,10 @@ function snapOf(value) {
1242
1008
  closed: Array.isArray(snap.closed) ? snap.closed.filter((note) => typeof note === "string") : []
1243
1009
  };
1244
1010
  }
1245
- function record$1(value) {
1011
+ function record$2(value) {
1246
1012
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1247
1013
  }
1248
1014
  //#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
1015
  //#region lib/types/tools/page-ops.js
1311
1016
  const IMAGE_VALUE = {
1312
1017
  type: "object",
@@ -1805,8 +1510,7 @@ const OPS = [
1805
1510
  })
1806
1511
  }
1807
1512
  ];
1808
- function pageOperationTools(ctx) {
1809
- const reader = logReader(ctx);
1513
+ function pageOperationTools(ctx, reader) {
1810
1514
  return OPS.map((spec) => defineOperation(ctx, reader, spec));
1811
1515
  }
1812
1516
  function defineOperation(ctx, reader, spec) {
@@ -1877,9 +1581,9 @@ function defineOperation(ctx, reader, spec) {
1877
1581
  }
1878
1582
  /** Probe the path before the action, then write the result the model would have been shown. */
1879
1583
  async function saveResult(target, filename, view) {
1880
- return writeInside(target, filename, view.shot?.bytes ?? textOf$1(view));
1584
+ return writeInside(target, filename, view.shot?.bytes ?? textOf$2(view));
1881
1585
  }
1882
- function textOf$1(view) {
1586
+ function textOf$2(view) {
1883
1587
  if (view.summary !== void 0 && view.summary !== "") return view.detail === void 0 || view.detail === "" ? view.summary : `${view.summary}\n${view.detail}`;
1884
1588
  return view.representation.text;
1885
1589
  }
@@ -1892,72 +1596,306 @@ function tabOf(reader, session, tab) {
1892
1596
  function string(value) {
1893
1597
  return typeof value === "string" ? value : "";
1894
1598
  }
1895
- function stringList$1(value) {
1896
- return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1599
+ function stringList$1(value) {
1600
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1601
+ }
1602
+ function optionalString(args, key) {
1603
+ return typeof args[key] === "string" ? { element: args[key] } : {};
1604
+ }
1605
+ function optional(key, value) {
1606
+ return typeof value === "string" ? { [key]: value } : {};
1607
+ }
1608
+ function fieldsOf(value) {
1609
+ if (!Array.isArray(value)) return [];
1610
+ return value.flatMap((item) => {
1611
+ if (item === null || typeof item !== "object") return [];
1612
+ const row = item;
1613
+ const type = row.type;
1614
+ if (type !== "textbox" && type !== "checkbox" && type !== "radio" && type !== "combobox" && type !== "slider") return [];
1615
+ return [{
1616
+ target: string(row.target),
1617
+ ...typeof row.element === "string" ? { element: row.element } : {},
1618
+ name: string(row.name),
1619
+ type,
1620
+ value: string(row.value)
1621
+ }];
1622
+ });
1623
+ }
1624
+ function callIdOf(exec) {
1625
+ return String(exec.callId);
1626
+ }
1627
+ function imageValue(ref) {
1628
+ return {
1629
+ attachmentId: ref.attachmentId,
1630
+ mediaType: ref.mediaType,
1631
+ bytes: ref.bytes,
1632
+ width: ref.width,
1633
+ height: ref.height,
1634
+ ...ref.name === void 0 ? {} : { name: ref.name },
1635
+ ...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
1636
+ };
1637
+ }
1638
+ function imageRef(value) {
1639
+ return {
1640
+ attachmentId: value.attachmentId,
1641
+ mediaType: value.mediaType,
1642
+ bytes: value.bytes,
1643
+ width: value.width,
1644
+ height: value.height,
1645
+ ...value.name === void 0 ? {} : { name: value.name },
1646
+ ...value.originalDimensions === void 0 ? {} : { originalDimensions: { ...value.originalDimensions } }
1647
+ };
1648
+ }
1649
+ async function saveShot(ctx, view) {
1650
+ const shot = view.shot;
1651
+ if (shot === void 0) return void 0;
1652
+ try {
1653
+ return await ctx.attachments.saveImage({
1654
+ data: shot.bytes,
1655
+ mediaType: shot.mediaType,
1656
+ name: `${view.tab ?? "page"}.${shot.mediaType.slice(6)}`
1657
+ });
1658
+ } catch (error) {
1659
+ ctx.logger.warn("saving the screenshot failed: %o", error);
1660
+ return;
1661
+ }
1662
+ }
1663
+ function isModifier(value) {
1664
+ return value === "Alt" || value === "Control" || value === "ControlOrMeta" || value === "Meta" || value === "Shift";
1665
+ }
1666
+ //#endregion
1667
+ //#region lib/types/presence/record.js
1668
+ /** Structured presence record carried on a message source. The model sees the rendered text, not this. */
1669
+ const PRESENCE_SOURCES = [
1670
+ "person via sidebar",
1671
+ "cwd reference",
1672
+ "model"
1673
+ ];
1674
+ //#endregion
1675
+ //#region lib/types/presence/fold.js
1676
+ function initPresenceLog() {
1677
+ return {
1678
+ committed: [],
1679
+ inbox: {
1680
+ "next-step": [],
1681
+ "next-turn": []
1682
+ },
1683
+ discarded: [],
1684
+ visible: false,
1685
+ hidden: false,
1686
+ shown: [],
1687
+ ops: []
1688
+ };
1689
+ }
1690
+ function applyPresenceLog(state, event) {
1691
+ if (event.type === "compaction/summary") {
1692
+ if (!state.visible && state.shown.length === 0) return state;
1693
+ return {
1694
+ ...state,
1695
+ visible: false,
1696
+ hidden: true,
1697
+ shown: []
1698
+ };
1699
+ }
1700
+ if (event.type === "user/message") {
1701
+ const carried = carriedOf(event.data, `history:${String(event.seq)}`);
1702
+ if (carried === void 0) return state;
1703
+ return {
1704
+ ...state,
1705
+ committed: applyRecord(state.committed, carried.record, "committed"),
1706
+ inbox: {
1707
+ "next-step": state.inbox["next-step"].map((item) => item?.id === carried.id ? null : item),
1708
+ "next-turn": state.inbox["next-turn"].map((item) => item?.id === carried.id ? null : item)
1709
+ },
1710
+ discarded: [],
1711
+ visible: true,
1712
+ hidden: false,
1713
+ shown: [...state.shown, textDigest(event.data)],
1714
+ ops: []
1715
+ };
1716
+ }
1717
+ if (event.type === "agent/inbox/discarded") {
1718
+ const carried = carriedOf(event.data, `discarded:${String(event.seq)}`);
1719
+ if (carried === void 0) return state;
1720
+ return {
1721
+ ...state,
1722
+ discarded: [...state.discarded, carried]
1723
+ };
1724
+ }
1725
+ if (event.type === "agent/inbox/spliced") return applySplice(state, event);
1726
+ if (event.type === "tool/result") return applyReceipts(state, event);
1727
+ return state;
1728
+ }
1729
+ /** The folded set, with pending reminders and unannounced receipts on top. */
1730
+ function presenceSet(state) {
1731
+ let pages = state.committed;
1732
+ for (const carried of [
1733
+ ...state.inbox["next-turn"],
1734
+ ...state.inbox["next-step"],
1735
+ ...state.discarded
1736
+ ]) if (carried !== null) pages = applyRecord(pages, carried.record, "pending");
1737
+ const removals = [];
1738
+ for (const op of state.ops) {
1739
+ const index = pages.findIndex((page) => page.url === op.url);
1740
+ if (op.op === "remove") {
1741
+ if (index < 0) continue;
1742
+ pages = pages.toSpliced(index, 1);
1743
+ removals.push(op);
1744
+ continue;
1745
+ }
1746
+ if (index >= 0) continue;
1747
+ pages = [...pages, {
1748
+ url: op.url,
1749
+ source: "model",
1750
+ state: "pending",
1751
+ order: pages.length
1752
+ }];
1753
+ }
1754
+ return {
1755
+ pages,
1756
+ removals
1757
+ };
1897
1758
  }
1898
- function optionalString(args, key) {
1899
- return typeof args[key] === "string" ? { element: args[key] } : {};
1759
+ /** The text of a reminder, as `shown` records it. */
1760
+ function textDigest(message) {
1761
+ return createHash("sha256").update(textOf$1(message)).digest("hex");
1900
1762
  }
1901
- function optional(key, value) {
1902
- return typeof value === "string" ? { [key]: value } : {};
1763
+ function applySplice(state, event) {
1764
+ const data = record$1(event.data);
1765
+ const target = data.target === "next-turn" ? "next-turn" : "next-step";
1766
+ const start = typeof data.start === "number" ? data.start : 0;
1767
+ const removedCount = typeof data.removedCount === "number" ? data.removedCount : 0;
1768
+ const slots = (Array.isArray(data.inserted) ? data.inserted : []).map((item, index) => carriedOf(item, `splice:${String(event.seq)}:${String(index)}`));
1769
+ const list = state.inbox[target];
1770
+ const removed = list.slice(start, start + removedCount).filter((item) => item !== null);
1771
+ if (removedCount === 0 && slots.length === 0) return state;
1772
+ const next = [
1773
+ ...list.slice(0, start),
1774
+ ...slots.map((slot) => slot ?? null),
1775
+ ...list.slice(start + removedCount)
1776
+ ];
1777
+ const announced = slots.some((slot) => slot !== void 0);
1778
+ return {
1779
+ ...state,
1780
+ inbox: {
1781
+ ...state.inbox,
1782
+ [target]: next
1783
+ },
1784
+ discarded: data.outcome === "canceled" ? [...state.discarded, ...removed] : state.discarded,
1785
+ ops: announced ? [] : state.ops
1786
+ };
1903
1787
  }
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
- });
1788
+ function applyReceipts(state, event) {
1789
+ const data = record$1(event.data);
1790
+ if (record$1(data.message).isError === true) return state;
1791
+ const meta = record$1(data.meta);
1792
+ if (meta.by !== "mh-browser" || meta.action !== "presence" || !Array.isArray(meta.receipts)) return state;
1793
+ const ops = [];
1794
+ for (const item of meta.receipts) {
1795
+ const row = record$1(item);
1796
+ if (typeof row.url !== "string" || row.url === "") continue;
1797
+ if (row.receipt === "added (pending)") ops.push({
1798
+ op: "put",
1799
+ url: row.url
1800
+ });
1801
+ if (row.receipt === "removed (pending)") ops.push({
1802
+ op: "remove",
1803
+ url: row.url
1804
+ });
1805
+ }
1806
+ return ops.length === 0 ? state : {
1807
+ ...state,
1808
+ ops: [...state.ops, ...ops]
1809
+ };
1919
1810
  }
1920
- function callIdOf(exec) {
1921
- return String(exec.callId);
1811
+ function applyRecord(pages, record, state) {
1812
+ if (record.kind === "baseline") {
1813
+ const next = [];
1814
+ for (const entry of record.entries) {
1815
+ if (entry.op === "removed") continue;
1816
+ next.push(heldFrom(entry, state, next.length));
1817
+ }
1818
+ return next;
1819
+ }
1820
+ const next = pages.map((page) => ({ ...page }));
1821
+ for (const entry of record.entries) {
1822
+ if (entry.op === "removed") {
1823
+ const index = next.findIndex((page) => page.url === entry.url);
1824
+ if (index >= 0) next.splice(index, 1);
1825
+ continue;
1826
+ }
1827
+ const index = next.findIndex((page) => page.url === entry.url);
1828
+ const held = heldFrom(entry, state, index >= 0 ? next[index]?.order ?? index : next.length);
1829
+ if (index >= 0) next[index] = held;
1830
+ else next.push(held);
1831
+ }
1832
+ return next;
1922
1833
  }
1923
- function imageValue(ref) {
1834
+ function heldFrom(entry, state, order) {
1835
+ const absent = entry.op === "absent";
1924
1836
  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 } }
1837
+ url: entry.url,
1838
+ source: entry.source,
1839
+ state,
1840
+ order,
1841
+ seen: absent ? { absent: entry.reason ?? "" } : {
1842
+ digest: entry.digest,
1843
+ observedAt: entry.observedAt,
1844
+ ...entry.version === void 0 ? {} : { version: entry.version },
1845
+ ...entry.id === void 0 ? {} : { id: entry.id }
1846
+ }
1932
1847
  };
1933
1848
  }
1934
- function imageRef(value) {
1849
+ /** A `user/message` event's data is the message itself. */
1850
+ function carriedOf(data, fallbackId) {
1851
+ const message = record$1(data);
1852
+ const source = record$1(message.source);
1853
+ if (source.kind !== "mh-browser") return void 0;
1854
+ const presence = record$1(source.presence);
1855
+ const kind = presence.kind;
1856
+ if (kind !== "baseline" && kind !== "delta") return void 0;
1857
+ const entries = Array.isArray(presence.entries) ? presence.entries.flatMap((item) => entryOf$1(item)) : [];
1935
1858
  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 } }
1859
+ id: typeof message.id === "string" && message.id !== "" ? message.id : fallbackId,
1860
+ record: {
1861
+ kind,
1862
+ entries,
1863
+ ...presence.replaces === true ? { replaces: true } : {}
1864
+ }
1943
1865
  };
1944
1866
  }
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
- }
1867
+ function entryOf$1(value) {
1868
+ const row = record$1(value);
1869
+ const op = row.op;
1870
+ if (op !== "present" && op !== "added" && op !== "changed" && op !== "absent" && op !== "removed") return [];
1871
+ const url = typeof row.url === "string" ? row.url : "";
1872
+ if (url === "") return [];
1873
+ const source = isSource(row.source) ? row.source : "model";
1874
+ const version = typeof row.version === "string" ? row.version : void 0;
1875
+ const id = typeof row.id === "string" && row.id !== "" ? row.id : void 0;
1876
+ const reason = typeof row.reason === "string" ? row.reason : void 0;
1877
+ return [{
1878
+ op,
1879
+ url,
1880
+ source,
1881
+ observedAt: typeof row.observedAt === "string" ? row.observedAt : "",
1882
+ digest: typeof row.digest === "string" ? row.digest : "",
1883
+ ...version === void 0 ? {} : { version },
1884
+ ...id === void 0 ? {} : { id },
1885
+ ...reason === void 0 ? {} : { reason }
1886
+ }];
1958
1887
  }
1959
- function isModifier(value) {
1960
- return value === "Alt" || value === "Control" || value === "ControlOrMeta" || value === "Meta" || value === "Shift";
1888
+ function textOf$1(value) {
1889
+ const content = record$1(value).content;
1890
+ if (!Array.isArray(content)) return "";
1891
+ return content.map((block) => record$1(block).text).filter((text) => typeof text === "string").join("\n");
1892
+ }
1893
+ function isSource(value) {
1894
+ return typeof value === "string" && PRESENCE_SOURCES.includes(value);
1895
+ }
1896
+ function record$1(value) {
1897
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) return value;
1898
+ return {};
1961
1899
  }
1962
1900
  //#endregion
1963
1901
  //#region lib/types/presence/notice.js
@@ -1983,8 +1921,7 @@ const BROWSER_TOOLS = /* @__PURE__ */ new Set([
1983
1921
  * the reminder after the claimed input. `await next()` runs first, so a
1984
1922
  * caller that puts pages and then returns `next()` is folded into this step.
1985
1923
  */
1986
- function registerPresence(ctx) {
1987
- const reader = logReader(ctx);
1924
+ function registerPresence(ctx, reader) {
1988
1925
  const tails = /* @__PURE__ */ new WeakMap();
1989
1926
  const stepTouches = /* @__PURE__ */ new WeakMap();
1990
1927
  const executionTouches = /* @__PURE__ */ new Map();
@@ -2012,8 +1949,7 @@ function registerPresence(ctx) {
2012
1949
  });
2013
1950
  };
2014
1951
  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");
1952
+ const boundary = ctx.agents.turnBoundaryOf(session);
2017
1953
  return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === "start" && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq;
2018
1954
  };
2019
1955
  const project = (touch) => {
@@ -2239,6 +2175,58 @@ function canonical$1(url) {
2239
2175
  }
2240
2176
  }
2241
2177
  //#endregion
2178
+ //#region lib/types/projections.js
2179
+ /** Host-only folds over the session log; every read of what the log says goes through these. */
2180
+ async function registerProjections(ctx) {
2181
+ return readerFrom(await ctx.sessionProjections.register({
2182
+ key: "mhBrowserDeliveries",
2183
+ stateVersion: 2,
2184
+ stateSchema: plainObject(["deliveries", "tokens"]),
2185
+ init: initDeliveries,
2186
+ apply: (state, event) => applyDelivery(state, {
2187
+ seq: event.seq,
2188
+ type: event.type,
2189
+ data: event.data,
2190
+ ..."surfaceOp" in event && event.surfaceOp !== void 0 ? { surfaceOp: event.surfaceOp } : {}
2191
+ })
2192
+ }), await ctx.sessionProjections.register({
2193
+ key: "mhBrowserPresence",
2194
+ stateVersion: 1,
2195
+ stateSchema: plainObject([
2196
+ "committed",
2197
+ "inbox",
2198
+ "discarded",
2199
+ "shown",
2200
+ "ops"
2201
+ ]),
2202
+ init: initPresenceLog,
2203
+ apply: (state, event) => applyPresenceLog(state, event)
2204
+ }), await ctx.sessionProjections.register({
2205
+ key: "mhBrowserAttachments",
2206
+ stateVersion: 1,
2207
+ stateSchema: plainObject(["refs"]),
2208
+ init: initAttachments,
2209
+ apply: (state, event) => applyAttachments(state, event)
2210
+ }), await ctx.sessionProjections.register({
2211
+ key: "mhBrowserTabs",
2212
+ stateVersion: 2,
2213
+ stateSchema: plainObject(["open"]),
2214
+ init: initTabLog,
2215
+ apply: (state, event) => applyTabLog(state, event)
2216
+ }));
2217
+ }
2218
+ function readerFrom(deliveries, presence, attachments, tabs) {
2219
+ return {
2220
+ deliveries: (session) => deliveries.stateOf(session),
2221
+ presence: (session) => presence.stateOf(session),
2222
+ attachments: (session) => attachments.stateOf(session),
2223
+ tabs: (session) => tabs.stateOf(session)
2224
+ };
2225
+ }
2226
+ function plainObject(keys) {
2227
+ return z$1.custom((value) => value !== null && typeof value === "object" && keys.every((key) => key in value));
2228
+ }
2229
+ //#endregion
2242
2230
  //#region lib/types/presence/book.js
2243
2231
  /** In-memory presence set for one process. The log is the source of a set that outlives the process. */
2244
2232
  var PresenceBook = class {
@@ -2952,6 +2940,15 @@ function missingExecutable(error) {
2952
2940
  return /ENOENT|doesn.t exist|Executable doesn't exist|not found/i.test(message);
2953
2941
  }
2954
2942
  /**
2943
+ * Playwright appends `--no-sandbox` unless this is true, and branded Chrome
2944
+ * then warns that stability and security will suffer. An installed browser
2945
+ * sandboxes on every platform. Playwright's own Chromium does too, except on
2946
+ * Linux, where that build has no usable sandbox helper and would fail to start.
2947
+ */
2948
+ function chromiumSandboxEnabled(browser, platform = process.platform) {
2949
+ return platform !== "linux" || browser !== "chromium";
2950
+ }
2951
+ /**
2955
2952
  * One persistent context for the whole process. `chromium` omits `channel`
2956
2953
  * so Playwright uses its own browser; the others use a local install.
2957
2954
  * A missing browser is reported and not downloaded.
@@ -2962,6 +2959,7 @@ async function launchBrowser(options) {
2962
2959
  return await chromium.launchPersistentContext(options.profileDir, {
2963
2960
  headless: options.headless,
2964
2961
  acceptDownloads: true,
2962
+ chromiumSandbox: chromiumSandboxEnabled(options.browser),
2965
2963
  ...options.headless ? {} : {
2966
2964
  ignoreDefaultArgs: ["--disable-extensions"],
2967
2965
  args: ["--enable-unsafe-extension-debugging", "--keep-alive-for-test"]
@@ -6589,8 +6587,7 @@ function stopped(signal) {
6589
6587
  //#region lib/types/tools/download.js
6590
6588
  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(" ");
6591
6589
  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.";
6592
- function browserDownloadTool(ctx) {
6593
- const reader = logReader(ctx);
6590
+ function browserDownloadTool(ctx, reader) {
6594
6591
  return defineTool({
6595
6592
  name: "browser_download",
6596
6593
  description: DOWNLOAD,
@@ -6667,13 +6664,12 @@ function browserDownloadTool(ctx) {
6667
6664
  const HANDOVER = [
6668
6665
  "Hand the browser to the person and wait until they hand it back.",
6669
6666
  "The person can take over a tab at any time; that stops the action in progress on that tab, and a tab they have touched stays with them until they hand it back. Other tabs keep working.",
6670
- "Call it instead of filling or submitting a login form, a Memory login or consent form, or any form that issues credentials, and whenever only the person can continue."
6667
+ "Call it instead of filling or submitting a login form, a Mind Harness login or consent form, or any form that issues credentials, and whenever only the person can continue."
6671
6668
  ].join(" ");
6672
6669
  const REASON = "The sentence the person sees on the handover card, unchanged.";
6673
6670
  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.";
6674
6671
  const ABORT = Symbol("abort");
6675
- function browserHandoverTool(ctx) {
6676
- const reader = logReader(ctx);
6672
+ function browserHandoverTool(ctx, reader) {
6677
6673
  return defineTool({
6678
6674
  name: "browser_handover",
6679
6675
  description: HANDOVER,
@@ -6809,8 +6805,7 @@ const PAGE = "The url attribute of a page element in a tool result or a presence
6809
6805
  const NAME = "Action name copied from that page's Agent View actions.";
6810
6806
  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.";
6811
6807
  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.";
6812
- function browserInvokeTool(ctx) {
6813
- const reader = logReader(ctx);
6808
+ function browserInvokeTool(ctx, reader) {
6814
6809
  return defineTool({
6815
6810
  name: "browser_invoke",
6816
6811
  description: INVOKE,
@@ -6929,8 +6924,7 @@ const PAGES = "The pages to read, 1 to 8, each returned in order as its own elem
6929
6924
  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.";
6930
6925
  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.";
6931
6926
  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.";
6932
- function browserOpenTool(ctx) {
6933
- const reader = logReader(ctx);
6927
+ function browserOpenTool(ctx, reader) {
6934
6928
  return defineTool({
6935
6929
  name: "browser_open",
6936
6930
  description: OPEN,
@@ -7054,8 +7048,7 @@ const PRESENCE = [
7054
7048
  ].join(" ");
7055
7049
  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.";
7056
7050
  const REMOVE = "Addresses to stop keeping, copied from a presence reminder; at most 8.";
7057
- function browserPresenceTool(ctx) {
7058
- const reader = logReader(ctx);
7051
+ function browserPresenceTool(ctx, reader) {
7059
7052
  return defineTool({
7060
7053
  name: "browser_presence",
7061
7054
  description: PRESENCE,
@@ -7132,14 +7125,14 @@ function line(receipt) {
7132
7125
  }
7133
7126
  //#endregion
7134
7127
  //#region lib/types/tools/register.js
7135
- function registerBrowserTools(ctx) {
7128
+ function registerBrowserTools(ctx, reader) {
7136
7129
  followSessionTitles(ctx);
7137
- ctx.tools.register(browserOpenTool(ctx));
7138
- for (const tool of pageOperationTools(ctx)) ctx.tools.register(tool);
7139
- ctx.tools.register(browserInvokeTool(ctx));
7140
- ctx.tools.register(browserDownloadTool(ctx));
7141
- ctx.tools.register(browserPresenceTool(ctx));
7142
- ctx.tools.register(browserHandoverTool(ctx));
7130
+ ctx.tools.register(browserOpenTool(ctx, reader));
7131
+ for (const tool of pageOperationTools(ctx, reader)) ctx.tools.register(tool);
7132
+ ctx.tools.register(browserInvokeTool(ctx, reader));
7133
+ ctx.tools.register(browserDownloadTool(ctx, reader));
7134
+ ctx.tools.register(browserPresenceTool(ctx, reader));
7135
+ ctx.tools.register(browserHandoverTool(ctx, reader));
7143
7136
  }
7144
7137
  //#endregion
7145
7138
  //#region lib/types/index.js
@@ -7148,10 +7141,11 @@ const inject = [
7148
7141
  "tools",
7149
7142
  "userQuestions",
7150
7143
  "attachments",
7151
- "sessionProjections"
7144
+ "sessionProjections",
7145
+ "agents"
7152
7146
  ];
7153
7147
  const Config = browserConfig;
7154
- function apply(ctx, config) {
7148
+ async function apply(ctx, config) {
7155
7149
  ctx.plugin({
7156
7150
  name: "mh-browser-slot",
7157
7151
  inject: ["browserUse", "mhBrowser"],
@@ -7159,14 +7153,14 @@ function apply(ctx, config) {
7159
7153
  inner.browserUse.register("mh-browser");
7160
7154
  }
7161
7155
  });
7162
- ctx.plugin(MhBrowserService, config);
7163
- ctx.plugin({
7156
+ await ctx.plugin(MhBrowserService, config);
7157
+ await ctx.plugin({
7164
7158
  name: "mh-browser-runtime",
7165
7159
  inject: ["mhBrowser", ...inject],
7166
- apply(inner) {
7167
- registerProjections(inner);
7168
- registerBrowserTools(inner);
7169
- registerPresence(inner);
7160
+ async apply(inner) {
7161
+ const reader = await registerProjections(inner);
7162
+ registerBrowserTools(inner, reader);
7163
+ registerPresence(inner, reader);
7170
7164
  }
7171
7165
  });
7172
7166
  }