@mind-harness/mh-browser 0.2.2 → 0.2.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Mind Harness",
4
- "version": "0.2.2",
4
+ "version": "0.2.3",
5
5
  "permissions": ["tabGroups"],
6
6
  "background": { "service_worker": "sw.js" }
7
7
  }
@@ -0,0 +1,94 @@
1
+ /* Keep in step with the sign-in callback page in packages/mh-cloud/src/page.ts. */
2
+ :root {
3
+ --bg: #f4f4f6;
4
+ --card: #fff;
5
+ --stroke: rgba(0, 0, 0, 0.08);
6
+ --text: #18181b;
7
+ --muted: #6b6b73;
8
+ --accent: #4d6bfe;
9
+ }
10
+
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --bg: #161618;
14
+ --card: #202023;
15
+ --stroke: rgba(255, 255, 255, 0.08);
16
+ --text: #ededf0;
17
+ --muted: #9a9aa3;
18
+ --accent: #7f95ff;
19
+ }
20
+ }
21
+
22
+ * {
23
+ box-sizing: border-box;
24
+ }
25
+
26
+ html,
27
+ body {
28
+ height: 100%;
29
+ }
30
+
31
+ body {
32
+ margin: 0;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ padding: 24px;
37
+ background: var(--bg);
38
+ color: var(--text);
39
+ font:
40
+ 15px/1.6 -apple-system,
41
+ BlinkMacSystemFont,
42
+ 'Segoe UI',
43
+ 'PingFang SC',
44
+ 'Hiragino Sans GB',
45
+ 'Microsoft YaHei',
46
+ sans-serif;
47
+ }
48
+
49
+ .card {
50
+ width: 100%;
51
+ max-width: 420px;
52
+ padding: 36px 32px 28px;
53
+ border: 1px solid var(--stroke);
54
+ border-radius: 20px;
55
+ background: var(--card);
56
+ text-align: center;
57
+ }
58
+
59
+ .product {
60
+ margin: 0 0 24px;
61
+ font-size: 12px;
62
+ letter-spacing: 0.06em;
63
+ text-transform: uppercase;
64
+ color: var(--muted);
65
+ }
66
+
67
+ .mark {
68
+ width: 56px;
69
+ height: 56px;
70
+ margin: 0 auto 16px;
71
+ border-radius: 50%;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ color: var(--accent);
76
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
77
+ }
78
+
79
+ h1 {
80
+ margin: 0 0 8px;
81
+ font-size: 20px;
82
+ font-weight: 600;
83
+ }
84
+
85
+ .line {
86
+ margin: 0 0 4px;
87
+ color: var(--muted);
88
+ }
89
+
90
+ .hint {
91
+ margin: 12px 0 0;
92
+ font-size: 13px;
93
+ color: var(--muted);
94
+ }
@@ -0,0 +1,40 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title data-text="tab">Mind Harness browser</title>
8
+ <link rel="stylesheet" href="welcome.css" />
9
+ <script src="welcome.js" defer></script>
10
+ </head>
11
+ <body>
12
+ <main class="card">
13
+ <p class="product">Mind Harness</p>
14
+ <div class="mark" aria-hidden="true">
15
+ <svg
16
+ viewBox="0 0 24 24"
17
+ width="28"
18
+ height="28"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ stroke-width="2.2"
22
+ stroke-linecap="round"
23
+ stroke-linejoin="round"
24
+ >
25
+ <rect x="3.5" y="5" width="17" height="14" rx="2.5" />
26
+ <path d="M3.5 9.5h17" />
27
+ </svg>
28
+ </div>
29
+ <h1 data-text="title">This is the Mind&nbsp;Harness browser</h1>
30
+ <p class="line" data-text="what">
31
+ The agent opens pages, reads them, and acts on them here. Sites you sign in to here stay
32
+ signed in for the agent.
33
+ </p>
34
+ <p class="line" data-text="keep">
35
+ Please keep this browser running. When the agent needs you, the conversation will tell you.
36
+ </p>
37
+ <p class="hint" data-text="closes">This window closes when the agent opens its first page.</p>
38
+ </main>
39
+ </body>
40
+ </html>
@@ -0,0 +1,15 @@
1
+ const ZH = {
2
+ tab: 'Mind Harness 浏览器',
3
+ title: '这是 Mind\u00a0Harness 的浏览器',
4
+ what: 'Agent 在这里替你打开网页、读取内容、完成操作。你在这里登录过的网站,Agent 之后也能用上。',
5
+ keep: '请不要退出这个浏览器。需要你来接手时,对话里会提示你。',
6
+ closes: 'Agent 打开第一个网页后,这个窗口会自动关闭。',
7
+ }
8
+
9
+ if (navigator.language.toLowerCase().startsWith('zh')) {
10
+ document.documentElement.lang = 'zh-CN'
11
+ for (const node of document.querySelectorAll('[data-text]')) {
12
+ const text = ZH[node.getAttribute('data-text')]
13
+ if (text !== undefined) node.textContent = text
14
+ }
15
+ }
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { parse } from "yaml";
9
9
  import { constants, readFileSync } from "node:fs";
10
- import { lstat, mkdir, open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
10
+ import { lstat, mkdir, open, readFile, readlink, realpath, unlink, writeFile } from "node:fs/promises";
11
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";
@@ -2979,6 +2979,50 @@ async function launchBrowser(options) {
2979
2979
  throw error;
2980
2980
  }
2981
2981
  }
2982
+ /**
2983
+ * Process id of the browser's main process. Playwright gives none for a
2984
+ * persistent context. Asks the browser first; on POSIX the profile's
2985
+ * `SingletonLock` link names it as `<host>-<pid>`.
2986
+ */
2987
+ async function browserPid(context, profileDir) {
2988
+ const browser = context.browser();
2989
+ if (browser !== null) try {
2990
+ const session = await browser.newBrowserCDPSession();
2991
+ try {
2992
+ const main = (await session.send("SystemInfo.getProcessInfo")).processInfo.find((item) => item.type === "browser");
2993
+ if (main !== void 0 && main.id > 0) return main.id;
2994
+ } finally {
2995
+ await session.detach().catch(() => void 0);
2996
+ }
2997
+ } catch {}
2998
+ if (process.platform === "win32") return void 0;
2999
+ try {
3000
+ const target = await readlink(join(profileDir, "SingletonLock"));
3001
+ const pid = Number(target.slice(target.lastIndexOf("-") + 1));
3002
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
3003
+ } catch {
3004
+ return;
3005
+ }
3006
+ }
3007
+ /**
3008
+ * Ask the browser to close; kill `pid` when that fails or takes longer than
3009
+ * `waitMs`, so a stuck browser does not keep the profile locked.
3010
+ */
3011
+ async function closeOrKill(close, pid, waitMs = 1e3, kill = (target) => {
3012
+ process.kill(target, "SIGKILL");
3013
+ }) {
3014
+ let timer;
3015
+ const outcome = await Promise.race([close().then(() => "closed", () => "failed"), new Promise((resolve) => {
3016
+ timer = setTimeout(() => resolve("timeout"), waitMs);
3017
+ })]);
3018
+ clearTimeout(timer);
3019
+ if (outcome === "closed") return "closed";
3020
+ if (pid === void 0) return "stuck";
3021
+ try {
3022
+ kill(pid);
3023
+ } catch {}
3024
+ return "killed";
3025
+ }
2982
3026
  //#endregion
2983
3027
  //#region lib/types/service/control.js
2984
3028
  /**
@@ -4121,7 +4165,7 @@ const REPLIED = "继续";
4121
4165
  //#endregion
4122
4166
  //#region lib/types/service/extension.js
4123
4167
  const PACKAGE = "@mind-harness/mh-browser";
4124
- const WORKER_MS = 5e3;
4168
+ const WORKER_MS = 15e3;
4125
4169
  /**
4126
4170
  * Directory of the unpacked extension shipped with this package.
4127
4171
  * Walks up from this module: sources live in `src/`, the bundle in `lib/`.
@@ -4156,6 +4200,10 @@ var Extension = class Extension {
4156
4200
  const worker = await findWorker(context, loaded.id);
4157
4201
  return new Extension(context, loaded.id, worker);
4158
4202
  }
4203
+ /** Address of a page shipped in the extension directory. */
4204
+ pageUrl(name) {
4205
+ return `chrome-extension://${this.id}/${name}`;
4206
+ }
4159
4207
  /** `chrome.tabs.create` in the named window. Returns the extension's tab id. */
4160
4208
  async createTab(windowId, url) {
4161
4209
  return (await this.worker()).evaluate(async (arg) => {
@@ -4231,13 +4279,21 @@ var Extension = class Extension {
4231
4279
  return worker;
4232
4280
  }
4233
4281
  };
4234
- async function findWorker(context, id) {
4235
- const open = context.serviceWorkers().find((worker) => worker.url().includes(id));
4282
+ /** The extension's service worker. Looks once more after the wait times out. */
4283
+ async function findWorker(context, id, timeoutMs = WORKER_MS) {
4284
+ const find = () => context.serviceWorkers().find((worker) => worker.url().includes(id));
4285
+ const open = find();
4236
4286
  if (open !== void 0) return open;
4237
- return context.waitForEvent("serviceworker", {
4238
- predicate: (worker) => worker.url().includes(id),
4239
- timeout: WORKER_MS
4240
- });
4287
+ try {
4288
+ return await context.waitForEvent("serviceworker", {
4289
+ predicate: (worker) => worker.url().includes(id),
4290
+ timeout: timeoutMs
4291
+ });
4292
+ } catch (error) {
4293
+ const late = find();
4294
+ if (late !== void 0) return late;
4295
+ throw new Error(`the browser extension did not start within ${String(timeoutMs / 1e3)} s`, { cause: error });
4296
+ }
4241
4297
  }
4242
4298
  //#endregion
4243
4299
  //#region lib/types/service/windows.js
@@ -4319,6 +4375,8 @@ var Windows = class {
4319
4375
  this.browserSession = await browser.newBrowserCDPSession();
4320
4376
  if (options.headed) this.extension = await Extension.load(context, this.browserSession);
4321
4377
  const launched = context.pages().filter((page) => page.url() === "about:blank");
4378
+ const extension = this.extension;
4379
+ if (extension !== void 0) for (const page of launched) await page.goto(extension.pageUrl("welcome.html")).catch(() => void 0);
4322
4380
  context.on("page", (page) => {
4323
4381
  for (const blank of launched.splice(0)) blank.close().catch(() => void 0);
4324
4382
  this.ingest(page);
@@ -5066,6 +5124,7 @@ let MhBrowserService = (() => {
5066
5124
  windows = (__runInitializers(this, _instanceExtraInitializers), new Windows());
5067
5125
  launchOptions;
5068
5126
  context;
5127
+ pids = /* @__PURE__ */ new WeakMap();
5069
5128
  ready = Promise.resolve();
5070
5129
  closing = false;
5071
5130
  /** The browser is not running. The next call that needs a page starts it. */
@@ -5123,7 +5182,8 @@ let MhBrowserService = (() => {
5123
5182
  this.agentViewOrigins.add(parsed.origin);
5124
5183
  } catch {}
5125
5184
  }
5126
- async open(owner, items, signal) {
5185
+ async open(owner, items, signal, options) {
5186
+ const reveal = options?.reveal === true;
5127
5187
  return this.exclusive(owner, async () => {
5128
5188
  await this.up(owner === PERSON_OWNER);
5129
5189
  const views = [];
@@ -5135,7 +5195,7 @@ let MhBrowserService = (() => {
5135
5195
  });
5136
5196
  continue;
5137
5197
  }
5138
- views.push(await this.openAddress(owner, item, signal));
5198
+ views.push(await this.openAddress(owner, item, signal, reveal));
5139
5199
  }
5140
5200
  await this.enforceCap(owner);
5141
5201
  return views;
@@ -5298,7 +5358,9 @@ let MhBrowserService = (() => {
5298
5358
  await (this.context?.browser())?.close();
5299
5359
  },
5300
5360
  page: (owner, tab) => this.windows.get(owner, tab)?.page,
5301
- running: () => this.context !== void 0
5361
+ running: () => this.context !== void 0,
5362
+ pages: () => this.context?.pages() ?? [],
5363
+ pid: () => this.context === void 0 ? void 0 : this.pids.get(this.context)
5302
5364
  };
5303
5365
  }
5304
5366
  async act(owner, tab, op, signal) {
@@ -5503,7 +5565,7 @@ let MhBrowserService = (() => {
5503
5565
  * A readable Agent View found on a tab this call opened is returned without
5504
5566
  * the tab, and that tab is not listed as closed.
5505
5567
  */
5506
- async openAddress(owner, item, signal) {
5568
+ async openAddress(owner, item, signal, reveal = false) {
5507
5569
  if (!openableAddress(item.url)) return {
5508
5570
  url: item.url,
5509
5571
  reason: OPEN_ADDRESS_REASON
@@ -5517,7 +5579,7 @@ let MhBrowserService = (() => {
5517
5579
  };
5518
5580
  if (via !== void 0) return via;
5519
5581
  }
5520
- const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true);
5582
+ const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true, reveal);
5521
5583
  this.keepAgentView(opened);
5522
5584
  if (!wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
5523
5585
  await this.windows.closeTab(opened.live);
@@ -5686,12 +5748,13 @@ let MhBrowserService = (() => {
5686
5748
  return body;
5687
5749
  }
5688
5750
  }
5689
- async openOne(owner, url, waitFor, signal, snapshot = false) {
5751
+ async openOne(owner, url, waitFor, signal, snapshot = false, reveal = false) {
5690
5752
  assertWaitFor(waitFor);
5691
5753
  const reused = this.idleTabOn(owner, url);
5692
5754
  const live = reused ?? await this.windows.openBlank(owner);
5693
5755
  const fresh = reused === void 0;
5694
5756
  const linked = this.control.link(owner, signal, live.id);
5757
+ if (reveal && !linked.signal.aborted) await live.page.bringToFront().catch(() => void 0);
5695
5758
  const nextDialog = this.windows.nextDialog(live);
5696
5759
  try {
5697
5760
  let seenDialog;
@@ -6365,6 +6428,8 @@ let MhBrowserService = (() => {
6365
6428
  }
6366
6429
  async start() {
6367
6430
  const context = await launchBrowser(this.launchOptions);
6431
+ const pid = await browserPid(context, this.launchOptions.profileDir);
6432
+ if (pid !== void 0) this.pids.set(context, pid);
6368
6433
  if (this.closing) {
6369
6434
  await this.closeContext(context);
6370
6435
  return;
@@ -6423,9 +6488,8 @@ let MhBrowserService = (() => {
6423
6488
  }
6424
6489
  /** Close a context the service is done with, and kill it if close stalls. */
6425
6490
  async closeContext(context) {
6426
- const browser = context.browser();
6427
- await Promise.race([context.close().catch(() => void 0), new Promise((resolve) => setTimeout(resolve, 1e3))]);
6428
- (browser?.process?.())?.kill("SIGKILL");
6491
+ await closeOrKill(() => context.close(), this.pids.get(context));
6492
+ this.pids.delete(context);
6429
6493
  }
6430
6494
  refuseIfPerson(owner, tab) {
6431
6495
  if (this.control.ofTab(owner, tab) !== "person") return;
@@ -44,7 +44,7 @@ const $remote = createTypertRemoteInfo({
44
44
  create: _mind_harness_mh_browser_mhBrowser_revealHandover_result$schema,
45
45
  jsonSchema: {"type":"object","properties":{"status":{"type":"string","enum":["revealed","not-handing-over","no-tab"]}},"required":["status"],"additionalProperties":true},
46
46
  },
47
- sourceLocation: {"file":"packages/mh-browser/src/service/service.ts","line":619,"column":9},
47
+ sourceLocation: {"file":"packages/mh-browser/src/service/service.ts","line":624,"column":9},
48
48
  },
49
49
  },
50
50
  }, z)
@@ -75,7 +75,7 @@ export const TYPERT = {
75
75
  {
76
76
  "kind": "method",
77
77
  "name": "open",
78
- "signature": "async open( owner: string, items: { url: string; waitFor?: WaitFor; snapshot?: boolean }[], signal?: AbortSignal, ): Promise<(PageView | PageError)[]>"
78
+ "signature": "async open( owner: string, items: { url: string; waitFor?: WaitFor; snapshot?: boolean }[], signal?: AbortSignal, options?: { reveal?: boolean }, ): Promise<(PageView | PageError)[]>"
79
79
  },
80
80
  {
81
81
  "kind": "method",
@@ -359,7 +359,7 @@ export const TYPERT = {
359
359
  },
360
360
  {
361
361
  "name": "TestHooks",
362
- "declaration": "export interface TestHooks {\n crash(): Promise<void>;\n page(owner: string, tab: string): unknown;\n running(): boolean;\n}"
362
+ "declaration": "export interface TestHooks {\n crash(): Promise<void>;\n page(owner: string, tab: string): unknown;\n running(): boolean;\n pages(): unknown[];\n pid(): number | undefined;\n}"
363
363
  },
364
364
  {
365
365
  "name": "WaitFor",
@@ -44,7 +44,7 @@ export const TYPERT_REMOTE = createTypertRemoteInfo({
44
44
  create: _mind_harness_mh_browser_mhBrowser_revealHandover_result$schema,
45
45
  jsonSchema: {"type":"object","properties":{"status":{"type":"string","enum":["revealed","not-handing-over","no-tab"]}},"required":["status"],"additionalProperties":true},
46
46
  },
47
- sourceLocation: {"file":"packages/mh-browser/src/service/service.ts","line":619,"column":9},
47
+ sourceLocation: {"file":"packages/mh-browser/src/service/service.ts","line":624,"column":9},
48
48
  },
49
49
  },
50
50
  }, z)
@@ -20,4 +20,15 @@ export declare function chromiumSandboxEnabled(browser: BrowserName, platform?:
20
20
  * A missing browser is reported and not downloaded.
21
21
  */
22
22
  export declare function launchBrowser(options: LaunchOptions): Promise<BrowserContext>;
23
+ /**
24
+ * Process id of the browser's main process. Playwright gives none for a
25
+ * persistent context. Asks the browser first; on POSIX the profile's
26
+ * `SingletonLock` link names it as `<host>-<pid>`.
27
+ */
28
+ export declare function browserPid(context: BrowserContext, profileDir: string): Promise<number | undefined>;
29
+ /**
30
+ * Ask the browser to close; kill `pid` when that fails or takes longer than
31
+ * `waitMs`, so a stuck browser does not keep the profile locked.
32
+ */
33
+ export declare function closeOrKill(close: () => Promise<void>, pid: number | undefined, waitMs?: number, kill?: (target: number) => void): Promise<'closed' | 'killed' | 'stuck'>;
23
34
  //# sourceMappingURL=browser.d.ts.map
@@ -1,4 +1,4 @@
1
- import type { BrowserContext, CDPSession } from 'playwright';
1
+ import type { BrowserContext, CDPSession, Worker } from 'playwright';
2
2
  /**
3
3
  * Directory of the unpacked extension shipped with this package.
4
4
  * Walks up from this module: sources live in `src/`, the bundle in `lib/`.
@@ -14,6 +14,8 @@ export declare class Extension {
14
14
  private workerPage;
15
15
  private constructor();
16
16
  static load(context: BrowserContext, browserSession: CDPSession): Promise<Extension>;
17
+ /** Address of a page shipped in the extension directory. */
18
+ pageUrl(name: string): string;
17
19
  /** `chrome.tabs.create` in the named window. Returns the extension's tab id. */
18
20
  createTab(windowId: number, url: string): Promise<number>;
19
21
  tabIdsIn(windowId: number): Promise<number[]>;
@@ -27,4 +29,6 @@ export declare class Extension {
27
29
  ungroup(tabId: number): Promise<void>;
28
30
  private worker;
29
31
  }
32
+ /** The extension's service worker. Looks once more after the wait times out. */
33
+ export declare function findWorker(context: BrowserContext, id: string, timeoutMs?: number): Promise<Worker>;
30
34
  //# sourceMappingURL=extension.d.ts.map
@@ -34,6 +34,7 @@ export declare class MhBrowserService extends TypertRemoteService {
34
34
  private readonly windows;
35
35
  private readonly launchOptions;
36
36
  private context;
37
+ private readonly pids;
37
38
  private ready;
38
39
  private closing;
39
40
  /** The browser is not running. The next call that needs a page starts it. */
@@ -58,7 +59,9 @@ export declare class MhBrowserService extends TypertRemoteService {
58
59
  url: string;
59
60
  waitFor?: WaitFor;
60
61
  snapshot?: boolean;
61
- }[], signal?: AbortSignal): Promise<(PageView | PageError)[]>;
62
+ }[], signal?: AbortSignal, options?: {
63
+ reveal?: boolean;
64
+ }): Promise<(PageView | PageError)[]>;
62
65
  view(owner: string, tab: string, signal?: AbortSignal): Promise<PageView | PageError>;
63
66
  /** Pages whose current representation is already in the model's presence. */
64
67
  rememberPresence(owner: string, urls: string[]): void;
@@ -5,5 +5,9 @@ export interface TestHooks {
5
5
  page(owner: string, tab: string): unknown;
6
6
  /** A browser process is up. */
7
7
  running(): boolean;
8
+ /** Every page of the running browser, launch page included. */
9
+ pages(): unknown[];
10
+ /** The main browser process id recorded at start. */
11
+ pid(): number | undefined;
8
12
  }
9
13
  //# sourceMappingURL=test-hooks.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mind-harness/mh-browser",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Opens pages in a real browser for the agent, keeps chosen Agent Views in context, and hands the browser to you when a person is needed.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "files": [