@pa1nd/horse-browser 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pa1nd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/pA1nD/pa1nd-media/main/horse-browser/banner-animated.svg" alt="Horse Browser — a dedicated browser for AI agents: colored per-session tab groups and a celestial navigation trail" width="100%" />
3
+ </p>
4
+ <!-- static fallback if the animated SVG ever fails to render:
5
+ <p align="center">
6
+ <img src="https://raw.githubusercontent.com/pA1nD/pa1nd-media/main/horse-browser/banner.jpg" alt="Horse Browser" width="100%" />
7
+ </p>
8
+ -->
9
+
10
+ # Horse Browser 🐴
11
+
12
+ **A browser where your agents live.** Log in once; every agent you point at it inherits the session — and you watch them all on one live wall.
13
+
14
+ <p align="center">
15
+ <a href="https://github.com/pA1nD/horse-browser/releases"><img src="https://img.shields.io/github/v/release/pA1nD/horse-browser?color=2f855a&label=release" alt="release" /></a>
16
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2f855a" alt="MIT" /></a>
17
+ <img src="https://img.shields.io/badge/platform-macOS-2f855a" alt="macOS" />
18
+ </p>
19
+
20
+ Every other tool hands each agent a *throwaway* browser. But you don't want throwaway — you want **your** browser, logged into your stuff, that agents quietly borrow. The catch: on macOS, every CDP command yanks the browser to the foreground ([a known open sore](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1254) across [the big tools](https://github.com/vercel-labs/agent-browser/issues/1247)). Horse Browser fixes that — agents open tabs **in the background**, each in their own **colored group**, in a **dedicated browser** that coexists with your daily one and never steals your focus.
21
+
22
+ > Why "horse"? *Browse* meant a horse grazing — nibbling shoots — long before it meant clicking links. A horse that browses is the *original* browser. And it rides in [browser-harness](https://github.com/browser-use/browser-harness): you harness a horse. 🐴
23
+
24
+ ## Setup
25
+
26
+ **Install (macOS, Node 18+):**
27
+
28
+ ```bash
29
+ npm install -g @pa1nd/horse-browser
30
+ ```
31
+
32
+ That fetches a dedicated **Chrome for Testing** (lives alongside your daily browser, never fights it for the dock), puts the `horse-browser` launcher on your PATH, and wires in the extension. Then start it and **sign into your apps once** — those logins persist for every agent:
33
+
34
+ ```bash
35
+ horse-browser # launches the dedicated browser, no focus steal
36
+ ```
37
+
38
+ To *drive* it, you also need **browser-harness** (a small Python tool horse-browser wraps) — a one-time prereq:
39
+
40
+ ```bash
41
+ uv tool install browser-harness # or: pipx install browser-harness
42
+ ```
43
+
44
+ Prefer your own Chromium? `export HORSE_BROWSER_BIN=/path/to/chromium` before installing.
45
+
46
+ <details>
47
+ <summary>Or hand the repo to your agent (clone + <code>./install.sh</code>)</summary>
48
+
49
+ You don't have to install by hand. Paste into **Claude Code** or **Codex**:
50
+
51
+ ```text
52
+ Set up https://github.com/pA1nD/horse-browser for me.
53
+
54
+ Clone it, run ./install.sh, then add SKILL.md to my CLAUDE.md so you always use
55
+ bh_open to open tabs from now on.
56
+ ```
57
+
58
+ `install.sh` does the same setup as the npm install, and additionally wires `SKILL.md` into your `CLAUDE.md` (the npm install prints that import line for you to add — see below).
59
+ </details>
60
+
61
+ ### Teaching agents the discipline
62
+
63
+ `SKILL.md` isn't an optional skill — it's a guardrail (open tabs with `bh_open`, never bare `goto_url`) that must be in context *before* the first browser call. So you register it by importing it into a `CLAUDE.md`, not as a load-on-demand skill.
64
+
65
+ **Recommended** — let `claude-md.sh` manage a small, marked block in your global `~/.claude/CLAUDE.md`:
66
+
67
+ ```bash
68
+ ./claude-md.sh apply # write/refresh the block (idempotent)
69
+ ./claude-md.sh print # just print it — to compare, or copy by hand
70
+ ./claude-md.sh check # is CLAUDE.md up to date? (exit 1 if drifted — good for a cron)
71
+ ```
72
+
73
+ It imports both playbooks — horse-browser's, and browser-harness's via a **stable symlink** it keeps aimed at the current install, so the `@`-import never rots when browser-harness is reinstalled on a different Python (`install.sh` refreshes that symlink too).
74
+
75
+ **By hand** — or just add the import yourself (global `~/.claude/CLAUDE.md`, or a single repo's `CLAUDE.md`):
76
+
77
+ ```text
78
+ @~/path/to/horse-browser/SKILL.md
79
+ ```
80
+
81
+ (Codex users: `~/.codex/AGENTS.md` works the same way.) Either way, the agent loads the `bh_open` discipline automatically from then on.
82
+
83
+ > **Installed via npm?** `SKILL.md` lives inside the global package — the installer prints the exact `@…/SKILL.md` line to paste, or find it with `echo "$(npm root -g)/@pa1nd/horse-browser/SKILL.md"`.
84
+
85
+ ## How it stays out of your way
86
+
87
+ Sign into Gmail, GitHub, your dashboards, whatever — **once** — and every agent you point at `:9223` inherits those sessions. No re-auth dance, no cookie juggling, no "paste your token" on every run. The catch with one shared browser is everyone trips over everyone, so three things keep it civil:
88
+
89
+ - **Coexists with your daily browser.** A *separate* browser (Chrome for Testing) — launching it never hijacks your everyday Chrome/Brave, and clicking yours never lands you in the agents' window.
90
+ - **Focus-safe by construction.** Tabs open in the background and activate through the extension instead of `Target.activateTarget` (which calls `[NSApp activate]` and yanks the browser over whatever you're doing). The page is told it's foregrounded via focus emulation, so nothing misbehaves.
91
+ - **Per-session tab groups.** Each agent's tabs live in their own colored group; you see whose-is-whose at a glance, humans and agents in one window.
92
+
93
+ (Browserbase, Steel, Hyperbrowser & co. solve scale by giving each agent its *own throwaway* browser. Great for the cloud; useless when you want **one real browser, on your machine, that stays logged in**. That's this.)
94
+
95
+ ## Watch them all — the Agent Monitor
96
+
97
+ <p align="center">
98
+ <img src="https://raw.githubusercontent.com/pA1nD/pa1nd-media/main/horse-browser/monitor.jpg" alt="The Agent Monitor — a live grid of every agent's tabs" width="100%" />
99
+ </p>
100
+
101
+ Click the 🐴 toolbar button for a live **2×2 / 3×3 wall** of screencasts — one tab per cell — so you can watch every agent browse at once on a big screen.
102
+
103
+ Built around **stable slots**: a tab keeps its cell, so the picture never shuffles under you. Activity lights up *in place* (a green pulse on the tab an agent just acted on) instead of reordering everything; the wall only changes membership when a tab has gone idle and a busier one is waiting. A theme-aware sidebar **groups every tab by session** — coloured Chrome-style groups (emoji + code), favicon rows, and a slot number on each on-wall tab so a row maps straight to its preview. Click any pane to jump to that tab. Pure read-only over CDP (a *second* client alongside whatever's driving), so it costs the agents nothing.
104
+
105
+ ## How agents drive it
106
+
107
+ `horse-browser` is a drop-in for [browser-harness](https://github.com/browser-use/browser-harness) that brings the dedicated browser up first (launching if down, self-healing a frozen GPU after sleep) and points the harness at it — so agents never touch a port:
108
+
109
+ ```bash
110
+ horse-browser <<'PY'
111
+ bh_open("https://example.com") # own colored tab group, no focus steal
112
+ print(page_info())
113
+ PY
114
+ ```
115
+
116
+ It owns the CDP endpoint, so the port lives in exactly one place (its config) — change it there and every agent follows. Under the hood it's just a dedicated browser speaking CDP, so any other CDP client can still attach the classic way:
117
+
118
+ ```bash
119
+ horse-browser && export BU_CDP_URL=http://127.0.0.1:9223 # for an arbitrary CDP client
120
+ ```
121
+
122
+ Agents open tabs with `bh_open(url)` (their own colored group, no focus steal) rather than bare `goto_url` (which clobbers whoever's focused). The discipline lives in [SKILL.md](SKILL.md) — import it into a `CLAUDE.md` ([see Setup](#teaching-agents-the-discipline)) and agents follow it automatically.
123
+
124
+ ## What's inside
125
+
126
+ - **`extension/`** — MV3 extension: the tab grouper, the Agent Monitor (sidebar collapsed by default), and a first-run welcome page.
127
+ - **`bin/horse-browser`** — the launcher *and* a browser-harness drop-in: ensures the browser is up (self-heals a GPU wedge after sleep), then runs your script against it. Also `horse-browser status` (versions + state) and `horse-browser update` (fetch the latest Chrome for Testing — it has no auto-updater — and restart onto it).
128
+ - **`install.sh`** — one-time setup; fetches the browser, registers the launcher + helpers.
129
+ - **`claude-md.sh`** — registers horse-browser's guidance in your `~/.claude/CLAUDE.md` (`apply`/`print`/`check`), via a version-proof symlink to the browser-harness SKILL.
130
+ - **`SKILL.md`** — the agent's playbook (the `bh_open` discipline + a helper recipe it self-installs).
131
+
132
+ A thin, self-contained setup — browser-harness (or anything else speaking CDP) is just a *consumer* on port 9223.
133
+
134
+ ## License
135
+
136
+ MIT © pa1nd
package/SKILL.md ADDED
@@ -0,0 +1,96 @@
1
+ ---
2
+ name: horse-browser
3
+ description: A dedicated, persistent CDP browser plus a tab-grouper extension that drops each agent's tabs into its own per-session group and opens them without stealing OS focus. Use whenever you drive a browser via horse-browser (a browser-harness drop-in) — opening tabs, navigating, scraping, rendering, screenshots.
4
+ ---
5
+
6
+ # horse-browser
7
+
8
+ **Rule:** when this skill is active, open tabs with `bh_open(url)` not `new_tab(url)`. If `bh_open` is undefined in your browser-harness call, install it from the [recipe](#extension) below *before* opening any tab. End-of-task: prune your group with `bh_list()` and `cdp("Target.closeTarget", ...)`.
9
+
10
+ **Never reach for bare `goto_url`.** It navigates whichever tab is currently focused in the browser — disastrous for other agents (it hijacks their work) and humans (it clobbers the tab they're reading). Open with `bh_open(url)`; re-navigate within your own session-grouped tab via `bh_switch_tab(tid)` first, then `goto_url(url)`. The only legitimate place for bare `goto_url` is *inside* `bh_open` itself.
11
+
12
+ ## Driving the browser
13
+
14
+ `horse-browser` is a drop-in for `browser-harness` that always brings the dedicated
15
+ browser up first (launching it if down, **self-healing a frozen GPU after sleep**) and
16
+ points the harness at it for you. **Anywhere the browser-harness skill says
17
+ `browser-harness <<'PY'`, use `horse-browser <<'PY'` instead.** One command drives it:
18
+
19
+ ```bash
20
+ horse-browser <<'PY'
21
+ tid = bh_open("https://example.com") # your own coloured tab group, no focus steal
22
+ wait_for_load()
23
+ print(page_info())
24
+ PY
25
+ ```
26
+
27
+ You never set `BU_CDP_URL` or name a port — **horse-browser owns the CDP endpoint** and
28
+ wires it up internally (change the port once in its config and the whole fleet follows).
29
+ Flags forward too (e.g. `horse-browser --doctor`). To *only* ensure the browser is up
30
+ without running a script, call it bare:
31
+
32
+ ```bash
33
+ horse-browser # launch if down, heal if wedged, no-op if healthy; then exit
34
+ ```
35
+
36
+ **Never launch a browser yourself** — don't `open` Chrome/Brave, don't spawn your own
37
+ Chromium, and don't run `browser-harness` against some other Chrome. Only `horse-browser`.
38
+ It runs a *dedicated* browser (Chrome for Testing, own profile) that won't collide with
39
+ the user's daily browser; improvising would.
40
+
41
+ If `horse-browser` isn't on your PATH, the one-time setup hasn't been run — tell the user
42
+ to run the repo's `./install.sh` (fetches the browser, registers the launcher). Don't
43
+ attempt setup yourself.
44
+
45
+ ## Extension
46
+
47
+ Gives each Claude session its own coloured tab group (label = last 4 chars of `CLAUDE_CODE_SESSION_ID`; subagents inherit it and share the group). Keeps RAM and tab-strip clutter from bleeding across parallel sessions, and lets you reason about "my tabs" as a real set. `chrome.tabGroups` is extension-only — no CDP equivalent — which is why an extension exists at all.
48
+
49
+ The service worker exposes three async functions on `self`:
50
+
51
+ ```js
52
+ self.groupTab(targetId: string, label: string) -> Promise<number>
53
+ // Puts the tab into a group titled `label`. Creates the group if missing
54
+ // (colour deterministic from `label`).
55
+ // targetId: CDP target id (uppercase hex string, e.g. "3C39F0B4…").
56
+ // label: string used as the group title.
57
+ // Returns: chrome tab id (number).
58
+ // Throws: Error("no tab for CDP target ...") if targetId has no live tab.
59
+
60
+ self.activateTab(targetId: string) -> Promise<number>
61
+ // Makes this tab the visible tab in its browser window WITHOUT raising the
62
+ // browser over your current macOS app. Replaces CDP Target.activateTarget, which
63
+ // calls [NSApp activate] and steals focus while the agent works. Returns
64
+ // the chrome tab id.
65
+
66
+ self.listTabs(label: string) -> Promise<Tab[]>
67
+ // Returns metadata for every tab whose group title equals `label`.
68
+ // Returns [] if no group with that title exists.
69
+ // Tab = {
70
+ // targetId: string | null, // CDP target id; null if no live target
71
+ // tabId: number, // chrome tab id
72
+ // url: string,
73
+ // title: string,
74
+ // lastAccessed: number | null, // ms since epoch; chrome may omit
75
+ // discarded: boolean,
76
+ // audible: boolean,
77
+ // active: boolean,
78
+ // }
79
+ ```
80
+
81
+ **`bh_open` / `bh_list` / `bh_switch_tab` are pre-installed.** `install.sh` writes them into browser-harness's `agent-workspace/agent_helpers.py` (which auto-loads on every call), so they're available immediately — just call `bh_open(url)`.
82
+
83
+ If `bh_open` is somehow undefined (a browser-harness checkout that never ran our `install.sh`), re-run `horse-browser`'s `install.sh` to install them — don't hand-roll your own; the focus-safe behaviour is subtle. You pass CDP `targetId`s only — the extension bridges to chrome `tabId`s internally.
84
+
85
+ ### Why this avoids stealing macOS focus
86
+
87
+ Two ingredients:
88
+
89
+ 1. **`Target.createTarget(background=True)` + `chrome.tabs.update({active:true})` instead of `Target.activateTarget`.** The latter calls `[NSApp activate]` on macOS and pulls the browser over whatever app you're in. `chrome.tabs.update` is documented to "not affect whether the window is focused" — it only changes which tab is visible inside the browser.
90
+ 2. **`Emulation.setFocusEmulationEnabled` per attached session.** Makes the renderer treat the page as always focused — `document.hasFocus()` returns true, `requestAnimationFrame` runs at full rate, focus/blur events fire as if user-driven. The OS app focus state is independent of this; the emulation just stops sites and Chromium internals from misbehaving because the tab "looks" backgrounded.
91
+
92
+ Native popovers (autofill, password save, translate) are *not* gated by focus emulation — they're triggered in the browser process per-form-field. If you see typing-time focus theft, those are the likely culprit; disable them at the profile level rather than chasing them through CDP.
93
+
94
+ ## Tab discipline
95
+
96
+ Regularly (e.g. before opening many tabs), glance at `bh_list()` and close stale ones via raw CDP. Close the rest when a task ends. Chrome removes empty groups, so a disciplined session leaves zero clutter.
@@ -0,0 +1,215 @@
1
+ # horse-browser helpers for browser-harness.
2
+ #
3
+ # install.sh appends these into browser-harness's agent-workspace/agent_helpers.py
4
+ # (which auto-loads on every browser-harness call), so bh_open() is available on
5
+ # the first run — no agent has to install the recipe by hand.
6
+ #
7
+ # What they give you (pass CDP targetIds; the extension bridges to chrome tabIds):
8
+ # bh_open(url) open a tab WITHOUT raising the browser over your macOS app,
9
+ # drop it into this session's coloured group, return targetId.
10
+ # bh_switch_tab(tid) focus-safe tab switch (no Target.activateTarget / NSApp).
11
+ # bh_list() tabs in this session's group.
12
+ #
13
+ # Why bh_open instead of new_tab/goto_url: bare goto_url navigates whatever tab is
14
+ # focused — clobbering other agents' and humans' work. bh_open never does that.
15
+
16
+ import json
17
+ import os
18
+ from browser_harness.helpers import cdp as _real_cdp, _send, goto_url, wait_for_load, current_tab
19
+
20
+
21
+ # ── auto-home: group-aware attach, from the helper side ──────────────────────────
22
+ # A stock browser-harness daemon attaches to the FIRST real page in the whole (shared)
23
+ # browser at startup — possibly another session's tab. And if its current tab later goes
24
+ # away (closed) it can fall back onto a neighbour's tab again. So a raw cdp()/page_info()/
25
+ # js()/capture_screenshot() could read, act on, or screenshot another session's page. We
26
+ # can't patch the daemon, but browser-harness MERGES this file into its helpers namespace,
27
+ # so defining cdp() here overrides it — and every helper that routes through cdp (page_info,
28
+ # js, goto_url, new_tab, capture_screenshot, …) goes through ours. We use that to home onto
29
+ # THIS session's own tab whenever the daemon's current tab is foreign — recreating
30
+ # group-aware attach without forking browser-harness.
31
+ _homed = False
32
+
33
+
34
+ def cdp(*args, **kwargs):
35
+ global _homed
36
+ if not _homed:
37
+ _homed = True # set BEFORE homing so re-entrant cdp() is a plain passthrough
38
+ try: _hb_home()
39
+ except Exception: pass # a home failure must never break cdp()
40
+ return _real_cdp(*args, **kwargs)
41
+
42
+
43
+ def _hb_home():
44
+ # Home the daemon onto one of THIS session's own tabs whenever its current tab is
45
+ # foreign — a neighbour's tab, or the visible one a stock daemon attached to at startup
46
+ # / fell back onto. Runs once per CLI process (the _homed gate). It's a NO-OP when we're
47
+ # already on an own tab, so it never clobbers the agent's own current-tab choice; it only
48
+ # acts when the current tab isn't ours (which is never a legitimate choice). This is what
49
+ # stops a raw cdp / page_info / js / capture_screenshot from touching a neighbour's page.
50
+ if not _session_id():
51
+ return
52
+ own = [t for t in bh_list() if t.get("targetId")]
53
+ own_ids = {t["targetId"] for t in own}
54
+ try:
55
+ cur = (current_tab() or {}).get("targetId")
56
+ except Exception:
57
+ cur = None
58
+ if cur and cur in own_ids:
59
+ return # already on one of my own tabs — leave it be
60
+ if not own:
61
+ bh_open("about:blank") # no tab yet → create + home a blank in my group
62
+ return
63
+ # Foreign: restore the EXACT tab I last drove (persisted on every bh_switch_tab). agents
64
+ # drive tabs in the background, so lastAccessed doesn't track which tab is "current" — the
65
+ # persisted id does. Only if that tab is truly gone do we fall back to the newest own tab.
66
+ want = _hb_recall()
67
+ if want and want not in own_ids:
68
+ import sys
69
+ print("\U0001F434 horse-browser: the tab you were driving has closed — switched to "
70
+ "another of your tabs (bh_list() to see them)", file=sys.stderr)
71
+ want = None
72
+ if want not in own_ids:
73
+ want = max(own, key=lambda t: t.get("lastAccessed") or 0)["targetId"]
74
+ bh_switch_tab(want)
75
+
76
+
77
+ def _hb_current_file():
78
+ return os.path.join(os.path.expanduser("~/.config/horse-browser/current"),
79
+ os.environ.get("BU_NAME", "default"))
80
+
81
+
82
+ def _hb_remember(target_id):
83
+ # Persist the tab this session is currently driving, so _hb_home can put us back on the
84
+ # SAME tab (not a guess) after the daemon drifts onto a foreign tab.
85
+ try:
86
+ f = _hb_current_file()
87
+ os.makedirs(os.path.dirname(f), exist_ok=True)
88
+ open(f, "w").write(target_id or "")
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ def _hb_recall():
94
+ try:
95
+ return (open(_hb_current_file()).read().strip() or None)
96
+ except Exception:
97
+ return None
98
+
99
+
100
+ def ext_call(fn, *args):
101
+ """Call an extension SW function. Returns the deserialised JS value,
102
+ or None if the extension's service worker isn't registered."""
103
+ sw = next((t["targetId"] for t in cdp("Target.getTargets")["targetInfos"]
104
+ if t.get("type") == "service_worker"
105
+ and t.get("url", "").startswith("chrome-extension://")), None)
106
+ if sw is None:
107
+ return None
108
+ s = cdp("Target.attachToTarget", targetId=sw, flatten=True)["sessionId"]
109
+ a = ", ".join(json.dumps(x) for x in args)
110
+ try:
111
+ return cdp("Runtime.evaluate", session_id=s,
112
+ expression=f"self.{fn}({a})",
113
+ awaitPromise=True, returnByValue=True)["result"].get("value")
114
+ finally:
115
+ cdp("Target.detachFromTarget", sessionId=s)
116
+
117
+
118
+ def _label():
119
+ return os.environ.get("CLAUDE_CODE_SESSION_ID", "")[-4:]
120
+
121
+
122
+ def _session_id():
123
+ # The FULL session id — passed to the extension so the browser derives the tab
124
+ # group's codename (emoji + colour + last-4) itself. Passing the whole id (not just
125
+ # the last-4) lets any companion tool that renders the same codename — a terminal
126
+ # statusline, a dashboard — match this group byte-for-byte.
127
+ return os.environ.get("CLAUDE_CODE_SESSION_ID", "")
128
+
129
+
130
+ def bh_switch_tab(target_id):
131
+ # Drop-in replacement for helpers.switch_tab that does NOT call
132
+ # Target.activateTarget (which fires [NSApp activate] on macOS and yanks
133
+ # the browser over your current app) AND does NOT change the window's visible
134
+ # tab: focus emulation lets us drive it fully in the background (navigate,
135
+ # click, screenshot), so concurrent agents never flip each other's view. The
136
+ # horse-browser monitor surfaces the busiest tabs for follow-along.
137
+ try: cdp("Runtime.evaluate", expression="if(document.title.startsWith('\U0001F434 '))document.title=document.title.slice(3)")
138
+ except Exception: pass
139
+ sid = cdp("Target.attachToTarget", targetId=target_id, flatten=True)["sessionId"]
140
+ _send({"meta": "set_session", "session_id": sid, "target_id": target_id})
141
+ _hb_remember(target_id) # remember the tab I'm now driving, so a drift can restore THIS one
142
+ cdp("Emulation.setFocusEmulationEnabled", enabled=True)
143
+ try: cdp("Runtime.evaluate", expression="if(!document.title.startsWith('\U0001F434'))document.title='\U0001F434 '+document.title")
144
+ except Exception: pass
145
+ return sid
146
+
147
+
148
+ _hb_reported = False # surface the group's open tabs once per browser-harness process
149
+
150
+
151
+ def _tab_report(tabs):
152
+ # Once per process, tell the agent which tabs its session group has open — on
153
+ # stderr, so it never pollutes a script's stdout. Always reports the count; when
154
+ # a lot have gone idle, adds a nudge to close what's no longer needed (agents
155
+ # accumulate tabs otherwise). Tune via HORSE_BROWSER_TAB_NUDGE (default 5) and
156
+ # HORSE_BROWSER_TAB_IDLE_MIN (default 10); set TAB_NUDGE to a huge number to mute.
157
+ global _hb_reported
158
+ if _hb_reported:
159
+ return
160
+ _hb_reported = True
161
+ try:
162
+ import time, sys
163
+ if not tabs:
164
+ return
165
+ idle_min = float(os.environ.get("HORSE_BROWSER_TAB_IDLE_MIN", "10"))
166
+ nudge_at = int(os.environ.get("HORSE_BROWSER_TAB_NUDGE", "5"))
167
+ now = time.time() * 1000
168
+ stale = sum(1 for t in tabs
169
+ if t.get("lastAccessed") and (now - t["lastAccessed"]) >= idle_min * 60000)
170
+ line = (f"\U0001F434 horse-browser: {len(tabs)} tab(s) open in your group "
171
+ f"({stale} idle ≥{int(idle_min)}m)")
172
+ if stale > nudge_at:
173
+ line += (" — close what you don't need: "
174
+ "cdp('Target.closeTarget', targetId=t['targetId']) per tab (ids via bh_list())")
175
+ print(line, file=sys.stderr)
176
+ except Exception:
177
+ pass
178
+
179
+
180
+ def bh_open(url):
181
+ # Reuse a blank tab already in MY group (e.g. the one the daemon opened on
182
+ # attach, when this session had no tab yet) instead of leaving it stray; else
183
+ # mint a fresh background tab. background=True keeps [NSApp activate] from firing.
184
+ tid = None
185
+ if _session_id():
186
+ tabs = bh_list()
187
+ _tab_report(tabs) # once per call: surface the group's tabs (+ nudge if hoarding)
188
+ for t in tabs:
189
+ if (t.get("url") or "") in ("", "about:blank") and t.get("targetId"):
190
+ tid = t["targetId"]
191
+ break
192
+ created = tid is None
193
+ if created:
194
+ tid = cdp("Target.createTarget", url="about:blank", background=True)["targetId"]
195
+ try:
196
+ # group BEFORE navigating: an open that fails (or a session killed
197
+ # mid-navigation) still lands in the session group, so bh_list()-based
198
+ # cleanup can see it instead of leaking an ungrouped about:blank tab.
199
+ if _session_id() and created:
200
+ ext_call("groupTab", tid, _session_id())
201
+ bh_switch_tab(tid)
202
+ if url != "about:blank":
203
+ goto_url(url)
204
+ wait_for_load()
205
+ except Exception:
206
+ # don't leak a tab WE created (never close a reused, pre-existing one)
207
+ if created:
208
+ try: cdp("Target.closeTarget", targetId=tid)
209
+ except Exception: pass
210
+ raise
211
+ return tid
212
+
213
+
214
+ def bh_list():
215
+ return ext_call("listTabs", _session_id()) or [] if _session_id() else []