@melaya/runner 1.1.9 → 1.1.12
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/dist/browserBridge.d.ts +27 -0
- package/dist/browserBridge.js +257 -10
- package/dist/connection.js +42 -0
- package/package.json +1 -1
package/dist/browserBridge.d.ts
CHANGED
|
@@ -90,6 +90,33 @@ export interface BrowserBridge {
|
|
|
90
90
|
* grant-scoped run sessions (byRunId).
|
|
91
91
|
*/
|
|
92
92
|
killInteractive(sessionId?: string): Promise<string[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Set or clear the takeover pause flag (Fix 3).
|
|
95
|
+
*
|
|
96
|
+
* Called by the connection layer when the server emits a
|
|
97
|
+
* `browser:takeover { sessionId?, paused }` socket event.
|
|
98
|
+
*
|
|
99
|
+
* While paused, `performAct` blocks all consequential act kinds
|
|
100
|
+
* (navigate, click, tap, input_text, press_key, scroll,
|
|
101
|
+
* select_option, upload_file, submit, back, forward, batch,
|
|
102
|
+
* ask_user) and returns a `BridgeError("paused_by_user", ...)` so
|
|
103
|
+
* the agent stops trying and waits. Read-only ops (get_screen_tree,
|
|
104
|
+
* screenshot, get_text, current_target, wait,
|
|
105
|
+
* wait_for_network_idle) are not blocked.
|
|
106
|
+
*
|
|
107
|
+
* If sessionId is provided, only that interactive session is paused.
|
|
108
|
+
* If sessionId is omitted, ALL sessions on this runner are paused.
|
|
109
|
+
* The flag is cleared on teardownRun / session teardown.
|
|
110
|
+
*/
|
|
111
|
+
setPaused(paused: boolean, sessionId?: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* Switch the active tab for an interactive session to the page identified
|
|
114
|
+
* by `ref` (the opaque string returned by getTabRef). The frame producer
|
|
115
|
+
* follows the new active page on its next tick.
|
|
116
|
+
*
|
|
117
|
+
* Returns true if the tab was found and switched, false otherwise.
|
|
118
|
+
*/
|
|
119
|
+
switchTabForSession(sessionId: string, ref: string): boolean;
|
|
93
120
|
shutdown(): Promise<void>;
|
|
94
121
|
}
|
|
95
122
|
export declare function startBrowserBridge(opts: {
|
package/dist/browserBridge.js
CHANGED
|
@@ -48,6 +48,9 @@ import { runSandboxedScript } from "./codeWorker.js";
|
|
|
48
48
|
// Palette: cyan #22D3EE primary accent, indigo #7C6FF0 subtle secondary,
|
|
49
49
|
// slate/navy #0F172A base. Less purple than the prior version.
|
|
50
50
|
const MELAYA_BRAND_SCRIPT = `(() => {
|
|
51
|
+
// Top document ONLY: injecting into sub-frames (e.g. the Google Sign-In
|
|
52
|
+
// iframe) rendered a stray badge on top of third-party widgets.
|
|
53
|
+
try { if (window.top !== window.self) return; } catch (e) { return; }
|
|
51
54
|
if (window.__melayaBrand) return; window.__melayaBrand = true;
|
|
52
55
|
const ID = "__melaya_brand";
|
|
53
56
|
const LOGO = "data:image/png;base64,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";
|
|
@@ -56,7 +59,7 @@ const MELAYA_BRAND_SCRIPT = `(() => {
|
|
|
56
59
|
if (document.getElementById(ID + "_border")) return;
|
|
57
60
|
const style = document.createElement("style");
|
|
58
61
|
style.id = ID + "_style";
|
|
59
|
-
style.textContent = "@keyframes melBrandPulse{0%,100%{box-shadow:inset 0 0 9px 2px rgba(34,211,238,.5),inset 0 0 30px 7px rgba(124,111,240,.18)}50%{box-shadow:inset 0 0 16px 4px rgba(34,211,238,.8),inset 0 0 54px 13px rgba(124,111,240,.32)}}@keyframes melBrandGlow{0%,100%{opacity:.78}50%{opacity:1}}#" + ID + "_border{position:fixed;inset:0;pointer-events:none;z-index:2147483647;border-radius:7px;animation:melBrandPulse 2.6s ease-in-out infinite}#" + ID + "_badge{position:fixed;top:10px;left:50%;transform:translateX(-50%);pointer-events:none;z-index:2147483647;font
|
|
62
|
+
style.textContent = "@keyframes melBrandPulse{0%,100%{box-shadow:inset 0 0 9px 2px rgba(34,211,238,.5),inset 0 0 30px 7px rgba(124,111,240,.18)}50%{box-shadow:inset 0 0 16px 4px rgba(34,211,238,.8),inset 0 0 54px 13px rgba(124,111,240,.32)}}@keyframes melBrandGlow{0%,100%{opacity:.78}50%{opacity:1}}#" + ID + "_border{position:fixed;inset:0;pointer-events:none;z-index:2147483647;border-radius:7px;animation:melBrandPulse 2.6s ease-in-out infinite}#" + ID + "_badge{position:fixed;top:10px;left:50%;transform:translateX(-50%);pointer-events:none;z-index:2147483647;font-family:-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;font-size:12px;line-height:1;white-space:nowrap;max-width:calc(100vw - 24px);box-sizing:border-box;color:#E2E8F0;background:linear-gradient(180deg,rgba(15,23,42,.94),rgba(15,23,42,.82));padding:5px 13px 5px 7px;border-radius:999px;letter-spacing:.2px;border:1px solid rgba(34,211,238,.45);box-shadow:0 0 16px 1px rgba(34,211,238,.4),0 4px 14px rgba(0,0,0,.45);display:flex;align-items:center;gap:8px;backdrop-filter:blur(6px);animation:melBrandGlow 2.6s ease-in-out infinite}#" + ID + "_logo{height:16px;width:auto;display:block;filter:drop-shadow(0 0 4px rgba(34,211,238,.55))}@media (max-width:600px){#" + ID + "_badge{font-size:10px;padding:4px 10px 4px 6px;gap:6px;top:6px}#" + ID + "_logo{height:13px}}";
|
|
60
63
|
(document.head || root).appendChild(style);
|
|
61
64
|
const border = document.createElement("div"); border.id = ID + "_border";
|
|
62
65
|
const badge = document.createElement("div"); badge.id = ID + "_badge";
|
|
@@ -96,7 +99,20 @@ const KIND_MIN_EFFECT = {
|
|
|
96
99
|
click: "read", dblclick: "read", hover: "read", scroll: "read",
|
|
97
100
|
input_text: "read", press_key: "read", select_option: "read",
|
|
98
101
|
wait: "read",
|
|
102
|
+
// Added kinds (Fix 1 + Fix 2):
|
|
103
|
+
tap: "read", // coordinate click; same effect ceiling as click
|
|
104
|
+
get_text: "read", // read-only page text extraction
|
|
105
|
+
wait_for_network_idle: "read", // read-only network wait
|
|
99
106
|
};
|
|
107
|
+
// Consequential act kinds blocked while the user has taken over the browser.
|
|
108
|
+
// Read-only kinds (get_text, get_screen_tree path, screenshot, wait,
|
|
109
|
+
// wait_for_network_idle) are intentionally excluded so the agent can observe.
|
|
110
|
+
const CONSEQUENTIAL_KINDS = new Set([
|
|
111
|
+
"navigate", "back", "forward",
|
|
112
|
+
"click", "dblclick", "hover", "tap",
|
|
113
|
+
"input_text", "press_key", "scroll", "select_option",
|
|
114
|
+
"upload_file", "submit", "ask_user", "batch",
|
|
115
|
+
]);
|
|
100
116
|
// ---------------------------------------------------------------------
|
|
101
117
|
// Bridge
|
|
102
118
|
// ---------------------------------------------------------------------
|
|
@@ -123,12 +139,32 @@ export async function startBrowserBridge(opts) {
|
|
|
123
139
|
// Active tab per run: tracks which page is currently active so tab ops
|
|
124
140
|
// and the frame-producer all follow the same page pointer.
|
|
125
141
|
const activePageByRunId = new Map();
|
|
142
|
+
// The active page for a SESSION (used by the interactive frame producer so the
|
|
143
|
+
// live mirror follows whichever tab the agent opened/switched to). The agent
|
|
144
|
+
// drives via a grant-run ATTACHED to the interactive session, so the active
|
|
145
|
+
// page is tracked under the grant-run's id; resolve it back to the session via
|
|
146
|
+
// the grant's browserSession claim. Falls back to the newest open tab.
|
|
147
|
+
function activePageForSession(sessionId) {
|
|
148
|
+
for (const [runId, pg] of activePageByRunId) {
|
|
149
|
+
if (pg.isClosed())
|
|
150
|
+
continue;
|
|
151
|
+
const r = byRunId.get(runId);
|
|
152
|
+
if (r && r.spec.grant.browserSession === sessionId)
|
|
153
|
+
return pg;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
126
157
|
// Interactive sessions: launched via launchInteractive(), tracked by the
|
|
127
158
|
// sessionId from the browser:launch socket event. The sessionManager record
|
|
128
159
|
// is stored here as well as in sessions (keyed by sessionId as runId) so
|
|
129
160
|
// getInteractiveSession and setWatchLease can find them without iterating
|
|
130
161
|
// the full grant-scoped run registry.
|
|
131
162
|
const interactiveSessions = new Map();
|
|
163
|
+
// Takeover pause state (Fix 3).
|
|
164
|
+
// A sessionId entry means that specific interactive session is paused.
|
|
165
|
+
// The sentinel key "" means ALL sessions on this runner are paused.
|
|
166
|
+
// Cleared per-session on teardown, or globally on teardownAll.
|
|
167
|
+
const pausedSessions = new Set();
|
|
132
168
|
let playwrightMod = null;
|
|
133
169
|
async function pw() {
|
|
134
170
|
if (!playwrightMod)
|
|
@@ -729,6 +765,17 @@ export async function startBrowserBridge(opts) {
|
|
|
729
765
|
}
|
|
730
766
|
throw new BridgeError("act_args_invalid", `kind '${args.kind}' needs a ref or x/y coordinates`);
|
|
731
767
|
}
|
|
768
|
+
// Returns true if the given run's session is currently paused by a takeover.
|
|
769
|
+
// The global sentinel "" pauses all sessions; a specific sessionId pauses
|
|
770
|
+
// only sessions attached to that interactive session.
|
|
771
|
+
function isActPaused(reg) {
|
|
772
|
+
if (pausedSessions.has(""))
|
|
773
|
+
return true;
|
|
774
|
+
const browserSessionId = String(reg.spec.grant?.browserSession || "");
|
|
775
|
+
if (browserSessionId && pausedSessions.has(browserSessionId))
|
|
776
|
+
return true;
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
732
779
|
async function performAct(reg, args) {
|
|
733
780
|
const kind = String(args.kind || "");
|
|
734
781
|
if (!(kind in KIND_MIN_EFFECT)) {
|
|
@@ -744,6 +791,13 @@ export async function startBrowserBridge(opts) {
|
|
|
744
791
|
if (!d.allowed)
|
|
745
792
|
throw new BridgeError(d.code, d.message);
|
|
746
793
|
}
|
|
794
|
+
// Takeover pause gate (Fix 3): block consequential acts while the user
|
|
795
|
+
// has manually taken over the browser. Read-only kinds pass through.
|
|
796
|
+
if (CONSEQUENTIAL_KINDS.has(kind) && isActPaused(reg)) {
|
|
797
|
+
throw new BridgeError("paused_by_user", "The user has taken manual control of the browser; agent actions are " +
|
|
798
|
+
"paused until they hand control back. Wait and retry, or call " +
|
|
799
|
+
"browser_get_screen_tree / browser_screenshot to observe the page.");
|
|
800
|
+
}
|
|
747
801
|
const { rec, lease } = await getLease(reg);
|
|
748
802
|
return sessions.runOnTarget(lease, async () => {
|
|
749
803
|
if (reg.cancelled)
|
|
@@ -769,6 +823,40 @@ export async function startBrowserBridge(opts) {
|
|
|
769
823
|
case "click":
|
|
770
824
|
case "dblclick":
|
|
771
825
|
case "hover": {
|
|
826
|
+
// Fix 2: if a "text" arg is present and there is no ref or x/y,
|
|
827
|
+
// resolve the text to an element point via Playwright locators
|
|
828
|
+
// (case-insensitive; prefers exact match, falls back to contains;
|
|
829
|
+
// scoped to visible elements). If nothing matches, give the model
|
|
830
|
+
// a clear directive to re-snapshot and use a @eN ref instead.
|
|
831
|
+
if (args.text && !args.ref && args.x === undefined) {
|
|
832
|
+
const label = String(args.text);
|
|
833
|
+
// Try exact visible text first, then partial, then ARIA name.
|
|
834
|
+
let locator = page.getByText(label, { exact: true }).first();
|
|
835
|
+
let box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
836
|
+
if (!box) {
|
|
837
|
+
locator = page.getByText(label, { exact: false }).first();
|
|
838
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
839
|
+
}
|
|
840
|
+
if (!box) {
|
|
841
|
+
locator = page.getByRole("button", { name: label, exact: false }).first();
|
|
842
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
843
|
+
}
|
|
844
|
+
if (!box) {
|
|
845
|
+
locator = page.getByRole("link", { name: label, exact: false }).first();
|
|
846
|
+
box = await locator.boundingBox({ timeout: 2_000 }).catch(() => null);
|
|
847
|
+
}
|
|
848
|
+
if (!box) {
|
|
849
|
+
throw new BridgeError("act_args_invalid", `click by text: no visible element matched "${label}". ` +
|
|
850
|
+
"Call browser_get_screen_tree to get fresh @eN refs and retry " +
|
|
851
|
+
"with a ref instead of a text label.");
|
|
852
|
+
}
|
|
853
|
+
const pt = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
854
|
+
if (kind === "hover")
|
|
855
|
+
await page.mouse.move(pt.x, pt.y);
|
|
856
|
+
else
|
|
857
|
+
await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
|
|
858
|
+
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) }, matched_text: label };
|
|
859
|
+
}
|
|
772
860
|
const pt = await resolveActionPoint(reg, rec, lease, args);
|
|
773
861
|
if (kind === "hover")
|
|
774
862
|
await page.mouse.move(pt.x, pt.y);
|
|
@@ -776,6 +864,14 @@ export async function startBrowserBridge(opts) {
|
|
|
776
864
|
await page.mouse.click(pt.x, pt.y, { clickCount: kind === "dblclick" ? 2 : 1 });
|
|
777
865
|
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
|
|
778
866
|
}
|
|
867
|
+
case "tap": {
|
|
868
|
+
// Fix 1: tap is a coordinate click (canvas/vision fallback).
|
|
869
|
+
// browser.py sends {x, y} as 0..1 fractions or CSS px.
|
|
870
|
+
// resolveActionPoint already handles the fraction/px heuristic.
|
|
871
|
+
const pt = await resolveActionPoint(reg, rec, lease, args);
|
|
872
|
+
await page.mouse.click(pt.x, pt.y);
|
|
873
|
+
return { at: { x: Math.round(pt.x), y: Math.round(pt.y) } };
|
|
874
|
+
}
|
|
779
875
|
case "input_text": {
|
|
780
876
|
const text = String(args.text ?? args.value ?? "");
|
|
781
877
|
if (text.length > 20_000)
|
|
@@ -801,9 +897,67 @@ export async function startBrowserBridge(opts) {
|
|
|
801
897
|
await cdp.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: binding.backendNodeId }).catch(() => { });
|
|
802
898
|
return { scrolled: "into_view" };
|
|
803
899
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
900
|
+
// Fix 1 (scroll): browser.py sends {value: "small"|"medium"|"large",
|
|
901
|
+
// text: "down"|"up"|"left"|"right"}. Map to pixel deltas.
|
|
902
|
+
// Fall back to args.dy for callers that send raw pixel deltas directly.
|
|
903
|
+
let dx = 0;
|
|
904
|
+
let dy = 0;
|
|
905
|
+
if (args.value !== undefined || args.text !== undefined) {
|
|
906
|
+
const amountMap = { small: 200, medium: 600, large: 1400 };
|
|
907
|
+
const amount = amountMap[String(args.value || "medium")] ?? 600;
|
|
908
|
+
const dir = String(args.text || "down").toLowerCase();
|
|
909
|
+
if (dir === "down")
|
|
910
|
+
dy = amount;
|
|
911
|
+
else if (dir === "up")
|
|
912
|
+
dy = -amount;
|
|
913
|
+
else if (dir === "right")
|
|
914
|
+
dx = amount;
|
|
915
|
+
else if (dir === "left")
|
|
916
|
+
dx = -amount;
|
|
917
|
+
else
|
|
918
|
+
dy = amount;
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
dy = Math.max(-4000, Math.min(4000, Number(args.dy ?? 600)));
|
|
922
|
+
}
|
|
923
|
+
await page.mouse.wheel(dx, dy);
|
|
924
|
+
return { scrolled: { dx, dy } };
|
|
925
|
+
}
|
|
926
|
+
case "get_text": {
|
|
927
|
+
// Fix 1: read-only page-text extraction.
|
|
928
|
+
// browser.py sends {ref?: "@eN"} (optional scoped element).
|
|
929
|
+
// The ref arg arrives under args.ref; if absent, read full body text.
|
|
930
|
+
const TEXT_BUDGET = 12_000;
|
|
931
|
+
let text;
|
|
932
|
+
if (args.ref) {
|
|
933
|
+
// Scope to the element's innerText via CDP object resolution.
|
|
934
|
+
const binding = sessions.resolveRef(lease, args.ref);
|
|
935
|
+
const cdp = await getCdp(rec, page);
|
|
936
|
+
const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId });
|
|
937
|
+
const result = await cdp.send("Runtime.callFunctionOn", {
|
|
938
|
+
objectId: resolved.object.objectId,
|
|
939
|
+
functionDeclaration: `function(){ return (this.innerText || this.textContent || "").trim(); }`,
|
|
940
|
+
returnByValue: true,
|
|
941
|
+
});
|
|
942
|
+
text = String(result.result.value ?? "");
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
text = await page.evaluate(() => (document.body?.innerText || document.body?.textContent || "").trim());
|
|
946
|
+
}
|
|
947
|
+
const truncated = text.length > TEXT_BUDGET;
|
|
948
|
+
return {
|
|
949
|
+
text: text.slice(0, TEXT_BUDGET) + (truncated ? `\n...[truncated; ${text.length} chars total]` : ""),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
case "wait_for_network_idle": {
|
|
953
|
+
// Fix 1: wait for network idle. browser.py sends {value: seconds}.
|
|
954
|
+
const maxMs = 30_000;
|
|
955
|
+
const timeoutMs = Math.min(Math.max(1_000, Math.round(Number(args.value ?? 10) * 1_000)), maxMs);
|
|
956
|
+
await page.waitForLoadState("networkidle", { timeout: timeoutMs }).catch(() => {
|
|
957
|
+
// Timeout is not fatal: the page may have long-polling connections.
|
|
958
|
+
// Return ok anyway; the agent will re-snapshot and observe.
|
|
959
|
+
});
|
|
960
|
+
return { network_idle: true, waited_ms: timeoutMs };
|
|
807
961
|
}
|
|
808
962
|
case "select_option": {
|
|
809
963
|
if (!args.ref)
|
|
@@ -890,11 +1044,14 @@ export async function startBrowserBridge(opts) {
|
|
|
890
1044
|
}
|
|
891
1045
|
// sessionId -> WatchLease
|
|
892
1046
|
const watchLeases = new Map();
|
|
893
|
-
function postFrame(lease, jpegB64) {
|
|
1047
|
+
function postFrame(lease, jpegB64, tabs) {
|
|
894
1048
|
// Never send if the lease has been deactivated concurrently.
|
|
895
1049
|
if (!lease.active)
|
|
896
1050
|
return;
|
|
897
|
-
const
|
|
1051
|
+
const bodyObj = { sessionId: lease.sessionId, runId: lease.runId, jpegB64 };
|
|
1052
|
+
if (tabs !== undefined)
|
|
1053
|
+
bodyObj.tabs = tabs;
|
|
1054
|
+
const body = JSON.stringify(bodyObj);
|
|
898
1055
|
const url = lease.framePostUrl;
|
|
899
1056
|
try {
|
|
900
1057
|
const u = new URL(url);
|
|
@@ -933,13 +1090,15 @@ export async function startBrowserBridge(opts) {
|
|
|
933
1090
|
const isInteractive = interactiveSessions.has(lease.runId) ||
|
|
934
1091
|
(interactiveSessions.has(lease.sessionId) && lease.sessionId === lease.runId);
|
|
935
1092
|
if (isInteractive) {
|
|
936
|
-
// Interactive:
|
|
1093
|
+
// Interactive: mirror the tab the agent is CURRENTLY driving (active
|
|
1094
|
+
// page for this session), else the newest open tab, else the first.
|
|
937
1095
|
const context = rec.context;
|
|
938
1096
|
if (!context) {
|
|
939
1097
|
lease.active = false;
|
|
940
1098
|
return;
|
|
941
1099
|
}
|
|
942
|
-
|
|
1100
|
+
const openPages = context.pages().filter((p) => !p.isClosed());
|
|
1101
|
+
let page = activePageForSession(lease.sessionId) ?? openPages[openPages.length - 1] ?? context.pages()[0];
|
|
943
1102
|
if (!page) {
|
|
944
1103
|
try {
|
|
945
1104
|
page = await context.newPage();
|
|
@@ -950,8 +1109,12 @@ export async function startBrowserBridge(opts) {
|
|
|
950
1109
|
}
|
|
951
1110
|
}
|
|
952
1111
|
// Lease the page under a stable "interactive" ref. leaseTarget is
|
|
953
|
-
// idempotent (returns existing lease if the ref already exists)
|
|
1112
|
+
// idempotent (returns existing lease if the ref already exists); when the
|
|
1113
|
+
// active tab changed, repoint the existing lease at the new page so the
|
|
1114
|
+
// captured frames follow it.
|
|
954
1115
|
targetLease = sessions.leaseTarget(rec, "interactive", page);
|
|
1116
|
+
if (targetLease.page !== page)
|
|
1117
|
+
targetLease.page = page;
|
|
955
1118
|
}
|
|
956
1119
|
else {
|
|
957
1120
|
const reg = byRunId.get(lease.runId);
|
|
@@ -970,11 +1133,46 @@ export async function startBrowserBridge(opts) {
|
|
|
970
1133
|
return;
|
|
971
1134
|
}
|
|
972
1135
|
}
|
|
1136
|
+
// Compute the tab list for this session (interactive sessions only).
|
|
1137
|
+
// Done defensively after the screenshot so a failing title/url read
|
|
1138
|
+
// never blocks frame delivery. Sent only when the list changed.
|
|
1139
|
+
let tabsPayload = undefined;
|
|
1140
|
+
if (isInteractive) {
|
|
1141
|
+
const context = rec.context;
|
|
1142
|
+
if (context) {
|
|
1143
|
+
const openPages = context.pages().filter((p) => !p.isClosed());
|
|
1144
|
+
const activePg = activePageForSession(lease.sessionId) ?? openPages[openPages.length - 1] ?? null;
|
|
1145
|
+
// Build tab descriptors synchronously where possible; fetch titles async.
|
|
1146
|
+
const tabDescs = openPages.map((p) => ({
|
|
1147
|
+
ref: getTabRef(p),
|
|
1148
|
+
title: "",
|
|
1149
|
+
url: p.url().slice(0, 200),
|
|
1150
|
+
active: p === activePg,
|
|
1151
|
+
}));
|
|
1152
|
+
// Fetch titles best-effort; skip any that reject or time out.
|
|
1153
|
+
await Promise.all(openPages.map(async (p, i) => {
|
|
1154
|
+
try {
|
|
1155
|
+
tabDescs[i].title = (await p.title()).slice(0, 80);
|
|
1156
|
+
}
|
|
1157
|
+
catch { /* skip */ }
|
|
1158
|
+
}));
|
|
1159
|
+
const tabsJson = JSON.stringify(tabDescs);
|
|
1160
|
+
if (tabsJson !== lease.lastTabsJson) {
|
|
1161
|
+
lease.lastTabsJson = tabsJson;
|
|
1162
|
+
tabsPayload = tabDescs;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
973
1166
|
const { image_b64: jpegB64 } = await takeScreenshot(targetLease);
|
|
974
1167
|
const changed = jpegB64 !== lease.lastJpegB64;
|
|
975
1168
|
if (changed) {
|
|
976
1169
|
lease.lastJpegB64 = jpegB64;
|
|
977
|
-
postFrame(lease, jpegB64);
|
|
1170
|
+
postFrame(lease, jpegB64, tabsPayload);
|
|
1171
|
+
}
|
|
1172
|
+
else if (tabsPayload !== undefined) {
|
|
1173
|
+
// Tabs changed but frame did not; send frame anyway so the tab update
|
|
1174
|
+
// is not dropped (the frame body carries the tabs field).
|
|
1175
|
+
postFrame(lease, jpegB64, tabsPayload);
|
|
978
1176
|
}
|
|
979
1177
|
// Adaptive FPS: 250 ms (4 fps) when changed, 500 ms (2 fps) when
|
|
980
1178
|
// the page is static. The cap is 4 fps; do not go lower than 2 fps
|
|
@@ -1063,6 +1261,7 @@ export async function startBrowserBridge(opts) {
|
|
|
1063
1261
|
active: true,
|
|
1064
1262
|
timer: null,
|
|
1065
1263
|
lastJpegB64: "",
|
|
1264
|
+
lastTabsJson: "",
|
|
1066
1265
|
};
|
|
1067
1266
|
watchLeases.set(sessionId, lease);
|
|
1068
1267
|
log(`[watch] frame producer started for session ${sessionId.slice(0, 16)} run=${matchedRunId.slice(0, 10)}`);
|
|
@@ -1442,6 +1641,8 @@ export async function startBrowserBridge(opts) {
|
|
|
1442
1641
|
// producer) are preserved.
|
|
1443
1642
|
interactive.targets.delete(reg.spec.grant.target.ref);
|
|
1444
1643
|
}
|
|
1644
|
+
// Clear any per-session pause flag so a restarted run is not stuck.
|
|
1645
|
+
pausedSessions.delete(browserSessionId);
|
|
1445
1646
|
}
|
|
1446
1647
|
log(`browser run detached (non-owner): ${runId.slice(0, 10)} reason=${reason}`);
|
|
1447
1648
|
}
|
|
@@ -1454,6 +1655,8 @@ export async function startBrowserBridge(opts) {
|
|
|
1454
1655
|
}
|
|
1455
1656
|
};
|
|
1456
1657
|
const teardownAll = async (reason) => {
|
|
1658
|
+
// Clear all pause flags so nothing is left paused after a full teardown.
|
|
1659
|
+
pausedSessions.clear();
|
|
1457
1660
|
// Tear down grant-scoped run sessions.
|
|
1458
1661
|
const runIds = [...byRunId.keys()];
|
|
1459
1662
|
// Tear down interactive sessions (those not already covered by byRunId).
|
|
@@ -1472,6 +1675,18 @@ export async function startBrowserBridge(opts) {
|
|
|
1472
1675
|
}),
|
|
1473
1676
|
]);
|
|
1474
1677
|
};
|
|
1678
|
+
// setPaused: set or clear the takeover pause flag (Fix 3).
|
|
1679
|
+
// sessionId present -> pause only that interactive session.
|
|
1680
|
+
// sessionId absent -> pause/resume all sessions (global sentinel "").
|
|
1681
|
+
const setPaused = (paused, sessionId) => {
|
|
1682
|
+
const key = sessionId ?? "";
|
|
1683
|
+
if (paused) {
|
|
1684
|
+
pausedSessions.add(key);
|
|
1685
|
+
}
|
|
1686
|
+
else {
|
|
1687
|
+
pausedSessions.delete(key);
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1475
1690
|
// killInteractive: close one or all interactive sessions (emergency stop).
|
|
1476
1691
|
// Does NOT touch grant-scoped run sessions in byRunId.
|
|
1477
1692
|
const killInteractive = async (sessionId) => {
|
|
@@ -1504,6 +1719,36 @@ export async function startBrowserBridge(opts) {
|
|
|
1504
1719
|
}));
|
|
1505
1720
|
return closed;
|
|
1506
1721
|
};
|
|
1722
|
+
// switchTabForSession: repoints the active page for an interactive session
|
|
1723
|
+
// so the frame producer + page-event handling follow the new tab.
|
|
1724
|
+
// The caller supplies the opaque tab ref from getTabRef.
|
|
1725
|
+
function switchTabForSession(sessionId, ref) {
|
|
1726
|
+
const rec = interactiveSessions.get(sessionId);
|
|
1727
|
+
if (!rec || rec.state === "closed" || rec.state === "crashed")
|
|
1728
|
+
return false;
|
|
1729
|
+
const context = rec.context;
|
|
1730
|
+
if (!context)
|
|
1731
|
+
return false;
|
|
1732
|
+
const pages = context.pages().filter((p) => !p.isClosed());
|
|
1733
|
+
const target = pages.find((p) => getTabRef(p) === ref);
|
|
1734
|
+
if (!target)
|
|
1735
|
+
return false;
|
|
1736
|
+
// Update the active page for every grant-run attached to this session,
|
|
1737
|
+
// and also force the watch-lease lastTabsJson to "" so the next frame
|
|
1738
|
+
// tick re-broadcasts the updated active flag to the client.
|
|
1739
|
+
for (const [runId, reg] of byRunId) {
|
|
1740
|
+
if (String(reg.spec.grant.browserSession || "") === sessionId) {
|
|
1741
|
+
activePageByRunId.set(runId, target);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// Reset the tab change sentinel so the next frame pick broadcasts the new state.
|
|
1745
|
+
const wl = watchLeases.get(sessionId);
|
|
1746
|
+
if (wl)
|
|
1747
|
+
wl.lastTabsJson = "";
|
|
1748
|
+
// Bring the tab to front best-effort (headed sessions).
|
|
1749
|
+
target.bringToFront().catch(() => { });
|
|
1750
|
+
return true;
|
|
1751
|
+
}
|
|
1507
1752
|
return {
|
|
1508
1753
|
url: `http://127.0.0.1:${port}`,
|
|
1509
1754
|
port,
|
|
@@ -1517,6 +1762,8 @@ export async function startBrowserBridge(opts) {
|
|
|
1517
1762
|
getInteractiveSession,
|
|
1518
1763
|
setWatchLease,
|
|
1519
1764
|
killInteractive,
|
|
1765
|
+
setPaused,
|
|
1766
|
+
switchTabForSession,
|
|
1520
1767
|
async shutdown() {
|
|
1521
1768
|
// Stop all watch-lease producers before closing sessions.
|
|
1522
1769
|
for (const sid of [...watchLeases.keys()])
|
package/dist/connection.js
CHANGED
|
@@ -360,6 +360,48 @@ export async function connect(opts) {
|
|
|
360
360
|
console.log(chalk.yellow(` ! browser:kill error: ${e?.message || e}`));
|
|
361
361
|
}
|
|
362
362
|
});
|
|
363
|
+
// ── BrowserControlPage takeover pause (browser:takeover) ─────────────
|
|
364
|
+
// The server emits browser:takeover { sessionId?, paused } when the user
|
|
365
|
+
// clicks "Take control" (paused:true) or "Hand back" (paused:false) in
|
|
366
|
+
// the BrowserControlPage UI. While paused, the bridge blocks all
|
|
367
|
+
// consequential acts (click, navigate, input_text, etc.) with a
|
|
368
|
+
// paused_by_user BridgeError so the agent waits instead of racing with
|
|
369
|
+
// the user. Read-only ops (get_screen_tree, screenshot, get_text, wait)
|
|
370
|
+
// are not blocked. If sessionId is absent the pause applies to all
|
|
371
|
+
// sessions on this runner.
|
|
372
|
+
socket.on("browser:takeover", (payload) => {
|
|
373
|
+
const sessionId = payload?.sessionId ? String(payload.sessionId) : undefined;
|
|
374
|
+
const paused = Boolean(payload?.paused);
|
|
375
|
+
if (!browserBridge) {
|
|
376
|
+
if (opts.verbose)
|
|
377
|
+
console.log(chalk.gray(" [browser-takeover] bridge not ready, ignoring"));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
browserBridge.setPaused(paused, sessionId);
|
|
381
|
+
if (opts.verbose) {
|
|
382
|
+
console.log(chalk.gray(` [browser-takeover] paused=${paused} sessionId=${sessionId ? sessionId.slice(0, 16) : "(all)"}`));
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
// ── BrowserControlPage tab switch (browser:switch-tab) ───────────────
|
|
386
|
+
// The server emits browser:switch-tab { sessionId, ref } when the user
|
|
387
|
+
// clicks a non-active tab in the live-mirror tab strip. The bridge
|
|
388
|
+
// repoints the active page for that interactive session so the frame
|
|
389
|
+
// producer and any attached agent ops follow the new tab.
|
|
390
|
+
socket.on("browser:switch-tab", (payload) => {
|
|
391
|
+
const sessionId = String(payload?.sessionId || "");
|
|
392
|
+
const ref = String(payload?.ref || "");
|
|
393
|
+
if (!sessionId || !ref)
|
|
394
|
+
return;
|
|
395
|
+
if (!browserBridge) {
|
|
396
|
+
if (opts.verbose)
|
|
397
|
+
console.log(chalk.gray(" [browser-switch-tab] bridge not ready, ignoring"));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const switched = browserBridge.switchTabForSession(sessionId, ref);
|
|
401
|
+
if (opts.verbose) {
|
|
402
|
+
console.log(chalk.gray(` [browser-switch-tab] sessionId=${sessionId.slice(0, 16)} ref=${ref} switched=${switched}`));
|
|
403
|
+
}
|
|
404
|
+
});
|
|
363
405
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
364
406
|
setInterval(() => {
|
|
365
407
|
if (socket.connected)
|