@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.
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env bash
2
+ # horse-browser — start the dedicated agent browser, and optionally drive it.
3
+ #
4
+ # Two modes, chosen automatically from stdin:
5
+ # • bare `horse-browser` → ensure the browser is up (+ self-heal), exit.
6
+ # • driver `horse-browser <<'PY' … PY` → ensure it's up, then run the script through
7
+ # browser-harness against it. A drop-in for `browser-harness` that always
8
+ # pre-flights the browser first. Args forward too, e.g. `horse-browser --doctor`.
9
+ #
10
+ # Driver mode sets BU_CDP_URL itself, so the CDP port is owned entirely by this tool —
11
+ # callers never hardcode it. Change PORT in the config and the whole fleet follows.
12
+ #
13
+ # Subcommands:
14
+ # horse-browser status versions (chrome / browser-harness / horse-browser) + state
15
+ # horse-browser update fetch the latest Chrome for Testing and restart onto it
16
+ #
17
+ # Idempotent. Config is written by install.sh to ~/.config/horse-browser/config.
18
+ # Env vars override it: HORSE_BROWSER_BIN, HORSE_BROWSER_PORT, HORSE_BROWSER_PROFILE.
19
+ set -euo pipefail
20
+
21
+ CONFIG="$HOME/.config/horse-browser/config"
22
+ # shellcheck disable=SC1090
23
+ [ -f "$CONFIG" ] && . "$CONFIG"
24
+
25
+ PORT="${HORSE_BROWSER_PORT:-${PORT:-9223}}"
26
+ PROFILE="${HORSE_BROWSER_PROFILE:-${PROFILE:-$HOME/.config/horse-browser/profile}}"
27
+ BIN="${HORSE_BROWSER_BIN:-${BROWSER_BIN:-}}"
28
+ EXT="${EXTENSION_DIR:-}"
29
+ CACHE="${HORSE_BROWSER_CACHE:-$HOME/.cache/horse-browser}"
30
+
31
+ # Capture a script ONLY when stdin is a heredoc / file / pipe (a regular file or a
32
+ # FIFO) — those always reach EOF, so the read can't block. A bare invocation's stdin
33
+ # is a tty, /dev/null, or the caller's open socket (none of which is -f/-p), so we
34
+ # never read — and so never hang — when there's no script to run.
35
+ script=""
36
+ if [ -f /dev/fd/0 ] || [ -p /dev/fd/0 ]; then script="$(cat)"; fi
37
+ # Driver mode = a script on stdin OR args to forward to browser-harness.
38
+ PASSTHROUGH=
39
+ if [ -n "$script" ] || [ "$#" -gt 0 ]; then PASSTHROUGH=1; fi
40
+
41
+ # Pre-flight info goes to stdout in bare mode, but to stderr in driver mode so the
42
+ # script's stdout stays pure browser-harness output.
43
+ say() { if [ -n "$PASSTHROUGH" ]; then echo "$@" >&2; else echo "$@"; fi; }
44
+
45
+ cdp_up() { curl -sf "http://127.0.0.1:$PORT/json/version" >/dev/null 2>&1; }
46
+
47
+ # ── self-heal: GPU-process wedge after macOS sleep/wake ──────────────────────
48
+ # After the machine or an external display sleeps, Chrome for Testing's GPU process
49
+ # can get stuck in the frame-present path and never re-acquire its window-server
50
+ # surface on wake. The renderer keeps running — JS, timers, non-render CDP all work —
51
+ # but NOTHING paints: the window is frozen and Page.captureScreenshot hangs forever.
52
+ # Stable Chrome/Brave auto-recover via a Finch seed + GPU watchdog; Chrome for Testing
53
+ # (Null variations seed) does not, so the wedge is permanent until the GPU process is
54
+ # restarted. RECOVERY (proven, non-destructive — no tabs/data lost): kill ONLY this
55
+ # profile's GPU child; Chrome respawns it and the compositor rebuilds. So every
56
+ # `horse-browser` invocation probes paint and heals a frozen browser automatically.
57
+
58
+ # gpu_healthy [timeout]: 0 = paints fine, 1 = wedged (screenshot hangs), 2 = can't
59
+ # tell. Probes with a 1x1 Page.captureScreenshot — it needs a composited frame, so
60
+ # it hangs exactly when the GPU is wedged — over a dependency-free stdlib websocket.
61
+ gpu_healthy() {
62
+ python3 - "$PORT" "${1:-5}" <<'PY'
63
+ import sys, os, json, socket, base64, struct, urllib.request
64
+ port, timeout = sys.argv[1], float(sys.argv[2])
65
+ def http_json(p):
66
+ return json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}{p}", timeout=4))
67
+ def ws_connect(path):
68
+ s = socket.create_connection(("127.0.0.1", int(port)), timeout=5)
69
+ key = base64.b64encode(os.urandom(16)).decode()
70
+ s.sendall((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
71
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n"
72
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
73
+ buf = b""
74
+ while b"\r\n\r\n" not in buf:
75
+ c = s.recv(1024)
76
+ if not c: raise ConnectionError("handshake closed")
77
+ buf += c
78
+ if b" 101 " not in buf.split(b"\r\n", 1)[0]: raise ConnectionError("no upgrade")
79
+ return s
80
+ def ws_send_text(s, payload):
81
+ d = payload.encode(); n = len(d); m = os.urandom(4); h = bytearray([0x81])
82
+ if n < 126: h.append(0x80 | n)
83
+ elif n < 65536: h.append(0x80 | 126); h += struct.pack(">H", n)
84
+ else: h.append(0x80 | 127); h += struct.pack(">Q", n)
85
+ h += m; s.sendall(bytes(h) + bytes(b ^ m[i % 4] for i, b in enumerate(d)))
86
+ def ws_recv_frame(s):
87
+ def rd(n):
88
+ b = b""
89
+ while len(b) < n:
90
+ c = s.recv(n - len(b))
91
+ if not c: raise ConnectionError("closed")
92
+ b += c
93
+ return b
94
+ ln = rd(2)[1] & 0x7f
95
+ if ln == 126: ln = struct.unpack(">H", rd(2))[0]
96
+ elif ln == 127: ln = struct.unpack(">Q", rd(8))[0]
97
+ return rd(ln)
98
+ try:
99
+ pages = [t for t in http_json("/json/list")
100
+ if t.get("type") == "page" and t.get("webSocketDebuggerUrl")]
101
+ if not pages: sys.exit(2)
102
+ s = ws_connect("/devtools/" + pages[0]["webSocketDebuggerUrl"].split("/devtools/", 1)[1])
103
+ except Exception:
104
+ sys.exit(2)
105
+ req = json.dumps({"id": 1, "method": "Page.captureScreenshot",
106
+ "params": {"format": "png",
107
+ "clip": {"x": 0, "y": 0, "width": 1, "height": 1, "scale": 1}}})
108
+ try:
109
+ ws_send_text(s, req); s.settimeout(timeout)
110
+ while True:
111
+ if json.loads(ws_recv_frame(s)).get("id") == 1: sys.exit(0) # any reply = GPU alive
112
+ except (socket.timeout, TimeoutError):
113
+ sys.exit(1) # no frame within timeout = wedged
114
+ except Exception:
115
+ sys.exit(2)
116
+ PY
117
+ }
118
+
119
+ # heal_gpu: restart ONLY this profile's wedged GPU process; Chrome respawns it.
120
+ heal_gpu() {
121
+ local pids
122
+ pids=$(ps -ww -o pid,command -ax | grep -- "--type=gpu-process" \
123
+ | grep -F -- "--user-data-dir=$PROFILE" | grep -v grep | awk '{print $1}') || true
124
+ if [ -z "$pids" ]; then
125
+ echo "horse-browser: GPU wedged but no matching GPU process found — quit the browser and re-run." >&2
126
+ return 1
127
+ fi
128
+ echo "horse-browser: GPU wedged after sleep — restarting GPU process ($pids); Chrome will respawn it." >&2
129
+
130
+ # One-line record of each heal, so leaving the browser running quietly tracks how
131
+ # often the wedge actually fires. No polling daemon.
132
+ HEAL_LOG="${HORSE_BROWSER_HEAL_LOG:-$HOME/.config/horse-browser/heal.log}"
133
+ mkdir -p "$(dirname "$HEAL_LOG")" 2>/dev/null || true
134
+ printf '%s\twedge-healed\tgpu_pid=%s\n' \
135
+ "$(date '+%Y-%m-%dT%H:%M:%S%z')" "${pids//$'\n'/,}" >> "$HEAL_LOG" 2>/dev/null || true
136
+
137
+ # shellcheck disable=SC2086
138
+ kill $pids 2>/dev/null || true
139
+ for _ in $(seq 1 15); do
140
+ sleep 1
141
+ cdp_up || continue
142
+ if gpu_healthy 6; then echo "horse-browser: GPU recovered on :$PORT" >&2; return 0; fi
143
+ done
144
+ echo "horse-browser: GPU did not recover — quit the browser and re-run horse-browser." >&2
145
+ return 1
146
+ }
147
+
148
+ # reap_orphan_daemons: stop per-session browser-harness daemons (BU_NAME cc-*) whose
149
+ # anchoring Claude session has exited. Conservative by design: it only touches daemons
150
+ # whose env carries BOTH a cc-* BU_NAME (i.e. one WE named, in driver mode below) and a
151
+ # BH_ANCHOR_PID, and only when that anchor PID is gone — so it never reaps the shared
152
+ # "default" daemon, an explicit BU_NAME lane, or any live session. A browser-harness with
153
+ # the self-reap patch already cleans up its own daemons; this covers a stock one, which
154
+ # otherwise leaks one daemon per ended session. HORSE_BROWSER_REAP_DRYRUN=1 logs only.
155
+ reap_orphan_daemons() {
156
+ command -v python3 >/dev/null 2>&1 || return 0
157
+ python3 - <<'PY' || true
158
+ import os, sys, signal, subprocess
159
+ def main():
160
+ dry = bool(os.environ.get("HORSE_BROWSER_REAP_DRYRUN"))
161
+ def envof(pid):
162
+ try:
163
+ out = subprocess.run(["ps", "eww", "-o", "command=", "-p", str(pid)],
164
+ capture_output=True, text=True, timeout=3).stdout
165
+ except Exception:
166
+ return {}
167
+ e = {}
168
+ for tok in out.split():
169
+ if "=" in tok:
170
+ k, v = tok.split("=", 1)
171
+ if k.isupper():
172
+ e[k] = v
173
+ return e
174
+ def alive(pid):
175
+ try:
176
+ os.kill(int(pid), 0); return True
177
+ except Exception:
178
+ return False
179
+ try:
180
+ pids = subprocess.run(["pgrep", "-f", "browser_harness.daemon"],
181
+ capture_output=True, text=True, timeout=3).stdout.split()
182
+ except Exception:
183
+ return
184
+ for pid in pids:
185
+ e = envof(pid)
186
+ if not e.get("BU_NAME", "").startswith("cc-"): # only daemons we per-session-named
187
+ continue
188
+ anchor = e.get("BH_ANCHOR_PID")
189
+ if not anchor or alive(anchor): # unverifiable or session live -> keep
190
+ continue
191
+ if not dry:
192
+ try: os.kill(int(pid), signal.SIGTERM)
193
+ except Exception: continue
194
+ # log on stderr so it's visible but never pollutes a driver script's stdout
195
+ print(f"horse-browser: {'would reap' if dry else 'reaped'} orphan daemon "
196
+ f"pid={pid} {e['BU_NAME']} (anchor {anchor} gone)", file=sys.stderr, flush=True)
197
+ try: main()
198
+ except Exception: pass
199
+ PY
200
+ }
201
+
202
+ # maybe_reap: run reap_orphan_daemons at most once per interval (cheap stamp-file throttle),
203
+ # so it costs nothing on the hot path. HORSE_BROWSER_NO_REAP=1 disables it entirely.
204
+ maybe_reap() {
205
+ [ -n "${HORSE_BROWSER_NO_REAP:-}" ] && return 0
206
+ local stamp="${HORSE_BROWSER_REAP_STAMP:-$HOME/.config/horse-browser/.last-reap}"
207
+ local interval="${HORSE_BROWSER_REAP_INTERVAL:-600}" now last
208
+ now=$(date +%s); last=$(cat "$stamp" 2>/dev/null || echo 0)
209
+ [ $(( now - last )) -lt "$interval" ] && return 0
210
+ mkdir -p "$(dirname "$stamp")" 2>/dev/null || true
211
+ echo "$now" > "$stamp" 2>/dev/null || true
212
+ reap_orphan_daemons
213
+ }
214
+
215
+ # ── status / update ──────────────────────────────────────────────────────────
216
+ _chrome_ver() { echo "${1:-}" | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1; }
217
+
218
+ hb_status() {
219
+ local self repo ver running
220
+ self="$0"; [ -L "$self" ] && self="$(readlink "$self")"
221
+ repo="$(cd "$(dirname "$self")/.." 2>/dev/null && pwd || true)"
222
+ ver="$(git -C "$repo" describe --tags 2>/dev/null || true)"
223
+ # No git checkout (e.g. an `npm i -g` install)? Fall back to package.json's version.
224
+ if [ -z "$ver" ]; then
225
+ ver="$(python3 - "$0" <<'PY' 2>/dev/null || true
226
+ import json, os, sys
227
+ root = os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[1])))
228
+ try: print("v" + json.load(open(os.path.join(root, "package.json")))["version"])
229
+ except Exception: pass
230
+ PY
231
+ )"
232
+ fi
233
+ ver="${ver:-?}"
234
+ running="$(curl -s "http://127.0.0.1:$PORT/json/version" 2>/dev/null \
235
+ | grep -oE '"Browser": *"[^"]*"' | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)"
236
+ echo "horse-browser $ver"
237
+ printf ' %-18s %s\n' "chrome (pinned)" "$(_chrome_ver "$BIN")"
238
+ if cdp_up; then printf ' %-18s %s (live on :%s)\n' "chrome (running)" "${running:-?}" "$PORT"
239
+ else printf ' %-18s %s\n' "chrome (running)" "down"; fi
240
+ printf ' %-18s %s\n' "browser-harness" "$(browser-harness --version 2>/dev/null || echo '?')"
241
+ printf ' %-18s %s\n' "profile" "$PROFILE"
242
+ printf ' %-18s %s\n' "config" "$CONFIG"
243
+ }
244
+
245
+ hb_update() {
246
+ command -v npx >/dev/null 2>&1 || { echo "horse-browser: npx (Node) not found — needed to fetch Chrome." >&2; return 1; }
247
+ local oldver out newbin newver
248
+ oldver="$(_chrome_ver "$BIN")"
249
+ echo "horse-browser: resolving the latest Chrome for Testing (chrome@stable)…"
250
+ out="$(npx -y @puppeteer/browsers install chrome@stable --path "$CACHE")" \
251
+ || { echo "horse-browser: fetch failed." >&2; return 1; }
252
+ newbin="$(printf '%s\n' "$out" | grep '^chrome@' | tail -1 | sed 's/^[^ ]* //')"
253
+ [ -n "$newbin" ] && [ -x "$newbin" ] || { echo "horse-browser: couldn't resolve the new binary." >&2; return 1; }
254
+ newver="$(_chrome_ver "$newbin")"
255
+ if [ "$newbin" != "$BIN" ]; then
256
+ python3 - "$CONFIG" "$newbin" <<'PY'
257
+ import os, sys
258
+ cfg, newbin = sys.argv[1], sys.argv[2]
259
+ lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
260
+ out, seen = [], False
261
+ for l in lines:
262
+ if l.startswith("BROWSER_BIN="): out.append(f'BROWSER_BIN="{newbin}"'); seen = True
263
+ else: out.append(l)
264
+ if not seen: out.append(f'BROWSER_BIN="{newbin}"')
265
+ open(cfg, "w").write("\n".join(out) + "\n")
266
+ PY
267
+ echo "horse-browser: Chrome ${oldver:-?} → $newver (updated $CONFIG)"
268
+ else
269
+ echo "horse-browser: latest stable is $newver."
270
+ fi
271
+ # Apply it: restart onto the latest, unless the browser is already running that version.
272
+ local running
273
+ running="$(curl -s "http://127.0.0.1:$PORT/json/version" 2>/dev/null \
274
+ | grep -oE '"Browser": *"[^"]*"' | grep -oE '[0-9]+(\.[0-9]+){3}' | head -1)"
275
+ if [ "$running" = "$newver" ]; then echo "horse-browser: already running $newver — nothing to apply."; return 0; fi
276
+ echo "horse-browser: restarting onto ${newver}…"
277
+ osascript -e 'quit app "Google Chrome for Testing"' >/dev/null 2>&1 || true
278
+ for _ in $(seq 1 20); do cdp_up || break; sleep 0.5; done
279
+ exec "$0" # bare relaunch → re-sources config (new BIN) → launches $newver
280
+ }
281
+
282
+ # ensure_browser: bring the browser up (launching if needed) and self-heal a GPU
283
+ # wedge. Returns 0 when CDP is live and painting; non-zero if it can't recover.
284
+ ensure_browser() {
285
+ if cdp_up; then
286
+ say "horse-browser: already up on :$PORT"
287
+ local rc=0
288
+ gpu_healthy 6 || rc=$?
289
+ if [ "$rc" = 1 ]; then heal_gpu || return 1; fi # wedged — restart the GPU process
290
+ return 0 # 0 = paints fine, 2 = no page to probe
291
+ fi
292
+
293
+ if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then
294
+ echo "horse-browser: no browser configured (BROWSER_BIN missing or not executable)." >&2
295
+ echo " Fetch Chrome for Testing with 'horse-browser update', run the repo's ./install.sh," >&2
296
+ echo " or point HORSE_BROWSER_BIN at your own Chromium." >&2
297
+ return 1
298
+ fi
299
+
300
+ # Seed profile defaults that make this feel like a dedicated agent browser: pin the
301
+ # extension's toolbar button (extensions.pinned_extensions), switch on the vertical
302
+ # (left) tab strip (vertical_tabs), turn off "save password?" popups (they steal focus
303
+ # and aren't gated by focus emulation), and skip the confirm dialog when closing/
304
+ # removing/deleting a tab group (it would block an agent mid-cleanup). All live in the
305
+ # profile's Preferences, which Chrome owns — so we only touch it here, while the browser
306
+ # is stopped. The unpacked extension's id is a hash of its absolute path, so we compute
307
+ # it. Creates Preferences if absent, so a fresh install gets them all. (Pin is re-asserted
308
+ # each launch; the rest are set-if-absent, so a user who changes them keeps their choice.)
309
+ if [ -n "$EXT" ] && command -v python3 >/dev/null 2>&1; then
310
+ python3 - "$PROFILE/Default/Preferences" "$EXT" <<'PY' || true
311
+ import hashlib, json, os, sys
312
+ pref, ext = sys.argv[1], sys.argv[2]
313
+ eid = "".join(chr(ord("a") + int(c, 16)) for c in hashlib.sha256(ext.encode()).hexdigest()[:32])
314
+ os.makedirs(os.path.dirname(pref), exist_ok=True)
315
+ d = {}
316
+ if os.path.exists(pref):
317
+ try: d = json.load(open(pref))
318
+ except Exception: d = {}
319
+ tb = d.setdefault("extensions", {}).get("pinned_extensions")
320
+ if not isinstance(tb, list): tb = []
321
+ if eid not in tb: tb.append(eid)
322
+ d["extensions"]["pinned_extensions"] = tb
323
+ # Default a fresh profile to the vertical (left) tab strip — the dedicated "agent
324
+ # browser" feel. Set-if-absent, so a user who turns it off keeps it off.
325
+ if "vertical_tabs" not in d:
326
+ d["vertical_tabs"] = {"enabled": True, "collapsed_state": False,
327
+ "uncollapsed_width": 240, "enabled_first_time": True}
328
+ # Agent-browser defaults (set-if-absent, so a user can re-enable them):
329
+ d.setdefault("credentials_enable_service", False) # no "save password?" popups
330
+ _tg = d.setdefault("tab_groups", {}).setdefault("deletion", {})
331
+ for _k in ("skip_dialog_on_close_tab", "skip_dialog_on_delete", "skip_dialog_on_remove_tab"):
332
+ _tg.setdefault(_k, True) # no confirm dialog closing a tab group
333
+ json.dump(d, open(pref, "w"))
334
+ PY
335
+ fi
336
+
337
+ # NB: the post-sleep freeze is the GPU-process wedge healed above, NOT occlusion
338
+ # throttling — the old keep-alive flags (--disable-backgrounding-occluded-windows,
339
+ # --disable-renderer-backgrounding, --disable-background-timer-throttling,
340
+ # --disable-features=CalculateNativeWinOcclusion) do NOT prevent it (verified), and
341
+ # Page.captureScreenshot forces a frame on demand regardless, so they're not set.
342
+ args=( --remote-debugging-port="$PORT" --user-data-dir="$PROFILE"
343
+ --test-type # suppresses the "unsupported command-line flag" infobar
344
+ --disable-infobars # hides the Chrome-for-Testing infobar (per Playwright's chromiumSwitches)
345
+ --remote-allow-origins=* # lets the in-browser Monitor page open the CDP socket (:9223)
346
+ --no-first-run --no-default-browser-check )
347
+ [ -n "$EXT" ] && args+=( --load-extension="$EXT" )
348
+
349
+ # Launch WITHOUT stealing focus. `open -g` starts the app in the background, so
350
+ # the window appears but your current app keeps keyboard focus. Falls back to a
351
+ # direct exec if the binary isn't inside a .app bundle.
352
+ APP="${BIN%/Contents/MacOS/*}"
353
+ if [ "$APP" != "$BIN" ] && [ -d "$APP" ]; then
354
+ open -g -a "$APP" --args "${args[@]}"
355
+ else
356
+ "$BIN" "${args[@]}" >/dev/null 2>&1 &
357
+ fi
358
+
359
+ say "horse-browser: launching… (waiting for CDP :$PORT)"
360
+ for _ in $(seq 1 30); do
361
+ cdp_up && { say "horse-browser: ready on :$PORT"; return 0; }
362
+ sleep 1
363
+ done
364
+ echo "horse-browser: timed out waiting for CDP :$PORT — check the browser window." >&2
365
+ return 1
366
+ }
367
+
368
+ # horse-browser's own subcommands — handled here, never forwarded to browser-harness.
369
+ case "${1:-}" in
370
+ status) hb_status; exit 0 ;;
371
+ update) hb_update && exit 0 || exit 1 ;;
372
+ esac
373
+
374
+ ensure_browser || exit 1
375
+ maybe_reap # opportunistically clean up daemons from ended sessions (throttled)
376
+
377
+ # Driver mode: run the captured script (and/or forwarded args) through browser-harness
378
+ # against our browser. exec replaces this process, so browser-harness inherits the
379
+ # here-string as its stdin exactly as if it had been called directly.
380
+ if [ -n "$PASSTHROUGH" ]; then
381
+ if ! command -v browser-harness >/dev/null 2>&1; then
382
+ echo "horse-browser: browser-harness not found on PATH — needed to run a script." >&2
383
+ echo " Install browser-harness, or run bare 'horse-browser' just to start the browser." >&2
384
+ exit 1
385
+ fi
386
+ export BU_CDP_URL="http://127.0.0.1:$PORT"
387
+
388
+ # Per-Claude-session daemon, so concurrent agents never share one daemon and clobber
389
+ # each other's current tab. browser-harness keys the daemon off BU_NAME; we derive a
390
+ # per-session one (the same scheme browser-harness uses internally). An explicit
391
+ # BU_NAME — e.g. a subagent lane — always wins.
392
+ if [ -z "${BU_NAME:-}" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then
393
+ export BU_NAME="cc-${CLAUDE_CODE_SESSION_ID##*-}"
394
+ fi
395
+ # Anchor that daemon to this Claude session's process: a self-reaping browser-harness
396
+ # exits with the session, and reap_orphan_daemons can clean it up on a stock one. Walk
397
+ # up to the nearest `claude` ancestor; if not found, leave it unset (reaper skips it).
398
+ if [ -z "${BH_ANCHOR_PID:-}" ]; then
399
+ _ap=$$
400
+ for _ in $(seq 1 12); do
401
+ _comm=$(ps -o comm= -p "$_ap" 2>/dev/null) || break
402
+ case "$_comm" in
403
+ *claude*) export BH_ANCHOR_PID="$_ap"
404
+ export BH_ANCHOR_START="$(ps -o lstart= -p "$_ap" 2>/dev/null)"; break ;;
405
+ esac
406
+ _pp=$(ps -o ppid= -p "$_ap" 2>/dev/null | tr -d ' ')
407
+ if [ -z "$_pp" ] || [ "$_pp" -le 1 ]; then break; fi
408
+ _ap="$_pp"
409
+ done
410
+ fi
411
+
412
+ if [ -n "$script" ]; then
413
+ exec browser-harness "$@" <<<"$script" # feed the captured heredoc as stdin
414
+ else
415
+ exec browser-harness "$@" # args only (e.g. --doctor) — inherit stdin
416
+ fi
417
+ fi
418
+
419
+ exit 0
package/claude-md.sh ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # claude-md.sh — manage horse-browser's entry in your global ~/.claude/CLAUDE.md.
3
+ #
4
+ # Why a script: Claude Code loads CLAUDE.md verbatim, so its @-imports must be LITERAL
5
+ # paths — they can't run a command to resolve anything. But the packaged browser-harness
6
+ # SKILL.md lives under a Python-version-specific dir (…/lib/python3.12/site-packages/…)
7
+ # that moves whenever browser-harness is reinstalled on a different Python. So instead of
8
+ # hardcoding that brittle path, we point CLAUDE.md at a STABLE symlink we keep aimed at the
9
+ # current SKILL, and manage a marked block so updates stay idempotent and checkable.
10
+ #
11
+ # Usage:
12
+ # ./claude-md.sh print print the canonical block to stdout (compare / copy by hand)
13
+ # ./claude-md.sh check is CLAUDE.md's block + the symlink current? (exit 0=yes, 1=drifted)
14
+ # ./claude-md.sh apply (re)point the symlink + write the block into ~/.claude/CLAUDE.md
15
+ # ./claude-md.sh symlink only (re)point the stable symlink at the current SKILL
16
+ #
17
+ # Env overrides: CLAUDE_MD, HORSE_BROWSER_BH_SKILL_LINK.
18
+ set -euo pipefail
19
+
20
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ CLAUDE_MD="${CLAUDE_MD:-$HOME/.claude/CLAUDE.md}"
22
+ LINK="${HORSE_BROWSER_BH_SKILL_LINK:-$HOME/.config/horse-browser/browser-harness-skill.md}"
23
+ BEGIN="<!-- horse-browser:begin (managed by claude-md.sh — edits between markers are overwritten) -->"
24
+ END="<!-- horse-browser:end -->"
25
+
26
+ _tilde() { echo "$1" | sed "s|^$HOME|~|"; }
27
+
28
+ # Echo the current packaged browser-harness SKILL.md path by ASKING browser-harness itself
29
+ # (its own interpreter, via the CLI shebang) — version- and install-agnostic. Empty if absent.
30
+ resolve_bh_skill() {
31
+ command -v browser-harness >/dev/null 2>&1 || return 0
32
+ local py; py="$(head -1 "$(command -v browser-harness)" 2>/dev/null | sed 's/^#!//;s/ .*//')"
33
+ [ -n "$py" ] && [ -x "$py" ] || return 0
34
+ "$py" -c 'import browser_harness, os; print(os.path.join(os.path.dirname(browser_harness.__file__), "SKILL.md"))' 2>/dev/null
35
+ }
36
+
37
+ ensure_symlink() {
38
+ local target; target="$(resolve_bh_skill)"
39
+ if [ -z "$target" ] || [ ! -f "$target" ]; then
40
+ echo "claude-md: couldn't resolve the browser-harness SKILL — is browser-harness installed?" >&2
41
+ return 1
42
+ fi
43
+ mkdir -p "$(dirname "$LINK")"
44
+ ln -sfn "$target" "$LINK"
45
+ }
46
+
47
+ # The canonical block. Kept tiny on purpose — it's loaded into every session's context; the
48
+ # @-imports pull the full SKILLs on demand.
49
+ block() {
50
+ cat <<EOF
51
+ $BEGIN
52
+ # Browsing
53
+
54
+ A dedicated, persistent CDP browser + tab-grouper extension (per-session colour groups, no
55
+ focus steal). Drive it with \`horse-browser <<'PY' … PY\` — a drop-in for browser-harness;
56
+ open tabs with \`bh_open(url)\`, then navigate, scrape, render, screenshot.
57
+
58
+ @$(_tilde "$LINK")
59
+ @$(_tilde "$HERE")/SKILL.md
60
+ $END
61
+ EOF
62
+ }
63
+
64
+ # Extract the current managed block (BEGIN..END inclusive) from CLAUDE.md, or empty.
65
+ _current_block() {
66
+ [ -f "$CLAUDE_MD" ] || return 0
67
+ awk -v b="$BEGIN" -v e="$END" '$0==b{f=1} f{print} $0==e{f=0; exit}' "$CLAUDE_MD"
68
+ }
69
+
70
+ apply() {
71
+ ensure_symlink
72
+ mkdir -p "$(dirname "$CLAUDE_MD")"; [ -f "$CLAUDE_MD" ] || : > "$CLAUDE_MD"
73
+ local tmp; tmp="$(mktemp)"
74
+ # drop any existing managed block, then trim trailing blank lines
75
+ awk -v b="$BEGIN" -v e="$END" '$0==b{skip=1} !skip{print} $0==e{skip=0}' "$CLAUDE_MD" \
76
+ | awk 'NF{last=NR} {buf[NR]=$0} END{for(i=1;i<=last;i++)print buf[i]}' > "$tmp"
77
+ { [ -s "$tmp" ] && printf '\n'; block; } >> "$tmp"
78
+ mv "$tmp" "$CLAUDE_MD"
79
+ echo "claude-md: wrote block + symlink → $CLAUDE_MD"
80
+ }
81
+
82
+ check() {
83
+ local rc=0
84
+ if [ "$(_current_block)" != "$(block)" ]; then
85
+ echo "claude-md: block in $CLAUDE_MD is DRIFTED (or missing)" >&2; rc=1
86
+ fi
87
+ local want have; want="$(resolve_bh_skill)"; have="$(readlink "$LINK" 2>/dev/null || true)"
88
+ if [ -z "$want" ]; then
89
+ echo "claude-md: cannot resolve browser-harness SKILL (not installed?)" >&2; rc=1
90
+ elif [ "$have" != "$want" ]; then
91
+ echo "claude-md: symlink stale — points at '${have:-<none>}', should be '$want'" >&2; rc=1
92
+ fi
93
+ [ "$rc" = 0 ] && echo "claude-md: up to date" || echo "claude-md: run './claude-md.sh apply' to fix" >&2
94
+ return $rc
95
+ }
96
+
97
+ case "${1:-print}" in
98
+ print) block ;;
99
+ apply) apply ;;
100
+ check) check ;;
101
+ symlink) ensure_symlink && echo "claude-md: symlink $(_tilde "$LINK") → $(readlink "$LINK")" ;;
102
+ *) echo "usage: $(basename "$0") [print|apply|check|symlink]" >&2; exit 2 ;;
103
+ esac
@@ -0,0 +1,139 @@
1
+ // Agent Tab Grouper — CDP-driven.
2
+ //
3
+ // A CDP client attaches to this service worker and calls self.groupTab /
4
+ // self.activateTab / self.listTabs with Runtime.evaluate (awaitPromise: true).
5
+ // chrome.debugger.getTargets() bridges CDP targetIds to chrome.tabs.Tab ids.
6
+ // Nothing here is specific to any one driver — it's a plain tab grouper.
7
+
8
+ // MV3 service workers sleep when idle and then vanish from Target.getTargets
9
+ // until something wakes them — so a dormant SW would make groupTab/listTabs
10
+ // (and the install smoke test) silently miss us. Two keepalives:
11
+ // • a 30s no-op alarm resets the idle timer so we stay warm once running;
12
+ // • waking on tab creation covers the gap before the first alarm tick — a
13
+ // tab is created right before we're asked to group it, and at launch.
14
+ chrome.runtime.onInstalled.addListener(() => chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }));
15
+ chrome.runtime.onStartup.addListener(() => chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }));
16
+ chrome.alarms.onAlarm.addListener(() => {});
17
+ chrome.tabs.onCreated.addListener(() => {});
18
+
19
+ const COLORS = ["blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple"];
20
+
21
+ function colorForName(name) {
22
+ let h = 0;
23
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
24
+ return COLORS[h % COLORS.length];
25
+ }
26
+
27
+ // ── Session codename ── a stable identity (emoji + colour + last-4) derived from the
28
+ // session id, so any companion tool that renders the same code — a terminal statusline,
29
+ // a dashboard — matches this tab group. 48 emoji grouped 6-per-colour; colour group =
30
+ // slot / 6. The colours are Chrome's 8 tab-group colours (the binding constraint).
31
+ // Hash = FNV-1a + a murmur3 finalizer over the FULL session id; mirror this exact
32
+ // function if you render the codename in another tool, so they stay identical.
33
+ const CODE_EMOJI = ["🔥","🍎","🍓","🍒","🌹","🐞","🦊","🍊","🦁","🐯","🥕","🏀","🍋","🌻","⭐","🐝","🍌","🐥","🐸","🍀","🌵","🐢","🌲","🐍","🐬","🌊","💎","🧊","🐳","💧","🐧","🫐","🦋","🌀","🌐","🐟","🦄","🍇","🔮","🐙","🍆","👾","🌸","🐷","🦩","🍑","🌷","🌺"];
34
+ const CODE_ORDER = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"];
35
+
36
+ function hash32(s) {
37
+ let h = 0x811c9dc5;
38
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
39
+ h ^= h >>> 16; h = Math.imul(h, 0x7feb352d); h ^= h >>> 15; h = Math.imul(h, 0x846ca68b); h ^= h >>> 16;
40
+ return h >>> 0;
41
+ }
42
+
43
+ // id → { title: "🐍 0FDA", color: "green" }. A bare ≤4-char label (no session id)
44
+ // falls back to the old plain-label group, so nothing breaks if the caller is older.
45
+ function codename(id) {
46
+ id = id || "";
47
+ if (id.length <= 4) return { title: id, color: colorForName(id) };
48
+ const slot = hash32(id) % CODE_EMOJI.length;
49
+ return { title: CODE_EMOJI[slot] + " " + id.slice(-4).toUpperCase(), color: CODE_ORDER[Math.floor(slot / 6)] };
50
+ }
51
+
52
+ async function tabIdForTargetId(targetId) {
53
+ const targets = await chrome.debugger.getTargets();
54
+ const t = targets.find(x => x.id === targetId);
55
+ if (!t || !t.tabId) throw new Error(`no tab for CDP target ${targetId}`);
56
+ return t.tabId;
57
+ }
58
+
59
+ // `session` is the FULL session id (the caller passes CLAUDE_CODE_SESSION_ID); the
60
+ // codename (emoji + colour + last-4) is derived here so the browser owns its render.
61
+ self.groupTab = async (targetId, session) => {
62
+ const tabId = await tabIdForTargetId(targetId);
63
+ const tab = await chrome.tabs.get(tabId);
64
+ const { title, color } = codename(session);
65
+ const existing = await chrome.tabGroups.query({ windowId: tab.windowId, title });
66
+ if (existing.length) {
67
+ await chrome.tabs.group({ tabIds: [tabId], groupId: existing[0].id });
68
+ } else {
69
+ const gid = await chrome.tabs.group({
70
+ tabIds: [tabId],
71
+ createProperties: { windowId: tab.windowId },
72
+ });
73
+ await chrome.tabGroups.update(gid, { title, color, collapsed: false });
74
+ }
75
+ return tabId;
76
+ };
77
+
78
+ // chrome.tabs.update({active:true}) swaps the browser's visible tab without raising
79
+ // the app — replaces CDP Target.activateTarget, which calls [NSApp activate]
80
+ // and steals macOS focus while the agent works.
81
+ self.activateTab = async (targetId) => {
82
+ const tabId = await tabIdForTargetId(targetId);
83
+ await chrome.tabs.update(tabId, { active: true });
84
+ return tabId;
85
+ };
86
+
87
+ self.listTabs = async (session) => {
88
+ const { title } = codename(session);
89
+ const groups = await chrome.tabGroups.query({ title });
90
+ if (!groups.length) return [];
91
+ const groupIds = new Set(groups.map(g => g.id));
92
+ const tabs = await chrome.tabs.query({});
93
+ const targets = await chrome.debugger.getTargets();
94
+ const targetByTabId = new Map(targets.filter(t => t.tabId).map(t => [t.tabId, t.id]));
95
+ return tabs
96
+ .filter(t => groupIds.has(t.groupId))
97
+ .map(t => ({
98
+ targetId: targetByTabId.get(t.id) ?? null,
99
+ tabId: t.id,
100
+ url: t.url,
101
+ title: t.title,
102
+ lastAccessed: t.lastAccessed ?? null,
103
+ discarded: !!t.discarded,
104
+ audible: !!t.audible,
105
+ active: !!t.active,
106
+ }));
107
+ };
108
+
109
+ // The Agent Monitor — a CCTV grid of every session's live tabs. It's a normal extension
110
+ // page that does its own CDP work as a second client on :9223. We keep exactly one, pinned
111
+ // as the first tab, "permanently": Chrome already shields pinned tabs from "Close other
112
+ // tabs", and showMonitor() reopens it if it's closed any other way. Defined as a const arrow
113
+ // (not a function declaration) right after MONITOR_URL — both bind reliably in the worker.
114
+ const MONITOR_URL = chrome.runtime.getURL("monitor.html");
115
+ const showMonitor = async (focus) => {
116
+ const tabs = await chrome.tabs.query({ url: MONITOR_URL });
117
+ if (tabs.length === 0) {
118
+ await chrome.tabs.create({ url: MONITOR_URL, pinned: true, active: !!focus, index: 0 });
119
+ return;
120
+ }
121
+ if (!tabs[0].pinned) await chrome.tabs.update(tabs[0].id, { pinned: true });
122
+ if (focus) {
123
+ await chrome.tabs.update(tabs[0].id, { active: true });
124
+ await chrome.windows.update(tabs[0].windowId, { focused: true });
125
+ }
126
+ // de-dup: keep one Monitor (a closure burst can briefly mint two before either query returns)
127
+ for (const t of tabs.slice(1)) { try { await chrome.tabs.remove(t.id); } catch (e) {} }
128
+ };
129
+
130
+ chrome.action.onClicked.addListener(() => showMonitor(true)); // toolbar button → focus it
131
+ chrome.runtime.onStartup.addListener(() => showMonitor(false)); // profile start → ensure pinned
132
+ // Closed any other way (explicit close, etc.) → reopen, unless the window itself is going away.
133
+ // Called directly (no setTimeout): chrome.tabs.* keeps the MV3 worker alive to finish; a timer wouldn't.
134
+ chrome.tabs.onRemoved.addListener((_id, info) => { if (!info.isWindowClosing) showMonitor(false); });
135
+ chrome.runtime.onInstalled.addListener((details) => {
136
+ showMonitor(false); // install/update → ensure pinned
137
+ // First run on a fresh profile only: open the welcome page (not on updates/restarts).
138
+ if (details.reason === "install") chrome.tabs.create({ url: chrome.runtime.getURL("hello.html"), active: true });
139
+ });