@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,541 @@
|
|
|
1
|
+
// monitor.js — Horse Browser console.
|
|
2
|
+
//
|
|
3
|
+
// This page is a *second* CDP client on :9223 (browser-harness is the first).
|
|
4
|
+
// Modern Chrome allows multiple flat sessions per target, so we can screencast a
|
|
5
|
+
// tab while an agent drives it.
|
|
6
|
+
//
|
|
7
|
+
// THE WALL IS A FIXED SET OF SLOTS. The grid is N² slots (2×2 / 3×3), row-major:
|
|
8
|
+
// slot 0 = top-left … slot N²-1 = bottom-right. A tab, once placed in a slot,
|
|
9
|
+
// STAYS in that slot — activity inside a shown tab never reorders the wall, it
|
|
10
|
+
// only lights the slot's "active" outline in place. The order changes only when
|
|
11
|
+
// MEMBERSHIP changes, governed by computeSlots():
|
|
12
|
+
// • a closed tab frees its slot; the hottest waiting tab fills it (no guard)
|
|
13
|
+
// • a hotter waiting tab evicts the COLDEST slot — but only once that slot has
|
|
14
|
+
// been idle longer than HOT_MS, so a wall full of busy agents stays frozen
|
|
15
|
+
// and changes at most once something idles.
|
|
16
|
+
// The sidebar mirrors this: slot order on top (numbered, hard-linked to cells),
|
|
17
|
+
// a divider, then the bench (everything not shown) ranked by recency.
|
|
18
|
+
|
|
19
|
+
const CDP = "http://127.0.0.1:9223";
|
|
20
|
+
|
|
21
|
+
// How long a slot stays "hot" after its last activity: it glows, and it is
|
|
22
|
+
// PROTECTED from eviction for this long. Tunable — we may try 5s / 10s / 15s.
|
|
23
|
+
const HOT_MS = 15000;
|
|
24
|
+
|
|
25
|
+
const GROUP_COLORS = {
|
|
26
|
+
grey: "#9aa0a6", blue: "#8ab4f8", red: "#f28b82", yellow: "#fdd663",
|
|
27
|
+
green: "#81c995", pink: "#ff8bcb", purple: "#c58af9", cyan: "#78d9ec", orange: "#fcad70",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const grid = document.getElementById("grid");
|
|
31
|
+
const emptyEl = document.getElementById("empty");
|
|
32
|
+
const gridSel = document.getElementById("gridsel");
|
|
33
|
+
const tabListEl = document.getElementById("tablist");
|
|
34
|
+
const statTabs = document.getElementById("stat-tabs");
|
|
35
|
+
const collapseBtn = document.getElementById("collapse");
|
|
36
|
+
|
|
37
|
+
let ws, msgId = 0;
|
|
38
|
+
const pending = new Map(); // request id → resolver
|
|
39
|
+
const sessionHandlers = new Map(); // CDP sessionId → frame handler
|
|
40
|
+
const panes = new Map(); // targetId → pane (only for tabs currently on the wall)
|
|
41
|
+
let slots = []; // slot index → targetId | null (the persistent wall)
|
|
42
|
+
|
|
43
|
+
// Every CDP request times out. Right after a browser restart the freshly-restored
|
|
44
|
+
// renderer sometimes never answers an attach/screencast call; without a timeout
|
|
45
|
+
// that promise hangs forever, and because reconcile() holds a non-reentrant lock
|
|
46
|
+
// across `await watch()`, the WHOLE monitor deadlocks (empty sidebar + grid until
|
|
47
|
+
// a manual reload). On timeout we resolve to a benign error shape so callers that
|
|
48
|
+
// read `.result` simply treat it as "no data" and move on.
|
|
49
|
+
function send(method, params, sessionId, timeoutMs = 8000) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const id = ++msgId;
|
|
52
|
+
let done = false;
|
|
53
|
+
const finish = (v) => { if (done) return; done = true; pending.delete(id); resolve(v); };
|
|
54
|
+
pending.set(id, finish);
|
|
55
|
+
const timer = setTimeout(() => finish({ error: { message: "timeout", method } }), timeoutMs);
|
|
56
|
+
// wrap so clearing the timer happens whenever the real response arrives
|
|
57
|
+
const wrapped = (v) => { clearTimeout(timer); finish(v); };
|
|
58
|
+
pending.set(id, wrapped);
|
|
59
|
+
try { ws.send(JSON.stringify({ id, method, params: params || {}, sessionId })); }
|
|
60
|
+
catch (e) { clearTimeout(timer); finish({ error: { message: String(e), method } }); }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function connect() {
|
|
65
|
+
const ver = await (await fetch(CDP + "/json/version")).json();
|
|
66
|
+
ws = new WebSocket(ver.webSocketDebuggerUrl);
|
|
67
|
+
ws.onmessage = (e) => {
|
|
68
|
+
const m = JSON.parse(e.data);
|
|
69
|
+
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
|
|
70
|
+
if (m.sessionId && sessionHandlers.has(m.sessionId)) sessionHandlers.get(m.sessionId)(m);
|
|
71
|
+
};
|
|
72
|
+
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── per-tab activity tracking ────────────────────────────────────────────────
|
|
76
|
+
// lastActivity[tabId] = ms of the most recent ACTION we can observe: a navigation
|
|
77
|
+
// or load (chrome.tabs.onUpdated url/status — fires for background tabs too, so it
|
|
78
|
+
// catches agent-driven navigations), a focus (onActivated), or the tab's own
|
|
79
|
+
// lastAccessed as a seed for history before the monitor opened. We deliberately do
|
|
80
|
+
// NOT count screencast repaints or passive title/favicon churn — those keep a tab
|
|
81
|
+
// painting without any real action and would peg every visible tab to "active".
|
|
82
|
+
const lastActivity = new Map();
|
|
83
|
+
function noteActivity(tabId, ts) {
|
|
84
|
+
if (tabId == null) return;
|
|
85
|
+
if (ts > (lastActivity.get(tabId) || 0)) lastActivity.set(tabId, ts);
|
|
86
|
+
}
|
|
87
|
+
chrome.tabs.onUpdated.addListener((tabId, ci) => {
|
|
88
|
+
if (ci.url || ci.status) noteActivity(tabId, Date.now()); // navigation / (re)load only
|
|
89
|
+
});
|
|
90
|
+
chrome.tabs.onActivated.addListener(({ tabId }) => noteActivity(tabId, Date.now()));
|
|
91
|
+
chrome.tabs.onCreated.addListener((t) => noteActivity(t.id, Date.now()));
|
|
92
|
+
|
|
93
|
+
function ago(ts) {
|
|
94
|
+
if (!ts) return "—";
|
|
95
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
96
|
+
if ((Date.now() - ts) < HOT_MS) return "active";
|
|
97
|
+
if (s < 60) return s + "s";
|
|
98
|
+
const m = Math.floor(s / 60); if (m < 60) return m + "m";
|
|
99
|
+
const h = Math.floor(m / 60); if (h < 24) return h + "h";
|
|
100
|
+
return Math.floor(h / 24) + "d";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Every real web tab (http/https/file), with its observed last-activity time.
|
|
104
|
+
async function discover() {
|
|
105
|
+
const groups = await chrome.tabGroups.query({});
|
|
106
|
+
const gById = new Map(groups.map((g) => [g.id, g]));
|
|
107
|
+
const tabs = await chrome.tabs.query({});
|
|
108
|
+
const targets = await chrome.debugger.getTargets();
|
|
109
|
+
const tgtByTab = new Map(targets.filter((t) => t.tabId).map((t) => [t.tabId, t.id]));
|
|
110
|
+
const self = location.href;
|
|
111
|
+
const out = tabs
|
|
112
|
+
.filter((t) => tgtByTab.has(t.id) && /^(https?|file):/.test(t.url || "") && t.url !== self)
|
|
113
|
+
.map((t) => {
|
|
114
|
+
const g = gById.get(t.groupId);
|
|
115
|
+
let host = ""; try { host = new URL(t.url).hostname.replace(/^www\./, ""); } catch {}
|
|
116
|
+
if (!lastActivity.has(t.id)) lastActivity.set(t.id, t.lastAccessed || Date.now());
|
|
117
|
+
else if (t.lastAccessed && t.lastAccessed > lastActivity.get(t.id)) lastActivity.set(t.id, t.lastAccessed);
|
|
118
|
+
return {
|
|
119
|
+
tabId: t.id, targetId: tgtByTab.get(t.id), title: t.title, url: t.url, host,
|
|
120
|
+
favIconUrl: t.favIconUrl || "", active: !!t.active, index: t.index,
|
|
121
|
+
groupId: (t.groupId != null && t.groupId >= 0) ? t.groupId : -1,
|
|
122
|
+
groupTitle: g ? g.title : "",
|
|
123
|
+
color: g ? (GROUP_COLORS[g.color] || "#9aa0a6") : "#5b6470",
|
|
124
|
+
lastActivity: lastActivity.get(t.id),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
// prune activity for tabs that no longer exist
|
|
128
|
+
const live = new Set(out.map((a) => a.tabId));
|
|
129
|
+
for (const k of [...lastActivity.keys()]) if (!live.has(k)) lastActivity.delete(k);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// <computeSlots> — PURE slot assignment. No DOM, no CDP, no globals (except it
|
|
134
|
+
// reads HOT_MS via the `guard` arg). Unit-tested in Node by slicing this region
|
|
135
|
+
// out of the file and eval'ing it, so the test exercises the real shipped logic.
|
|
136
|
+
// prev : previous slots array (targetId|null per cell)
|
|
137
|
+
// agents : [{ targetId, lastActivity }, ...] (the live tab set)
|
|
138
|
+
// cap : number of slots (N²)
|
|
139
|
+
// now : Date.now()
|
|
140
|
+
// guard : ms a slot must be idle before it can be evicted (HOT_MS)
|
|
141
|
+
// → new slots array of length `cap`.
|
|
142
|
+
function computeSlots(prev, agents, cap, now, guard) {
|
|
143
|
+
const byId = new Map(agents.map((a) => [a.targetId, a]));
|
|
144
|
+
// resize: keep slot→tab bindings for indices that still exist; pad/truncate.
|
|
145
|
+
const slots = new Array(cap).fill(null);
|
|
146
|
+
for (let i = 0; i < Math.min(cap, prev.length); i++) slots[i] = prev[i];
|
|
147
|
+
// prune closed tabs and any duplicate (safety) — a tab lives in at most one slot.
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
for (let i = 0; i < cap; i++) {
|
|
150
|
+
const id = slots[i];
|
|
151
|
+
if (id == null) continue;
|
|
152
|
+
if (!byId.has(id) || seen.has(id)) slots[i] = null;
|
|
153
|
+
else seen.add(id);
|
|
154
|
+
}
|
|
155
|
+
const benchSorted = () => {
|
|
156
|
+
const placed = new Set();
|
|
157
|
+
for (const id of slots) if (id != null) placed.add(id);
|
|
158
|
+
return agents.filter((a) => !placed.has(a.targetId))
|
|
159
|
+
.sort((x, y) => y.lastActivity - x.lastActivity);
|
|
160
|
+
};
|
|
161
|
+
// 1) fill EMPTY slots, lowest index first, with the hottest bench tab — no guard.
|
|
162
|
+
for (let i = 0; i < cap; i++) {
|
|
163
|
+
if (slots[i] != null) continue;
|
|
164
|
+
const b = benchSorted();
|
|
165
|
+
if (!b.length) break;
|
|
166
|
+
slots[i] = b[0].targetId;
|
|
167
|
+
}
|
|
168
|
+
// 2) GUARDED swaps: the hottest bench tab evicts the COLDEST slot iff it is
|
|
169
|
+
// strictly hotter AND that slot has been idle longer than `guard`. Repeats
|
|
170
|
+
// until no warranted swap (bounded by cap — converges, can't oscillate:
|
|
171
|
+
// an evicted tab is the coldest, so it can't be hotter than a remaining slot).
|
|
172
|
+
for (let loop = 0; loop < cap; loop++) {
|
|
173
|
+
const b = benchSorted();
|
|
174
|
+
if (!b.length) break;
|
|
175
|
+
const hottest = b[0];
|
|
176
|
+
let coldIdx = -1, coldAct = Infinity;
|
|
177
|
+
for (let i = 0; i < cap; i++) {
|
|
178
|
+
const id = slots[i];
|
|
179
|
+
if (id == null) continue;
|
|
180
|
+
const act = byId.get(id).lastActivity;
|
|
181
|
+
if (act < coldAct) { coldAct = act; coldIdx = i; }
|
|
182
|
+
}
|
|
183
|
+
if (coldIdx < 0) break;
|
|
184
|
+
if (hottest.lastActivity > coldAct && (now - coldAct) > guard) {
|
|
185
|
+
slots[coldIdx] = hottest.targetId; // evict cold; newcomer inherits the exact slot
|
|
186
|
+
} else break;
|
|
187
|
+
}
|
|
188
|
+
return slots;
|
|
189
|
+
}
|
|
190
|
+
// </computeSlots>
|
|
191
|
+
|
|
192
|
+
// The hottest bench tab that WANTS a slot but is blocked by the guard (its target
|
|
193
|
+
// slot is still hot). Marked "standing by" in the sidebar. null if none waiting.
|
|
194
|
+
function standbyId(slots, agents, byId, now, guard) {
|
|
195
|
+
const placed = new Set();
|
|
196
|
+
for (const id of slots) if (id != null) placed.add(id);
|
|
197
|
+
const bench = agents.filter((a) => !placed.has(a.targetId))
|
|
198
|
+
.sort((x, y) => y.lastActivity - x.lastActivity);
|
|
199
|
+
if (!bench.length) return null;
|
|
200
|
+
let coldAct = Infinity;
|
|
201
|
+
for (const id of slots) if (id != null) coldAct = Math.min(coldAct, byId.get(id).lastActivity);
|
|
202
|
+
const h = bench[0];
|
|
203
|
+
if (h.lastActivity > coldAct && (now - coldAct) <= guard) return h.targetId;
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── sidebar: tabs grouped by session — Chrome-style coloured groups ────────────
|
|
208
|
+
// (emoji + code header, a coloured spine, favicon rows). The wall is computed
|
|
209
|
+
// separately; this is display-only. Reorders are minimal so rows don't re-animate.
|
|
210
|
+
const groupEls = new Map(); // groupId → { wrap, name, head, tabsEl }
|
|
211
|
+
const tabEls = new Map(); // targetId → row element
|
|
212
|
+
|
|
213
|
+
function makeGroup() {
|
|
214
|
+
const wrap = document.createElement("div");
|
|
215
|
+
wrap.className = "sb-group";
|
|
216
|
+
wrap.innerHTML =
|
|
217
|
+
'<div class="sb-ghead"><span class="sb-gname"></span>' +
|
|
218
|
+
'<svg class="sb-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10l4-4 4 4"/></svg></div>' +
|
|
219
|
+
'<div class="sb-gtabs"></div>';
|
|
220
|
+
return { wrap, name: wrap.querySelector(".sb-gname"), head: wrap.querySelector(".sb-ghead"), tabsEl: wrap.querySelector(".sb-gtabs") };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function makeEntry(key) {
|
|
224
|
+
const el = document.createElement("div");
|
|
225
|
+
el.className = "tab";
|
|
226
|
+
el.dataset.key = key;
|
|
227
|
+
el.innerHTML =
|
|
228
|
+
'<span class="tab-ico"><img alt="" /></span>' +
|
|
229
|
+
'<span class="tab-title"></span>' +
|
|
230
|
+
'<span class="slot-no"></span>' +
|
|
231
|
+
'<span class="tab-dot"></span>';
|
|
232
|
+
el.querySelector("img").addEventListener("error", (e) => {
|
|
233
|
+
e.target.removeAttribute("src"); e.target.closest(".tab-ico").classList.remove("has-ico");
|
|
234
|
+
});
|
|
235
|
+
return el;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function setIco(el, favIconUrl) {
|
|
239
|
+
const ico = el.querySelector(".tab-ico");
|
|
240
|
+
const img = ico.querySelector("img");
|
|
241
|
+
if (favIconUrl && /^(https?:|data:)/.test(favIconUrl)) {
|
|
242
|
+
if (img.getAttribute("src") !== favIconUrl) img.src = favIconUrl;
|
|
243
|
+
ico.classList.add("has-ico");
|
|
244
|
+
} else { img.removeAttribute("src"); ico.classList.remove("has-ico"); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderSidebar(slots, agents, byId, cap) {
|
|
248
|
+
try {
|
|
249
|
+
// which wall cell each tab sits in (1-based, matches the pane's slot badge)
|
|
250
|
+
const slotNo = new Map();
|
|
251
|
+
slots.forEach((id, i) => { if (id != null) slotNo.set(id, i + 1); });
|
|
252
|
+
// bucket tabs by their session tab-group
|
|
253
|
+
const groups = new Map(); // gid → { gid, title, color, tabs, minIndex }
|
|
254
|
+
for (const a of agents) {
|
|
255
|
+
if (!groups.has(a.groupId)) groups.set(a.groupId, { gid: a.groupId, title: a.groupTitle || "", color: a.color, tabs: [], minIndex: a.index });
|
|
256
|
+
const g = groups.get(a.groupId); g.tabs.push(a); if (a.index < g.minIndex) g.minIndex = a.index;
|
|
257
|
+
}
|
|
258
|
+
const glist = [...groups.values()];
|
|
259
|
+
// tab-strip order, within a group and across groups, so it matches Chrome's own sidebar;
|
|
260
|
+
// ungrouped (-1) sinks to the bottom
|
|
261
|
+
for (const g of glist) g.tabs.sort((x, y) => x.index - y.index);
|
|
262
|
+
glist.sort((A, B) => (A.gid === -1) - (B.gid === -1) || A.minIndex - B.minIndex);
|
|
263
|
+
|
|
264
|
+
// drop groups / rows that vanished
|
|
265
|
+
const liveGids = new Set(glist.map((g) => g.gid));
|
|
266
|
+
for (const [gid, ge] of [...groupEls]) if (!liveGids.has(gid)) { ge.wrap.remove(); groupEls.delete(gid); }
|
|
267
|
+
const liveTids = new Set(agents.map((a) => a.targetId));
|
|
268
|
+
for (const [tid, el] of [...tabEls]) if (!liveTids.has(tid)) { el.remove(); tabEls.delete(tid); }
|
|
269
|
+
|
|
270
|
+
glist.forEach((g) => {
|
|
271
|
+
let ge = groupEls.get(g.gid);
|
|
272
|
+
if (!ge) { ge = makeGroup(); groupEls.set(g.gid, ge); }
|
|
273
|
+
ge.wrap.classList.toggle("ungrouped", g.gid === -1);
|
|
274
|
+
ge.name.textContent = g.title;
|
|
275
|
+
ge.head.style.background = g.color;
|
|
276
|
+
ge.tabsEl.style.boxShadow = "inset 2px 0 0 " + g.color;
|
|
277
|
+
g.tabs.forEach((a) => {
|
|
278
|
+
let el = tabEls.get(a.targetId);
|
|
279
|
+
if (!el) { el = makeEntry(a.targetId); tabEls.set(a.targetId, el); }
|
|
280
|
+
if (el.parentElement !== ge.tabsEl) ge.tabsEl.appendChild(el);
|
|
281
|
+
el.style.setProperty("--c", a.color);
|
|
282
|
+
setIco(el, a.favIconUrl);
|
|
283
|
+
el.querySelector(".tab-title").textContent = a.title || a.host || a.url;
|
|
284
|
+
const n = slotNo.get(a.targetId);
|
|
285
|
+
el.classList.toggle("on-wall", n != null);
|
|
286
|
+
el.querySelector(".slot-no").textContent = n != null ? String(n) : "";
|
|
287
|
+
el.classList.toggle("active", (Date.now() - a.lastActivity) < HOT_MS);
|
|
288
|
+
});
|
|
289
|
+
// minimal reorder of rows within the group
|
|
290
|
+
let node = ge.tabsEl.firstChild;
|
|
291
|
+
for (const a of g.tabs) {
|
|
292
|
+
const el = tabEls.get(a.targetId);
|
|
293
|
+
if (node === el) node = node.nextSibling;
|
|
294
|
+
else ge.tabsEl.insertBefore(el, node);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
// minimal reorder of the groups themselves
|
|
298
|
+
let gnode = tabListEl.firstChild;
|
|
299
|
+
for (const g of glist) {
|
|
300
|
+
const ge = groupEls.get(g.gid);
|
|
301
|
+
if (gnode === ge.wrap) gnode = gnode.nextSibling;
|
|
302
|
+
else tabListEl.insertBefore(ge.wrap, gnode);
|
|
303
|
+
}
|
|
304
|
+
statTabs.textContent = agents.length;
|
|
305
|
+
} catch (e) { /* a sidebar hiccup must never break the wall reconcile */ }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── screencast panes ─────────────────────────────────────────────────────────
|
|
309
|
+
function draw(pane, b64) {
|
|
310
|
+
const img = pane.img;
|
|
311
|
+
img.onload = () => {
|
|
312
|
+
const c = pane.canvas;
|
|
313
|
+
if (c.width !== img.naturalWidth) { c.width = img.naturalWidth; c.height = img.naturalHeight; }
|
|
314
|
+
pane.ctx.drawImage(img, 0, 0);
|
|
315
|
+
};
|
|
316
|
+
img.src = "data:image/jpeg;base64," + b64;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function forceCapture(pane) {
|
|
320
|
+
if (!pane.sid) return;
|
|
321
|
+
try {
|
|
322
|
+
// fromSurface:true captures even a backgrounded tab. startScreencast only
|
|
323
|
+
// streams frames for the VISIBLE tab, so background panes (and every tab right
|
|
324
|
+
// after a browser restart) are painted by this still capture, not the stream.
|
|
325
|
+
const r = await send("Page.captureScreenshot",
|
|
326
|
+
{ format: "jpeg", quality: 50, fromSurface: true }, pane.sid);
|
|
327
|
+
if (r.result && r.result.data) draw(pane, r.result.data);
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function makePane(info) {
|
|
332
|
+
const el = document.createElement("div");
|
|
333
|
+
el.className = "pane";
|
|
334
|
+
el.dataset.tid = info.targetId; // lets reconcile detect & drop orphaned duplicate panes
|
|
335
|
+
el.style.setProperty("--accent", info.color);
|
|
336
|
+
el.innerHTML = '<canvas></canvas><div class="slot-badge"></div>';
|
|
337
|
+
el.addEventListener("click", async () => {
|
|
338
|
+
await chrome.tabs.update(info.tabId, { active: true });
|
|
339
|
+
const tab = await chrome.tabs.get(info.tabId);
|
|
340
|
+
chrome.windows.update(tab.windowId, { focused: true });
|
|
341
|
+
});
|
|
342
|
+
grid.appendChild(el);
|
|
343
|
+
const canvas = el.querySelector("canvas");
|
|
344
|
+
return { el, badge: el.querySelector(".slot-badge"),
|
|
345
|
+
canvas, ctx: canvas.getContext("2d"), img: new Image(), lastFrame: 0 };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function watch(info) {
|
|
349
|
+
// Short timeout (not the default 8s): right after a browser restart the target
|
|
350
|
+
// subsystem isn't ready to attach for ~1–3s and simply doesn't answer. Failing
|
|
351
|
+
// fast lets the 1s poll retry and succeed once the browser settles, instead of
|
|
352
|
+
// every pane stalling the full 8s backstop (which read as a ~20s cold start).
|
|
353
|
+
const FAST = 2500;
|
|
354
|
+
const att = await send("Target.attachToTarget", { targetId: info.targetId, flatten: true }, undefined, FAST);
|
|
355
|
+
const sid = att.result && att.result.sessionId;
|
|
356
|
+
if (!sid) return; // not ready yet — next poll retries
|
|
357
|
+
const pane = makePane(info);
|
|
358
|
+
pane.sid = sid;
|
|
359
|
+
pane.tabId = info.tabId;
|
|
360
|
+
panes.set(info.targetId, pane);
|
|
361
|
+
sessionHandlers.set(sid, (m) => {
|
|
362
|
+
if (m.method !== "Page.screencastFrame") return;
|
|
363
|
+
pane.lastFrame = Date.now();
|
|
364
|
+
draw(pane, m.params.data); // NB: a repaint is NOT activity — pages paint passively
|
|
365
|
+
send("Page.screencastFrameAck", { sessionId: m.params.sessionId }, sid);
|
|
366
|
+
});
|
|
367
|
+
await send("Page.enable", {}, sid);
|
|
368
|
+
// fire the first-paint still immediately (don't await) so the pane shows ASAP,
|
|
369
|
+
// and kick off the screencast in parallel — neither blocks the other.
|
|
370
|
+
forceCapture(pane); // still: fastest first paint; the 2s heartbeat repaints thereafter
|
|
371
|
+
send("Page.startScreencast",
|
|
372
|
+
{ format: "jpeg", quality: 50, maxWidth: 900, maxHeight: 560, everyNthFrame: 1 }, sid);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function removePane(targetId) {
|
|
376
|
+
const p = panes.get(targetId);
|
|
377
|
+
if (!p) return;
|
|
378
|
+
if (p.sid) {
|
|
379
|
+
try { send("Page.stopScreencast", {}, p.sid); } catch {}
|
|
380
|
+
try { send("Target.detachFromTarget", { sessionId: p.sid }); } catch {}
|
|
381
|
+
sessionHandlers.delete(p.sid);
|
|
382
|
+
}
|
|
383
|
+
p.el.remove();
|
|
384
|
+
panes.delete(targetId);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Reconcile the live tab set into the fixed-slot wall. Non-reentrant: watch()
|
|
388
|
+
// awaits an attach before it registers its pane, so two overlapping runs could
|
|
389
|
+
// both attach the SAME target — spawning a duplicate pane whose loser orphans.
|
|
390
|
+
// The lock serialises runs; a request mid-flight re-queues.
|
|
391
|
+
let reconcileBusy = false, reconcileQueued = false;
|
|
392
|
+
async function reconcile() {
|
|
393
|
+
if (reconcileBusy) { reconcileQueued = true; return; }
|
|
394
|
+
reconcileBusy = true;
|
|
395
|
+
try {
|
|
396
|
+
const agents = await discover();
|
|
397
|
+
const byId = new Map(agents.map((a) => [a.targetId, a]));
|
|
398
|
+
const N = +gridSel.value || 2;
|
|
399
|
+
const cap = N * N;
|
|
400
|
+
slots = computeSlots(slots, agents, cap, Date.now(), HOT_MS);
|
|
401
|
+
|
|
402
|
+
// Render the sidebar FIRST — it's pure DOM (no CDP), so the tab list always
|
|
403
|
+
// appears even if pane attaches are slow or failing after a restart.
|
|
404
|
+
renderSidebar(slots, agents, byId, cap);
|
|
405
|
+
statTabs.textContent = agents.length;
|
|
406
|
+
|
|
407
|
+
const shownSet = new Set(slots.filter((id) => id != null));
|
|
408
|
+
for (const tid of [...panes.keys()]) if (!shownSet.has(tid)) removePane(tid);
|
|
409
|
+
|
|
410
|
+
// Attach all missing panes IN PARALLEL. Each watch() is ~3 serial CDP
|
|
411
|
+
// round-trips; doing them one-at-a-time made a fresh 3×3 wall take ~30 trips
|
|
412
|
+
// back-to-back. Concurrently, wall-clock ≈ the slowest single tab. The Map
|
|
413
|
+
// key + orphan sweep already prevent duplicate panes, and the reconcile lock
|
|
414
|
+
// means only one batch runs at a time, so parallel attach is safe.
|
|
415
|
+
await Promise.all(slots.map((id) => (id != null && !panes.has(id))
|
|
416
|
+
? watch(byId.get(id)).catch(() => {}) : null));
|
|
417
|
+
|
|
418
|
+
for (let i = 0; i < cap; i++) {
|
|
419
|
+
const id = slots[i];
|
|
420
|
+
if (id == null) continue;
|
|
421
|
+
const a = byId.get(id);
|
|
422
|
+
const p = panes.get(id);
|
|
423
|
+
if (!p) continue;
|
|
424
|
+
// pin the pane to its slot's grid cell (row-major). Recomputed each tick so a
|
|
425
|
+
// grid-size change (slot index → different cell) repositions correctly.
|
|
426
|
+
p.el.style.gridColumn = (i % N) + 1;
|
|
427
|
+
p.el.style.gridRow = Math.floor(i / N) + 1;
|
|
428
|
+
p.badge.textContent = i + 1;
|
|
429
|
+
p.el.classList.toggle("is-active", (Date.now() - a.lastActivity) < HOT_MS);
|
|
430
|
+
}
|
|
431
|
+
// orphan sweep: drop any pane DOM that isn't the tracked pane for its tab
|
|
432
|
+
for (const el of grid.querySelectorAll(".pane")) {
|
|
433
|
+
const tracked = panes.get(el.dataset.tid);
|
|
434
|
+
if (!tracked || tracked.el !== el) el.remove();
|
|
435
|
+
}
|
|
436
|
+
emptyEl.hidden = panes.size > 0;
|
|
437
|
+
layout();
|
|
438
|
+
} finally {
|
|
439
|
+
reconcileBusy = false;
|
|
440
|
+
if (reconcileQueued) { reconcileQueued = false; scheduleReconcile(); }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let reconcileTimer;
|
|
445
|
+
function scheduleReconcile() { clearTimeout(reconcileTimer); reconcileTimer = setTimeout(reconcile, 250); }
|
|
446
|
+
for (const ev of [chrome.tabs.onCreated, chrome.tabs.onRemoved, chrome.tabs.onUpdated,
|
|
447
|
+
chrome.tabs.onMoved, chrome.tabs.onAttached, chrome.tabs.onDetached,
|
|
448
|
+
chrome.tabs.onActivated,
|
|
449
|
+
chrome.tabGroups.onCreated, chrome.tabGroups.onUpdated, chrome.tabGroups.onRemoved]) {
|
|
450
|
+
ev.addListener(scheduleReconcile);
|
|
451
|
+
}
|
|
452
|
+
// 1s poll: re-runs the slot machine so an idling slot crosses the HOT_MS guard and
|
|
453
|
+
// any standing-by tab can enter promptly — and keeps the relative times ticking.
|
|
454
|
+
setInterval(reconcile, 1000);
|
|
455
|
+
|
|
456
|
+
// ── fixed N×N layout ─────────────────────────────────────────────────────────
|
|
457
|
+
// The wall is a uniform N×N grid that always FILLS the stage: every cell is an
|
|
458
|
+
// equal fraction (1fr), so a cell's size depends only on the stage size and N —
|
|
459
|
+
// never on what's playing in it. Each feed is letterboxed inside its own cell by
|
|
460
|
+
// the canvas's object-fit:contain, so panes never jump or re-ratio as frames
|
|
461
|
+
// arrive. CSS reflows the 1fr cells on its own as the stage resizes (window
|
|
462
|
+
// resize, sidebar collapse), so there's no per-frame JS layout to drive.
|
|
463
|
+
function layout() {
|
|
464
|
+
const N = +gridSel.value || 2;
|
|
465
|
+
grid.style.gridTemplateColumns = `repeat(${N}, 1fr)`;
|
|
466
|
+
grid.style.gridTemplateRows = `repeat(${N}, 1fr)`;
|
|
467
|
+
}
|
|
468
|
+
// persist the grid size across reloads / browser restarts
|
|
469
|
+
const GRID_KEY = "hb-monitor-grid";
|
|
470
|
+
const savedGrid = localStorage.getItem(GRID_KEY);
|
|
471
|
+
if (savedGrid === "2" || savedGrid === "3") gridSel.value = savedGrid;
|
|
472
|
+
gridSel.addEventListener("change", () => { // grid size changes the cap → resize slots
|
|
473
|
+
localStorage.setItem(GRID_KEY, gridSel.value);
|
|
474
|
+
layout();
|
|
475
|
+
reconcile();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── sidebar collapse + hide (persisted) ──────────────────────────────────────
|
|
479
|
+
// Two independent affordances:
|
|
480
|
+
// • collapse — shrink between the full panel (248px) and the icon rail (60px);
|
|
481
|
+
// toggled by the chevron or the logo.
|
|
482
|
+
// • hide — remove the panel entirely so the wall fills the window; only a
|
|
483
|
+
// floating horse remains, which reveals it again. The collapsed/expanded
|
|
484
|
+
// state is kept while hidden, so revealing restores it. ⌘B toggles hide.
|
|
485
|
+
const COLLAPSE_KEY = "hb-monitor-collapsed";
|
|
486
|
+
const HIDDEN_KEY = "hb-monitor-hidden";
|
|
487
|
+
const logoEl = document.querySelector(".logo");
|
|
488
|
+
const hideBtn = document.getElementById("hide");
|
|
489
|
+
const revealBtn = document.getElementById("reveal");
|
|
490
|
+
|
|
491
|
+
function setCollapsed(c) {
|
|
492
|
+
document.body.classList.toggle("collapsed", c);
|
|
493
|
+
localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
|
|
494
|
+
// the 1fr cells track the stage width as the sidebar animates — pure CSS, no JS.
|
|
495
|
+
}
|
|
496
|
+
function setHidden(h) {
|
|
497
|
+
document.body.classList.toggle("sb-hidden", h);
|
|
498
|
+
localStorage.setItem(HIDDEN_KEY, h ? "1" : "0");
|
|
499
|
+
}
|
|
500
|
+
const isHidden = () => document.body.classList.contains("sb-hidden");
|
|
501
|
+
|
|
502
|
+
// Collapsed by default (maximise the wall); expanded only if the user explicitly chose it.
|
|
503
|
+
if (localStorage.getItem(COLLAPSE_KEY) !== "0") document.body.classList.add("collapsed");
|
|
504
|
+
if (localStorage.getItem(HIDDEN_KEY) === "1") document.body.classList.add("sb-hidden");
|
|
505
|
+
|
|
506
|
+
collapseBtn.addEventListener("click", () => setCollapsed(!document.body.classList.contains("collapsed")));
|
|
507
|
+
logoEl.addEventListener("click", () => setCollapsed(!document.body.classList.contains("collapsed")));
|
|
508
|
+
hideBtn.addEventListener("click", () => setHidden(true));
|
|
509
|
+
revealBtn.addEventListener("click", () => setHidden(false));
|
|
510
|
+
// "?" → open the welcome page (same one shown on first install)
|
|
511
|
+
document.getElementById("help").addEventListener("click", () => {
|
|
512
|
+
const url = chrome.runtime.getURL("hello.html");
|
|
513
|
+
if (chrome.tabs && chrome.tabs.create) chrome.tabs.create({ url, active: true });
|
|
514
|
+
else window.open(url, "_blank");
|
|
515
|
+
});
|
|
516
|
+
// ⌘B / Ctrl+B fully hides / reveals the panel (VS Code / Chrome side-panel feel)
|
|
517
|
+
window.addEventListener("keydown", (e) => {
|
|
518
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && (e.key === "b" || e.key === "B")) {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
setHidden(!isHidden());
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// quietly refresh thumbnails for tabs that aren't streaming frames (no blink)
|
|
525
|
+
setInterval(() => {
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
for (const p of panes.values())
|
|
528
|
+
if (now - p.lastFrame > 4000 && now - (p.lastPing || 0) > 5000) { p.lastPing = now; forceCapture(p); }
|
|
529
|
+
}, 2000);
|
|
530
|
+
|
|
531
|
+
(async () => {
|
|
532
|
+
layout();
|
|
533
|
+
let ok = false;
|
|
534
|
+
for (let i = 0; i < 60 && !ok; i++) {
|
|
535
|
+
try { await connect(); ok = true; }
|
|
536
|
+
catch { statTabs.textContent = "…"; await new Promise((r) => setTimeout(r, 1000)); }
|
|
537
|
+
}
|
|
538
|
+
if (!ok) { statTabs.textContent = "—"; return; }
|
|
539
|
+
ws.onclose = () => setTimeout(() => location.reload(), 1500); // browser restart → reconnect
|
|
540
|
+
await reconcile();
|
|
541
|
+
})();
|