@chalky/chalky 0.1.5 → 0.1.6

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.
Files changed (52) hide show
  1. package/README.md +8 -2
  2. package/agent/SYSTEM.md +2 -1
  3. package/agent/extensions/app/README.md +15 -11
  4. package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
  5. package/agent/extensions/board/browser.js +5 -9
  6. package/agent/extensions/card/index.ts +18 -18
  7. package/agent/extensions/file/browser.js +46 -7
  8. package/agent/extensions/file/index.ts +3 -2
  9. package/agent/extensions/frame/README.md +2 -2
  10. package/agent/extensions/frame/index.ts +16 -7
  11. package/agent/extensions/history/index.ts +3 -1
  12. package/agent/extensions/note/README.md +3 -1
  13. package/agent/extensions/note/browser.js +33 -7
  14. package/agent/extensions/note/guide.md +2 -1
  15. package/agent/extensions/note/index.ts +36 -13
  16. package/agent/extensions/page/browser.js +453 -0
  17. package/agent/extensions/page/index.ts +70 -1
  18. package/agent/extensions/page/page.html +47 -0
  19. package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
  20. package/agent/extensions/screen/browser.js +6 -19
  21. package/agent/extensions/version/browser.js +9 -0
  22. package/agent/extensions/version/index.ts +73 -21
  23. package/agent/skills/design/SKILL.md +1 -1
  24. package/agent/skills/show-me/SKILL.md +66 -0
  25. package/package.json +2 -1
  26. package/server.ts +169 -7
  27. package/node_modules/xlsx/CHANGELOG.md +0 -361
  28. package/node_modules/xlsx/LICENSE +0 -201
  29. package/node_modules/xlsx/README.md +0 -41
  30. package/node_modules/xlsx/bin/xlsx.njs +0 -310
  31. package/node_modules/xlsx/bower.json +0 -22
  32. package/node_modules/xlsx/dist/LICENSE +0 -201
  33. package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
  34. package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
  35. package/node_modules/xlsx/dist/cpexcel.js +0 -1506
  36. package/node_modules/xlsx/dist/shim.min.js +0 -2
  37. package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
  38. package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
  39. package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
  40. package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
  41. package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
  42. package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
  43. package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
  44. package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
  45. package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
  46. package/node_modules/xlsx/dist/zahl.d.ts +0 -4
  47. package/node_modules/xlsx/package.json +0 -183
  48. package/node_modules/xlsx/types/index.d.ts +0 -1065
  49. package/node_modules/xlsx/types/tsconfig.json +0 -15
  50. package/node_modules/xlsx/xlsx.js +0 -28103
  51. package/node_modules/xlsx/xlsx.mjs +0 -28228
  52. package/node_modules/xlsx/xlsxworker.js +0 -14
@@ -3,13 +3,16 @@
3
3
  // note_add (page) click anything on the page (or draw around an area) and write: the target is the card,
4
4
  // block, element, part (partAt), its text and source file, the spot and the area.
5
5
  // Files dropped on the page come as a note too ("files": paths in <workspace>/drops).
6
+ // Written by an agent's code through the page (msg.by, page/browser.js): that agent's own note.
6
7
  // note_combine the user dragged card a onto card b and wrote how to combine them. The agent changes b to
7
8
  // take a in, reading a's data live ({"ref"} props); a stays as it is.
8
9
  // note_reply the user wrote in a note's thread (its bubble): it goes to the note's agent, with the thread so far
9
- // note_close / note_reopen / note_delete the user's ✓ Done in the bubble; Reopen or Delete (for good) on a closed one
10
+ // note_close / note_reopen / note_delete the user's ✓ (close) or ✕ (delete, for good; open or closed) at the
11
+ // bubble's top right, Reopen on a closed one
10
12
  // The agents' tools: note_add(on, text) starts a thread on a thing (on: as doodle_mark's, checked on the open page),
11
- // note_reply(id, text) writes in one, note_done(id) closes one. A closed note stays as a faded ✓ pin (the user can
12
- // reopen or delete it); the agents never delete notes, and a removed board takes its notes with it.
13
+ // note_reply(id, text) writes in one, note_done(id) closes one, note_done(id, delete: true) removes one the agent started
14
+ // (never the user's or another agent's). A closed note stays as a faded ✓ pin (the user can reopen or delete it); a
15
+ // removed board takes its notes with it.
13
16
  // Each note goes to the card's owner (else the agent picked in the chat; an agent's own note: that agent), gets an id
14
17
  // (n1, n2, ...: never reused) and is saved: data/note.json {"last", "notes": {id: {thread: [{by, text, ts}], to, by,
15
18
  // board, state: "open" | "done", at: {x, y} (main px), anchor: {card, dx, dy}, stroke, target?, box?}}}. The pin sits
@@ -40,6 +43,12 @@ const save = () => fs.writeFileSync(FILE, JSON.stringify(D));
40
43
  const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
41
44
  const show = (id: string) => host.broadcast("note_pin", { id, ...D.notes[id] });
42
45
  const opened = () => Object.keys(D.notes).filter((id) => D.notes[id].state === "open");
46
+ const remove = (id: string) => { // a note, for good: off every page
47
+ delete D.notes[id];
48
+ items.delete(id);
49
+ save();
50
+ host.broadcast("note_gone", { id });
51
+ };
43
52
 
44
53
  function pin(note: any) { // a new note: saved, pinned on every page
45
54
  const id = `n${++D.last}`;
@@ -114,10 +123,18 @@ export default function (pi: ExtensionAPI) {
114
123
 
115
124
  pi.registerTool({
116
125
  name: "note_done", label: "Note done", description: "Close a note that is dealt with (you did what it asked): its pin turns into a faded ✓ (the user can "
117
- + "reopen it). Not while your answer waits on the user (a suggestion, a question).",
118
- parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }) }),
126
+ + "reopen it). Not while your answer waits on the user (a suggestion, a question). With delete: true, a note you started "
127
+ + "goes from the page instead (open or closed), e.g. one that explained a step and has served its purpose.",
128
+ parameters: Type.Object({ id: Type.String({ description: "e.g. n3" }),
129
+ delete: Type.Optional(Type.Boolean({ description: "remove it from the page for good: only notes you started" })) }),
119
130
  async execute(_id, a) {
120
131
  const n = D.notes[a.id];
132
+ if (a.delete) {
133
+ if (!n) return result(`error: no note '${a.id}'`);
134
+ if (n.by !== me()) return result(`error: ${a.id} was started by ${n.by === "user" ? "the user" : n.by}: only its starter deletes it. Close it instead (note_done without delete).`);
135
+ remove(a.id);
136
+ return result(`note ${a.id} is gone from the page`);
137
+ }
121
138
  if (!n || n.state !== "open") return result(`error: no open note '${a.id}'. Open notes: ${opened().join(", ") || "none"}`);
122
139
  n.state = "done";
123
140
  items.delete(a.id);
@@ -142,12 +159,11 @@ export default function (pi: ExtensionAPI) {
142
159
  return;
143
160
  }
144
161
  if (msg.type === "note_reopen" && n?.state === "done") { n.state = "open"; save(); return show(msg.id); }
145
- if (msg.type === "note_delete" && n?.state === "done") { // a closed note, for good
146
- delete D.notes[msg.id];
147
- items.delete(msg.id);
148
- save();
149
- host.broadcast("note_gone", { id: msg.id });
150
- if (host.agents.has(n.to)) team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page.`);
162
+ if (msg.type === "note_delete" && n) { // open or closed, for good
163
+ const item = items.get(msg.id);
164
+ remove(msg.id);
165
+ if (host.agents.has(n.to) && !(item && team?.withdraw(item))) // not seen yet: just take it back
166
+ team?.fyi("page", n.to, `[note on the page] the user deleted note ${msg.id} ("${n.thread[0]?.text}"): it is gone from the page; drop it if you haven't finished it.`);
151
167
  return;
152
168
  }
153
169
  if (msg.type === "note_reply" && n && team && String(msg.text ?? "").trim()) {
@@ -163,10 +179,17 @@ export default function (pi: ExtensionAPI) {
163
179
  }
164
180
  if (!["note_add", "note_combine"].includes(msg.type) || !team) return;
165
181
  host.api.doodle?.dim();
166
- const to = host.agents.has(msg.to) ? msg.to : "agent1";
167
182
  const text = msg.text ?? "";
168
- const note = { to, by: "user", board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
183
+ const base = { board: Number(msg.board) || 1, at: msg.at ?? { x: 0, y: 0 }, ...(msg.anchor && { anchor: msg.anchor }),
169
184
  stroke: msg.stroke ?? null }; // with its drawing, if any (main px)
185
+ // an agent's code wrote it through the page's tools (msg.by, page/browser.js): that agent's own note, as if it
186
+ // called note_add; nothing to tell it, it knows (the reply to page_run says what happened)
187
+ if (host.agents.has(msg.by)) {
188
+ pin({ ...base, to: msg.by, by: msg.by, thread: [{ by: msg.by, text: msg.type === "note_combine" ? `combine ${msg.a} into ${msg.b}: ${text}` : text, ts: Date.now() }] });
189
+ return;
190
+ }
191
+ const to = host.agents.has(msg.to) ? msg.to : "agent1";
192
+ const note = { ...base, to, by: "user" };
170
193
  if (msg.type === "note_combine") {
171
194
  const { a, b } = msg;
172
195
  const madeFrom = [...(host.shared.get("card")?.cards[b]?.made_from ?? []).filter((m: string) => m !== a), a];
@@ -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) => {