@jobshimo/browser-link 0.16.0 → 0.18.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/dist/agent-instructions/content.js +30 -1
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/commands/about.js +4 -0
- package/dist/commands/about.js.map +1 -1
- package/dist/extension/background.js +308 -195
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow.d.ts +174 -0
- package/dist/extension/flow.js +213 -0
- package/dist/extension/flow.js.map +1 -0
- package/dist/extension/inpage/builders.d.ts +56 -2
- package/dist/extension/inpage/builders.js +201 -4
- package/dist/extension/inpage/builders.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/map/db.js +26 -0
- package/dist/map/db.js.map +1 -1
- package/dist/map/queries.d.ts +29 -0
- package/dist/map/queries.js +44 -2
- package/dist/map/queries.js.map +1 -1
- package/dist/map/tools.js +71 -8
- package/dist/map/tools.js.map +1 -1
- package/dist/permissions.js +12 -0
- package/dist/permissions.js.map +1 -1
- package/dist/tools/browser-definitions.js +185 -1
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.d.ts +37 -0
- package/dist/tools/browser-dispatch.js +221 -2
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/dist/tools/server-instructions.js +41 -3
- package/dist/tools/server-instructions.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, } from './inpage/builders.js';
|
|
1
|
+
import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, } from './inpage/builders.js';
|
|
2
2
|
import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
|
|
3
3
|
import { resolveSettleParams, settleSafely } from './settle.js';
|
|
4
|
+
import { runFlow, } from './flow.js';
|
|
4
5
|
const WS_URL = 'ws://127.0.0.1:17529';
|
|
5
6
|
const CDP_VERSION = '1.3';
|
|
6
7
|
const CONSOLE_BUFFER_MAX = 200;
|
|
@@ -528,6 +529,239 @@ async function waitForLoad(tabId, timeoutMs = 20_000) {
|
|
|
528
529
|
chrome.tabs.onUpdated.addListener(handler);
|
|
529
530
|
});
|
|
530
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* `performFind` / `performClick` / `performType` / `performPress` /
|
|
534
|
+
* `performWaitFor` — the extracted bodies of the standalone `find` /
|
|
535
|
+
* `click` / `type` / `press` / `wait_for` cases in `handleTool` below.
|
|
536
|
+
*
|
|
537
|
+
* Both the standalone `case` handlers AND `browser.flow`'s step executor
|
|
538
|
+
* (`runFlow` in `./flow.js`) call these exact functions — there is one
|
|
539
|
+
* implementation of each action, never a copy that can drift. Each
|
|
540
|
+
* returns an `ActionOutcome<T>` (`{ok:true,result} | {ok:false,error}`)
|
|
541
|
+
* instead of a wire-level `tool.response`, so the two call sites can
|
|
542
|
+
* unwrap it however they need to (the standalone case wraps it straight
|
|
543
|
+
* into a `tool.response`; `runFlow` inspects `result` for its own
|
|
544
|
+
* fail-fast rules — e.g. `wait_for`'s `matched:false` is `ok:true` here,
|
|
545
|
+
* matching the standalone tool's contract, and it is `runFlow`, not this
|
|
546
|
+
* function, that decides a non-match should stop the flow).
|
|
547
|
+
*
|
|
548
|
+
* `performType`'s `selector` is optional here (the standalone tool's JSON
|
|
549
|
+
* schema still requires it, so that path never changes): when omitted,
|
|
550
|
+
* this skips element resolution entirely and types into whatever
|
|
551
|
+
* currently has focus, mirroring how `performPress` already behaves with
|
|
552
|
+
* no `selector`. That is what lets a `browser.flow` step continue typing
|
|
553
|
+
* into a freshly-appeared, auto-focused input (e.g. a search box that
|
|
554
|
+
* opened after a preceding click) without a selector for it.
|
|
555
|
+
*/
|
|
556
|
+
async function performFind(tabId, params) {
|
|
557
|
+
try {
|
|
558
|
+
const { text } = params;
|
|
559
|
+
if (!text)
|
|
560
|
+
return { ok: false, error: 'find: text required' };
|
|
561
|
+
const value = await evaluateInTab(tabId, buildFindJs({ text, role: params.role, exact: params.exact === true }));
|
|
562
|
+
return { ok: true, result: value };
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async function performClick(tabId, params) {
|
|
569
|
+
try {
|
|
570
|
+
const { selector, force = false } = params;
|
|
571
|
+
const settle = resolveSettleParams({
|
|
572
|
+
settle_ms: params.settle_ms,
|
|
573
|
+
settle_timeout_ms: params.settle_timeout_ms,
|
|
574
|
+
});
|
|
575
|
+
const resolved = await evaluateInTab(tabId, buildClickResolveJs({ selector, force }));
|
|
576
|
+
if (!resolved.ok) {
|
|
577
|
+
if (resolved.reason === 'occluded') {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
error: `Element covered by ${resolved.blocker} — click the covering element or dismiss it first`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (resolved.reason === 'invalid-selector') {
|
|
584
|
+
return { ok: false, error: `Invalid selector "${selector}": ${resolved.error}` };
|
|
585
|
+
}
|
|
586
|
+
return { ok: false, error: `Element not found: ${selector}` };
|
|
587
|
+
}
|
|
588
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
589
|
+
type: 'mouseMoved',
|
|
590
|
+
x: resolved.x,
|
|
591
|
+
y: resolved.y,
|
|
592
|
+
});
|
|
593
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
594
|
+
type: 'mousePressed',
|
|
595
|
+
x: resolved.x,
|
|
596
|
+
y: resolved.y,
|
|
597
|
+
button: 'left',
|
|
598
|
+
clickCount: 1,
|
|
599
|
+
});
|
|
600
|
+
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
601
|
+
type: 'mouseReleased',
|
|
602
|
+
x: resolved.x,
|
|
603
|
+
y: resolved.y,
|
|
604
|
+
button: 'left',
|
|
605
|
+
clickCount: 1,
|
|
606
|
+
});
|
|
607
|
+
const settleResult = await runSettle(tabId, settle);
|
|
608
|
+
const result = { clicked: selector, tag: resolved.tag };
|
|
609
|
+
if (settleResult)
|
|
610
|
+
result.settle = settleResult;
|
|
611
|
+
return { ok: true, result };
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function performType(tabId, params) {
|
|
618
|
+
try {
|
|
619
|
+
const { selector, text, clear = false } = params;
|
|
620
|
+
const settle = resolveSettleParams({
|
|
621
|
+
settle_ms: params.settle_ms,
|
|
622
|
+
settle_timeout_ms: params.settle_timeout_ms,
|
|
623
|
+
});
|
|
624
|
+
if (selector !== undefined) {
|
|
625
|
+
const resolved = await evaluateInTab(tabId, buildTypeResolveJs({ selector, clear }));
|
|
626
|
+
if (!resolved.ok) {
|
|
627
|
+
if (resolved.reason === 'invalid-selector') {
|
|
628
|
+
return { ok: false, error: `Invalid selector "${selector}": ${resolved.error}` };
|
|
629
|
+
}
|
|
630
|
+
return { ok: false, error: `Element not found: ${selector}` };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
await cdp(tabId, 'Input.insertText', { text });
|
|
634
|
+
const settleResult = await runSettle(tabId, settle);
|
|
635
|
+
const result = { typed: text.length };
|
|
636
|
+
if (selector !== undefined)
|
|
637
|
+
result.selector = selector;
|
|
638
|
+
if (settleResult)
|
|
639
|
+
result.settle = settleResult;
|
|
640
|
+
return { ok: true, result };
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
async function performPress(tabId, params) {
|
|
647
|
+
try {
|
|
648
|
+
const key = params.key;
|
|
649
|
+
if (!key)
|
|
650
|
+
return { ok: false, error: 'press: key required' };
|
|
651
|
+
const def = resolveKey(key);
|
|
652
|
+
if (!def)
|
|
653
|
+
return { ok: false, error: `press: unrecognized key "${key}"` };
|
|
654
|
+
const modifierNames = params.modifiers ?? [];
|
|
655
|
+
const modifiers = modifiersToBitmask(modifierNames) | (def.needsShift ? MODIFIER_BITS.Shift : 0);
|
|
656
|
+
const settle = resolveSettleParams({
|
|
657
|
+
settle_ms: params.settle_ms,
|
|
658
|
+
settle_timeout_ms: params.settle_timeout_ms,
|
|
659
|
+
});
|
|
660
|
+
if (params.selector) {
|
|
661
|
+
const focused = await evaluateInTab(tabId, buildFocusJs({ selector: params.selector }));
|
|
662
|
+
if (!focused) {
|
|
663
|
+
return { ok: false, error: `Element not found: ${params.selector}` };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
await dispatchKeyEvent(tabId, def, modifiers);
|
|
667
|
+
const settleResult = await runSettle(tabId, settle);
|
|
668
|
+
const result = { key, modifiers: modifierNames };
|
|
669
|
+
if (settleResult)
|
|
670
|
+
result.settle = settleResult;
|
|
671
|
+
return { ok: true, result };
|
|
672
|
+
}
|
|
673
|
+
catch (err) {
|
|
674
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
async function performWaitFor(tabId, state, params) {
|
|
678
|
+
try {
|
|
679
|
+
const waitSelector = params.selector;
|
|
680
|
+
const waitExpression = params.expression;
|
|
681
|
+
const waitNetworkUrl = params.network_url;
|
|
682
|
+
const waitCondition = params.condition ?? 'visible';
|
|
683
|
+
// Defensive caps mirror the dispatcher's contract and keep CodeQL
|
|
684
|
+
// happy about setTimeout durations driven by request params.
|
|
685
|
+
const MAX_WAIT_TIMEOUT_MS = 30_000;
|
|
686
|
+
const MIN_POLL_MS = 50;
|
|
687
|
+
const MAX_POLL_MS = 1_000;
|
|
688
|
+
const requestedTimeout = params.timeout_ms ?? 5_000;
|
|
689
|
+
const timeoutMs = requestedTimeout < MAX_WAIT_TIMEOUT_MS ? requestedTimeout : MAX_WAIT_TIMEOUT_MS;
|
|
690
|
+
const requestedPoll = params.poll_interval_ms ?? 100;
|
|
691
|
+
const pollIntervalMs = requestedPoll < MIN_POLL_MS
|
|
692
|
+
? MIN_POLL_MS
|
|
693
|
+
: requestedPoll > MAX_POLL_MS
|
|
694
|
+
? MAX_POLL_MS
|
|
695
|
+
: requestedPoll;
|
|
696
|
+
// Build the check function based on which mode the caller picked. The
|
|
697
|
+
// dispatcher already enforced "exactly one of selector / expression /
|
|
698
|
+
// network_url" so we just pick the first defined one. If none are
|
|
699
|
+
// defined (shouldn't happen from a well-formed call), the check stays
|
|
700
|
+
// null and we return matched=false immediately.
|
|
701
|
+
let check = null;
|
|
702
|
+
if (waitSelector !== undefined) {
|
|
703
|
+
const expr = buildWaitSelectorExpr(waitSelector, waitCondition);
|
|
704
|
+
check = async () => {
|
|
705
|
+
try {
|
|
706
|
+
const result = await evaluateInTab(tabId, expr);
|
|
707
|
+
return Boolean(result);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// If the page is unreachable mid-poll, count as "not yet matched".
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
else if (waitExpression !== undefined) {
|
|
716
|
+
const wrapped = `Boolean(${waitExpression})`;
|
|
717
|
+
check = async () => {
|
|
718
|
+
try {
|
|
719
|
+
const result = await evaluateInTab(tabId, wrapped);
|
|
720
|
+
return Boolean(result);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
else if (waitNetworkUrl !== undefined) {
|
|
728
|
+
const needle = waitNetworkUrl.toLowerCase();
|
|
729
|
+
check = () => {
|
|
730
|
+
for (const id of state.networkOrder) {
|
|
731
|
+
const r = state.networkBuffer.get(id);
|
|
732
|
+
if (!r)
|
|
733
|
+
continue;
|
|
734
|
+
if (r.finished_at === undefined)
|
|
735
|
+
continue;
|
|
736
|
+
if (r.url.toLowerCase().includes(needle))
|
|
737
|
+
return Promise.resolve(true);
|
|
738
|
+
}
|
|
739
|
+
return Promise.resolve(false);
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const startedAt = Date.now();
|
|
743
|
+
let checks = 0;
|
|
744
|
+
let matched = false;
|
|
745
|
+
while (check) {
|
|
746
|
+
checks++;
|
|
747
|
+
if (await check()) {
|
|
748
|
+
matched = true;
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
752
|
+
break;
|
|
753
|
+
await sleep(pollIntervalMs);
|
|
754
|
+
}
|
|
755
|
+
const elapsedMs = Date.now() - startedAt;
|
|
756
|
+
const result = matched
|
|
757
|
+
? { matched: true, elapsed_ms: elapsedMs, checks }
|
|
758
|
+
: { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' };
|
|
759
|
+
return { ok: true, result };
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
531
765
|
async function handleTool(state, msg) {
|
|
532
766
|
const tabId = state.tabId;
|
|
533
767
|
// Params come over the wire as JSON: each field is unknown until we
|
|
@@ -572,22 +806,20 @@ async function handleTool(state, msg) {
|
|
|
572
806
|
}));
|
|
573
807
|
return { kind: 'tool.response', id: msg.id, ok: true, result: value };
|
|
574
808
|
}
|
|
809
|
+
case 'state': {
|
|
810
|
+
const value = await evaluateInTab(tabId, buildStateJs());
|
|
811
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: value };
|
|
812
|
+
}
|
|
575
813
|
case 'find': {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
return {
|
|
579
|
-
kind: 'tool.response',
|
|
580
|
-
id: msg.id,
|
|
581
|
-
ok: false,
|
|
582
|
-
error: 'find: text required',
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
const value = await evaluateInTab(tabId, buildFindJs({
|
|
586
|
-
text,
|
|
814
|
+
const outcome = await performFind(tabId, {
|
|
815
|
+
text: str('text'),
|
|
587
816
|
role: optStr('role'),
|
|
588
817
|
exact: p.exact === true,
|
|
589
|
-
})
|
|
590
|
-
|
|
818
|
+
});
|
|
819
|
+
if (!outcome.ok) {
|
|
820
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
821
|
+
}
|
|
822
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
591
823
|
}
|
|
592
824
|
case 'canvas_screenshot': {
|
|
593
825
|
const selector = optStr('selector');
|
|
@@ -629,109 +861,67 @@ async function handleTool(state, msg) {
|
|
|
629
861
|
return { kind: 'tool.response', id: msg.id, ok: true, result: body };
|
|
630
862
|
}
|
|
631
863
|
case 'click': {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (resolved.reason === 'occluded') {
|
|
638
|
-
return {
|
|
639
|
-
kind: 'tool.response',
|
|
640
|
-
id: msg.id,
|
|
641
|
-
ok: false,
|
|
642
|
-
error: `Element covered by ${resolved.blocker} — click the covering element or dismiss it first`,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
return {
|
|
646
|
-
kind: 'tool.response',
|
|
647
|
-
id: msg.id,
|
|
648
|
-
ok: false,
|
|
649
|
-
error: `Element not found: ${selector}`,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
653
|
-
type: 'mouseMoved',
|
|
654
|
-
x: resolved.x,
|
|
655
|
-
y: resolved.y,
|
|
864
|
+
const outcome = await performClick(tabId, {
|
|
865
|
+
selector: str('selector'),
|
|
866
|
+
force: p.force === true,
|
|
867
|
+
settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
|
|
868
|
+
settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
|
|
656
869
|
});
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
button: 'left',
|
|
662
|
-
clickCount: 1,
|
|
663
|
-
});
|
|
664
|
-
await cdp(tabId, 'Input.dispatchMouseEvent', {
|
|
665
|
-
type: 'mouseReleased',
|
|
666
|
-
x: resolved.x,
|
|
667
|
-
y: resolved.y,
|
|
668
|
-
button: 'left',
|
|
669
|
-
clickCount: 1,
|
|
670
|
-
});
|
|
671
|
-
const settleResult = await runSettle(tabId, settle);
|
|
672
|
-
const result = { clicked: selector, tag: resolved.tag };
|
|
673
|
-
if (settleResult)
|
|
674
|
-
result.settle = settleResult;
|
|
675
|
-
return { kind: 'tool.response', id: msg.id, ok: true, result };
|
|
870
|
+
if (!outcome.ok) {
|
|
871
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
872
|
+
}
|
|
873
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
676
874
|
}
|
|
677
875
|
case 'type': {
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
ok: false,
|
|
688
|
-
error: `Element not found: ${selector}`,
|
|
689
|
-
};
|
|
876
|
+
const outcome = await performType(tabId, {
|
|
877
|
+
selector: str('selector'),
|
|
878
|
+
text: str('text'),
|
|
879
|
+
clear: p.clear === true,
|
|
880
|
+
settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
|
|
881
|
+
settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
|
|
882
|
+
});
|
|
883
|
+
if (!outcome.ok) {
|
|
884
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
690
885
|
}
|
|
691
|
-
|
|
692
|
-
const settleResult = await runSettle(tabId, settle);
|
|
693
|
-
const result = { typed: text.length, selector };
|
|
694
|
-
if (settleResult)
|
|
695
|
-
result.settle = settleResult;
|
|
696
|
-
return { kind: 'tool.response', id: msg.id, ok: true, result };
|
|
886
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
697
887
|
}
|
|
698
888
|
case 'press': {
|
|
699
|
-
const key = optStr('key');
|
|
700
|
-
if (!key) {
|
|
701
|
-
return { kind: 'tool.response', id: msg.id, ok: false, error: 'press: key required' };
|
|
702
|
-
}
|
|
703
|
-
const def = resolveKey(key);
|
|
704
|
-
if (!def) {
|
|
705
|
-
return {
|
|
706
|
-
kind: 'tool.response',
|
|
707
|
-
id: msg.id,
|
|
708
|
-
ok: false,
|
|
709
|
-
error: `press: unrecognized key "${key}"`,
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
889
|
const modifierNames = Array.isArray(p.modifiers)
|
|
713
890
|
? p.modifiers.filter((m) => typeof m === 'string')
|
|
714
891
|
: [];
|
|
715
|
-
const
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
ok: false,
|
|
725
|
-
error: `Element not found: ${selector}`,
|
|
726
|
-
};
|
|
727
|
-
}
|
|
892
|
+
const outcome = await performPress(tabId, {
|
|
893
|
+
key: optStr('key'),
|
|
894
|
+
modifiers: modifierNames,
|
|
895
|
+
selector: optStr('selector'),
|
|
896
|
+
settle_ms: typeof p.settle_ms === 'number' ? p.settle_ms : undefined,
|
|
897
|
+
settle_timeout_ms: typeof p.settle_timeout_ms === 'number' ? p.settle_timeout_ms : undefined,
|
|
898
|
+
});
|
|
899
|
+
if (!outcome.ok) {
|
|
900
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
728
901
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
902
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
903
|
+
}
|
|
904
|
+
case 'flow': {
|
|
905
|
+
// Wire-boundary narrowing: `p.steps` is untrusted JSON. Keep only
|
|
906
|
+
// object-shaped entries here — `runFlow`'s own `stepKind()` guard
|
|
907
|
+
// re-validates each one at runtime regardless, so a malformed
|
|
908
|
+
// entry fails the flow cleanly instead of throwing.
|
|
909
|
+
const rawSteps = Array.isArray(p.steps)
|
|
910
|
+
? p.steps.filter((s) => typeof s === 'object' && s !== null)
|
|
911
|
+
: [];
|
|
912
|
+
const flowResult = await runFlow(rawSteps, {
|
|
913
|
+
performFind: (params) => performFind(tabId, params),
|
|
914
|
+
performClick: (params) => performClick(tabId, params),
|
|
915
|
+
performType: (params) => performType(tabId, params),
|
|
916
|
+
performPress: (params) => performPress(tabId, params),
|
|
917
|
+
performWaitFor: (params) => performWaitFor(tabId, state, params),
|
|
918
|
+
buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
|
|
919
|
+
});
|
|
920
|
+
// The wire-level response is ok:true whenever the flow RAN (even a
|
|
921
|
+
// failed step is a legitimate business outcome, same pattern as
|
|
922
|
+
// wait_for's matched:false) — flowResult itself carries the
|
|
923
|
+
// ok:true/false the agent reads.
|
|
924
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
|
|
735
925
|
}
|
|
736
926
|
case 'drag': {
|
|
737
927
|
const optNum = (key) => {
|
|
@@ -1019,95 +1209,18 @@ async function handleTool(state, msg) {
|
|
|
1019
1209
|
return { kind: 'tool.response', id: msg.id, ok: true, result: value };
|
|
1020
1210
|
}
|
|
1021
1211
|
case 'wait_for': {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
const MAX_WAIT_TIMEOUT_MS = 30_000;
|
|
1033
|
-
const MIN_POLL_MS = 50;
|
|
1034
|
-
const MAX_POLL_MS = 1_000;
|
|
1035
|
-
const requestedTimeout = optNum('timeout_ms') ?? 5_000;
|
|
1036
|
-
const timeoutMs = requestedTimeout < MAX_WAIT_TIMEOUT_MS ? requestedTimeout : MAX_WAIT_TIMEOUT_MS;
|
|
1037
|
-
const requestedPoll = optNum('poll_interval_ms') ?? 100;
|
|
1038
|
-
const pollIntervalMs = requestedPoll < MIN_POLL_MS
|
|
1039
|
-
? MIN_POLL_MS
|
|
1040
|
-
: requestedPoll > MAX_POLL_MS
|
|
1041
|
-
? MAX_POLL_MS
|
|
1042
|
-
: requestedPoll;
|
|
1043
|
-
// Build the check function based on which mode the caller picked.
|
|
1044
|
-
// The dispatcher already enforced "exactly one of selector / expression
|
|
1045
|
-
// / network_url" so we just pick the first defined one. If none are
|
|
1046
|
-
// defined (shouldn't happen from a well-formed call), the check stays
|
|
1047
|
-
// null and we return matched=false immediately.
|
|
1048
|
-
let check = null;
|
|
1049
|
-
if (waitSelector !== undefined) {
|
|
1050
|
-
const expr = buildWaitSelectorExpr(waitSelector, waitCondition);
|
|
1051
|
-
check = async () => {
|
|
1052
|
-
try {
|
|
1053
|
-
const result = await evaluateInTab(tabId, expr);
|
|
1054
|
-
return Boolean(result);
|
|
1055
|
-
}
|
|
1056
|
-
catch {
|
|
1057
|
-
// If the page is unreachable mid-poll, count as "not yet matched".
|
|
1058
|
-
return false;
|
|
1059
|
-
}
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
else if (waitExpression !== undefined) {
|
|
1063
|
-
const wrapped = `Boolean(${waitExpression})`;
|
|
1064
|
-
check = async () => {
|
|
1065
|
-
try {
|
|
1066
|
-
const result = await evaluateInTab(tabId, wrapped);
|
|
1067
|
-
return Boolean(result);
|
|
1068
|
-
}
|
|
1069
|
-
catch {
|
|
1070
|
-
return false;
|
|
1071
|
-
}
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
else if (waitNetworkUrl !== undefined) {
|
|
1075
|
-
const needle = waitNetworkUrl.toLowerCase();
|
|
1076
|
-
check = () => {
|
|
1077
|
-
for (const id of state.networkOrder) {
|
|
1078
|
-
const r = state.networkBuffer.get(id);
|
|
1079
|
-
if (!r)
|
|
1080
|
-
continue;
|
|
1081
|
-
if (r.finished_at === undefined)
|
|
1082
|
-
continue;
|
|
1083
|
-
if (r.url.toLowerCase().includes(needle))
|
|
1084
|
-
return Promise.resolve(true);
|
|
1085
|
-
}
|
|
1086
|
-
return Promise.resolve(false);
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
const startedAt = Date.now();
|
|
1090
|
-
let checks = 0;
|
|
1091
|
-
let matched = false;
|
|
1092
|
-
while (check) {
|
|
1093
|
-
checks++;
|
|
1094
|
-
if (await check()) {
|
|
1095
|
-
matched = true;
|
|
1096
|
-
break;
|
|
1097
|
-
}
|
|
1098
|
-
if (Date.now() - startedAt >= timeoutMs)
|
|
1099
|
-
break;
|
|
1100
|
-
await sleep(pollIntervalMs);
|
|
1212
|
+
const outcome = await performWaitFor(tabId, state, {
|
|
1213
|
+
selector: optStr('selector'),
|
|
1214
|
+
expression: optStr('expression'),
|
|
1215
|
+
network_url: optStr('network_url'),
|
|
1216
|
+
condition: optStr('condition'),
|
|
1217
|
+
timeout_ms: typeof p.timeout_ms === 'number' ? p.timeout_ms : undefined,
|
|
1218
|
+
poll_interval_ms: typeof p.poll_interval_ms === 'number' ? p.poll_interval_ms : undefined,
|
|
1219
|
+
});
|
|
1220
|
+
if (!outcome.ok) {
|
|
1221
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
|
|
1101
1222
|
}
|
|
1102
|
-
|
|
1103
|
-
return {
|
|
1104
|
-
kind: 'tool.response',
|
|
1105
|
-
id: msg.id,
|
|
1106
|
-
ok: true,
|
|
1107
|
-
result: matched
|
|
1108
|
-
? { matched: true, elapsed_ms: elapsedMs, checks }
|
|
1109
|
-
: { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' },
|
|
1110
|
-
};
|
|
1223
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: outcome.result };
|
|
1111
1224
|
}
|
|
1112
1225
|
case 'dialog_respond': {
|
|
1113
1226
|
const accept = p.accept === true;
|