@chalky/chalky 0.1.5 → 0.1.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.
@@ -0,0 +1,453 @@
1
+ // The page side of page_run (page/index.ts): runs an agent's code in this page, or inside a card's app, where the user
2
+ // can watch it, and answers.
3
+ // page message page_tab {tab} this tab is the one the user looks at: when this script loads, and on focus
4
+ // event "page_run" {req, tab, card, code, agent} only the tab named runs it -> page message page_ran {req, value?, error?, errors}
5
+ // page message page_stop {agent} the bar's Stop: stops that agent only
6
+ // page message {to, text} the bar's "Let <agent> continue": a message to the agent
7
+ // The code runs with the app frame's own Function (document and window are the app's) and gets helpers that act the
8
+ // way a person would, slowly enough to watch: click, type, press, drag (the agent's pointer moves there first, then
9
+ // the events a real hand makes fire), read (what's on screen, as short lines) and wait. Plain DOM code works too.
10
+ // What to act on: an element, a CSS selector, its visible text, or a spot {x, y} (window px: whatever is on top there,
11
+ // as with a real mouse; for empty space, and see-through layers like Point's).
12
+ // What the user sees: the pointer (the agent's colour and name), an outline on each thing touched (also when code
13
+ // skips the helpers: any event code makes, isTrusted false, while a run is on), and a notice (top right) while the agent is using
14
+ // the page (from its first page_run to the end of its turn). The user's own click or key in the main space or an app
15
+ // pauses it: the current helper stops, the agent hears "the user took over", later runs are refused until the user
16
+ // presses "Let <agent> continue". While agent code runs, the page's Stop button and rollbacks refuse it.
17
+ // What the page sends because of the agent's code (its click, key or drop set off a page message) carries
18
+ // {by: <agent>}, so the extension that gets it can say who did it (frames: "agent1 pressed Implement", not "the user").
19
+ // A run in a card first brings that card to the front (data-agent-front, until a run in another card) and scrolls it
20
+ // into view, so the user sees it. If the app reloads or leaves during a run, the answer comes at once (pagehide).
21
+ // The value comes back as short text: an element as tag#id "its text", anything else as JSON, cut at MAX characters.
22
+ const MAX = 2000;
23
+ const PACE = { moveMin: 280, moveMax: 750, afterClick: 350, keyMin: 40, keyMax: 90, linger: 1100 };
24
+ const ACTIVE = "button, a[href], input, select, textarea, summary, label, [role=button], [role=tab], [role=checkbox], [role=radio], "
25
+ + "[role=switch], [role=menuitem], [role=option], [role=link], [role=slider], [contenteditable=''], [contenteditable=true]";
26
+ const colour = (a) => `hsl(${([...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 997, 7) * 137) % 360} 65% 45%)`; // as page.html
27
+ const short = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
28
+ const norm = (s) => String(s ?? "").trim().replace(/\s+/g, " ");
29
+ class TookOver extends Error {}
30
+
31
+ export default function (page) {
32
+ const tab = Math.random().toString(36).slice(2, 10);
33
+ const claim = () => { try { page.send({ type: "page_tab", tab }); return true; } catch { return false; } }; // false: not connected yet
34
+ const first = () => claim() || setTimeout(first, 300); // the first status may come before this script loads: don't wait for it
35
+ first();
36
+ page.on("status", () => { if (document.hasFocus()) claim(); }); // status comes again on every reconnect
37
+ addEventListener("focus", claim);
38
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) claim(); });
39
+
40
+ const main = page.slot("top");
41
+ let session = null; // {agent, paused}: from an agent's first page_run to the end of its turn
42
+ let busy = 0, mine = 0; // runs going on; >0 while a helper fires its own events (they get their own outline)
43
+ let refused = []; // what the guard refused during the current run
44
+ let codeClicked = false; // the last click on this page came from code
45
+ let byCode = false; // the last click, key, input or drop came from code
46
+
47
+ // every page message goes through page.send (one pageApi for all plugins): while agent code acts, say whose it is
48
+ const send = page.send;
49
+ page.send = (obj) => send(busy && byCode && session ? { ...obj, by: session.agent } : obj);
50
+
51
+ // ---------- what the user sees ----------
52
+ const style = document.createElement("style");
53
+ style.textContent = `
54
+ [data-card][data-agent-front] { position: relative; z-index: 3; } /* a pinned card keeps its inline absolute; a held or flipped one goes higher */
55
+ .page-pointer { position: fixed; left: 0; top: 0; z-index: 2147483000; pointer-events: none; display: flex; gap: 2px;
56
+ opacity: 0; transition: opacity .3s; will-change: transform; }
57
+ .page-pointer.on { opacity: 1; }
58
+ .page-pointer svg { width: 20px; height: 20px; filter: drop-shadow(0 2px 3px rgba(0,0,0,.25)); }
59
+ .page-pointer span { margin-top: 14px; color: #fff; font: 600 11px system-ui, sans-serif; padding: 2px 7px; border-radius: 7px; }
60
+ .page-touch { position: fixed; z-index: 2147482999; pointer-events: none; border: 2px solid; border-radius: 9px; transition: opacity .5s; }
61
+ .page-touch b { position: absolute; top: -18px; left: -2px; color: #fff; font: 600 10px system-ui, sans-serif; padding: 1px 6px;
62
+ border-radius: 6px; white-space: nowrap; }
63
+ .page-ripple { position: fixed; z-index: 2147483000; pointer-events: none; width: 36px; height: 36px; margin: -18px 0 0 -18px;
64
+ border-radius: 50%; border: 2px solid; animation: page-ripple .55s ease-out forwards; }
65
+ @keyframes page-ripple { from { transform: scale(.3); opacity: 1; } to { transform: scale(1.5); opacity: 0; } }
66
+ .page-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; animation: page-pulse 1.2s infinite; }
67
+ .page-dot.paused { background: var(--muted, #999) !important; animation: none; }
68
+ @keyframes page-pulse { 50% { opacity: .35; } }
69
+ @media (prefers-reduced-motion: reduce) { .page-pointer { transition: opacity .1s; } .page-dot { animation: none; } }`;
70
+ document.head.append(style);
71
+ const pointer = Object.assign(document.createElement("div"), { className: "page-pointer" });
72
+ pointer.innerHTML = `<svg viewBox="0 0 20 20"><path d="M3 2 L3 16 L7 12.5 L9.8 18 L12 17 L9.3 11.6 L14.5 11.2 Z" stroke="#fff"
73
+ stroke-width="1.3" stroke-linejoin="round"/></svg><span></span>`;
74
+ let bar = null; // the notice (pageApi.notice) while an agent uses the page
75
+ document.body.append(pointer);
76
+ let px = innerWidth - 80, py = innerHeight - 80, hideTimer, idleTimer;
77
+
78
+ function showBar() { // new, or the same notice with new words: ✕ hides it for the turn (Close, when paused)
79
+ const a = session.agent;
80
+ if (session.quiet && !session.paused) return;
81
+ const dot = Object.assign(document.createElement("span"), { className: "page-dot" + (session.paused ? " paused" : "") });
82
+ dot.style.background = colour(a);
83
+ const msg = session.paused ? `You took over: ${a} is paused` : `${a} is using the page`;
84
+ const button = (text, go, fn) => Object.assign(document.createElement("button"), { textContent: text, className: go ? "go" : "", onclick: fn });
85
+ const content = [dot, msg, session.paused
86
+ ? button(`Let ${a} continue`, true, () => {
87
+ session.paused = false; session.quiet = false; showBar();
88
+ page.send({ to: a, text: "[from the page] The user is done using the page: carry on where you left off. "
89
+ + "Read the page again first, it may have changed." });
90
+ })
91
+ : button("Stop", false, () => { page.send({ type: "page_stop", agent: a }); endSession(); })];
92
+ if (bar?.open) bar.set(...content);
93
+ else bar = page.notice(content, () => { bar = null; if (!session) return; if (session.paused) endSession(); else session.quiet = true; });
94
+ }
95
+ function endSession() { session = null; bar?.close(); bar = null; pointer.classList.remove("on"); }
96
+
97
+ // where an element (or a point in its frame) is in this window, through any app frames (scaled ones too)
98
+ function toTop(doc, x, y) {
99
+ let k = 1;
100
+ for (let w = doc.defaultView; w && w !== window; w = w.parent) {
101
+ const fr = w.frameElement, r = fr.getBoundingClientRect(), s = r.width / (fr.offsetWidth || r.width || 1);
102
+ x = r.left + (fr.clientLeft + x) * s; y = r.top + (fr.clientTop + y) * s; k *= s;
103
+ }
104
+ return { x, y, k };
105
+ }
106
+ function box(el) {
107
+ const r = el.getBoundingClientRect(), p = toTop(el.ownerDocument, r.left, r.top);
108
+ return { x: p.x, y: p.y, w: r.width * p.k, h: r.height * p.k };
109
+ }
110
+ const spot = (el) => { const r = el.getBoundingClientRect(); return { x: r.left + Math.min(r.width / 2, 40), y: r.top + r.height / 2 }; };
111
+ const outlined = new WeakMap();
112
+ function outline(el, label, agent) {
113
+ if (!el?.getBoundingClientRect || Date.now() - (outlined.get(el) ?? 0) < 400) return; // typing: one outline, not one per letter
114
+ outlined.set(el, Date.now());
115
+ const b = box(el), c = colour(agent), d = Object.assign(document.createElement("div"), { className: "page-touch" });
116
+ Object.assign(d.style, { left: b.x - 3 + "px", top: b.y - 3 + "px", width: b.w + 6 + "px", height: b.h + 6 + "px", borderColor: c,
117
+ background: c.replace(")", " / .09)") });
118
+ const tag = document.createElement("b"); tag.textContent = `${agent} · ${short(label, 40)}`; tag.style.background = c; d.append(tag);
119
+ document.body.append(d);
120
+ setTimeout(() => { d.style.opacity = "0"; }, PACE.linger);
121
+ setTimeout(() => d.remove(), PACE.linger + 600);
122
+ }
123
+ function ripple(x, y, agent) {
124
+ const d = Object.assign(document.createElement("div"), { className: "page-ripple" });
125
+ Object.assign(d.style, { left: x + "px", top: y + "px", borderColor: colour(agent) });
126
+ document.body.append(d); setTimeout(() => d.remove(), 600);
127
+ }
128
+
129
+ // ---------- pausing, and the guard ----------
130
+ const gate = () => { if (session?.paused) throw new TookOver(); };
131
+ async function sleep(ms) {
132
+ const end = Date.now() + ms;
133
+ while (Date.now() < end) { gate(); await new Promise((r) => setTimeout(r, Math.min(60, end - Date.now()))); }
134
+ gate();
135
+ }
136
+ function takeOver(e) {
137
+ if (!e.isTrusted || !session || session.paused) return;
138
+ if (e.target.ownerDocument === document && !main.contains(e.target)) return; // the chat, the palette, the bar: not the page's content
139
+ session.paused = true; pointer.classList.remove("on"); showBar();
140
+ }
141
+ const watched = new WeakSet();
142
+ function watch(doc) { // the user's real input, and events code makes (outlined), in this document
143
+ if (watched.has(doc)) return;
144
+ watched.add(doc);
145
+ doc.addEventListener("pointerdown", takeOver, true);
146
+ doc.addEventListener("keydown", takeOver, true);
147
+ for (const type of ["click", "keydown", "input", "change", "submit", "drop"]) doc.addEventListener(type, (e) => { byCode = !e.isTrusted; }, true);
148
+ for (const type of ["click", "input", "change", "submit"]) doc.addEventListener(type, (e) => {
149
+ if (!e.isTrusted && busy && !mine && session) outline(e.target, type === "click" ? "clicked" : type === "submit" ? "submitted" : "changed", session.agent);
150
+ }, true);
151
+ }
152
+ watch(document);
153
+ document.addEventListener("click", (e) => {
154
+ codeClicked = !e.isTrusted;
155
+ if (!e.isTrusted && busy && e.target.closest?.("#stop")) { // Stop acts on the agents: only the user presses it
156
+ e.preventDefault(); e.stopImmediatePropagation(); refused.push("the Stop button: it acts on the agents, so only the user presses it");
157
+ }
158
+ }, true);
159
+ const realFetch = window.fetch;
160
+ window.fetch = function (input, init) {
161
+ if (busy && codeClicked && /\/version\/[^/?]+\/rollback/.test(String(input?.url ?? input))) {
162
+ refused.push("a rollback: it changes the agents' code, so only the user starts it");
163
+ return Promise.reject(new Error("refused: only the user rolls back"));
164
+ }
165
+ return realFetch.call(this, input, init);
166
+ };
167
+
168
+ // ---------- the helpers an agent's code gets ----------
169
+ function helpers(doc, agent) {
170
+ const w = doc.defaultView;
171
+ function fire(el, type, init = {}) {
172
+ const C = type.startsWith("pointer") ? w.PointerEvent : type.startsWith("key") ? w.KeyboardEvent
173
+ : type === "input" ? w.InputEvent : /^(drag|drop)/.test(type) ? w.DragEvent
174
+ : /^(mouse|click|dblclick)/.test(type) ? w.MouseEvent : w.Event;
175
+ const bubbles = !/^(pointerenter|pointerleave|mouseenter|mouseleave|focus|blur)$/.test(type);
176
+ mine++;
177
+ try { return el.dispatchEvent(new C(type, { bubbles, cancelable: true, composed: true, view: w, ...init })); } finally { mine--; }
178
+ }
179
+ const label = (el) => norm(el.getAttribute?.("aria-label") || el.placeholder || (el.labels?.[0]?.innerText) || el.innerText || el.value || el.title || el.alt || "");
180
+ const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && w.getComputedStyle(el).visibility !== "hidden"; };
181
+ // A spot {x, y} (window px of this document, as screen_see shows them) is where a real mouse would be: the thing on
182
+ // top there gets the events (a see-through layer like Point's, empty main space), at exactly that spot.
183
+ const isSpot = (t) => typeof t?.x === "number" && typeof t?.y === "number" && !t.nodeType;
184
+ function find(t, typing) {
185
+ if (t?.nodeType === 1) return t;
186
+ if (isSpot(t)) {
187
+ const el = doc.elementFromPoint(t.x, t.y);
188
+ if (!el) throw new Error(`nothing at x ${t.x}, y ${t.y}: the window is ${w.innerWidth}x${w.innerHeight}`);
189
+ return el;
190
+ }
191
+ if (typeof t !== "string" || !t) throw new Error("name what to act on: an element, a CSS selector, or its visible text");
192
+ try { const el = doc.querySelector(t); if (el) return el; } catch {} // not a selector: it's text
193
+ const want = norm(t).toLowerCase();
194
+ const fields = [...doc.querySelectorAll("input, textarea, select, [contenteditable=''], [contenteditable=true]")].filter(visible);
195
+ const pool = typing ? fields : [...doc.querySelectorAll(ACTIVE), ...doc.querySelectorAll("body *")].filter(visible);
196
+ const names = (el) => [label(el), norm(el.innerText), el.name, el.id].filter(Boolean).map((s) => s.toLowerCase());
197
+ for (const test of [(s) => s === want, (s) => s.startsWith(want), (s) => s.includes(want)]) {
198
+ const hit = pool.find((el) => (typing || !el.children.length || el.matches(ACTIVE)) && names(el).some(test));
199
+ if (hit) return typing ? hit : (hit.closest(ACTIVE) ?? hit);
200
+ }
201
+ if (typing) { const lab = [...doc.querySelectorAll("label")].find((l) => norm(l.innerText).toLowerCase().includes(want)); if (lab?.control) return lab.control; }
202
+ throw new Error(`nothing ${typing ? "to type into " : ""}on the page matches "${t}": read() lists what's there`);
203
+ }
204
+ async function reveal(el) {
205
+ const r = el.getBoundingClientRect();
206
+ if (r.top >= 0 && r.bottom <= w.innerHeight && r.left >= 0 && r.right <= w.innerWidth) return;
207
+ el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
208
+ await sleep(450);
209
+ }
210
+ async function moveTo(el, target) { // target a spot: go exactly there (it is in the window already: no scrolling)
211
+ gate();
212
+ if (!isSpot(target)) await reveal(el);
213
+ const s = isSpot(target) ? { x: target.x, y: target.y } : spot(el), p = toTop(doc, s.x, s.y);
214
+ pointer.querySelector("path").setAttribute("fill", colour(agent));
215
+ Object.assign(pointer.querySelector("span"), { textContent: agent }).style.background = colour(agent);
216
+ pointer.classList.add("on"); clearTimeout(hideTimer);
217
+ const ms = Math.max(PACE.moveMin, Math.min(PACE.moveMax, Math.hypot(p.x - px, p.y - py) * 1.3));
218
+ pointer.style.transition = `transform ${ms}ms cubic-bezier(.3,.7,.3,1), opacity .3s`;
219
+ pointer.style.transform = `translate(${p.x}px, ${p.y}px)`; px = p.x; py = p.y;
220
+ await sleep(ms + 80);
221
+ return { s, p };
222
+ }
223
+ const at = (s, extra = {}) => ({ clientX: s.x, clientY: s.y, pointerId: 1, pointerType: "mouse", isPrimary: true, ...extra });
224
+ function press1(el, s) { // the events of one real click, in order
225
+ fire(el, "pointerover", at(s)); fire(el, "pointerenter", at(s)); fire(el, "mouseover", at(s));
226
+ fire(el, "pointermove", at(s)); fire(el, "mousemove", at(s));
227
+ fire(el, "pointerdown", at(s, { buttons: 1 })); fire(el, "mousedown", at(s, { buttons: 1, detail: 1 }));
228
+ if (el.matches(ACTIVE) || el.tabIndex >= 0) el.focus({ preventScroll: true });
229
+ fire(el, "pointerup", at(s)); fire(el, "mouseup", at(s, { detail: 1 })); fire(el, "click", at(s, { detail: 1 }));
230
+ }
231
+ const valueSetter = (el) => Object.getOwnPropertyDescriptor(w[el.localName === "textarea" ? "HTMLTextAreaElement" : "HTMLInputElement"].prototype, "value").set;
232
+ const keyInit = (key) => {
233
+ const parts = key.split("+"), k = parts.pop() || "+", has = (m) => parts.some((p) => p.toLowerCase() === m);
234
+ const code = k.length === 1 ? (/[a-z]/i.test(k) ? `Key${k.toUpperCase()}` : /\d/.test(k) ? `Digit${k}` : k === " " ? "Space" : "") : k;
235
+ return { key: k === "Space" ? " " : k, code, ctrlKey: has("ctrl") || has("control"), shiftKey: has("shift"), altKey: has("alt"), metaKey: has("meta") || has("cmd") };
236
+ };
237
+
238
+ const api = {
239
+ async click(target) {
240
+ const el = find(target), { s, p } = await moveTo(el, target);
241
+ ripple(p.x, p.y, agent); press1(el, s); outline(el, label(el) || el.localName, agent);
242
+ await sleep(PACE.afterClick);
243
+ },
244
+ async type(target, text, { add = false } = {}) {
245
+ let el = find(target, true);
246
+ const { s, p } = await moveTo(el, target);
247
+ ripple(p.x, p.y, agent); press1(el, s);
248
+ text = String(text);
249
+ if (isSpot(target)) { // as a keyboard would: into what the click focused, else as keys to the page (Text's words)
250
+ await sleep(120);
251
+ const f = doc.activeElement;
252
+ if (f?.matches?.("input, textarea, select") || f?.isContentEditable) el = f;
253
+ else {
254
+ for (const ch of text) {
255
+ gate();
256
+ const k = keyInit(ch === "\n" ? "Enter" : ch === " " ? "Space" : ch);
257
+ fire(f ?? doc.body, "keydown", k); fire(f ?? doc.body, "keyup", k);
258
+ await sleep(PACE.keyMin + Math.random() * (PACE.keyMax - PACE.keyMin));
259
+ }
260
+ outline(el, `typed "${short(text, 24)}"`, agent);
261
+ return sleep(PACE.afterClick);
262
+ }
263
+ }
264
+ if (el.localName === "select") { // a dropdown: pick the option with that text (or value)
265
+ const o = [...el.options].find((o) => norm(o.text).toLowerCase() === norm(text).toLowerCase() || o.value === text);
266
+ if (!o) throw new Error(`"${label(el)}" has no option "${text}": ${[...el.options].map((o) => o.text).join(", ")}`);
267
+ el.value = o.value; fire(el, "input"); fire(el, "change"); outline(el, `picked "${o.text}"`, agent);
268
+ return sleep(PACE.afterClick);
269
+ }
270
+ const editable = el.isContentEditable, set = editable ? null : valueSetter(el);
271
+ if (/^(range|color|date|time|datetime-local|month|week)$/.test(el.type)) { // no letters to type: set it, as a slider drag or picker would
272
+ set.call(el, text); fire(el, "input"); fire(el, "change"); outline(el, `set to ${short(text, 24)}`, agent);
273
+ return sleep(PACE.afterClick);
274
+ }
275
+ if (!add) {
276
+ if (editable) { mine++; try { doc.execCommand("selectAll"); doc.execCommand("delete"); } finally { mine--; } }
277
+ else if (el.value) { set.call(el, ""); fire(el, "input", { inputType: "deleteContentBackward" }); }
278
+ }
279
+ for (const ch of text) {
280
+ gate();
281
+ const k = keyInit(ch === "\n" ? "Enter" : ch === " " ? "Space" : ch);
282
+ fire(el, "keydown", k);
283
+ if (editable) { mine++; try { doc.execCommand("insertText", false, ch); } finally { mine--; } }
284
+ else { set.call(el, el.value + ch); fire(el, "input", { inputType: "insertText", data: ch }); }
285
+ fire(el, "keyup", k);
286
+ outline(el, `typing "${short(text, 24)}"`, agent);
287
+ await sleep(PACE.keyMin + Math.random() * (PACE.keyMax - PACE.keyMin));
288
+ }
289
+ fire(el, "change");
290
+ await sleep(PACE.afterClick);
291
+ },
292
+ async press(key, target) {
293
+ const el = target ? find(target) : doc.activeElement && doc.activeElement !== doc.body ? doc.activeElement : doc.body;
294
+ if (target) await moveTo(el, target);
295
+ gate();
296
+ const k = keyInit(key), go = fire(el, "keydown", k);
297
+ if (go) { // what a browser does by itself for a real key, which events from code don't trigger
298
+ const field = el.localName === "input" && !/^(button|submit|checkbox|radio|reset)$/.test(el.type);
299
+ if (k.key === "Enter" && field && el.form) { mine++; try { el.form.requestSubmit(); } finally { mine--; } }
300
+ else if ((k.key === "Enter" || k.key === " ") && el.matches("button, a[href], [role=button], summary, input[type=checkbox], input[type=radio], input[type=submit]")) press1(el, spot(el));
301
+ else if (k.key === "Tab") {
302
+ const all = [...doc.querySelectorAll(ACTIVE)].filter((e) => visible(e) && !e.disabled && e.tabIndex >= 0);
303
+ const i = all.indexOf(el), next = all[(i + (k.shiftKey ? -1 : 1) + all.length) % all.length];
304
+ next?.focus();
305
+ }
306
+ }
307
+ fire(el, "keyup", k);
308
+ outline(el, `pressed ${key}`, agent);
309
+ await sleep(PACE.afterClick);
310
+ },
311
+ async drag(target, dx, dy = 0) { // by dx, dy px, or onto another thing or a spot: drag(a, b)
312
+ const el = find(target), { s } = await moveTo(el, target);
313
+ const to = typeof dx === "number" ? { x: s.x + dx, y: s.y + dy } : isSpot(dx) ? { x: dx.x, y: dx.y } : spot(find(dx));
314
+ // A draggable thing (draggable="true", e.g. a file row) moves by the browser's drag-and-drop, which code
315
+ // can't start: fire its events ourselves, with one DataTransfer from dragstart to drop, as the browser would.
316
+ const source = el.closest('[draggable="true"]'), data = source && new w.DataTransfer();
317
+ fire(el, "pointerdown", at(s, { buttons: 1 })); fire(el, "mousedown", at(s, { buttons: 1 }));
318
+ const dragging = source && fire(source, "dragstart", at(s, { buttons: 1, dataTransfer: data })); // cancelled: no drag
319
+ let over = el, accepted = false;
320
+ for (let i = 1; i <= 12; i++) {
321
+ const q = { x: s.x + (to.x - s.x) * i / 12, y: s.y + (to.y - s.y) * i / 12 }, p = toTop(doc, q.x, q.y);
322
+ pointer.style.transition = "transform 40ms linear, opacity .3s"; pointer.style.transform = `translate(${p.x}px, ${p.y}px)`; px = p.x; py = p.y;
323
+ const now = doc.elementFromPoint(q.x, q.y) ?? el;
324
+ if (dragging) {
325
+ if (now !== over) { fire(over, "dragleave", at(q, { dataTransfer: data })); fire(now, "dragenter", at(q, { buttons: 1, dataTransfer: data })); }
326
+ accepted = !fire(now, "dragover", at(q, { buttons: 1, dataTransfer: data })); // a drop target cancels dragover
327
+ } else { fire(now, "pointermove", at(q, { buttons: 1 })); fire(now, "mousemove", at(q, { buttons: 1 })); }
328
+ over = now;
329
+ await sleep(45);
330
+ }
331
+ if (dragging) {
332
+ if (accepted) fire(over, "drop", at(to, { dataTransfer: data }));
333
+ fire(source, "dragend", at(to, { dataTransfer: data }));
334
+ } else { fire(over, "pointerup", at(to)); fire(over, "mouseup", at(to)); }
335
+ outline(el, accepted ? "dragged and dropped" : "dragged", agent);
336
+ await sleep(PACE.afterClick);
337
+ },
338
+ read(target, { all = false } = {}) { // what's on screen: headings, controls (with value and state), short texts
339
+ const root = target ? find(target) : doc.body, lines = [];
340
+ // the conversation (#log) and the working rows are what the agent already knows, and on Chalky's page they
341
+ // drown out the rest: left out, unless asked for by name (read("#log"))
342
+ const known = doc === document && !root.closest("#log, #working") ? "#log, #working" : null;
343
+ let out = 0;
344
+ for (const el of [...(target ? [root] : []), ...root.querySelectorAll("*")]) {
345
+ if (el.closest("script, style, template, noscript") || (known && el.closest(known))) continue;
346
+ const control = el.matches(ACTIVE) && el.localName !== "label", heading = /^h[1-6]$/.test(el.localName);
347
+ const text = !el.children.length ? norm(el.innerText) : "";
348
+ if (!control && !heading && !(text && text.length <= 80)) continue;
349
+ if (!control && el.parentElement?.closest(ACTIVE)) continue; // text inside a button: the button says it
350
+ if (!visible(el)) continue;
351
+ const r = el.getBoundingClientRect();
352
+ if (!all && (r.bottom < 0 || r.top > w.innerHeight)) { out++; continue; }
353
+ if (lines.length >= 80) { out++; continue; }
354
+ const tag = el.localName, role = el.getAttribute("role");
355
+ const kind = heading ? "heading" : tag === "input" ? `input ${el.type}` : control ? (role ?? tag) : "text";
356
+ const state = [el.checked && "checked", el.disabled && "disabled", el.getAttribute("aria-selected") === "true" && "selected",
357
+ el.getAttribute("aria-pressed") === "true" && "pressed", el.getAttribute("aria-checked") === "true" && "checked"].filter(Boolean);
358
+ const value = /^(input|textarea|select)$/.test(tag) && !/^(checkbox|radio|button|submit)$/.test(el.type) ? ` = ${JSON.stringify(short(el.value, 60))}` : "";
359
+ lines.push(`${kind} "${short(label(el) || norm(el.innerText), 70)}"${value}${state.length ? ` [${state.join(", ")}]` : ""}`);
360
+ }
361
+ return lines.join("\n") + (out ? `\n(+${out} more out of view: scroll, or read(${target ? "target, " : "null, "}{all: true}))` : "");
362
+ },
363
+ async wait(what, timeout = 5000) { // ms, a CSS selector to appear, or a function to become true
364
+ if (typeof what === "number") return sleep(what);
365
+ const test = typeof what === "string" ? () => doc.querySelector(what) : what, end = Date.now() + timeout;
366
+ for (;;) {
367
+ const v = await test();
368
+ if (v) return v;
369
+ if (Date.now() > end) throw new Error(`wait: still not there after ${timeout} ms`);
370
+ await sleep(100);
371
+ }
372
+ },
373
+ };
374
+ return api;
375
+ }
376
+
377
+ // ---------- running a page_run ----------
378
+ page.on("page_run", async ({ req, tab: target, card, code, agent = "agent1" }) => {
379
+ if (target !== tab) return;
380
+ const answer = (a) => page.send({ type: "page_ran", req, errors: [], ...a });
381
+ if (session?.paused) return answer({ error: `the user has taken over the page: wait until they press "Let ${session.agent} continue" (you get a message)` });
382
+ let w = window;
383
+ if (card) {
384
+ const el = document.querySelector(`[data-card="${CSS.escape(card)}"]`);
385
+ if (!el) return answer({ error: `no card "${card}" on the page` });
386
+ const fr = el.querySelector("iframe");
387
+ if (!fr) return answer({ error: `card "${card}" shows blocks, not an app: run without card and start from document.querySelector('[data-card="${card}"]')` });
388
+ try { fr.contentDocument.body; w = fr.contentWindow; } catch { return answer({ error: `card "${card}" shows another site: its page can't be reached` }); }
389
+ if (el.closest(".board:not(.on)")) code = `/* not on the board the user is looking at */\n${code}`;
390
+ else { // in front of the other cards, and in view
391
+ for (const o of document.querySelectorAll("[data-agent-front]")) if (o !== el) o.removeAttribute("data-agent-front");
392
+ el.setAttribute("data-agent-front", "");
393
+ const r = el.getBoundingClientRect();
394
+ if (r.top < 0 || r.bottom > innerHeight || r.left < 0 || r.right > innerWidth) {
395
+ el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
396
+ await new Promise((ok) => setTimeout(ok, 450));
397
+ }
398
+ }
399
+ }
400
+ if (!session || session.agent !== agent) session = { agent, paused: false };
401
+ showBar();
402
+ for (const fr of main.querySelectorAll("iframe")) try { if (fr.contentDocument) watch(fr.contentDocument); } catch {}
403
+ const errors = [], onError = (e) => errors.push(String(e.error?.stack ?? e.message ?? e).split("\n").slice(0, 3).join("\n"));
404
+ const onReject = (e) => errors.push(`unhandled rejection: ${e.reason?.message ?? e.reason}`);
405
+ const consoleError = w.console.error;
406
+ w.addEventListener("error", onError);
407
+ w.addEventListener("unhandledrejection", onReject);
408
+ w.console.error = (...args) => { errors.push(args.map(String).join(" ")); consoleError.apply(w.console, args); };
409
+ busy++; refused = []; clearTimeout(hideTimer);
410
+ const h = helpers(w.document, agent);
411
+ let settled = false;
412
+ const finish = (a) => { // once: the code ended, or the page it ran in went away (then the code never ends)
413
+ if (settled) return;
414
+ settled = true;
415
+ answer({ ...a, errors: [...errors, ...refused.map((r) => `refused ${r}`)] });
416
+ busy--;
417
+ w.removeEventListener("error", onError);
418
+ w.removeEventListener("unhandledrejection", onReject);
419
+ w.removeEventListener("pagehide", onLeave);
420
+ w.console.error = consoleError;
421
+ hideTimer = setTimeout(() => pointer.classList.remove("on"), 2500);
422
+ clearTimeout(idleTimer); // no turn behind it (curl), or a turn that never ends: the bar goes after a quiet minute
423
+ idleTimer = setTimeout(() => { if (session && !session.paused && !busy) endSession(); }, 60000);
424
+ };
425
+ const onLeave = () => finish({ value: "the app reloaded (or left its page): the code after that didn't run; continue in a new page_run" });
426
+ w.addEventListener("pagehide", onLeave);
427
+ try {
428
+ const value = await new w.Function("click", "type", "press", "drag", "read", "wait", `return (async () => {\n${code}\n})()`)(
429
+ h.click, h.type, h.press, h.drag, h.read, h.wait);
430
+ finish({ value: describe(value) });
431
+ } catch (e) {
432
+ finish({ error: e instanceof TookOver ? `the user took over the page (they clicked or typed): stop here and wait; when they press "Let ${agent} continue" you get a message`
433
+ : e?.name ? `${e.name}: ${e.message}` : String(e) }); // the stack only points into this file
434
+ }
435
+ });
436
+ page.on("run_end", ({ agent }) => { if (session?.agent === agent && !session.paused) endSession(); }); // paused: the bar stays, to continue
437
+ }
438
+
439
+ // An element (from any frame: no instanceof) as tag#id.class "its text"
440
+ function element(el) {
441
+ const id = el.id ? `#${el.id}` : "", cls = typeof el.className === "string" && el.className.trim() ? "." + el.className.trim().split(/\s+/).join(".") : "";
442
+ const text = norm(el.innerText ?? el.textContent ?? "");
443
+ const value = "value" in el && el.value !== "" && el.localName !== "button" ? ` = ${JSON.stringify(el.value)}` : "";
444
+ return `${el.localName}${id}${cls}${text ? ` "${short(text, 60)}"` : ""}${value}`;
445
+ }
446
+ function describe(v) {
447
+ if (v === undefined) return "done (nothing returned)";
448
+ if (v?.nodeType === 1) return element(v);
449
+ if (v && typeof v === "object" && typeof v.length === "number" && v.length && [...v].every((x) => x?.nodeType === 1)) v = [...v].map(element);
450
+ let s;
451
+ try { s = typeof v === "string" ? v : JSON.stringify(v, (_k, x) => (x?.nodeType === 1 ? element(x) : x), 1); } catch { s = String(v); }
452
+ return s.length > MAX ? s.slice(0, MAX) + `… (${s.length} characters, cut)` : s;
453
+ }
@@ -12,15 +12,84 @@
12
12
  // (compaction keeps the original entries, so the page still shows everything)
13
13
  // "joined" (an agent's first system message) and reply_to (who a turn's answer goes to: the host's "chalky.turn"
14
14
  // entry just before the user message that starts it) are read from the session, so nothing is kept here.
15
+ // Running code in the user's open page (browser.js here runs it):
16
+ // tool page_run {code, card?} JavaScript in the page the user has open, or inside a card's app: its value back
17
+ // POST /page/run {code, card?, agent?} the same from bash (curl): {value?, error?, errors}
18
+ // event "page_run" {req, tab, card, code, agent} to every page; only the tab named runs it
19
+ // page message page_tab {tab} this tab is the one the user looks at (on load and on focus)
20
+ // page message page_ran {req, value?, error?, errors: [str]}
21
+ // page message page_stop {agent} the bar shown while an agent uses the page: Stop (that agent only)
22
+ // The code gets helpers that act as a person would, slowly enough for the user to watch (browser.js's header).
15
23
  import fs from "node:fs";
16
24
  import path from "node:path";
17
25
  import { SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
+ import { Type } from "typebox";
18
27
 
19
28
  const host = (globalThis as any).chalky;
20
29
  const EXTENSIONS = path.dirname(import.meta.dirname);
21
30
  const browserFile = (name: string) => path.join(EXTENSIONS, name, "browser.js");
31
+ const RUN_WAIT = 60; // s for the page to answer a page_run
32
+ // req -> resolve, while page_run waits (on the host: each agent loads this file, the page handler is one); the tab to run in
33
+ const runs: Map<string, (msg: any) => void> = host.shared.get("page-runs") ?? new Map();
34
+ host.shared.set("page-runs", runs);
22
35
 
23
- export default function (_pi: ExtensionAPI) {
36
+ host.pageHandlers.set("page", (msg: any) => {
37
+ if (msg.type === "page_tab") host.shared.set("page-tab", msg.tab);
38
+ else if (msg.type === "page_ran") runs.get(msg.req)?.(msg);
39
+ else if (msg.type === "page_stop" && host.agents.has(msg.agent)) host.abort(msg.agent); // the bar's Stop: that agent only
40
+ });
41
+
42
+ function runInPage(code: string, card: string | undefined, agent: string, signal?: AbortSignal): Promise<any> {
43
+ const req = `r${Date.now()}${Math.random().toString(36).slice(2, 6)}`, tab = host.shared.get("page-tab");
44
+ if (!tab) return Promise.resolve({ error: "no page is open: ask the user to open Chalky" });
45
+ return new Promise((resolve) => {
46
+ const done = ({ value, error, errors }: any) => { clearTimeout(timer); runs.delete(req); resolve({ value, error, errors }); };
47
+ const timer = setTimeout(() => done({ error: `no answer within ${RUN_WAIT} s: the page may be closed, or the code still running` }), RUN_WAIT * 1000);
48
+ runs.set(req, done);
49
+ signal?.addEventListener("abort", () => done({ error: "stopped" }));
50
+ host.broadcast("page_run", { req, tab, card, code, agent });
51
+ });
52
+ }
53
+
54
+ const said = (r: any) => (r.error ? `error: ${r.error}` : r.value)
55
+ + (r.errors?.length ? `\nerrors in the page while it ran:\n${r.errors.join("\n")}` : "");
56
+
57
+ export default function (pi: ExtensionAPI) {
58
+ pi.on("resources_discover", () => ({ skillPaths: [path.join(import.meta.dirname, "skills")] })); // interact-with-page
59
+ pi.registerTool({
60
+ name: "page_run", label: "Page run", exposure: "deferred",
61
+ description: "Run JavaScript in the page the user has open, as it is right now, and get its value back. With card=<id>, "
62
+ + "it runs inside that card's app (document is the app's page); without it, in Chalky's page itself. The code is the "
63
+ + "body of an async function: use await, and return what you want back (an element comes back as a short description, "
64
+ + "long values are cut). Errors the page logs while it runs come back too. It is the user's live page: what you click "
65
+ + "really happens, and they watch it. Helpers act as a person would, with your pointer moving there first: "
66
+ + "await click(t), await type(t, text), await press(key, t?), await drag(t, dx, dy), read(t?) (what's on screen, "
67
+ + "as short lines), await wait(ms | selector | fn); t is an element, a CSS selector, its visible text, or a spot "
68
+ + "{x, y} in window px (whatever is on top there, as with a real mouse: empty space, a drawing layer). "
69
+ + "For acting, use the helpers: a bare el.click() happens at once, and the user only sees an outline afterwards. "
70
+ + "If the user clicks or types on the page, they have taken over: stop and wait for their message.",
71
+ parameters: Type.Object({
72
+ code: Type.String({ description: "the body of an async function, e.g. return document.querySelector('h1').innerText" }),
73
+ card: Type.Optional(Type.String({ description: "a card showing an app: run inside that app" })),
74
+ }),
75
+ async execute(_id, args: any, signal) {
76
+ const r = await runInPage(args.code, args.card, pi.getSessionName(), signal);
77
+ return { details: undefined, content: [{ type: "text" as const, text: said(r) }] };
78
+ },
79
+ });
80
+
81
+ host.routes.set("/page/run", (req: any, res: any) => {
82
+ if (req.method !== "POST") return res.writeHead(405).end();
83
+ let body = "";
84
+ req.on("data", (c: Buffer) => { body += c; });
85
+ req.on("end", async () => {
86
+ let a: any;
87
+ try { a = JSON.parse(body); } catch { a = null; }
88
+ const r = typeof a?.code !== "string" ? { error: 'send JSON {"code": "...", "card": "<id>"}' }
89
+ : await runInPage(a.code, a.card, a.agent ?? "agent1");
90
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(r));
91
+ });
92
+ });
24
93
  host.routes.set("/", (_req: any, res: any) =>
25
94
  res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }).end(fs.readFileSync(path.join(import.meta.dirname, "page.html"))));
26
95
  host.routes.set("/page/scripts", (_req: any, res: any) => {
@@ -28,6 +28,9 @@
28
28
  Extending without editing this file: an extension's browser.js (extensions/<name>/browser.js; GET /page/scripts lists them);
29
29
  each script's default export gets pageApi: on(type, fn), send(obj) (-> the extensions' page handlers),
30
30
  slot("top" | "status" | "side"), tool(button) -> the row under that palette tool (for its sub-tools), note(text) -> the line, load(name), block(name, props) (blocks from extensions/card/blocks),
31
+ notice(content, onClose) -> {el, set(...content), close(), open}: a notice in the column at the top right, newest on
32
+ top (content: a string, a node, or a list of them; a <button class="go"> is the main action); its ✕ calls onClose,
33
+ then closes it; an action closes it with close(). Notices of every extension go here, so they never overlap.
31
34
  command(name, fn, description): "/name rest" typed in the input calls fn(rest) instead of sending (other "/..." text
32
35
  is sent); typing "/" lists the commands above the input, narrowing as you type.
33
36
  provide(name, obj) / use(name) -> promise of obj: what one browser plugin offers others (scripts load in any
@@ -82,6 +85,24 @@
82
85
  #status .sub:not(:has(> :not([hidden]))) { display: none; } /* only hidden buttons (or none): no row */
83
86
  #status .tool .badge { top: -4px; right: -4px; } /* on the corner, off the icon */
84
87
  #side { position: fixed; top: 0; right: 0; z-index: 20; display: flex; flex-direction: column; align-items: flex-end; }
88
+ /* notices (pageApi.notice): a column at the top right, newest on top; above cards and frames */
89
+ #notices { position: fixed; top: 14px; right: 16px; z-index: 200000; width: min(340px, calc(100vw - 32px));
90
+ display: flex; flex-direction: column; pointer-events: none; }
91
+ #notices .notice { display: grid; grid-template-rows: 1fr; pointer-events: auto;
92
+ transition: grid-template-rows .32s cubic-bezier(.3,.7,.3,1), opacity .25s, transform .32s cubic-bezier(.3,.7,.3,1); }
93
+ #notices .notice.out { grid-template-rows: 0fr; opacity: 0; transform: translateX(28px); } /* arriving or leaving: no height, so the rest slide */
94
+ #notices .notice > div { min-height: 0; overflow: hidden; }
95
+ #notices .notice-box { position: relative; margin-bottom: 8px; padding: 10px 34px 10px 14px; border-radius: 14px; display: flex;
96
+ flex-wrap: wrap; align-items: center; gap: 8px 10px; font-size: 13px; color: var(--text);
97
+ background: var(--glass-strong, #fff); border: 1px solid var(--edge, #eee); box-shadow: 0 8px 26px rgba(0,0,0,.12);
98
+ backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); }
99
+ #notices .notice-box button { border: 0; border-radius: 8px; padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
100
+ background: var(--line, #eee); color: var(--text); }
101
+ #notices .notice-box button.go { background: var(--accent, #0071e3); color: #fff; }
102
+ #notices .notice-box button.notice-x { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; padding: 0;
103
+ background: none; color: var(--muted); font-size: 13px; line-height: 22px; }
104
+ #notices .notice-box button.notice-x:hover { background: var(--hover, #eee); color: var(--text); }
105
+ @media (prefers-reduced-motion: reduce) { #notices .notice { transition: opacity .15s; } }
85
106
  #rail button, #status > button, #status > details > summary, #status > .tool > button, #status .sub > button {
86
107
  width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; color: var(--muted);
87
108
  position: relative; list-style: none; cursor: pointer; transition: background 0.15s, color 0.15s; }
@@ -172,7 +193,7 @@
172
193
  #f { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 30; width: min(720px, 64vw);
173
194
  display: flex; align-items: center; gap: 8px; padding: 2px 0 6px; border-bottom: 1px solid rgba(0, 0, 0, 0.16); }
174
195
  #f:focus-within { border-bottom-color: rgba(0, 0, 0, 0.4); }
175
- #to { border: 0; background: none; font: inherit; font-size: 12px; color: var(--muted); }
196
+ #to { border: 0; background: none; font: inherit; font-size: 12px; color: var(--muted); outline: none; }
176
197
  #text { flex: 1; border: 0; outline: none; background: none; font: inherit; font-size: 16.5px; color: var(--text); padding: 4px 0; }
177
198
  #text::placeholder { color: var(--muted); }
178
199
  /* Slash commands: the list above the input while a command's name is typed */
@@ -212,6 +233,7 @@
212
233
  </form>
213
234
  <nav id="status" data-name="tools"></nav>
214
235
  <aside id="side"></aside>
236
+ <div id="notices" aria-live="polite"></div>
215
237
  <script>
216
238
  const log = document.getElementById("log"), to = document.getElementById("to");
217
239
  const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
@@ -402,6 +424,31 @@
402
424
  command: (name, fn, description = "") => { commands[name] = { fn, description }; },
403
425
  provide: (name, obj) => { offered[name] = obj; for (const ok of wanted[name] || []) ok(obj); delete wanted[name]; },
404
426
  use: (name) => name in offered ? Promise.resolve(offered[name]) : new Promise((ok) => (wanted[name] ??= []).push(ok)),
427
+ notice: (content, onClose) => { // a notice at the top right, newest on top; ✕ calls onClose, then it goes
428
+ const item = Object.assign(document.createElement("div"), { className: "notice out" });
429
+ const inner = document.createElement("div"), box = Object.assign(document.createElement("div"), { className: "notice-box" });
430
+ const x = Object.assign(document.createElement("button"), { className: "notice-x", textContent: "✕", title: "Close" });
431
+ let open = true;
432
+ const handle = {
433
+ el: box,
434
+ set: (...nodes) => box.replaceChildren(...nodes, x), // new content, no new animation
435
+ close: () => {
436
+ if (!open) return;
437
+ open = false;
438
+ item.classList.add("out");
439
+ setTimeout(() => item.remove(), 360);
440
+ },
441
+ get open() { return open; },
442
+ };
443
+ x.onclick = () => { onClose?.(); handle.close(); };
444
+ handle.set(...[content].flat());
445
+ inner.append(box);
446
+ item.append(inner);
447
+ document.getElementById("notices").prepend(item);
448
+ item.offsetHeight; // laid out closed first, so opening animates
449
+ item.classList.remove("out");
450
+ return handle;
451
+ },
405
452
  tool: (button) => { // puts button in the palette with a row under it; returns the row, for its sub-tools
406
453
  const sub = Object.assign(document.createElement("div"), { className: "sub" });
407
454
  const wrap = Object.assign(document.createElement("span"), { className: "tool" });