@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
|
@@ -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
|
+
});
|