@chalky/chalky 0.0.0-stage → 0.1.4

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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/app/SYSTEM.md +31 -0
  4. package/app/extensions/appdata/README.md +10 -0
  5. package/app/extensions/appdata/guide.md +4 -0
  6. package/app/extensions/appdata/index.ts +47 -0
  7. package/app/extensions/boards/browser.js +357 -0
  8. package/app/extensions/boards/guide.md +4 -0
  9. package/app/extensions/boards/index.ts +98 -0
  10. package/app/extensions/commands/browser.js +13 -0
  11. package/app/extensions/commands/guide.md +3 -0
  12. package/app/extensions/commands/index.ts +96 -0
  13. package/app/extensions/doodle/README.md +15 -0
  14. package/app/extensions/doodle/browser.js +398 -0
  15. package/app/extensions/doodle/guide.md +6 -0
  16. package/app/extensions/doodle/index.ts +54 -0
  17. package/app/extensions/files/browser.js +96 -0
  18. package/app/extensions/files/guide.md +7 -0
  19. package/app/extensions/files/index.ts +73 -0
  20. package/app/extensions/frames/README.md +37 -0
  21. package/app/extensions/frames/browser.js +166 -0
  22. package/app/extensions/frames/guide.md +7 -0
  23. package/app/extensions/frames/index.ts +85 -0
  24. package/app/extensions/login/browser.js +363 -0
  25. package/app/extensions/login/index.ts +214 -0
  26. package/app/extensions/mcp/browser.js +64 -0
  27. package/app/extensions/mcp/guide.md +5 -0
  28. package/app/extensions/mcp/index.ts +64 -0
  29. package/app/extensions/model/index.ts +20 -0
  30. package/app/extensions/page/index.ts +96 -0
  31. package/app/extensions/page/page.html +615 -0
  32. package/app/extensions/point/README.md +22 -0
  33. package/app/extensions/point/browser.js +247 -0
  34. package/app/extensions/point/guide.md +6 -0
  35. package/app/extensions/point/index.ts +112 -0
  36. package/app/extensions/prompt/index.ts +19 -0
  37. package/app/extensions/reload/index.ts +21 -0
  38. package/app/extensions/screen/browser.js +76 -0
  39. package/app/extensions/screen/index.ts +80 -0
  40. package/app/extensions/steps/index.ts +17 -0
  41. package/app/extensions/swarm/README.md +47 -0
  42. package/app/extensions/swarm/guide.md +11 -0
  43. package/app/extensions/swarm/index.ts +304 -0
  44. package/app/extensions/ui/README.md +69 -0
  45. package/app/extensions/ui/agents.js +45 -0
  46. package/app/extensions/ui/browser.js +364 -0
  47. package/app/extensions/ui/components/App.js +28 -0
  48. package/app/extensions/ui/components/Button.js +28 -0
  49. package/app/extensions/ui/components/Card.js +23 -0
  50. package/app/extensions/ui/components/Column.js +17 -0
  51. package/app/extensions/ui/components/Markdown.js +37 -0
  52. package/app/extensions/ui/components/Row.js +17 -0
  53. package/app/extensions/ui/components/Table.js +31 -0
  54. package/app/extensions/ui/components/Text.js +19 -0
  55. package/app/extensions/ui/components/TextField.js +30 -0
  56. package/app/extensions/ui/guide.md +7 -0
  57. package/app/extensions/ui/index.ts +291 -0
  58. package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
  59. package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
  60. package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
  61. package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
  62. package/app/extensions/ui/voice.js +114 -0
  63. package/app/extensions/ui_history/browser.js +97 -0
  64. package/app/extensions/ui_history/index.ts +109 -0
  65. package/app/extensions/versions/browser.js +58 -0
  66. package/app/extensions/versions/index.ts +168 -0
  67. package/app/extensions/viewer/README.md +12 -0
  68. package/app/extensions/viewer/browser.js +322 -0
  69. package/app/extensions/viewer/guide.md +5 -0
  70. package/app/extensions/viewer/index.ts +209 -0
  71. package/app/extensions/voice/browser.js +71 -0
  72. package/app/extensions/voice/index.ts +37 -0
  73. package/app/mcp.json +3 -0
  74. package/app/settings.json +3 -0
  75. package/app/skills/plugin-map/SKILL.md +49 -0
  76. package/app/skills/write-skill/SKILL.md +57 -0
  77. package/app/ui/apps/.keep +0 -0
  78. package/bin/chalky.mjs +11 -0
  79. package/node_modules/xlsx/CHANGELOG.md +361 -0
  80. package/node_modules/xlsx/LICENSE +201 -0
  81. package/node_modules/xlsx/README.md +41 -0
  82. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  83. package/node_modules/xlsx/bower.json +22 -0
  84. package/node_modules/xlsx/dist/LICENSE +201 -0
  85. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  86. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  87. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  88. package/node_modules/xlsx/dist/shim.min.js +2 -0
  89. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  90. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  91. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  92. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  93. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  94. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  95. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  96. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  97. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  98. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  99. package/node_modules/xlsx/package.json +183 -0
  100. package/node_modules/xlsx/types/index.d.ts +1065 -0
  101. package/node_modules/xlsx/types/tsconfig.json +15 -0
  102. package/node_modules/xlsx/xlsx.js +28103 -0
  103. package/node_modules/xlsx/xlsx.mjs +28228 -0
  104. package/node_modules/xlsx/xlsxworker.js +14 -0
  105. package/package.json +42 -4
  106. package/server.ts +281 -0
@@ -0,0 +1,291 @@
1
+ // UI surfaces from the page's components (port of the Python ui plugin; browser.js, the page side, is its ui.js).
2
+ // Tools ui_catalog, ui_render, ui_update, ui_delete; guide.md, a short prompt section (README.md, copied from ui.py: the whole of it).
3
+ // The surfaces are shared by every agent: one Surfaces object on the host, made by whichever agent loads first.
4
+ // One door: every change goes through change(): it saves, tells every open page, and calls every watcher with the
5
+ // surface, who did it and the words for it ("you moved s1"; none: part of a bigger change). restore() puts back a
6
+ // saved state (not a change: no watchers). Watchers (Python: the "ui_changed" event), registered from session_start:
7
+ // host.api.ui.watch(name, fn(surface, by, what)) name: the extension's, so its reload replaces its own
8
+ // (ui_history: a step per change with words; frames: a frame is done when its surface appears).
9
+ // app/ui/surfaces.json design (everything but data) state/ui-data.json data (what users typed)
10
+ // In this folder: components/*.js (the catalog), agents.js and voice.js (what apps import), package.json and
11
+ // node_modules/ (browser libraries components import), skills/ (ui-design, ui-check, agent-apps: given to pi).
12
+ // GET /ui/apps/<name>/... an app (app/ui/apps: what the agents built)
13
+ // GET /ui/components/..., /ui/agents.js, /ui/voice.js from this folder; /ui/node_modules/{marked,dompurify}/... the host's
14
+ // GET /ui-api/catalog (name -> mtime), /ui-api/about (name -> about block), /ui-api/surfaces
15
+ // App messages and app_reset: swarm/.
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+
21
+ const host = (globalThis as any).chalky;
22
+ const DESIGN = path.join(host.app, "ui/surfaces.json");
23
+ const APPS = path.join(host.app, "ui/apps");
24
+ const COMPONENTS = path.join(import.meta.dirname, "components");
25
+ const SERVED = ["components/", "agents.js", "voice.js"]; // what /ui/ serves from this folder
26
+ const LIBS = ["marked/", "dompurify/"]; // the page's browser libraries, from the host's node_modules (its package.json)
27
+ const DATA = path.join(host.state, "ui-data.json");
28
+ const read = (p: string, d: any) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : d);
29
+ const clone = (v: any) => structuredClone(v);
30
+ const isRef = (v: any) => v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).sort().join() === "path,ref";
31
+ const propsOf = (c: any) => Object.fromEntries(Object.entries(c).filter(([k]) => !["id", "component", "children", "child"].includes(k)));
32
+
33
+ function getPath(data: any, p: string) {
34
+ for (const k of p.split("/").filter(Boolean)) data = data?.[Array.isArray(data) ? Number(k) : k];
35
+ return data;
36
+ }
37
+ function setPath(data: any, p: string, value: any) {
38
+ const keys = p.split("/").filter(Boolean);
39
+ for (const k of keys.slice(0, -1)) data = data[k] ??= {};
40
+ data[keys.at(-1)!] = value;
41
+ }
42
+
43
+ // On the host, so they outlive this file's reloads; one per watching extension
44
+ const watchers: Map<string, (sid: string, by?: string, what?: string) => void> = host.shared.get("ui-watchers") ?? new Map();
45
+ host.shared.set("ui-watchers", watchers);
46
+ host.api.ui = { watch: (name: string, fn: (sid: string, by?: string, what?: string) => void) => watchers.set(name, fn) };
47
+
48
+ class Surfaces {
49
+ surfaces: Record<string, any> = read(DESIGN, {});
50
+ constructor() {
51
+ const data = read(DATA, {});
52
+ for (const [sid, r] of Object.entries(this.surfaces)) r.data = data[sid] ?? {};
53
+ }
54
+ save() {
55
+ fs.writeFileSync(DESIGN, JSON.stringify(Object.fromEntries(Object.entries(this.surfaces)
56
+ .map(([sid, r]) => [sid, { ...r, data: undefined }])), null, 1));
57
+ fs.writeFileSync(DATA, JSON.stringify(Object.fromEntries(Object.entries(this.surfaces).map(([sid, r]) => [sid, r.data ?? {}]))));
58
+ }
59
+ get(sid: string) { return clone(this.surfaces[sid]); }
60
+ snapshot() { return clone(this.surfaces); }
61
+ // record null: delete. by: "user" or an agent; what: the history's words for it (none: part of a bigger change,
62
+ // so no step of its own; the last change makes one)
63
+ change(sid: string, record: any, by?: string, what?: string) {
64
+ if (record === null) delete this.surfaces[sid];
65
+ else this.surfaces[sid] = record;
66
+ this.save();
67
+ host.broadcast("ui", record === null ? { op: "delete", surface: sid } : { op: "render", surface: sid, ...record });
68
+ for (const [name, fn] of watchers) {
69
+ try { fn(sid, by, what); }
70
+ catch (e) { host.broadcast("error", { agent: "server", text: `ui watcher ${name}: ${e}` }); } // one broken watcher doesn't stop the rest
71
+ }
72
+ }
73
+ load() { // read the files again (after a rollback of the code changed surfaces.json); true if the surfaces changed
74
+ const before = JSON.stringify(this.surfaces), design = read(DESIGN, {}), data = read(DATA, {});
75
+ for (const [sid, r] of Object.entries(design) as any) r.data = data[sid] ?? {};
76
+ this.surfaces = design;
77
+ host.broadcast("ui", { op: "reset", surfaces: this.surfaces });
78
+ return JSON.stringify(this.surfaces) !== before;
79
+ }
80
+ restore(surfaces: Record<string, any>) { // a whole saved state (undo, redo, rewind): not a change of its own
81
+ this.surfaces = clone(surfaces);
82
+ this.save();
83
+ host.broadcast("ui", { op: "reset", surfaces: this.surfaces });
84
+ }
85
+ locked(sid: string) {
86
+ return this.surfaces[sid]?.pinned ? `error: surface '${sid}' is pinned by the user, so agents can't change or delete it. `
87
+ + `Make a new surface instead (made_from=['${sid}'] keeps the link), or ask the user to unpin it.` : null;
88
+ }
89
+ copyIntoUsers(sid: string) { // before sid goes: surfaces reading from it get its current values
90
+ const users = Object.keys(this.surfaces).filter((o) => o !== sid && this.surfaces[o].components
91
+ .some((c: any) => Object.values(propsOf(c)).some((v: any) => isRef(v) && v.ref === sid)));
92
+ for (const o of users) {
93
+ const r = this.get(o);
94
+ for (const c of r.components)
95
+ for (const [k, v] of Object.entries(propsOf(c)) as any)
96
+ if (isRef(v) && v.ref === sid) c[k] = clone(getPath(this.surfaces[sid].data, v.path));
97
+ this.change(o, r);
98
+ }
99
+ return users;
100
+ }
101
+ }
102
+
103
+ if (!host.shared.has("ui")) host.shared.set("ui", new Surfaces());
104
+ const ui: Surfaces = host.shared.get("ui");
105
+
106
+ function problems(components: any[], madeFrom: string[] = []) {
107
+ const catalog = readCatalog(), ids = new Set(components.map((c) => c.id));
108
+ const errors = ids.has("root") ? [] : ['one component must have "id": "root"'];
109
+ for (const c of components) {
110
+ if (!(c.component in catalog)) { errors.push(`${c.id}: unknown component '${c.component}' (have: ${Object.keys(catalog).join(", ")})`); continue; }
111
+ for (const child of [...(c.children ?? []), ...(c.child ? [c.child] : [])])
112
+ if (!ids.has(child)) errors.push(`${c.id}: child '${child}' is not defined`);
113
+ const known = catalog[c.component].about?.props;
114
+ for (const [k, v] of Object.entries(propsOf(c)) as any) {
115
+ if (known && !(k in known)) errors.push(`${c.id}: ${c.component} has no prop '${k}' (props: ${Object.keys(known).join(", ") || "none"})`);
116
+ if (isRef(v) && !(v.ref in ui.surfaces)) errors.push(`${c.id}.${k}: refers to surface '${v.ref}', which does not exist`);
117
+ }
118
+ }
119
+ return [...errors, ...madeFrom.filter((m) => !(m in ui.surfaces)).map((m) => `made_from: no surface '${m}'`)];
120
+ }
121
+
122
+ // The components (components/*.js): {name: {doc, mtime, about, problem}}; doc = the comment lines at the top
123
+ function readCatalog() {
124
+ const out: Record<string, any> = {};
125
+ for (const f of fs.readdirSync(COMPONENTS).filter((f) => f.endsWith(".js")).sort()) {
126
+ const text = fs.readFileSync(path.join(COMPONENTS, f), "utf8");
127
+ const doc: string[] = [];
128
+ for (const line of text.split("\n")) {
129
+ if (!line.startsWith("//")) break;
130
+ doc.push(line.slice(2).trim());
131
+ }
132
+ let about = null, problem = null;
133
+ const i = text.indexOf("export const about = ");
134
+ if (i >= 0) {
135
+ try { about = JSON.parse(text.slice(i + 21, text.indexOf("};", i) + 1)); }
136
+ catch (e) { problem = `its about block is not valid JSON (${e})`; }
137
+ }
138
+ out[f.slice(0, -3)] = { doc: doc.join("\n"), mtime: Math.floor(fs.statSync(path.join(COMPONENTS, f)).mtimeMs / 1000), about, problem };
139
+ }
140
+ return out;
141
+ }
142
+
143
+ const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
144
+ const Place = Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number(), width: Type.Optional(Type.Number()), height: Type.Optional(Type.Number()) },
145
+ { description: "pin it at a spot (px from the main space's top-left); leave out to stack it" }));
146
+
147
+ export default function (pi: ExtensionAPI) {
148
+ const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
149
+ .replace("README.md", path.join(import.meta.dirname, "README.md"));
150
+ pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.ui = guide; });
151
+ pi.on("resources_discover", () => ({ skillPaths: [path.join(import.meta.dirname, "skills")] }));
152
+ // a removed board (boards/) takes its surfaces with it: one step in the history (undo brings them back)
153
+ pi.on("session_start", () => host.api.boards?.onRemove("ui", (n: number) => {
154
+ const sids = Object.keys(ui.surfaces).filter((sid) => (ui.surfaces[sid].board ?? 1) === n);
155
+ const gone = sids.map((sid) => ({ owner: ui.surfaces[sid].owner, what: `surface ${sid}` }));
156
+ sids.forEach((sid, i) => {
157
+ ui.copyIntoUsers(sid); // surfaces elsewhere reading from it keep its values
158
+ ui.change(sid, null, "user", i === sids.length - 1 ? `you removed board ${n} (${sids.join(", ")})` : undefined);
159
+ });
160
+ return gone;
161
+ }));
162
+
163
+ pi.registerTool({
164
+ name: "ui_catalog", label: "UI catalog", description: "List the UI components you can use in surfaces, with their props.",
165
+ parameters: Type.Object({}),
166
+ async execute() {
167
+ const catalog = readCatalog();
168
+ const broken = Object.entries(catalog).filter(([, m]: any) => m.problem).map(([n, m]: any) => `${n}: ${m.problem}`);
169
+ return result(Object.values(catalog).map((m: any) => m.doc + (m.about ? `\nabout: ${JSON.stringify(m.about)}` : "")).join("\n\n")
170
+ + (broken.length ? `\n\nProblems:\n${broken.join("\n")}` : ""));
171
+ },
172
+ });
173
+
174
+ pi.registerTool({
175
+ name: "ui_render", label: "UI render", description: "Draw or replace a UI surface (a card in the main space) from catalog components.",
176
+ parameters: Type.Object({
177
+ surface: Type.String(),
178
+ components: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()))),
179
+ data: Type.Optional(Type.Record(Type.String(), Type.Any())),
180
+ place: Place,
181
+ draggable: Type.Optional(Type.Boolean()), resizable: Type.Optional(Type.Boolean()),
182
+ about: Type.Optional(Type.String({ description: "what it is for and why it is built this way (its back side)" })),
183
+ options: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()), { description: 'alternatives instead of components: [{"label", "components", "data"}]' })),
184
+ made_from: Type.Optional(Type.Array(Type.String())), instruction: Type.Optional(Type.String()),
185
+ board: Type.Optional(Type.Union([Type.Number(), Type.Literal("new")], { description:
186
+ 'a board number, or "new" for a new board; leave out: where the surface is, else the board the user is on' })),
187
+ }),
188
+ async execute(_id, a) {
189
+ const agent = pi.getSessionName() ?? "agent";
190
+ const locked = ui.locked(a.surface);
191
+ if (locked) return result(locked);
192
+ if (!a.components && !a.options) return result("error: give components, or options (a list of alternatives)");
193
+ const options = (a.options ?? []).map((o: any, i: number) => ({ label: o.label ?? `option ${i + 1}`, components: o.components ?? [], data: o.data ?? {} }));
194
+ for (const o of options.length ? options : [{ label: "", components: a.components! }]) {
195
+ const errors = problems(o.components, a.made_from);
196
+ if (errors.length) return result("error: " + (o.label ? `${o.label}: ` : "") + errors.join("; "));
197
+ }
198
+ const old = ui.surfaces[a.surface] ?? {}, catalog = readCatalog(), boards = host.api.boards;
199
+ if (typeof a.board === "number" && boards && !boards.has(a.board))
200
+ return result(`error: no board ${a.board}. Boards: ${boards.list().join(", ")} (or board "new")`);
201
+ const first = options[0] ?? { components: a.components, data: a.data ?? {} };
202
+ const record: any = { owner: agent, components: first.components, data: first.data, place: a.place ?? old.place ?? null,
203
+ draggable: a.draggable ?? true, resizable: a.resizable ?? true, about: a.about ?? old.about ?? "",
204
+ kinds: Object.fromEntries((options.length ? options : [first]).flatMap((o: any) => o.components)
205
+ .map((c: any) => [c.component, catalog[c.component].about?.version ?? 1])) };
206
+ for (const [k, v] of [["made_from", a.made_from ?? old.made_from], ["instruction", a.instruction ?? old.instruction],
207
+ ["options", options.length ? options : null]] as const) if (v) record[k] = v;
208
+ // its board (boards/): the one asked for, a new one, where it already is, or the one the user is on
209
+ if (boards) record.board = a.board === "new" ? boards.add() : a.board ?? old.board ?? boards.current();
210
+ ui.change(a.surface, record, agent, `${agent} drew ${a.surface}` + (options.length ? ` (${options.length} options)` : ""));
211
+ const where = boards ? `on board ${record.board}${a.board === "new" ? " (a new board)" : ""}` : "on screen";
212
+ return result(options.length ? `surface '${a.surface}' shows ${options.length} options ${where}; the user flips through them and keeps one`
213
+ : `surface '${a.surface}' is ${where}`);
214
+ },
215
+ });
216
+
217
+ pi.registerTool({
218
+ name: "ui_update", label: "UI update", description: "Change one value in a surface's data (e.g. a score or a list).",
219
+ parameters: Type.Object({ surface: Type.String(), path: Type.String({ description: "e.g. /score" }), value: Type.Any() }),
220
+ async execute(_id, a) {
221
+ if (!(a.surface in ui.surfaces)) return result(`error: no surface '${a.surface}'. Surfaces: ${Object.keys(ui.surfaces).join(", ")}`);
222
+ const locked = ui.locked(a.surface);
223
+ if (locked) return result(locked);
224
+ const r = ui.get(a.surface);
225
+ setPath(r.data, a.path, a.value);
226
+ const agent = pi.getSessionName() ?? "agent";
227
+ ui.change(a.surface, r, agent, `${agent} changed ${a.path} in ${a.surface}`);
228
+ return result("updated");
229
+ },
230
+ });
231
+
232
+ pi.registerTool({
233
+ name: "ui_delete", label: "UI delete", description: "Remove a UI surface.",
234
+ parameters: Type.Object({ surface: Type.String() }),
235
+ async execute(_id, a) {
236
+ const locked = ui.locked(a.surface);
237
+ if (locked) return result(locked);
238
+ const users = ui.copyIntoUsers(a.surface), agent = pi.getSessionName() ?? "agent";
239
+ ui.change(a.surface, null, agent, `${agent} deleted ${a.surface}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
240
+ return result("deleted" + (users.length ? `; ${users.join(", ")} read from it, so they got a copy of its data` : ""));
241
+ },
242
+ });
243
+
244
+ // The page side: registered once for all agents (keyed by name, so each agent's load replaces the last one)
245
+ host.routes.set("/ui-api/catalog", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" })
246
+ .end(JSON.stringify(Object.fromEntries(Object.entries(readCatalog()).map(([n, m]: any) => [n, m.mtime])))));
247
+ host.routes.set("/ui-api/about", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" })
248
+ .end(JSON.stringify(Object.fromEntries(Object.entries(readCatalog()).filter(([, m]: any) => m.about).map(([n, m]: any) => [n, m.about])))));
249
+ host.routes.set("/ui/", (req: any, res: any) => {
250
+ let url = "";
251
+ try { url = path.posix.normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)); } catch {} // bad %-escape: 404
252
+ if (!url.startsWith("/ui/")) return res.writeHead(404).end(); // also a ".." out of /ui/
253
+ const p = url.slice("/ui/".length);
254
+ if (p.startsWith("apps/")) return host.file(res, path.join(APPS, p.slice("apps/".length)));
255
+ const lib = p.slice("node_modules/".length);
256
+ if (p.startsWith("node_modules/")) return LIBS.some((l) => lib.startsWith(l)) ? host.file(res, path.join(host.modules, lib)) : res.writeHead(404).end();
257
+ if (!SERVED.some((s) => p === s || (s.endsWith("/") && p.startsWith(s)))) return res.writeHead(404).end();
258
+ host.file(res, path.join(import.meta.dirname, p));
259
+ });
260
+ host.routes.set("/ui-api/surfaces", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(ui.surfaces)));
261
+ host.pageHandlers.set("ui", (msg: any) => {
262
+ const { type, surface: sid } = msg;
263
+ const r = typeof sid === "string" ? ui.get(sid) : undefined;
264
+ if (!r) return;
265
+ if (type === "ui_close") {
266
+ const users = ui.copyIntoUsers(sid);
267
+ ui.change(sid, null, "user", `you closed ${sid}` + (users.length ? ` (${users.join(", ")} got a copy)` : ""));
268
+ }
269
+ else if (type === "ui_move") {
270
+ if (r.pinned) return host.broadcast("ui", { op: "render", surface: sid, ...r });
271
+ r.place = msg.place; ui.change(sid, r, "user", `you moved ${sid}`);
272
+ } else if (type === "ui_board" && host.api.boards?.has(msg.board)) { // dropped on another board's thumbnail
273
+ if (r.pinned) return host.broadcast("ui", { op: "render", surface: sid, ...r });
274
+ r.board = msg.board; r.place = msg.place ?? null; ui.change(sid, r, "user", `you moved ${sid} to board ${msg.board}`);
275
+ } else if (type === "ui_set") { setPath(r.data, msg.path ?? "/", msg.value); ui.change(sid, r, "user", `you changed ${msg.path} in ${sid}`); }
276
+ else if (type === "ui_prop") {
277
+ for (const c of r.components) if (c.id === msg.component && !["id", "component", "children", "child"].includes(msg.prop)) c[msg.prop] = msg.value;
278
+ ui.change(sid, r, "user", `you set ${msg.prop} of ${msg.component} in ${sid}`);
279
+ } else if (type === "ui_pin") { r.pinned = !!msg.pinned; ui.change(sid, r, "user", `you ${r.pinned ? "pinned" : "unpinned"} ${sid}`); }
280
+ else if (type === "ui_keep" && r.options) {
281
+ const o = r.options[Number(msg.index ?? 0)];
282
+ delete r.options;
283
+ if (o) { r.components = o.components; r.data = o.data; ui.change(sid, r, "user", `you kept ${o.label} of ${sid}`); }
284
+ } else if (type === "ui_action") {
285
+ r.data = msg.data ?? r.data;
286
+ ui.change(sid, r, "user", `you used ${msg.name} in ${sid}`);
287
+ host.send("user", r.owner, `[ui action] ${msg.name} on surface '${sid}' (component ${msg.component})\n`
288
+ + `context: ${JSON.stringify(msg.context ?? {})}\ndata: ${JSON.stringify(r.data)}`);
289
+ }
290
+ });
291
+ }
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: agent-apps
3
+ description: Build an app whose members are real AI agents (a simulated board meeting, a debate, a negotiation, a panel of experts, game characters), run and shown by the app itself, in turns or all at once. Use when the user wants agents inside what you build, as opposed to agents helping you build it.
4
+ ---
5
+ # Apps with real agents inside
6
+
7
+ ## Two kinds of agents: keep them apart
8
+ | | Worker agents | App agents |
9
+ |-------------------|---------------------------------|----------------------------------------------|
10
+ | What for | help you build or fix the app | are part of the app (a board member, a debater) |
11
+ | Spawn | `spawn_agent(role)` | `spawn_agent(role, name, app="<surface>")` |
12
+ | Who talks to them | you (`tell`, `ask_agent`) | the app, through `/ui/agents.js` |
13
+ | Messages panel | shown | hidden: the app shows their talk |
14
+ | When they run | while you build | each time the user runs the app |
15
+
16
+ Don't mix them: an app agent never edits code, and a worker never plays a character. The `app` label
17
+ is what keeps app agents out of the user's messages panel and lets the app reset them.
18
+
19
+ ## Why the app runs the activity, not you
20
+ Your context is large (system prompt plus everything you did while building): each of your model calls
21
+ costs all of it, often 50k tokens or more. If you relay a meeting yourself (ask the chair who's next, ask
22
+ that member, `ui_update` the transcript), every line spoken costs two or three of your calls. The app
23
+ can talk to its agents directly, and their contexts are small. So you build, fix, and answer the user;
24
+ at run time you make no calls at all.
25
+
26
+ ## Steps
27
+ 1. **Plan the members**: a short name each (it is the agent's name, seen in every event), and a role with
28
+ stance and personality. End each role with: "You are a character in <app>. Answer in plain text,
29
+ briefly, starting with [to: <name>] or [to: all]. Don't use tools." App agents have the same tools
30
+ you have; this keeps them in character, fast, and gives the app a "to" to draw.
31
+ 2. **Build the UI as an App**: `ui/apps/<name>/index.html` in your code folder (the one your prompt names, with
32
+ `extensions/` in it), and its scripts, shown with
33
+ `ui_render("<surface>", [{"id": "root", "component": "App", "src": "<name>/index.html"}])`.
34
+ An App can import `/ui/agents.js`; the bridge finds the app's surface from the frame it runs in.
35
+ 3. **Spawn the members once**, while building: `spawn_agent(role, name=..., app="<surface>")`, the same
36
+ string as the surface id. In the app, check `agents.list()`: if a member is missing, show that
37
+ instead of failing.
38
+ 4. **Wire the app with `/ui/agents.js`** (read its header):
39
+ - `send(to, text)`: ask a member. Its answer arrives as a `"message"` event: `agent` is that member,
40
+ `role` is `"assistant"`, and there are no `tool_calls` (its final answer).
41
+ - `on("message", fn)`: draw each answer as it arrives (speaker, the `[to: …]` target, the text).
42
+ - `reset()`: a fresh round, members' memories empty and roles kept. Call it before each run if the
43
+ user wants rounds to start fresh.
44
+ - `action(name, context)`: only for what needs you, e.g. "add a member" (you spawn it).
45
+ 5. **Run the activity in the app's own JavaScript**, the way the user asked:
46
+ - In turns: one member at a time; the next speaker comes from a rule or from a chair member (ask
47
+ it to answer "NEXT: <name>").
48
+ - All at once: send to several members together; answers come back in any order, so draw each one
49
+ as it arrives.
50
+ Members only know what you send them: include the transcript so far (or its last lines) each time.
51
+ 6. **Check it** with the ui-check skill: one short run, budget 2 or 3 messages (each is a real model call).
52
+
53
+ ## The ask helper (copy into the app)
54
+ ```js
55
+ import agents from "/ui/agents.js";
56
+ const waiting = {}; // member -> resolve() for its next final answer
57
+ agents.on("message", (ev) => {
58
+ if (ev.role === "assistant" && !ev.tool_calls?.length && waiting[ev.agent]) {
59
+ waiting[ev.agent](ev.content);
60
+ delete waiting[ev.agent];
61
+ }
62
+ });
63
+ function ask(name, text) { // send, then wait for that member's answer
64
+ const answer = new Promise((resolve) => (waiting[name] = resolve));
65
+ agents.send(name, text);
66
+ return answer;
67
+ }
68
+ // in turns: const said = await ask(next, `${scenario}\nSo far:\n${lines.join("\n")}\nYour turn.`);
69
+ // all at once: const said = await Promise.all(names.map((n) => ask(n, prompt)));
70
+ ```
71
+ Only start asking after the user presses Start: on load, past events are replayed first, and an
72
+ answer from an earlier run must not resolve a new question.
73
+
74
+ ## Record and replay
75
+ - Keep the run in the app: `[{ts, from, to, text}]` from the events. Replay draws them in `ts` order
76
+ with play, pause and a scrubber.
77
+ - After a reload, past events replay: rebuild earlier runs from them (only your members' messages).
78
+ A member's `"system"` message means a fresh memory, i.e. a new round began.
79
+ - Don't keep the transcript in the surface's data with `ui_update`: that is you relaying again.
80
+
81
+ ## Limits
82
+ - App messages: at most 30 a minute (each is a model call); more are dropped with an `"error"` event.
83
+ Show the run's message budget in the UI and stop when it is spent.
84
+ - At most 8 agents in all, you and any workers included.
85
+ - An agent takes one turn at a time: sending to a busy member queues the message.
86
+
87
+ ## Pitfalls
88
+ - Disable Start while a run is going: a double click starts two runs.
89
+ - An answer without a `[to: …]` tag: treat it as to all.
90
+ - If the app shows nothing, the panel won't help (app agents are hidden there): check the app with
91
+ ui-check, e.g. `eval:` the length of its transcript, and look for `"error"` events.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: ui-check
3
+ description: Check a web UI in a real headless browser - JavaScript errors, failed requests, clipped or overflowing elements, how it looks, and whether clicks and keys work. Use after changing any UI (the page, UI components or surfaces, an app you built) and before calling UI work done.
4
+ ---
5
+ # Checking a UI in a headless browser
6
+
7
+ Reading the code back doesn't show what the user sees: the page is built when its scripts run, and
8
+ layout only exists once the browser has computed it. So check UI work by running it.
9
+
10
+ ## 1. Run the check
11
+ From this skill's folder (paths here are relative to it):
12
+ ```
13
+ node scripts/check.mjs URL [STEPS ...] [--size 1280x800] [--shot /tmp/ui.png] [--full]
14
+ ```
15
+ - URL: the user's page is `{page}/`. An app you built is served by it too
16
+ (`{page}/ui/apps/<name>/index.html`); any other file: `file:///<full path>`.
17
+ - Steps run in order after the page loads (it gets 1.5 s first):
18
+ `wait:500`, `click:#pause`, `drag:#card=200,0`, `altdrag:#top=-150,0` (Alt held), `hold:.label=800` (press and hold),
19
+ `dblclick:.label`, `type:#message=hello`, `key:Enter`, `eval:document.querySelectorAll('.agent').length`,
20
+ `shot:/tmp/after.png`, `dialog:accept` (`node scripts/check.mjs --help` lists them).
21
+ - confirm()/alert()/prompt() dialogs are answered for you and reported (`dialog confirm "Stop agent3?" -> dismissed`).
22
+ Default is dismiss (Cancel). Put `dialog:accept` (or `dialog:accept=TEXT` for a prompt) before the click
23
+ whose dialog you want to accept.
24
+ - It prints errors (uncaught exceptions, console errors, failed requests), layout problems (an element
25
+ cut off by a box that hides overflow, an element sticking out of the window, cut-off text, sideways
26
+ scrolling), each step's result, and the screenshot path. Exit code 1 means a step failed or there were
27
+ errors. Layout problems don't change the exit code: judge each one (a decoration meant to bleed off the edge
28
+ is fine; a cut-off button isn't).
29
+
30
+ Example: did the map fit, and does pause work?
31
+ ```
32
+ node scripts/check.mjs {page}/ \
33
+ "eval:getComputedStyle(document.querySelector('#map')).height" click:#pause \
34
+ "eval:document.querySelector('#pause').textContent" --shot /tmp/ui.png
35
+ ```
36
+
37
+ ## The browser
38
+ The check needs a headless browser. It uses $CHROME if set, else one downloaded before, else an installed
39
+ Chrome, Chromium or Edge. If none is there it stops (exit code 2) and prints a download command with pinned
40
+ versions: run exactly that command once (about 270 MB on disk; later runs reuse it), then run the check again. Only
41
+ download when the check says none was found. If the download fails (no network, no space), tell the user what
42
+ the error said; don't try other browsers or versions.
43
+
44
+ ## 2. Read the report, fix, run again
45
+ - Fix every error and layout problem, or say in your evidence why one is fine.
46
+ - Check at the size users have (1280x800) and a narrow one (`--size 390x844`) if the layout should adapt.
47
+ - `eval:` is how you check state precisely: counts, text, sizes, a value your code exposes.
48
+
49
+ ## 3. Look at it
50
+ The layout check sees elements, not pixels: a sprite drawn on a canvas, colours, contrast, overlapping
51
+ drawings, whether it looks right at all - those need eyes. Take a screenshot (`--shot`) and look at it
52
+ with `read(path)`, then check what you were looking for ("are all five agent sprites fully inside the map?
53
+ is any text unreadable?"). Look after a real change, not after every run: each image stays in your
54
+ conversation and adds to every later call.
55
+
56
+ ## Careful: the page is live
57
+ `{page}/` is the real page connected to the real agents. A click or a typed message
58
+ there really happens: sending a message wakes an agent, Stop stops everyone, rollback changes
59
+ code. Click only what you mean to test, prefer pause/resume over stop, and test destructive controls
60
+ on a copy (a replay, or your own test page), not on the live system. Dialogs are dismissed unless
61
+ you say `dialog:accept`, so a "Stop agent3?" confirm only acts when you mean it to.
62
+
63
+ ## Good evidence for "done"
64
+ The command you ran, its counts ("0 errors, 0 layout problems at 1280x800 and 390x844"), what the
65
+ eval steps returned, and what you saw in the screenshot.
66
+
67
+ ## Pitfalls
68
+ - `type:` splits at the first `=`: use a selector without one (`ui-textfield input`, not `[data-surface=x] input`).
69
+ - `querySelector` doesn't reach inside a shadow root; use `eval:` with `el.shadowRoot.querySelector(...)`.
70
+ - Content that arrives over the WebSocket may need `wait:1000` before you check it.
71
+ - Chrome starts fresh for each run (no cookies, no storage); each run takes a few seconds.