aipeek 0.2.6 → 0.2.7
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/README.md +2 -1
- package/dist/{chunk-XA2LT6I4.js → chunk-37VLLZIU.js} +104 -23
- package/dist/{chunk-5ZZYOETF.cjs → chunk-STYCUT23.cjs} +105 -24
- package/dist/index.cjs +2 -2
- package/dist/index.js +1 -1
- package/dist/plugin.cjs +4 -2
- package/dist/plugin.js +5 -3
- package/package.json +1 -1
- package/src/client/client.ts +12 -1
- package/src/core/action.ts +82 -3
- package/src/server/plugin.ts +125 -24
package/README.md
CHANGED
|
@@ -39,9 +39,10 @@ All endpoints are available on your Vite dev server:
|
|
|
39
39
|
| `GET /__aipeek/{section}/{index}` | Detail for a specific item in a section |
|
|
40
40
|
| `GET /__aipeek/{section}?full` | Full detail (no truncation) |
|
|
41
41
|
| `GET /__aipeek/dom[?scope=Name\|?sel=css]` | Semantic DOM — UI as text (see below) |
|
|
42
|
+
| `GET /__aipeek/query?sel=css` | Read-side twin of `sel=`: a selector's live `count` + each match's `text`/`visible`/`attrs` (role, `data-state`, `aria-*`/`data-*`, value, disabled). Per-element assertions without `/eval`. |
|
|
42
43
|
| `GET /__aipeek/{action}?...` | Drive the page (see Actions) |
|
|
43
44
|
| `POST /__aipeek/chain` | Run a JSON array of actions in one round-trip (see Actions) |
|
|
44
|
-
| `GET\|POST /__aipeek/eval` | Run arbitrary JS in the page (`?code=` or POST body); returns the result. Escape hatch for what typed endpoints can't do. |
|
|
45
|
+
| `GET\|POST /__aipeek/eval` | Run arbitrary JS in the page (`?code=` or POST body); returns the result. Escape hatch for what typed endpoints can't do — for count/text/state/attr checks reach for `/query` first. |
|
|
45
46
|
|
|
46
47
|
### Perception layers — UI as text, not pixels
|
|
47
48
|
|
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "url";
|
|
|
6
6
|
import { transformSync } from "esbuild";
|
|
7
7
|
|
|
8
8
|
// src/core/action.ts
|
|
9
|
-
var TYPES = ["click", "fill", "press", "wait", "screenshot"];
|
|
9
|
+
var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query"];
|
|
10
10
|
function resolveAction(type, args) {
|
|
11
11
|
if (!TYPES.includes(type))
|
|
12
12
|
return { valid: false, error: `unknown action: ${type}` };
|
|
@@ -14,6 +14,8 @@ function resolveAction(type, args) {
|
|
|
14
14
|
switch (type) {
|
|
15
15
|
case "click":
|
|
16
16
|
return hasTarget ? { valid: true } : { valid: false, error: "click needs sel= or text=" };
|
|
17
|
+
case "realclick":
|
|
18
|
+
return hasTarget || args.x !== void 0 && args.y !== void 0 ? { valid: true } : { valid: false, error: "realclick needs sel=, text=, or x= & y=" };
|
|
17
19
|
case "fill":
|
|
18
20
|
if (!hasTarget)
|
|
19
21
|
return { valid: false, error: "fill needs sel= or text=" };
|
|
@@ -26,6 +28,8 @@ function resolveAction(type, args) {
|
|
|
26
28
|
return hasTarget ? { valid: true } : { valid: false, error: "wait needs sel= or text=" };
|
|
27
29
|
case "screenshot":
|
|
28
30
|
return { valid: true };
|
|
31
|
+
case "query":
|
|
32
|
+
return args.sel ? { valid: true } : { valid: false, error: "query needs sel=" };
|
|
29
33
|
default:
|
|
30
34
|
return { valid: false, error: `unknown action: ${type}` };
|
|
31
35
|
}
|
|
@@ -797,8 +801,12 @@ curl ${base}/console # console logs (errors, warnings, info)
|
|
|
797
801
|
curl ${base}/network # fetch/XHR requests with status and timing
|
|
798
802
|
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
799
803
|
curl ${base}/state # registered store snapshots
|
|
804
|
+
curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
|
|
800
805
|
\`\`\`
|
|
801
806
|
|
|
807
|
+
\`/query\` is the read-side twin of click/fill's \`sel=\` \u2014 assert on a specific element
|
|
808
|
+
(how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
|
|
809
|
+
|
|
802
810
|
\`/screen\` projects the whole UI to a few state variables \u2014 start there, not \`/ui\`. Append
|
|
803
811
|
\`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
|
|
804
812
|
|
|
@@ -835,34 +843,33 @@ curl -X POST ${base}/chain -d '[
|
|
|
835
843
|
\`\`\`
|
|
836
844
|
|
|
837
845
|
**Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
|
|
838
|
-
JS in the page and returns the result \u2014 for
|
|
846
|
+
JS in the page and returns the result \u2014 for what the typed endpoints can't do (install listeners,
|
|
847
|
+
read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
|
|
839
848
|
|
|
840
849
|
aipeek auto-detects errors after HMR and prints them to the terminal \u2014 watch for \`[aipeek]\` messages.
|
|
841
850
|
`;
|
|
842
851
|
}
|
|
843
852
|
var START_TAG = "<!-- AIPEEK:START -->";
|
|
844
853
|
var END_TAG = "<!-- AIPEEK:END -->";
|
|
845
|
-
function
|
|
846
|
-
const path = resolve(root, "CLAUDE.md");
|
|
854
|
+
function renderClaudeMd(existing, port) {
|
|
847
855
|
const block = `${START_TAG}
|
|
848
856
|
${aipeekSnippet(port).trim()}
|
|
849
857
|
${END_TAG}
|
|
850
858
|
`;
|
|
859
|
+
if (existing === null)
|
|
860
|
+
return block;
|
|
861
|
+
const si = existing.indexOf(START_TAG);
|
|
862
|
+
const ei = existing.indexOf(END_TAG);
|
|
863
|
+
if (si !== -1 && ei !== -1)
|
|
864
|
+
return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length);
|
|
865
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
866
|
+
return `${existing}${sep}
|
|
867
|
+
${block}`;
|
|
868
|
+
}
|
|
869
|
+
function injectClaudeMd(root, port) {
|
|
870
|
+
const path = resolve(root, "CLAUDE.md");
|
|
851
871
|
try {
|
|
852
|
-
|
|
853
|
-
writeFileSync(path, block);
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
856
|
-
const content = readFileSync(path, "utf-8");
|
|
857
|
-
const si = content.indexOf(START_TAG);
|
|
858
|
-
const ei = content.indexOf(END_TAG);
|
|
859
|
-
if (si !== -1 && ei !== -1) {
|
|
860
|
-
writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length));
|
|
861
|
-
return;
|
|
862
|
-
}
|
|
863
|
-
const sep = content.endsWith("\n") ? "" : "\n";
|
|
864
|
-
writeFileSync(path, `${content}${sep}
|
|
865
|
-
${block}`);
|
|
872
|
+
writeFileSync(path, renderClaudeMd(existsSync(path) ? readFileSync(path, "utf-8") : null, port));
|
|
866
873
|
} catch {
|
|
867
874
|
}
|
|
868
875
|
}
|
|
@@ -873,6 +880,27 @@ function aipeekPlugin() {
|
|
|
873
880
|
let pushTimer;
|
|
874
881
|
const pendingActions = /* @__PURE__ */ new Map();
|
|
875
882
|
let actionId = 0;
|
|
883
|
+
const cdpQueue = [];
|
|
884
|
+
let cdpWaiter = null;
|
|
885
|
+
const cdpResults = /* @__PURE__ */ new Map();
|
|
886
|
+
let cdpId = 0;
|
|
887
|
+
function runCdpClick(x, y, button) {
|
|
888
|
+
const id = ++cdpId;
|
|
889
|
+
const cmd = { id, x, y, button };
|
|
890
|
+
return new Promise((resolve2, reject) => {
|
|
891
|
+
cdpResults.set(id, resolve2);
|
|
892
|
+
if (cdpWaiter) {
|
|
893
|
+
cdpWaiter(cmd);
|
|
894
|
+
cdpWaiter = null;
|
|
895
|
+
} else {
|
|
896
|
+
cdpQueue.push(cmd);
|
|
897
|
+
}
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
if (cdpResults.delete(id))
|
|
900
|
+
reject(new Error("cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)"));
|
|
901
|
+
}, 1e4);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
876
904
|
let pendingDom = null;
|
|
877
905
|
let pendingScreen = null;
|
|
878
906
|
const pendingEvals = /* @__PURE__ */ new Map();
|
|
@@ -933,6 +961,18 @@ function aipeekPlugin() {
|
|
|
933
961
|
};
|
|
934
962
|
}, fullMs);
|
|
935
963
|
}
|
|
964
|
+
async function runAction(type, args) {
|
|
965
|
+
const result = await sendAction(type, args);
|
|
966
|
+
lastRaw = null;
|
|
967
|
+
if (type === "realclick" && result.ok && result.ui === void 0) {
|
|
968
|
+
const cdp = await runCdpClick(result.x, result.y, args.button ?? "left");
|
|
969
|
+
if (!cdp.ok)
|
|
970
|
+
return { ok: false, error: `cdp click failed: ${cdp.error ?? "unknown"}` };
|
|
971
|
+
result.detail = `${result.detail} \u2192 clicked via extension`;
|
|
972
|
+
result.ui = await collectScreenFromClient();
|
|
973
|
+
}
|
|
974
|
+
return result;
|
|
975
|
+
}
|
|
936
976
|
function evalInClient(code) {
|
|
937
977
|
const id = ++evalId;
|
|
938
978
|
return twoPhase("aipeek:eval", { id, code }, (resolve2) => {
|
|
@@ -1045,6 +1085,44 @@ function aipeekPlugin() {
|
|
|
1045
1085
|
send(res, 200, screen || "(empty)");
|
|
1046
1086
|
return;
|
|
1047
1087
|
}
|
|
1088
|
+
if (parts[0] === "cdp" && parts[1] === "poll") {
|
|
1089
|
+
const queued = cdpQueue.shift();
|
|
1090
|
+
if (queued) {
|
|
1091
|
+
send(res, 200, JSON.stringify(queued));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const cmd = await new Promise((resolve2) => {
|
|
1095
|
+
cdpWaiter = resolve2;
|
|
1096
|
+
setTimeout(() => {
|
|
1097
|
+
if (cdpWaiter === resolve2) {
|
|
1098
|
+
cdpWaiter = null;
|
|
1099
|
+
resolve2(null);
|
|
1100
|
+
}
|
|
1101
|
+
}, 25e3);
|
|
1102
|
+
});
|
|
1103
|
+
if (cmd)
|
|
1104
|
+
send(res, 200, JSON.stringify(cmd));
|
|
1105
|
+
else
|
|
1106
|
+
send(res, 204, "");
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (parts[0] === "cdp" && parts[1] === "result") {
|
|
1110
|
+
const body = await readBody(req);
|
|
1111
|
+
let data;
|
|
1112
|
+
try {
|
|
1113
|
+
data = JSON.parse(body);
|
|
1114
|
+
} catch {
|
|
1115
|
+
send(res, 400, "cdp/result needs a JSON body {id, ok, error?}");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const resolveCdp = cdpResults.get(data.id);
|
|
1119
|
+
if (resolveCdp) {
|
|
1120
|
+
cdpResults.delete(data.id);
|
|
1121
|
+
resolveCdp({ ok: data.ok, error: data.error });
|
|
1122
|
+
}
|
|
1123
|
+
send(res, 200, "ok");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1048
1126
|
if (parts[0] === "chain") {
|
|
1049
1127
|
const body = await readBody(req);
|
|
1050
1128
|
let steps;
|
|
@@ -1068,7 +1146,7 @@ function aipeekPlugin() {
|
|
|
1068
1146
|
allOk = false;
|
|
1069
1147
|
break;
|
|
1070
1148
|
}
|
|
1071
|
-
const r = await
|
|
1149
|
+
const r = await runAction(type, args);
|
|
1072
1150
|
lines.push(`[${i}] ${r.ok ? "\u2713" : "\u2717"} ${type}: ${r.ok ? r.detail || "ok" : r.error}`);
|
|
1073
1151
|
if (r.screen)
|
|
1074
1152
|
lines.push(r.screen.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
@@ -1085,7 +1163,7 @@ function aipeekPlugin() {
|
|
|
1085
1163
|
${lastUi}` : lines.join("\n"));
|
|
1086
1164
|
return;
|
|
1087
1165
|
}
|
|
1088
|
-
if (["click", "fill", "press", "wait", "screenshot"].includes(parts[0])) {
|
|
1166
|
+
if (["click", "fill", "press", "wait", "screenshot", "realclick", "query"].includes(parts[0])) {
|
|
1089
1167
|
const q = url.searchParams;
|
|
1090
1168
|
const args = {
|
|
1091
1169
|
sel: q.get("sel") || void 0,
|
|
@@ -1093,15 +1171,17 @@ ${lastUi}` : lines.join("\n"));
|
|
|
1093
1171
|
value: q.has("value") ? q.get("value") : void 0,
|
|
1094
1172
|
key: q.get("key") || void 0,
|
|
1095
1173
|
timeout: q.has("timeout") ? Number(q.get("timeout")) : void 0,
|
|
1096
|
-
gone: q.has("gone") ? q.get("gone") !== "false" : void 0
|
|
1174
|
+
gone: q.has("gone") ? q.get("gone") !== "false" : void 0,
|
|
1175
|
+
button: q.get("button") === "right" ? "right" : q.get("button") === "left" ? "left" : void 0,
|
|
1176
|
+
x: q.has("x") ? Number(q.get("x")) : void 0,
|
|
1177
|
+
y: q.has("y") ? Number(q.get("y")) : void 0
|
|
1097
1178
|
};
|
|
1098
1179
|
const check2 = resolveAction(parts[0], args);
|
|
1099
1180
|
if (!check2.valid) {
|
|
1100
1181
|
send(res, 400, check2.error ?? "invalid action");
|
|
1101
1182
|
return;
|
|
1102
1183
|
}
|
|
1103
|
-
const result = await
|
|
1104
|
-
lastRaw = null;
|
|
1184
|
+
const result = await runAction(parts[0], args);
|
|
1105
1185
|
if (parts[0] === "screenshot" && result.dataUrl) {
|
|
1106
1186
|
const dir = resolve(server.config.root, ".aipeek");
|
|
1107
1187
|
mkdirSync(dir, { recursive: true });
|
|
@@ -1163,6 +1243,7 @@ export {
|
|
|
1163
1243
|
emitDiff,
|
|
1164
1244
|
START_TAG,
|
|
1165
1245
|
END_TAG,
|
|
1246
|
+
renderClaudeMd,
|
|
1166
1247
|
injectClaudeMd,
|
|
1167
1248
|
aipeekPlugin
|
|
1168
1249
|
};
|
|
@@ -10,7 +10,7 @@ var _url = require('url');
|
|
|
10
10
|
var _esbuild = require('esbuild');
|
|
11
11
|
|
|
12
12
|
// src/core/action.ts
|
|
13
|
-
var TYPES = ["click", "fill", "press", "wait", "screenshot"];
|
|
13
|
+
var TYPES = ["click", "fill", "press", "wait", "screenshot", "realclick", "query"];
|
|
14
14
|
function resolveAction(type, args) {
|
|
15
15
|
if (!TYPES.includes(type))
|
|
16
16
|
return { valid: false, error: `unknown action: ${type}` };
|
|
@@ -18,6 +18,8 @@ function resolveAction(type, args) {
|
|
|
18
18
|
switch (type) {
|
|
19
19
|
case "click":
|
|
20
20
|
return hasTarget ? { valid: true } : { valid: false, error: "click needs sel= or text=" };
|
|
21
|
+
case "realclick":
|
|
22
|
+
return hasTarget || args.x !== void 0 && args.y !== void 0 ? { valid: true } : { valid: false, error: "realclick needs sel=, text=, or x= & y=" };
|
|
21
23
|
case "fill":
|
|
22
24
|
if (!hasTarget)
|
|
23
25
|
return { valid: false, error: "fill needs sel= or text=" };
|
|
@@ -30,6 +32,8 @@ function resolveAction(type, args) {
|
|
|
30
32
|
return hasTarget ? { valid: true } : { valid: false, error: "wait needs sel= or text=" };
|
|
31
33
|
case "screenshot":
|
|
32
34
|
return { valid: true };
|
|
35
|
+
case "query":
|
|
36
|
+
return args.sel ? { valid: true } : { valid: false, error: "query needs sel=" };
|
|
33
37
|
default:
|
|
34
38
|
return { valid: false, error: `unknown action: ${type}` };
|
|
35
39
|
}
|
|
@@ -801,8 +805,12 @@ curl ${base}/console # console logs (errors, warnings, info)
|
|
|
801
805
|
curl ${base}/network # fetch/XHR requests with status and timing
|
|
802
806
|
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
803
807
|
curl ${base}/state # registered store snapshots
|
|
808
|
+
curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
|
|
804
809
|
\`\`\`
|
|
805
810
|
|
|
811
|
+
\`/query\` is the read-side twin of click/fill's \`sel=\` \u2014 assert on a specific element
|
|
812
|
+
(how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
|
|
813
|
+
|
|
806
814
|
\`/screen\` projects the whole UI to a few state variables \u2014 start there, not \`/ui\`. Append
|
|
807
815
|
\`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
|
|
808
816
|
|
|
@@ -839,34 +847,33 @@ curl -X POST ${base}/chain -d '[
|
|
|
839
847
|
\`\`\`
|
|
840
848
|
|
|
841
849
|
**Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
|
|
842
|
-
JS in the page and returns the result \u2014 for
|
|
850
|
+
JS in the page and returns the result \u2014 for what the typed endpoints can't do (install listeners,
|
|
851
|
+
read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
|
|
843
852
|
|
|
844
853
|
aipeek auto-detects errors after HMR and prints them to the terminal \u2014 watch for \`[aipeek]\` messages.
|
|
845
854
|
`;
|
|
846
855
|
}
|
|
847
856
|
var START_TAG = "<!-- AIPEEK:START -->";
|
|
848
857
|
var END_TAG = "<!-- AIPEEK:END -->";
|
|
849
|
-
function
|
|
850
|
-
const path = _path.resolve.call(void 0, root, "CLAUDE.md");
|
|
858
|
+
function renderClaudeMd(existing, port) {
|
|
851
859
|
const block = `${START_TAG}
|
|
852
860
|
${aipeekSnippet(port).trim()}
|
|
853
861
|
${END_TAG}
|
|
854
862
|
`;
|
|
863
|
+
if (existing === null)
|
|
864
|
+
return block;
|
|
865
|
+
const si = existing.indexOf(START_TAG);
|
|
866
|
+
const ei = existing.indexOf(END_TAG);
|
|
867
|
+
if (si !== -1 && ei !== -1)
|
|
868
|
+
return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length);
|
|
869
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
870
|
+
return `${existing}${sep}
|
|
871
|
+
${block}`;
|
|
872
|
+
}
|
|
873
|
+
function injectClaudeMd(root, port) {
|
|
874
|
+
const path = _path.resolve.call(void 0, root, "CLAUDE.md");
|
|
855
875
|
try {
|
|
856
|
-
|
|
857
|
-
_fs.writeFileSync.call(void 0, path, block);
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
const content = _fs.readFileSync.call(void 0, path, "utf-8");
|
|
861
|
-
const si = content.indexOf(START_TAG);
|
|
862
|
-
const ei = content.indexOf(END_TAG);
|
|
863
|
-
if (si !== -1 && ei !== -1) {
|
|
864
|
-
_fs.writeFileSync.call(void 0, path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length));
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
const sep = content.endsWith("\n") ? "" : "\n";
|
|
868
|
-
_fs.writeFileSync.call(void 0, path, `${content}${sep}
|
|
869
|
-
${block}`);
|
|
876
|
+
_fs.writeFileSync.call(void 0, path, renderClaudeMd(_fs.existsSync.call(void 0, path) ? _fs.readFileSync.call(void 0, path, "utf-8") : null, port));
|
|
870
877
|
} catch (e7) {
|
|
871
878
|
}
|
|
872
879
|
}
|
|
@@ -877,6 +884,27 @@ function aipeekPlugin() {
|
|
|
877
884
|
let pushTimer;
|
|
878
885
|
const pendingActions = /* @__PURE__ */ new Map();
|
|
879
886
|
let actionId = 0;
|
|
887
|
+
const cdpQueue = [];
|
|
888
|
+
let cdpWaiter = null;
|
|
889
|
+
const cdpResults = /* @__PURE__ */ new Map();
|
|
890
|
+
let cdpId = 0;
|
|
891
|
+
function runCdpClick(x, y, button) {
|
|
892
|
+
const id = ++cdpId;
|
|
893
|
+
const cmd = { id, x, y, button };
|
|
894
|
+
return new Promise((resolve2, reject) => {
|
|
895
|
+
cdpResults.set(id, resolve2);
|
|
896
|
+
if (cdpWaiter) {
|
|
897
|
+
cdpWaiter(cmd);
|
|
898
|
+
cdpWaiter = null;
|
|
899
|
+
} else {
|
|
900
|
+
cdpQueue.push(cmd);
|
|
901
|
+
}
|
|
902
|
+
setTimeout(() => {
|
|
903
|
+
if (cdpResults.delete(id))
|
|
904
|
+
reject(new Error("cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)"));
|
|
905
|
+
}, 1e4);
|
|
906
|
+
});
|
|
907
|
+
}
|
|
880
908
|
let pendingDom = null;
|
|
881
909
|
let pendingScreen = null;
|
|
882
910
|
const pendingEvals = /* @__PURE__ */ new Map();
|
|
@@ -937,6 +965,18 @@ function aipeekPlugin() {
|
|
|
937
965
|
};
|
|
938
966
|
}, fullMs);
|
|
939
967
|
}
|
|
968
|
+
async function runAction(type, args) {
|
|
969
|
+
const result = await sendAction(type, args);
|
|
970
|
+
lastRaw = null;
|
|
971
|
+
if (type === "realclick" && result.ok && result.ui === void 0) {
|
|
972
|
+
const cdp = await runCdpClick(result.x, result.y, _nullishCoalesce(args.button, () => ( "left")));
|
|
973
|
+
if (!cdp.ok)
|
|
974
|
+
return { ok: false, error: `cdp click failed: ${_nullishCoalesce(cdp.error, () => ( "unknown"))}` };
|
|
975
|
+
result.detail = `${result.detail} \u2192 clicked via extension`;
|
|
976
|
+
result.ui = await collectScreenFromClient();
|
|
977
|
+
}
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
940
980
|
function evalInClient(code) {
|
|
941
981
|
const id = ++evalId;
|
|
942
982
|
return twoPhase("aipeek:eval", { id, code }, (resolve2) => {
|
|
@@ -1049,6 +1089,44 @@ function aipeekPlugin() {
|
|
|
1049
1089
|
send(res, 200, screen || "(empty)");
|
|
1050
1090
|
return;
|
|
1051
1091
|
}
|
|
1092
|
+
if (parts[0] === "cdp" && parts[1] === "poll") {
|
|
1093
|
+
const queued = cdpQueue.shift();
|
|
1094
|
+
if (queued) {
|
|
1095
|
+
send(res, 200, JSON.stringify(queued));
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const cmd = await new Promise((resolve2) => {
|
|
1099
|
+
cdpWaiter = resolve2;
|
|
1100
|
+
setTimeout(() => {
|
|
1101
|
+
if (cdpWaiter === resolve2) {
|
|
1102
|
+
cdpWaiter = null;
|
|
1103
|
+
resolve2(null);
|
|
1104
|
+
}
|
|
1105
|
+
}, 25e3);
|
|
1106
|
+
});
|
|
1107
|
+
if (cmd)
|
|
1108
|
+
send(res, 200, JSON.stringify(cmd));
|
|
1109
|
+
else
|
|
1110
|
+
send(res, 204, "");
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (parts[0] === "cdp" && parts[1] === "result") {
|
|
1114
|
+
const body = await readBody(req);
|
|
1115
|
+
let data;
|
|
1116
|
+
try {
|
|
1117
|
+
data = JSON.parse(body);
|
|
1118
|
+
} catch (e9) {
|
|
1119
|
+
send(res, 400, "cdp/result needs a JSON body {id, ok, error?}");
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const resolveCdp = cdpResults.get(data.id);
|
|
1123
|
+
if (resolveCdp) {
|
|
1124
|
+
cdpResults.delete(data.id);
|
|
1125
|
+
resolveCdp({ ok: data.ok, error: data.error });
|
|
1126
|
+
}
|
|
1127
|
+
send(res, 200, "ok");
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1052
1130
|
if (parts[0] === "chain") {
|
|
1053
1131
|
const body = await readBody(req);
|
|
1054
1132
|
let steps;
|
|
@@ -1072,7 +1150,7 @@ function aipeekPlugin() {
|
|
|
1072
1150
|
allOk = false;
|
|
1073
1151
|
break;
|
|
1074
1152
|
}
|
|
1075
|
-
const r = await
|
|
1153
|
+
const r = await runAction(type, args);
|
|
1076
1154
|
lines.push(`[${i}] ${r.ok ? "\u2713" : "\u2717"} ${type}: ${r.ok ? r.detail || "ok" : r.error}`);
|
|
1077
1155
|
if (r.screen)
|
|
1078
1156
|
lines.push(r.screen.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
@@ -1089,7 +1167,7 @@ function aipeekPlugin() {
|
|
|
1089
1167
|
${lastUi}` : lines.join("\n"));
|
|
1090
1168
|
return;
|
|
1091
1169
|
}
|
|
1092
|
-
if (["click", "fill", "press", "wait", "screenshot"].includes(parts[0])) {
|
|
1170
|
+
if (["click", "fill", "press", "wait", "screenshot", "realclick", "query"].includes(parts[0])) {
|
|
1093
1171
|
const q = url.searchParams;
|
|
1094
1172
|
const args = {
|
|
1095
1173
|
sel: q.get("sel") || void 0,
|
|
@@ -1097,15 +1175,17 @@ ${lastUi}` : lines.join("\n"));
|
|
|
1097
1175
|
value: q.has("value") ? q.get("value") : void 0,
|
|
1098
1176
|
key: q.get("key") || void 0,
|
|
1099
1177
|
timeout: q.has("timeout") ? Number(q.get("timeout")) : void 0,
|
|
1100
|
-
gone: q.has("gone") ? q.get("gone") !== "false" : void 0
|
|
1178
|
+
gone: q.has("gone") ? q.get("gone") !== "false" : void 0,
|
|
1179
|
+
button: q.get("button") === "right" ? "right" : q.get("button") === "left" ? "left" : void 0,
|
|
1180
|
+
x: q.has("x") ? Number(q.get("x")) : void 0,
|
|
1181
|
+
y: q.has("y") ? Number(q.get("y")) : void 0
|
|
1101
1182
|
};
|
|
1102
1183
|
const check2 = resolveAction(parts[0], args);
|
|
1103
1184
|
if (!check2.valid) {
|
|
1104
1185
|
send(res, 400, _nullishCoalesce(check2.error, () => ( "invalid action")));
|
|
1105
1186
|
return;
|
|
1106
1187
|
}
|
|
1107
|
-
const result = await
|
|
1108
|
-
lastRaw = null;
|
|
1188
|
+
const result = await runAction(parts[0], args);
|
|
1109
1189
|
if (parts[0] === "screenshot" && result.dataUrl) {
|
|
1110
1190
|
const dir = _path.resolve.call(void 0, server.config.root, ".aipeek");
|
|
1111
1191
|
_fs.mkdirSync.call(void 0, dir, { recursive: true });
|
|
@@ -1169,4 +1249,5 @@ ${result.ui}` : head);
|
|
|
1169
1249
|
|
|
1170
1250
|
|
|
1171
1251
|
|
|
1172
|
-
|
|
1252
|
+
|
|
1253
|
+
exports.check = check; exports.diffState = diffState; exports.emitSummary = emitSummary; exports.emitCheck = emitCheck; exports.emitDiff = emitDiff; exports.START_TAG = START_TAG; exports.END_TAG = END_TAG; exports.renderClaudeMd = renderClaudeMd; exports.injectClaudeMd = injectClaudeMd; exports.aipeekPlugin = aipeekPlugin;
|
package/dist/index.cjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
var
|
|
8
|
+
var _chunkSTYCUT23cjs = require('./chunk-STYCUT23.cjs');
|
|
9
9
|
require('./chunk-Z2Y65YOY.cjs');
|
|
10
10
|
|
|
11
11
|
|
|
@@ -14,4 +14,4 @@ require('./chunk-Z2Y65YOY.cjs');
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
exports.aipeekPlugin =
|
|
17
|
+
exports.aipeekPlugin = _chunkSTYCUT23cjs.aipeekPlugin; exports.check = _chunkSTYCUT23cjs.check; exports.diffState = _chunkSTYCUT23cjs.diffState; exports.emitCheck = _chunkSTYCUT23cjs.emitCheck; exports.emitDiff = _chunkSTYCUT23cjs.emitDiff; exports.emitSummary = _chunkSTYCUT23cjs.emitSummary;
|
package/dist/index.js
CHANGED
package/dist/plugin.cjs
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
var _chunkSTYCUT23cjs = require('./chunk-STYCUT23.cjs');
|
|
7
8
|
require('./chunk-Z2Y65YOY.cjs');
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
|
|
15
|
+
exports.END_TAG = _chunkSTYCUT23cjs.END_TAG; exports.START_TAG = _chunkSTYCUT23cjs.START_TAG; exports.aipeekPlugin = _chunkSTYCUT23cjs.aipeekPlugin; exports.injectClaudeMd = _chunkSTYCUT23cjs.injectClaudeMd; exports.renderClaudeMd = _chunkSTYCUT23cjs.renderClaudeMd;
|
package/dist/plugin.js
CHANGED
|
@@ -2,11 +2,13 @@ import {
|
|
|
2
2
|
END_TAG,
|
|
3
3
|
START_TAG,
|
|
4
4
|
aipeekPlugin,
|
|
5
|
-
injectClaudeMd
|
|
6
|
-
|
|
5
|
+
injectClaudeMd,
|
|
6
|
+
renderClaudeMd
|
|
7
|
+
} from "./chunk-37VLLZIU.js";
|
|
7
8
|
export {
|
|
8
9
|
END_TAG,
|
|
9
10
|
START_TAG,
|
|
10
11
|
aipeekPlugin,
|
|
11
|
-
injectClaudeMd
|
|
12
|
+
injectClaudeMd,
|
|
13
|
+
renderClaudeMd
|
|
12
14
|
};
|
package/package.json
CHANGED
package/src/client/client.ts
CHANGED
|
@@ -647,10 +647,21 @@ if (import.meta.hot) {
|
|
|
647
647
|
const { result, dialogs } = await withDialogGuard(() => performAction(msg.type, msg.args))
|
|
648
648
|
if (dialogs.length)
|
|
649
649
|
result.detail = `${result.detail ?? ''} [auto-dismissed ${dialogs.join('; ')}]`.trim()
|
|
650
|
+
// realclick resolved to (x,y) but didn't click — synthetic events can't open a Radix
|
|
651
|
+
// ContextMenu. Fire a trusted click through whatever channel can: in Electron the page
|
|
652
|
+
// can reach the main process via electronAPI.invoke('aipeek:input') → sendInputEvent;
|
|
653
|
+
// in a plain Chrome tab it can't (no chrome.debugger from page JS), so leave ui
|
|
654
|
+
// undefined and let the server drive its extension queue.
|
|
655
|
+
const electronAPI = (window as { electronAPI?: { invoke: (channel: string, ...args: unknown[]) => Promise<unknown> } }).electronAPI
|
|
656
|
+
if (msg.type === 'realclick' && result.ok && electronAPI) {
|
|
657
|
+
await electronAPI.invoke('aipeek:input', { type: 'click', button: msg.args.button ?? 'left', x: result.x, y: result.y })
|
|
658
|
+
result.ui = await waitForStable()
|
|
659
|
+
result.screen = collectScreen()
|
|
660
|
+
}
|
|
650
661
|
// For mutating actions, settle the DOM then ship both the full UI tree and
|
|
651
662
|
// the compact screen projection — the caller skips a round-trip to /ui, and
|
|
652
663
|
// /chain uses the per-step screen so an interaction's every transition shows.
|
|
653
|
-
if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
|
|
664
|
+
else if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
|
|
654
665
|
result.ui = await waitForStable()
|
|
655
666
|
result.screen = collectScreen()
|
|
656
667
|
}
|
package/src/core/action.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// - performAction(): real DOM mutation. Runs browser-side (client.ts imports it).
|
|
7
7
|
// References window/document — never imported plugin-side.
|
|
8
8
|
|
|
9
|
-
export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot'
|
|
9
|
+
export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot' | 'realclick' | 'query'
|
|
10
10
|
|
|
11
11
|
export interface ActionArgs {
|
|
12
12
|
sel?: string
|
|
@@ -15,6 +15,9 @@ export interface ActionArgs {
|
|
|
15
15
|
key?: string
|
|
16
16
|
timeout?: number
|
|
17
17
|
gone?: boolean
|
|
18
|
+
button?: 'left' | 'right'
|
|
19
|
+
x?: number
|
|
20
|
+
y?: number
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface ActionResult {
|
|
@@ -24,9 +27,11 @@ export interface ActionResult {
|
|
|
24
27
|
dataUrl?: string
|
|
25
28
|
ui?: string
|
|
26
29
|
screen?: string
|
|
30
|
+
x?: number
|
|
31
|
+
y?: number
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
|
|
34
|
+
const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query']
|
|
30
35
|
|
|
31
36
|
// --- Pure validation (plugin-side) ---
|
|
32
37
|
|
|
@@ -38,6 +43,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
38
43
|
switch (type) {
|
|
39
44
|
case 'click':
|
|
40
45
|
return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
|
|
46
|
+
case 'realclick':
|
|
47
|
+
return hasTarget || (args.x !== undefined && args.y !== undefined) ? { valid: true } : { valid: false, error: 'realclick needs sel=, text=, or x= & y=' }
|
|
41
48
|
case 'fill':
|
|
42
49
|
if (!hasTarget)
|
|
43
50
|
return { valid: false, error: 'fill needs sel= or text=' }
|
|
@@ -50,6 +57,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
|
|
|
50
57
|
return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
|
|
51
58
|
case 'screenshot':
|
|
52
59
|
return { valid: true }
|
|
60
|
+
case 'query':
|
|
61
|
+
return args.sel ? { valid: true } : { valid: false, error: 'query needs sel=' }
|
|
53
62
|
default:
|
|
54
63
|
return { valid: false, error: `unknown action: ${type}` }
|
|
55
64
|
}
|
|
@@ -103,10 +112,12 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
|
|
|
103
112
|
try {
|
|
104
113
|
switch (type) {
|
|
105
114
|
case 'click': return doClick(args)
|
|
115
|
+
case 'realclick': return doResolveRealClick(args)
|
|
106
116
|
case 'fill': return doFill(args)
|
|
107
117
|
case 'press': return doPress(args)
|
|
108
118
|
case 'wait': return await doWait(args)
|
|
109
119
|
case 'screenshot': return await doScreenshot(args)
|
|
120
|
+
case 'query': return doQuery(args)
|
|
110
121
|
}
|
|
111
122
|
}
|
|
112
123
|
catch (e) {
|
|
@@ -226,6 +237,24 @@ function doClick(args: ActionArgs): ActionResult {
|
|
|
226
237
|
return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
|
|
227
238
|
}
|
|
228
239
|
|
|
240
|
+
// realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
|
|
241
|
+
// The trusted click is fired by whichever channel can produce isTrusted=true input:
|
|
242
|
+
// Electron's webContents.sendInputEvent (via electronAPI.invoke, in client.ts) or a Chrome
|
|
243
|
+
// extension's chrome.debugger (via the server's CDP queue). Synthetic events can't open a
|
|
244
|
+
// Radix ContextMenu — that's the whole reason this path exists. When x= & y= are given
|
|
245
|
+
// directly we pass them through unchanged.
|
|
246
|
+
function doResolveRealClick(args: ActionArgs): ActionResult {
|
|
247
|
+
if (args.x !== undefined && args.y !== undefined)
|
|
248
|
+
return { ok: true, x: args.x, y: args.y, detail: `resolved (${args.x}, ${args.y})` }
|
|
249
|
+
const el = findElement(args.sel, args.text)
|
|
250
|
+
if (!el)
|
|
251
|
+
return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
|
|
252
|
+
const r = el.getBoundingClientRect()
|
|
253
|
+
const x = Math.round(r.left + r.width / 2)
|
|
254
|
+
const y = Math.round(r.top + r.height / 2)
|
|
255
|
+
return { ok: true, x, y, detail: `resolved ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
|
|
256
|
+
}
|
|
257
|
+
|
|
229
258
|
function doFill(args: ActionArgs): ActionResult {
|
|
230
259
|
const el = findElement(args.sel, args.text)
|
|
231
260
|
if (!el)
|
|
@@ -250,8 +279,15 @@ function doFill(args: ActionArgs): ActionResult {
|
|
|
250
279
|
return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
|
|
251
280
|
}
|
|
252
281
|
|
|
282
|
+
// React overrides the value setter on the element *instance* to track changes; a plain
|
|
283
|
+
// `input.value = x` writes through it so React's tracker never sees a diff and onChange
|
|
284
|
+
// never fires (controlled inputs stay empty). Call the *prototype* setter instead — the
|
|
285
|
+
// tracker observes the change and the synthetic onChange fires.
|
|
253
286
|
const input = el as HTMLInputElement
|
|
254
|
-
|
|
287
|
+
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
288
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
289
|
+
if (setter) setter.call(input, value)
|
|
290
|
+
else input.value = value
|
|
255
291
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
256
292
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
257
293
|
return { ok: true, detail: `filled ${value.length} chars` }
|
|
@@ -323,3 +359,46 @@ async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
|
|
|
323
359
|
return { ok: false, error: `screenshot failed: ${msg}` }
|
|
324
360
|
}
|
|
325
361
|
}
|
|
362
|
+
|
|
363
|
+
// The read-side twin of click/fill's `sel=`: instead of acting on the matched
|
|
364
|
+
// element, report the facts you'd otherwise reach for via /eval — count of
|
|
365
|
+
// matches, and per-element text / visible / assertion-relevant attrs. /wait
|
|
366
|
+
// answers "appears over time"; query answers "what is it now". Attrs are a
|
|
367
|
+
// whitelist (role, data-state, data-*, aria-*, value, disabled, checked, href,
|
|
368
|
+
// title) — a node's full class/style set is noise. Capped at 20 matches.
|
|
369
|
+
const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
|
|
370
|
+
const QUERY_PREFIXES = ['data-', 'aria-']
|
|
371
|
+
|
|
372
|
+
function elAttrs(el: Element): Record<string, string> {
|
|
373
|
+
const out: Record<string, string> = {}
|
|
374
|
+
for (const attr of Array.from(el.attributes)) {
|
|
375
|
+
if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
|
|
376
|
+
out[attr.name] = attr.value
|
|
377
|
+
}
|
|
378
|
+
const input = el as HTMLInputElement
|
|
379
|
+
if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
|
|
380
|
+
out.value = input.value
|
|
381
|
+
if (input.disabled)
|
|
382
|
+
out.disabled = 'true'
|
|
383
|
+
if (input.checked)
|
|
384
|
+
out.checked = 'true'
|
|
385
|
+
return out
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function doQuery(args: ActionArgs): ActionResult {
|
|
389
|
+
let els: Element[]
|
|
390
|
+
try {
|
|
391
|
+
els = Array.from(document.querySelectorAll(args.sel!))
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return { ok: false, error: `invalid selector: ${args.sel} — URL-encode it (curl -G --data-urlencode 'sel=...')` }
|
|
395
|
+
}
|
|
396
|
+
const count = els.length
|
|
397
|
+
const matches = els.slice(0, 20).map(el => ({
|
|
398
|
+
text: visibleText(el).slice(0, 80),
|
|
399
|
+
visible: isVisible(el),
|
|
400
|
+
attrs: elAttrs(el),
|
|
401
|
+
}))
|
|
402
|
+
const head = count > 20 ? `(showing 20 of ${count})\n` : ''
|
|
403
|
+
return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
|
|
404
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -69,8 +69,12 @@ curl ${base}/console # console logs (errors, warnings, info)
|
|
|
69
69
|
curl ${base}/network # fetch/XHR requests with status and timing
|
|
70
70
|
curl ${base}/errors # uncaught errors and unhandled rejections
|
|
71
71
|
curl ${base}/state # registered store snapshots
|
|
72
|
+
curl '${base}/query?sel=[role=menuitem]' # this selector right now: count + each element's text/visible/attrs
|
|
72
73
|
\`\`\`
|
|
73
74
|
|
|
75
|
+
\`/query\` is the read-side twin of click/fill's \`sel=\` — assert on a specific element
|
|
76
|
+
(how many match, its text, \`data-state\`, \`aria-*\`/\`data-*\`, value, disabled) without \`/eval\`.
|
|
77
|
+
|
|
74
78
|
\`/screen\` projects the whole UI to a few state variables — start there, not \`/ui\`. Append
|
|
75
79
|
\`?full\` for untruncated output. Append \`/{index}\` for a specific item's detail.
|
|
76
80
|
|
|
@@ -107,7 +111,8 @@ curl -X POST ${base}/chain -d '[
|
|
|
107
111
|
\`\`\`
|
|
108
112
|
|
|
109
113
|
**Escape hatch.** \`curl '${base}/eval?code=...'\` (or POST the code as the body) runs arbitrary
|
|
110
|
-
JS in the page and returns the result — for
|
|
114
|
+
JS in the page and returns the result — for what the typed endpoints can't do (install listeners,
|
|
115
|
+
read closures, probe event flow). For count/text/state/attr assertions reach for \`/query\` first.
|
|
111
116
|
|
|
112
117
|
aipeek auto-detects errors after HMR and prints them to the terminal — watch for \`[aipeek]\` messages.
|
|
113
118
|
`
|
|
@@ -116,27 +121,26 @@ aipeek auto-detects errors after HMR and prints them to the terminal — watch f
|
|
|
116
121
|
export const START_TAG = '<!-- AIPEEK:START -->'
|
|
117
122
|
export const END_TAG = '<!-- AIPEEK:END -->'
|
|
118
123
|
|
|
119
|
-
// Marker-based injection
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
124
|
+
// Marker-based injection 的纯核心:existing(文件现内容,缺文件传 null)+ port → 新内容。
|
|
125
|
+
// 块夹在 START_TAG..END_TAG 间,再注入是确定性 splice(找标记替换中间)而非模糊行匹配。
|
|
126
|
+
// 缺文件 → 仅块;有标记 → 替换其内容;无标记 → 末尾追加新块。fs 读写是 injectClaudeMd 的边界,
|
|
127
|
+
// 这里 0 副作用——四条分支(新建/替换/追加/补换行)全可被快照锁死。
|
|
128
|
+
export function renderClaudeMd(existing: string | null, port: number): string {
|
|
129
|
+
const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
|
|
130
|
+
if (existing === null)
|
|
131
|
+
return block
|
|
132
|
+
const si = existing.indexOf(START_TAG)
|
|
133
|
+
const ei = existing.indexOf(END_TAG)
|
|
134
|
+
if (si !== -1 && ei !== -1)
|
|
135
|
+
return existing.slice(0, si) + block.trimEnd() + existing.slice(ei + END_TAG.length)
|
|
136
|
+
const sep = existing.endsWith('\n') ? '' : '\n'
|
|
137
|
+
return `${existing}${sep}\n${block}`
|
|
138
|
+
}
|
|
139
|
+
|
|
123
140
|
export function injectClaudeMd(root: string, port: number) {
|
|
124
141
|
const path = resolve(root, 'CLAUDE.md')
|
|
125
|
-
const block = `${START_TAG}\n${aipeekSnippet(port).trim()}\n${END_TAG}\n`
|
|
126
142
|
try {
|
|
127
|
-
|
|
128
|
-
writeFileSync(path, block)
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
const content = readFileSync(path, 'utf-8')
|
|
132
|
-
const si = content.indexOf(START_TAG)
|
|
133
|
-
const ei = content.indexOf(END_TAG)
|
|
134
|
-
if (si !== -1 && ei !== -1) {
|
|
135
|
-
writeFileSync(path, content.slice(0, si) + block.trimEnd() + content.slice(ei + END_TAG.length))
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
const sep = content.endsWith('\n') ? '' : '\n'
|
|
139
|
-
writeFileSync(path, `${content}${sep}\n${block}`)
|
|
143
|
+
writeFileSync(path, renderClaudeMd(existsSync(path) ? readFileSync(path, 'utf-8') : null, port))
|
|
140
144
|
}
|
|
141
145
|
catch {}
|
|
142
146
|
}
|
|
@@ -149,6 +153,38 @@ export function aipeekPlugin(): Plugin {
|
|
|
149
153
|
const pendingActions = new Map<number, (r: ActionResult) => void>()
|
|
150
154
|
let actionId = 0
|
|
151
155
|
|
|
156
|
+
// Chrome real-input channel: synthetic events can't open a Radix ContextMenu, and the
|
|
157
|
+
// in-page script can't reach chrome.debugger. So for a plain browser tab, realclick is a
|
|
158
|
+
// two-step handshake — the page resolves the element to (x,y), then the server enqueues a
|
|
159
|
+
// CDP command here for the extension to execute with trusted input. The extension long-polls
|
|
160
|
+
// /cdp/poll for the next command and POSTs the verdict to /cdp/result. Electron never touches
|
|
161
|
+
// this (it fires sendInputEvent in-process from the page — see client.ts).
|
|
162
|
+
interface CdpCommand { id: number, x: number, y: number, button: 'left' | 'right' }
|
|
163
|
+
const cdpQueue: CdpCommand[] = []
|
|
164
|
+
let cdpWaiter: ((cmd: CdpCommand | null) => void) | null = null
|
|
165
|
+
const cdpResults = new Map<number, (r: { ok: boolean, error?: string }) => void>()
|
|
166
|
+
let cdpId = 0
|
|
167
|
+
|
|
168
|
+
function runCdpClick(x: number, y: number, button: 'left' | 'right'): Promise<{ ok: boolean, error?: string }> {
|
|
169
|
+
const id = ++cdpId
|
|
170
|
+
const cmd: CdpCommand = { id, x, y, button }
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
cdpResults.set(id, resolve)
|
|
173
|
+
// hand the command to a parked poller, else queue it for the next poll
|
|
174
|
+
if (cdpWaiter) {
|
|
175
|
+
cdpWaiter(cmd)
|
|
176
|
+
cdpWaiter = null
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
cdpQueue.push(cmd)
|
|
180
|
+
}
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
if (cdpResults.delete(id))
|
|
183
|
+
reject(new Error('cdp timeout: no extension result within 10s (is the aipeek extension loaded and the debugger attached?)'))
|
|
184
|
+
}, 10000)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
152
188
|
let pendingDom: ((dom: string) => void) | null = null
|
|
153
189
|
let pendingScreen: ((screen: string) => void) | null = null
|
|
154
190
|
const pendingEvals = new Map<number, (r: { ok: boolean, value?: string, error?: string }) => void>()
|
|
@@ -226,6 +262,24 @@ export function aipeekPlugin(): Plugin {
|
|
|
226
262
|
}, fullMs)
|
|
227
263
|
}
|
|
228
264
|
|
|
265
|
+
// sendAction + the Chrome realclick handshake, in one place so the single endpoint and
|
|
266
|
+
// /chain both get it. The page resolves realclick to (x,y): if it set result.ui, Electron
|
|
267
|
+
// already fired the trusted click in-process — done. If ui is undefined (plain Chrome tab),
|
|
268
|
+
// the page couldn't click, so drive the extension's CDP queue with the coords, then collect
|
|
269
|
+
// the settled screen as the ui. A CDP failure comes back as a normal ok:false result.
|
|
270
|
+
async function runAction(type: string, args: ActionArgs): Promise<ActionResult> {
|
|
271
|
+
const result = await sendAction(type, args)
|
|
272
|
+
lastRaw = null // page mutated; force fresh collect next read
|
|
273
|
+
if (type === 'realclick' && result.ok && result.ui === undefined) {
|
|
274
|
+
const cdp = await runCdpClick(result.x!, result.y!, args.button ?? 'left')
|
|
275
|
+
if (!cdp.ok)
|
|
276
|
+
return { ok: false, error: `cdp click failed: ${cdp.error ?? 'unknown'}` }
|
|
277
|
+
result.detail = `${result.detail} → clicked via extension`
|
|
278
|
+
result.ui = await collectScreenFromClient()
|
|
279
|
+
}
|
|
280
|
+
return result
|
|
281
|
+
}
|
|
282
|
+
|
|
229
283
|
function evalInClient(code: string): Promise<{ ok: boolean, value?: string, error?: string }> {
|
|
230
284
|
const id = ++evalId
|
|
231
285
|
return twoPhase('aipeek:eval', { id, code }, (resolve) => {
|
|
@@ -362,6 +416,51 @@ export function aipeekPlugin(): Plugin {
|
|
|
362
416
|
return
|
|
363
417
|
}
|
|
364
418
|
|
|
419
|
+
// /__aipeek/cdp/poll — the Chrome extension long-polls here for the next
|
|
420
|
+
// trusted-input command. Returns the command as JSON, or 204 on timeout
|
|
421
|
+
// (the extension simply re-polls). Only one poller is parked at a time.
|
|
422
|
+
if (parts[0] === 'cdp' && parts[1] === 'poll') {
|
|
423
|
+
const queued = cdpQueue.shift()
|
|
424
|
+
if (queued) {
|
|
425
|
+
send(res, 200, JSON.stringify(queued))
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const cmd = await new Promise<CdpCommand | null>((resolve) => {
|
|
429
|
+
cdpWaiter = resolve
|
|
430
|
+
setTimeout(() => {
|
|
431
|
+
if (cdpWaiter === resolve) {
|
|
432
|
+
cdpWaiter = null
|
|
433
|
+
resolve(null)
|
|
434
|
+
}
|
|
435
|
+
}, 25000)
|
|
436
|
+
})
|
|
437
|
+
if (cmd)
|
|
438
|
+
send(res, 200, JSON.stringify(cmd))
|
|
439
|
+
else
|
|
440
|
+
send(res, 204, '')
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// /__aipeek/cdp/result — POST {id, ok, error?}; resolves the awaiting realclick.
|
|
445
|
+
if (parts[0] === 'cdp' && parts[1] === 'result') {
|
|
446
|
+
const body = await readBody(req)
|
|
447
|
+
let data: { id: number, ok: boolean, error?: string }
|
|
448
|
+
try {
|
|
449
|
+
data = JSON.parse(body)
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
send(res, 400, 'cdp/result needs a JSON body {id, ok, error?}')
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const resolveCdp = cdpResults.get(data.id)
|
|
456
|
+
if (resolveCdp) {
|
|
457
|
+
cdpResults.delete(data.id)
|
|
458
|
+
resolveCdp({ ok: data.ok, error: data.error })
|
|
459
|
+
}
|
|
460
|
+
send(res, 200, 'ok')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
|
|
365
464
|
// /__aipeek/chain — POST a JSON array of actions, run them in
|
|
366
465
|
// sequence (each settles the DOM before the next), stop on first
|
|
367
466
|
// failure. One round-trip for a whole interaction.
|
|
@@ -389,7 +488,7 @@ export function aipeekPlugin(): Plugin {
|
|
|
389
488
|
allOk = false
|
|
390
489
|
break
|
|
391
490
|
}
|
|
392
|
-
const r = await
|
|
491
|
+
const r = await runAction(type, args)
|
|
393
492
|
lines.push(`[${i}] ${r.ok ? '✓' : '✗'} ${type}: ${r.ok ? (r.detail || 'ok') : r.error}`)
|
|
394
493
|
// Per-step screen projection — captures the transition each
|
|
395
494
|
// mutating step caused, so a view change mid-chain is visible
|
|
@@ -407,8 +506,8 @@ export function aipeekPlugin(): Plugin {
|
|
|
407
506
|
return
|
|
408
507
|
}
|
|
409
508
|
|
|
410
|
-
// action endpoints: /__aipeek/{click|fill|press|wait|screenshot}?...
|
|
411
|
-
if (['click', 'fill', 'press', 'wait', 'screenshot'].includes(parts[0])) {
|
|
509
|
+
// action endpoints: /__aipeek/{click|fill|press|wait|screenshot|realclick}?...
|
|
510
|
+
if (['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query'].includes(parts[0])) {
|
|
412
511
|
const q = url.searchParams
|
|
413
512
|
const args: ActionArgs = {
|
|
414
513
|
sel: q.get('sel') || undefined,
|
|
@@ -417,14 +516,16 @@ export function aipeekPlugin(): Plugin {
|
|
|
417
516
|
key: q.get('key') || undefined,
|
|
418
517
|
timeout: q.has('timeout') ? Number(q.get('timeout')) : undefined,
|
|
419
518
|
gone: q.has('gone') ? q.get('gone') !== 'false' : undefined,
|
|
519
|
+
button: q.get('button') === 'right' ? 'right' : q.get('button') === 'left' ? 'left' : undefined,
|
|
520
|
+
x: q.has('x') ? Number(q.get('x')) : undefined,
|
|
521
|
+
y: q.has('y') ? Number(q.get('y')) : undefined,
|
|
420
522
|
}
|
|
421
523
|
const check = resolveAction(parts[0], args)
|
|
422
524
|
if (!check.valid) {
|
|
423
525
|
send(res, 400, check.error ?? 'invalid action')
|
|
424
526
|
return
|
|
425
527
|
}
|
|
426
|
-
const result = await
|
|
427
|
-
lastRaw = null // page mutated; force fresh collect next read
|
|
528
|
+
const result = await runAction(parts[0], args)
|
|
428
529
|
if (parts[0] === 'screenshot' && result.dataUrl) {
|
|
429
530
|
const dir = resolve(server.config.root, '.aipeek')
|
|
430
531
|
mkdirSync(dir, { recursive: true })
|