@chalky/chalky 0.0.0-stage → 0.1.3

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/app/SYSTEM.md +30 -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/doodle/README.md +15 -0
  8. package/app/extensions/doodle/browser.js +388 -0
  9. package/app/extensions/doodle/guide.md +6 -0
  10. package/app/extensions/doodle/index.ts +54 -0
  11. package/app/extensions/files/browser.js +96 -0
  12. package/app/extensions/files/guide.md +7 -0
  13. package/app/extensions/files/index.ts +73 -0
  14. package/app/extensions/frames/README.md +37 -0
  15. package/app/extensions/frames/browser.js +163 -0
  16. package/app/extensions/frames/guide.md +7 -0
  17. package/app/extensions/frames/index.ts +76 -0
  18. package/app/extensions/mcp/browser.js +64 -0
  19. package/app/extensions/mcp/guide.md +5 -0
  20. package/app/extensions/mcp/index.ts +64 -0
  21. package/app/extensions/model/index.ts +20 -0
  22. package/app/extensions/page/index.ts +96 -0
  23. package/app/extensions/page/page.html +615 -0
  24. package/app/extensions/point/README.md +22 -0
  25. package/app/extensions/point/browser.js +245 -0
  26. package/app/extensions/point/guide.md +6 -0
  27. package/app/extensions/point/index.ts +97 -0
  28. package/app/extensions/prompt/index.ts +19 -0
  29. package/app/extensions/reload/index.ts +21 -0
  30. package/app/extensions/screen/browser.js +76 -0
  31. package/app/extensions/screen/index.ts +80 -0
  32. package/app/extensions/steps/index.ts +17 -0
  33. package/app/extensions/swarm/README.md +47 -0
  34. package/app/extensions/swarm/guide.md +11 -0
  35. package/app/extensions/swarm/index.ts +304 -0
  36. package/app/extensions/ui/README.md +69 -0
  37. package/app/extensions/ui/agents.js +45 -0
  38. package/app/extensions/ui/browser.js +363 -0
  39. package/app/extensions/ui/components/App.js +28 -0
  40. package/app/extensions/ui/components/Button.js +28 -0
  41. package/app/extensions/ui/components/Card.js +23 -0
  42. package/app/extensions/ui/components/Column.js +17 -0
  43. package/app/extensions/ui/components/Markdown.js +37 -0
  44. package/app/extensions/ui/components/Row.js +17 -0
  45. package/app/extensions/ui/components/Table.js +31 -0
  46. package/app/extensions/ui/components/Text.js +19 -0
  47. package/app/extensions/ui/components/TextField.js +30 -0
  48. package/app/extensions/ui/guide.md +7 -0
  49. package/app/extensions/ui/index.ts +271 -0
  50. package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
  51. package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
  52. package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
  53. package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
  54. package/app/extensions/ui/voice.js +114 -0
  55. package/app/extensions/ui_history/browser.js +97 -0
  56. package/app/extensions/ui_history/index.ts +109 -0
  57. package/app/extensions/versions/browser.js +58 -0
  58. package/app/extensions/versions/index.ts +168 -0
  59. package/app/extensions/viewer/README.md +12 -0
  60. package/app/extensions/viewer/browser.js +322 -0
  61. package/app/extensions/viewer/guide.md +5 -0
  62. package/app/extensions/viewer/index.ts +209 -0
  63. package/app/mcp.json +3 -0
  64. package/app/skills/plugin-map/SKILL.md +49 -0
  65. package/app/skills/write-skill/SKILL.md +57 -0
  66. package/app/ui/apps/.keep +0 -0
  67. package/bin/chalky.mjs +11 -0
  68. package/node_modules/xlsx/CHANGELOG.md +361 -0
  69. package/node_modules/xlsx/LICENSE +201 -0
  70. package/node_modules/xlsx/README.md +41 -0
  71. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  72. package/node_modules/xlsx/bower.json +22 -0
  73. package/node_modules/xlsx/dist/LICENSE +201 -0
  74. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  75. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  76. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  77. package/node_modules/xlsx/dist/shim.min.js +2 -0
  78. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  79. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  80. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  81. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  82. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  83. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  84. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  85. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  86. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  87. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  88. package/node_modules/xlsx/package.json +183 -0
  89. package/node_modules/xlsx/types/index.d.ts +1065 -0
  90. package/node_modules/xlsx/types/tsconfig.json +15 -0
  91. package/node_modules/xlsx/xlsx.js +28103 -0
  92. package/node_modules/xlsx/xlsx.mjs +28228 -0
  93. package/node_modules/xlsx/xlsxworker.js +14 -0
  94. package/package.json +42 -4
  95. package/server.ts +275 -0
@@ -0,0 +1,14 @@
1
+ /* xlsx.js (C) 2013-present SheetJS -- http://sheetjs.com */
2
+ importScripts('dist/shim.min.js');
3
+ /* uncomment the next line for encoding support */
4
+ importScripts('dist/cpexcel.js');
5
+ importScripts('xlsx.js');
6
+ postMessage({t:"ready"});
7
+
8
+ onmessage = function (evt) {
9
+ var v;
10
+ try {
11
+ v = XLSX.read(evt.data.d, {type: evt.data.b, codepage: evt.data.c});
12
+ postMessage({t:"xlsx", d:JSON.stringify(v)});
13
+ } catch(e) { postMessage({t:"e",d:e.stack||e}); }
14
+ };
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@chalky/chalky",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "description": "Chalky on pi: agents build what you see in the browser, and you point at it and mark it up",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "chalky": "bin/chalky.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "server.ts",
13
+ "app/",
14
+ "!app/auth.json",
15
+ "!app/models-store.json",
16
+ "!app/mcp-auth.json",
17
+ "!app/ui/surfaces.json",
18
+ "!app/ui/apps/*",
19
+ "app/ui/apps/.keep"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "bundleDependencies": [
25
+ "xlsx"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22.19.0"
29
+ },
30
+ "dependencies": {
31
+ "@earendil-works/pi-coding-agent": "1.4.0",
32
+ "dom-to-image-more": "3.11.0",
33
+ "dompurify": "3.4.17",
34
+ "isomorphic-git": "1.44.2",
35
+ "jiti": "2.7.0",
36
+ "mammoth": "1.13.0",
37
+ "marked": "18.0.14",
38
+ "node-pptx-parser": "1.0.1",
39
+ "unpdf": "1.9.1",
40
+ "word-extractor": "1.0.4",
41
+ "ws": "8.22.0",
42
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
43
+ }
44
+ }
package/server.ts ADDED
@@ -0,0 +1,275 @@
1
+ // Host: the fixed core under the agents. Everything else, the page included, is a pi extension in
2
+ // app/extensions/<name>/ (agents can change those; this file they can't). This file only:
3
+ // - runs the web server: its WebSocket /ws, and every route extensions add (the page: extensions/page);
4
+ // - keeps one pi AgentSession per agent (session name = agent name; saved under state/sessions/<agent>);
5
+ // - serves /recover: going back to a working version when an agent broke the page (the safety net);
6
+ // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
7
+ // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
8
+ // Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
9
+ // Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] [MODEL_THINKING=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
10
+ import fs from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import http from "node:http";
13
+ import path from "node:path";
14
+ import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, SessionManager,
15
+ SettingsManager } from "@earendil-works/pi-coding-agent";
16
+ import git from "isomorphic-git";
17
+ import { WebSocketServer } from "ws";
18
+
19
+ const ROOT = import.meta.dirname; // this package: server.ts and the starting app/
20
+ // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder
21
+ const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
22
+ const APP = path.join(HOME, "app"); // agent-editable, pi's agentDir: extensions/, skills/, ui/ (what they built)
23
+ const STATE = path.join(HOME, "state"); // conversations and data, not code
24
+ // our dependencies' folder (npm may put it above this package)
25
+ const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
26
+ if (!fs.existsSync(APP)) fs.cpSync(path.join(ROOT, "app"), APP, { recursive: true }); // first run: the starting code
27
+ if (HOME !== ROOT) { // HOME/app's extensions import our dependencies: Node looks for a node_modules above them
28
+ const link = path.join(HOME, "node_modules");
29
+ if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
30
+ if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
31
+ fs.symlinkSync(MODULES, link, "junction");
32
+ }
33
+ // pi's agent folder for the code that doesn't take our agentDir but calls getAgentDir() (its MCP extension: mcp.json
34
+ // and mcp-auth.json): the same app/
35
+ process.env.PI_CODING_AGENT_DIR = APP;
36
+ const WORK = path.join(HOME, "workspace"); // the agents' working folder (bash runs here)
37
+ const PORT = Number(process.env.PORT || 3000); // the page's port
38
+ for (const d of [STATE, WORK]) fs.mkdirSync(d, { recursive: true });
39
+
40
+ type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
41
+ stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
42
+
43
+ const sockets = new Set<any>();
44
+ const host = {
45
+ root: ROOT, app: APP, state: STATE, work: WORK, modules: MODULES, port: PORT,
46
+ agents: new Map<string, Agent>(),
47
+ routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
48
+ pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
49
+ eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
50
+ // also what pi.on misses, e.g. a message filed while idle); key -> handler
51
+ connectHandlers: new Map<string, (send: (type: string, data: object) => void) => void>(), // a page opened: what it gets first
52
+ shared: new Map<string, any>(), // state extensions share across agents (e.g. the surfaces)
53
+ reloadAfterTurn: new Map<string, boolean>(), // agents whose extensions load again when their turn ends -> quiet
54
+ api: {} as Record<string, any>, // services extensions offer by name (e.g. swarm); the newest load wins
55
+ file, // file(res, path): send a file (type from its extension; 404 if none)
56
+
57
+ broadcast(type: string, data: object) {
58
+ const text = JSON.stringify({ type, ...data });
59
+ for (const s of sockets) s.send(text);
60
+ },
61
+
62
+ // Without the swarm extension: each agent idle or working, and messages go straight to a run
63
+ status() {
64
+ if (host.api.swarm) return host.api.swarm.status();
65
+ return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
66
+ state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
67
+ },
68
+ send(from: string, to: string, text: string) {
69
+ if (host.api.swarm) return host.api.swarm.send(from, to, text);
70
+ void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
71
+ },
72
+
73
+ // One run: the agent answers input. Returns its final answer, or "(error: ...)". Then a reload it asked for.
74
+ // replyTo: who the answer goes to (the page labels it). Saved as a "chalky.turn" entry, which the model doesn't see.
75
+ // images: pictures sent with the input (pi's {type: "image", data: base64, mimeType}), e.g. doodle's steps.
76
+ async prompt(name: string, input: string, replyTo?: string, images?: { type: "image"; data: string; mimeType: string }[]) {
77
+ const a = host.agents.get(name)!;
78
+ if (replyTo) a.session.sessionManager.appendCustomEntry("chalky.turn", { reply_to: replyTo }); // for the user message next
79
+ a.stopped = false;
80
+ try {
81
+ await a.session.prompt(input, images?.length ? { images } : undefined);
82
+ const last = [...a.session.messages].reverse().find((m: any) => m.role === "assistant");
83
+ if (a.stopped) return "(stopped)"; // pi 1.4.0 can record a stop as stopReason "error" ("This operation was aborted")
84
+ if (last?.stopReason === "error") {
85
+ emit("error", { agent: name, text: last.errorMessage });
86
+ return `(error: ${last.errorMessage})`;
87
+ }
88
+ return last ? text(last.content) : "";
89
+ } catch (e) {
90
+ if (a.stopped) return "(stopped)";
91
+ emit("error", { agent: name, text: String(e) });
92
+ return `(error: ${e})`;
93
+ } finally {
94
+ const quiet = host.reloadAfterTurn.get(name);
95
+ if (host.reloadAfterTurn.delete(name)) await host.reloadAgent(name, quiet);
96
+ }
97
+ },
98
+
99
+ // Stop an agent's run on purpose (Stop, the step limit): not shown as an error
100
+ async abort(name: string) {
101
+ const a = host.agents.get(name);
102
+ if (!a) return;
103
+ a.stopped = true;
104
+ await a.session.abort();
105
+ },
106
+
107
+ // load an agent's extensions, skills and prompt again (pi's reload; the conversation stays). quiet: no
108
+ // "extensions reloaded" note on the page (errors still show)
109
+ async reloadAgent(name: string, quiet = false) {
110
+ const a = host.agents.get(name)!;
111
+ await a.session.reload();
112
+ for (const e of a.session.resourceLoader.getExtensions().errors) emit("error", { agent: name, text: `extension ${e.path}: ${e.error}` });
113
+ if (!quiet) emit("error", { agent: name, text: "extensions reloaded" }); // shown as a note on the page
114
+ },
115
+
116
+ // fresh: a new session file (an empty memory); the old files stay, and the page still shows them
117
+ async createAgent(name: string, role: string, app?: string, fresh = false) {
118
+ const old = host.agents.get(name);
119
+ if (old) { old.unsubscribe(); old.session.dispose(); }
120
+ const dir = path.join(STATE, "sessions", name);
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ // projectTrusted false: pi ignores workspace/.pi/ (its SYSTEM.md, extensions, skills, settings), so an agent's
123
+ // project files can't change it; app/ (versioned) is the only place
124
+ const settingsManager = SettingsManager.inMemory({}, { projectTrusted: false });
125
+ // agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
126
+ // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in app/mcp.json, with
127
+ // codemode and tool_search for servers whose tools aren't direct. Sign-ins: app/mcp-auth.json (not versioned).
128
+ const resourceLoader = new DefaultResourceLoader({ cwd: WORK, agentDir: APP, settingsManager, noContextFiles: true,
129
+ extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
130
+ createMcpExtension({ logPath: path.join(STATE, "mcp.log") })] });
131
+ await resourceLoader.reload();
132
+ for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
133
+ const { session } = await createAgentSession({
134
+ cwd: WORK, agentDir: APP, resourceLoader, settingsManager,
135
+ // the saved conversation, or a new one
136
+ sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
137
+ });
138
+ if (session.sessionName !== name) session.setSessionName(name);
139
+ // starts the extensions' session (session_start). onError, besides showing extensions' errors: pi 1.4.0's reload()
140
+ // sends session_start again only to a session with a binding, and without it pi's MCP doesn't reconnect after a reload
141
+ await session.bindExtensions({ onError: (e) => {
142
+ console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
143
+ emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
144
+ } });
145
+ const model = session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
146
+ if (model) await session.setModel(model);
147
+ // MODEL_THINKING: pi's thinking level (minimal, low, medium, high, ...), sent as reasoning_effort. Unset: none is
148
+ // sent and the model uses its own default (pi then says "off", but the model may still think).
149
+ if (process.env.MODEL_THINKING) session.setThinkingLevel(process.env.MODEL_THINKING);
150
+ const a: Agent = { name, role, app, session, unsubscribe: () => {} };
151
+ host.agents.set(name, a);
152
+ a.unsubscribe = session.subscribe((ev: any) => {
153
+ for (const [key, fn] of host.eventHandlers) try { fn(name, ev); } catch (e) { console.error(`event handler ${key}:`, e); }
154
+ });
155
+ fs.writeFileSync(path.join(dir, "role.json"), JSON.stringify({ role, app }));
156
+ host.broadcast("status", { agents: host.status() });
157
+ return a;
158
+ },
159
+ };
160
+ (globalThis as any).chalky = host;
161
+
162
+ // An error nothing handled (in an extension's route or timer, say): keep serving, and show it. Without this, one
163
+ // mistake in any extension, also one an agent wrote, would stop the whole process (no start.sh here).
164
+ process.on("uncaughtException", (e) => {
165
+ console.error("uncaught:", e);
166
+ host.broadcast("error", { agent: "server", text: `uncaught ${e?.name}: ${e?.message}` });
167
+ });
168
+
169
+ const text = (content: any) => typeof content === "string" ? content
170
+ : content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("");
171
+
172
+ const emit = (type: string, data: object) => host.broadcast(type, data);
173
+
174
+ // ---- web server: /ws, and the routes extensions add ----
175
+ const types: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript",
176
+ ".css": "text/css", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png" };
177
+ function file(res: http.ServerResponse, p: string) {
178
+ if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.writeHead(404).end();
179
+ res.writeHead(200, { "content-type": types[path.extname(p)] ?? "application/octet-stream", "cache-control": "no-cache" });
180
+ fs.createReadStream(p).pipe(res);
181
+ }
182
+
183
+ // ---- recovery: the safety net for when an agent breaks the page (or the versions extension, so its panel) ----
184
+ // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever app/ holds.
185
+ // GET /recover the saved versions (state/versions.git, written by the versions extension), newest first;
186
+ // "the page worked" = the page took its picture of that version (state/shots/<sha>.png);
187
+ // the newest too (a broken versions extension doesn't save the edit that broke it)
188
+ // POST /recover/<sha> that version's files back into app/, as a new commit (as the versions panel's rollback,
189
+ // without its extras), then every agent's extensions load again; back to the page
190
+ // Also shown at / when no extension serves the page (the page extension failed to load).
191
+ const REPO = { fs, dir: APP, gitdir: path.join(STATE, "versions.git") };
192
+ const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
193
+
194
+ async function recoverPage(res: http.ServerResponse) {
195
+ const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO) : [];
196
+ const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
197
+ <td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
198
+ <td>${fs.existsSync(path.join(STATE, "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
199
+ <td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
200
+ const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
201
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
202
+ <style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
203
+ <h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
204
+ files are saved first, and going back is itself a new version. <a href="/">Back to the page</a></p>
205
+ ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(APP, e.path))}: ${esc(String(e.error))}</p>`).join("")}
206
+ <table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
207
+ }
208
+
209
+ async function recover(sha: string, res: http.ServerResponse) {
210
+ try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
211
+ await host.shared.get("versions")?.queue; // after the versions extension's own work
212
+ try { await host.api.swarm?.stop(); } catch {} // the swarm extension may be the broken one
213
+ for (const name of host.agents.keys()) await host.abort(name);
214
+ const stage = async () => { // git add -A (new ignored files skipped: statusMatrix misses info/exclude, see versions)
215
+ for (const [f, h, w, i] of await git.statusMatrix(REPO)) {
216
+ if ((h === 1 && w === 1 && i === 1) || (h === 0 && i === 0 && await git.isIgnored({ ...REPO, filepath: f }))) continue;
217
+ if (w === 0) { if (i !== 0) await git.remove({ ...REPO, filepath: f }); } else await git.add({ ...REPO, filepath: f });
218
+ }
219
+ };
220
+ const author = { name: "user", email: "user@chalky" };
221
+ const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
222
+ await stage();
223
+ try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
224
+ await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true });
225
+ const files = (await git.statusMatrix(REPO)).filter(([, h, w, i]) => !(h === 1 && w === 1 && i === 1)).map(([f]) => f);
226
+ for (const f of files) for (let d = path.dirname(f); d !== "."; d = path.dirname(d)) { // folders it emptied
227
+ try { fs.rmdirSync(path.join(APP, d)); } catch { break; }
228
+ }
229
+ await git.commit({ ...REPO, message: `recover to ${subject}`, author });
230
+ for (const name of host.agents.keys()) await host.reloadAgent(name);
231
+ if (files.includes("ui/surfaces.json")) host.shared.get("ui")?.load?.();
232
+ for (const name of host.agents.keys())
233
+ host.api.swarm?.fyi("system", name, `The user recovered ${APP} to version ${subject} (the page was broken). `
234
+ + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/versions). Check before relying on them.`);
235
+ console.log(`recovered to ${subject}`);
236
+ res.writeHead(303, { location: "/" }).end();
237
+ }
238
+
239
+ const server = http.createServer((req, res) => {
240
+ const url = new URL(req.url!, "http://x").pathname;
241
+ if (url === "/recover") return void recoverPage(res);
242
+ if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
243
+ let route = host.routes.get(url);
244
+ for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
245
+ if (route) return route(req, res);
246
+ if (url === "/") return void recoverPage(res); // nothing serves the page: an extension broke
247
+ res.writeHead(404).end();
248
+ });
249
+
250
+ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
251
+ const send = (type: string, data: object) => sock.send(JSON.stringify({ type, ...data }));
252
+ send("status", { agents: host.status() });
253
+ for (const [key, fn] of host.connectHandlers) try { fn(send); } catch (e) { console.error(`connect handler ${key}:`, e); }
254
+ sockets.add(sock);
255
+ sock.on("close", () => sockets.delete(sock));
256
+ sock.on("message", async (raw) => {
257
+ const data = JSON.parse(String(raw));
258
+ const kind = data.type ?? "chat";
259
+ if (kind === "chat") host.send("user", data.to === "main" || !host.agents.has(data.to) ? "agent1" : data.to, data.text);
260
+ else if (kind === "stop" && host.api.swarm) await host.api.swarm.stop();
261
+ else if (kind === "stop") {
262
+ for (const a of host.agents.values()) await host.abort(a.name);
263
+ emit("stopped", { agent: "all" });
264
+ } else for (const fn of host.pageHandlers.values()) await fn(data);
265
+ });
266
+ });
267
+
268
+ // agent1, then every agent saved by an earlier run (their conversations come back from their session files)
269
+ await host.createAgent("agent1", "the user's main agent");
270
+ for (const name of fs.readdirSync(path.join(STATE, "sessions")).filter((n) => n !== "agent1")) {
271
+ const { role, app } = JSON.parse(fs.readFileSync(path.join(STATE, "sessions", name, "role.json"), "utf8"));
272
+ await host.createAgent(name, role, app);
273
+ }
274
+ const hostName = process.env.UI_HOST || "127.0.0.1";
275
+ server.listen(PORT, hostName, () => console.log(`chalky (pi) on http://${hostName}:${PORT} (if the page breaks: http://${hostName}:${PORT}/recover)`));