@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 +21 -0
- package/README.md +136 -0
- package/SKILL.md +96 -0
- package/agent-helpers.py +215 -0
- package/bin/horse-browser +419 -0
- package/claude-md.sh +103 -0
- package/extension/background.js +139 -0
- package/extension/hello.html +233 -0
- package/extension/hello.js +12 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +14 -0
- package/extension/monitor.css +234 -0
- package/extension/monitor.html +53 -0
- package/extension/monitor.js +541 -0
- package/install.sh +178 -0
- package/package.json +53 -0
- package/scripts/postinstall.sh +34 -0
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.
|
package/agent-helpers.py
ADDED
|
@@ -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 []
|