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 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 anything the typed endpoints can't do.
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 injectClaudeMd(root, port) {
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
- if (!existsSync(path)) {
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 sendAction(type, args);
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 sendAction(parts[0], args);
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 anything the typed endpoints can't do.
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 injectClaudeMd(root, port) {
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
- if (!_fs.existsSync.call(void 0, path)) {
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 sendAction(type, args);
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 sendAction(parts[0], args);
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
- 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.injectClaudeMd = injectClaudeMd; exports.aipeekPlugin = aipeekPlugin;
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 _chunk5ZZYOETFcjs = require('./chunk-5ZZYOETF.cjs');
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 = _chunk5ZZYOETFcjs.aipeekPlugin; exports.check = _chunk5ZZYOETFcjs.check; exports.diffState = _chunk5ZZYOETFcjs.diffState; exports.emitCheck = _chunk5ZZYOETFcjs.emitCheck; exports.emitDiff = _chunk5ZZYOETFcjs.emitDiff; exports.emitSummary = _chunk5ZZYOETFcjs.emitSummary;
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
@@ -5,7 +5,7 @@ import {
5
5
  emitCheck,
6
6
  emitDiff,
7
7
  emitSummary
8
- } from "./chunk-XA2LT6I4.js";
8
+ } from "./chunk-37VLLZIU.js";
9
9
  export {
10
10
  aipeekPlugin,
11
11
  check,
package/dist/plugin.cjs CHANGED
@@ -3,11 +3,13 @@
3
3
 
4
4
 
5
5
 
6
- var _chunk5ZZYOETFcjs = require('./chunk-5ZZYOETF.cjs');
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
- exports.END_TAG = _chunk5ZZYOETFcjs.END_TAG; exports.START_TAG = _chunk5ZZYOETFcjs.START_TAG; exports.aipeekPlugin = _chunk5ZZYOETFcjs.aipeekPlugin; exports.injectClaudeMd = _chunk5ZZYOETFcjs.injectClaudeMd;
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
- } from "./chunk-XA2LT6I4.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aipeek",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Gives AI a peek into your running browser app — UI tree, console, network, errors, state",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
  }
@@ -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
- input.value = value
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
+ }
@@ -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 anything the typed endpoints can't do.
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 the block lives between START_TAG and END_TAG, so
120
- // re-injection is a deterministic splice (find markers, replace between) rather
121
- // than fuzzy line matching. New file write markers + snippet. Existing markers
122
- // replace their contents. No markers yet → append a fresh marked block.
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
- if (!existsSync(path)) {
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 sendAction(type, args)
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 sendAction(parts[0], args)
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 })