@chalky/chalky 0.1.3 → 0.1.5

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 (103) hide show
  1. package/README.md +10 -6
  2. package/{app → agent}/SYSTEM.md +16 -11
  3. package/agent/extensions/app/README.md +68 -0
  4. package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
  5. package/agent/extensions/app/guide.md +5 -0
  6. package/agent/extensions/app/index.ts +66 -0
  7. package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
  8. package/agent/extensions/board/browser.js +359 -0
  9. package/agent/extensions/board/guide.md +4 -0
  10. package/agent/extensions/board/index.ts +100 -0
  11. package/agent/extensions/card/README.md +88 -0
  12. package/agent/extensions/card/blocks/Button.js +34 -0
  13. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  14. package/agent/extensions/card/blocks/Markdown.js +37 -0
  15. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  16. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  18. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -128
  19. package/agent/extensions/card/guide.md +13 -0
  20. package/agent/extensions/card/index.ts +370 -0
  21. package/agent/extensions/command/browser.js +13 -0
  22. package/agent/extensions/command/guide.md +2 -0
  23. package/agent/extensions/command/index.ts +94 -0
  24. package/agent/extensions/doodle/README.md +57 -0
  25. package/agent/extensions/doodle/browser.js +1013 -0
  26. package/agent/extensions/doodle/guide.md +22 -0
  27. package/agent/extensions/doodle/index.ts +274 -0
  28. package/agent/extensions/file/browser.js +143 -0
  29. package/agent/extensions/file/guide.md +5 -0
  30. package/{app/extensions/files → agent/extensions/file}/index.ts +34 -16
  31. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  32. package/{app/extensions/frames → agent/extensions/frame}/browser.js +24 -19
  33. package/agent/extensions/frame/guide.md +3 -0
  34. package/agent/extensions/frame/index.ts +81 -0
  35. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  36. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +32 -33
  37. package/{app → agent}/extensions/mcp/browser.js +1 -1
  38. package/agent/extensions/mcp/guide.md +3 -0
  39. package/{app → agent}/extensions/mcp/index.ts +2 -4
  40. package/agent/extensions/model/browser.js +363 -0
  41. package/{app → agent}/extensions/model/index.ts +8 -4
  42. package/agent/extensions/model/login.ts +215 -0
  43. package/agent/extensions/note/README.md +35 -0
  44. package/agent/extensions/note/browser.js +356 -0
  45. package/agent/extensions/note/guide.md +8 -0
  46. package/agent/extensions/note/index.ts +192 -0
  47. package/{app → agent}/extensions/page/index.ts +2 -2
  48. package/{app → agent}/extensions/page/page.html +55 -49
  49. package/agent/extensions/prompt/index.ts +38 -0
  50. package/{app → agent}/extensions/reload/index.ts +3 -3
  51. package/{app → agent}/extensions/screen/browser.js +4 -4
  52. package/{app → agent}/extensions/screen/index.ts +11 -11
  53. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  54. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  55. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  56. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  57. package/{app/extensions/versions → agent/extensions/version}/browser.js +10 -10
  58. package/{app/extensions/versions → agent/extensions/version}/index.ts +39 -54
  59. package/{app → agent}/extensions/viewer/README.md +4 -4
  60. package/{app → agent}/extensions/viewer/browser.js +8 -8
  61. package/agent/extensions/viewer/guide.md +3 -0
  62. package/{app → agent}/extensions/viewer/index.ts +22 -17
  63. package/agent/extensions/voice/browser.js +72 -0
  64. package/agent/extensions/voice/index.ts +39 -0
  65. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  66. package/agent/settings.json +8 -0
  67. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  68. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  69. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +14 -14
  70. package/agent/skills/extension-map/SKILL.md +56 -0
  71. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  72. package/bin/chalky.mjs +3 -3
  73. package/package.json +8 -8
  74. package/server.ts +162 -63
  75. package/app/extensions/appdata/README.md +0 -10
  76. package/app/extensions/appdata/guide.md +0 -4
  77. package/app/extensions/appdata/index.ts +0 -47
  78. package/app/extensions/doodle/README.md +0 -15
  79. package/app/extensions/doodle/browser.js +0 -388
  80. package/app/extensions/doodle/guide.md +0 -6
  81. package/app/extensions/doodle/index.ts +0 -54
  82. package/app/extensions/files/browser.js +0 -96
  83. package/app/extensions/files/guide.md +0 -7
  84. package/app/extensions/frames/guide.md +0 -7
  85. package/app/extensions/frames/index.ts +0 -76
  86. package/app/extensions/mcp/guide.md +0 -5
  87. package/app/extensions/point/README.md +0 -22
  88. package/app/extensions/point/browser.js +0 -245
  89. package/app/extensions/point/guide.md +0 -6
  90. package/app/extensions/point/index.ts +0 -97
  91. package/app/extensions/prompt/index.ts +0 -19
  92. package/app/extensions/ui/README.md +0 -69
  93. package/app/extensions/ui/components/App.js +0 -28
  94. package/app/extensions/ui/components/Button.js +0 -28
  95. package/app/extensions/ui/components/Markdown.js +0 -37
  96. package/app/extensions/ui/guide.md +0 -7
  97. package/app/extensions/ui/index.ts +0 -271
  98. package/app/extensions/viewer/guide.md +0 -5
  99. package/app/mcp.json +0 -3
  100. package/app/skills/plugin-map/SKILL.md +0 -49
  101. package/app/ui/apps/.keep +0 -0
  102. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  103. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
package/server.ts CHANGED
@@ -1,12 +1,15 @@
1
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);
2
+ // agent/extensions/<name>/ (agents can change those; this file they can't). This file only:
3
+ // - lays out the four places (agent/, built/, data/, workspace/) and moves an older layout into them once;
4
+ // - runs the web server: its WebSocket /ws, /built/ (what the agents made), and every route extensions add;
5
+ // - keeps one pi AgentSession per agent (session name = agent name; saved under data/sessions/<agent>);
6
+ // - keeps the rule for the version history (host.history) and serves /recover, going back to a working version
7
+ // when an agent broke the page (the safety net);
6
8
  // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
7
9
  // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
8
10
  // 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)
11
+ // Run: MODEL_BASE_URL=... MODEL_API_KEY=... [MODEL_NAME=...] node server.ts (Node >= 22.19: it runs the TypeScript as is)
12
+ // CHALKY_LOGIN=off: no /login, /logout, /model on the page; agents always use MODEL_BASE_URL (a cloud deployment)
10
13
  import fs from "node:fs";
11
14
  import { createRequire } from "node:module";
12
15
  import http from "node:http";
@@ -16,42 +19,115 @@ import { createAgentSession, createCodemodeExtension, createMcpExtension, create
16
19
  import git from "isomorphic-git";
17
20
  import { WebSocketServer } from "ws";
18
21
 
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
22
+ const ROOT = import.meta.dirname; // this package: server.ts and the starting agent/
23
+ // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder.
24
+ // Four places, one job each (the prompt gives them as {agent}, {built}, {data}, {workspace}):
21
25
  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
26
+ const AGENT = path.join(HOME, "agent"); // what the agents ARE, pi's agentDir: SYSTEM.md, settings, extensions/, skills/
27
+ const BUILT = path.join(HOME, "built"); // what they MADE for the user: card.json, board.json, apps/; served at /built/
28
+ const DATA = path.join(HOME, "data"); // what is kept while running: conversations, typed data, notes (not versioned)
29
+ const WORKSPACE = path.join(HOME, "workspace"); // the agents' working folder (bash runs here); workspace/drops: the user's files
30
+ // The version history covers agent/ and built/ together (a rollback puts both back), but not the logins and endpoints
31
+ // (the model extension writes them, not the agents) or installed packages. Here, so /recover follows it too.
32
+ const NOT_IN_HISTORY = ["agent/auth.json", "agent/models.json", "agent/models-store.json", "agent/mcp-auth.json"];
33
+ const inHistory = (f: string) => /^(agent|built)\//.test(f) && !NOT_IN_HISTORY.includes(f) && !f.split("/").includes("node_modules");
34
+ // What an agent is made of (paths in agent/). A new home starts with these from this package (no logins, nothing
35
+ // built); anything else an agent puts in agent/ doesn't belong there (the version extension tells it so).
36
+ const AGENT_PARTS = ["extensions/", "skills/", "SYSTEM.md", "mcp.json", "settings.json"];
37
+ const isAgentPart = (rel: string) => !rel.split(path.sep).includes("node_modules")
38
+ && AGENT_PARTS.some((p) => (rel.replaceAll(path.sep, "/") + "/").startsWith(p) || rel === p);
39
+ const startAgent = () => fs.cpSync(path.join(ROOT, "agent"), AGENT, { recursive: true,
40
+ filter: (src) => { const rel = path.relative(path.join(ROOT, "agent"), src); return !rel || isAgentPart(rel); } });
24
41
  // our dependencies' folder (npm may put it above this package)
25
42
  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
43
+
44
+ // The layout before 2026-11 (app/: the agent and what it built; state/: the data), moved once into the four places.
45
+ // An old app/ (an installed home) gives up only the user's things: its code stays as app-before-reorg/, and agent/ is
46
+ // this package's (the old code expects the old layout). The old version history stays too: its paths were app/'s.
47
+ function migrate() {
48
+ const move = (from: string, to: string) => {
49
+ if (!fs.existsSync(from) || fs.existsSync(to)) return;
50
+ fs.mkdirSync(path.dirname(to), { recursive: true });
51
+ fs.renameSync(from, to);
52
+ console.log(`moved ${path.relative(HOME, from)} -> ${path.relative(HOME, to)}`);
53
+ };
54
+ const old = path.join(HOME, "app");
55
+ if (fs.existsSync(old)) {
56
+ if (!fs.existsSync(AGENT)) startAgent();
57
+ for (const f of [...NOT_IN_HISTORY.map((f) => path.basename(f)), "mcp.json", "settings.json"]) // the user's, not code
58
+ if (fs.existsSync(path.join(old, f))) { fs.rmSync(path.join(AGENT, f), { force: true }); move(path.join(old, f), path.join(AGENT, f)); }
59
+ }
60
+ for (const ui of [path.join(old, "ui"), path.join(AGENT, "ui")]) { // what they built: app/ui (installed), agent/ui (this repo)
61
+ const apps = path.join(ui, "apps");
62
+ if (fs.existsSync(apps)) for (const name of fs.readdirSync(apps)) if (name !== ".keep") move(path.join(apps, name), path.join(BUILT, "apps", name));
63
+ move(path.join(ui, "surfaces.json"), path.join(BUILT, "card.json"));
64
+ move(path.join(ui, "boards.json"), path.join(BUILT, "board.json"));
65
+ fs.rmSync(path.join(apps, ".keep"), { force: true });
66
+ for (const d of [apps, ui]) try { fs.rmdirSync(d); } catch {}
67
+ }
68
+ if (fs.existsSync(old)) move(old, path.join(HOME, "app-before-reorg"));
69
+ move(path.join(HOME, "state"), DATA);
70
+ for (const [from, to] of [["ui-data.json", "card.json"], ["ui-history.jsonl", "history.jsonl"], ["notes.json", "note.json"],
71
+ ["frames.json", "frame.json"], ["login.json", "model.json"], ["app-data", "app"], ["screens", "screen"], ["pointed", "viewer"],
72
+ ["shots", "version/shots"], ["versions.git", "version-before-reorg.git"]]) move(path.join(DATA, from), path.join(DATA, to));
73
+ // what was in them, in the new words: a card's "components" are its "blocks", each {"block": kind}; the Card block
74
+ // is Panel; a card that was one App block shows that app; the undo steps' "surfaces" are "cards"; apps use the new addresses
75
+ const blocks = (list: any[]) => list.map(({ component, ...b }: any) => ({ ...b, block: component === "Card" ? "Panel" : component }));
76
+ const content = ({ components, ...rest }: any) => components.length === 1 && components[0].component === "App"
77
+ ? { ...rest, app: `apps/${components[0].src}`, ...(components[0].height ? { height: components[0].height } : {}) }
78
+ : { ...rest, blocks: blocks(components) };
79
+ const card = (r: any) => {
80
+ if (!r.components) return r;
81
+ const out: any = content(r);
82
+ if (out.options) out.options = out.options.map((o: any) => o.components ? content(o) : o);
83
+ if (out.kinds) out.kinds = Object.fromEntries(Object.entries(out.kinds).filter(([k]) => k !== "App").map(([k, v]) => [k === "Card" ? "Panel" : k, v]));
84
+ return out;
85
+ };
86
+ const cards = (all: Record<string, any>) => Object.fromEntries(Object.entries(all).map(([id, r]) => [id, card(r)]));
87
+ const rewrite = (file: string, change: (text: string) => string) => {
88
+ const text = fs.readFileSync(file, "utf8"), now = change(text);
89
+ if (now !== text) { fs.writeFileSync(file, now); console.log(`updated ${path.relative(HOME, file)}`); }
90
+ };
91
+ if (fs.existsSync(path.join(BUILT, "card.json"))) rewrite(path.join(BUILT, "card.json"), (t) => t.includes('"components"') ? JSON.stringify(cards(JSON.parse(t)), null, 1) : t);
92
+ if (fs.existsSync(path.join(DATA, "history.jsonl"))) rewrite(path.join(DATA, "history.jsonl"), (t) => !t.includes('"surfaces"') ? t
93
+ : t.split("\n").map((l) => { if (!l) return l; const { surfaces, ...e } = JSON.parse(l); return JSON.stringify(surfaces ? { ...e, cards: cards(surfaces) } : e); }).join("\n"));
94
+ const apps = path.join(BUILT, "apps");
95
+ if (fs.existsSync(apps)) for (const f of fs.readdirSync(apps, { recursive: true }) as string[])
96
+ if (/\.(html|js|mjs)$/.test(f) && !f.split(path.sep).includes("node_modules")) rewrite(path.join(apps, f), (t) => t
97
+ .replaceAll("/ui/agents.js", "/app/agents.js").replaceAll("/ui/voice.js", "/voice/voice.js")
98
+ .replaceAll("/ui-api/app-data/", "/app/data/").replaceAll("/ui/apps/", "/built/apps/"));
99
+ }
100
+ migrate();
101
+ if (!fs.existsSync(AGENT)) startAgent(); // first run: the starting agent
102
+ if (HOME !== ROOT) { // HOME/agent's extensions import our dependencies: Node looks for a node_modules above them
28
103
  const link = path.join(HOME, "node_modules");
29
104
  if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
30
105
  if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
31
106
  fs.symlinkSync(MODULES, link, "junction");
32
107
  }
33
108
  // 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)
109
+ // and mcp-auth.json): the same agent/
110
+ process.env.PI_CODING_AGENT_DIR = AGENT;
37
111
  const PORT = Number(process.env.PORT || 3000); // the page's port
38
- for (const d of [STATE, WORK]) fs.mkdirSync(d, { recursive: true });
112
+ for (const d of [path.join(BUILT, "apps"), DATA, WORKSPACE]) fs.mkdirSync(d, { recursive: true });
39
113
 
40
114
  type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
41
115
  stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
42
116
 
43
117
  const sockets = new Set<any>();
44
118
  const host = {
45
- root: ROOT, app: APP, state: STATE, work: WORK, modules: MODULES, port: PORT,
119
+ root: ROOT, home: HOME, agent: AGENT, built: BUILT, data: DATA, workspace: WORKSPACE, modules: MODULES, port: PORT,
120
+ inHistory, // inHistory(path in HOME): whether the version history keeps that file
121
+ isAgentPart, // isAgentPart(path in agent/): whether it is part of an agent
46
122
  agents: new Map<string, Agent>(),
47
123
  routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
48
124
  pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
49
125
  eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
50
126
  // also what pi.on misses, e.g. a message filed while idle); key -> handler
51
127
  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)
128
+ shared: new Map<string, any>(), // state extensions share across agents (e.g. the cards)
53
129
  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
130
+ api: {} as Record<string, any>, // services extensions offer by name (e.g. team); the newest load wins
55
131
  file, // file(res, path): send a file (type from its extension; 404 if none)
56
132
 
57
133
  broadcast(type: string, data: object) {
@@ -59,14 +135,14 @@ const host = {
59
135
  for (const s of sockets) s.send(text);
60
136
  },
61
137
 
62
- // Without the swarm extension: each agent idle or working, and messages go straight to a run
138
+ // Without the team extension: each agent idle or working, and messages go straight to a run
63
139
  status() {
64
- if (host.api.swarm) return host.api.swarm.status();
140
+ if (host.api.team) return host.api.team.status();
65
141
  return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
66
142
  state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
67
143
  },
68
144
  send(from: string, to: string, text: string) {
69
- if (host.api.swarm) return host.api.swarm.send(from, to, text);
145
+ if (host.api.team) return host.api.team.send(from, to, text);
70
146
  void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
71
147
  },
72
148
 
@@ -117,36 +193,41 @@ const host = {
117
193
  async createAgent(name: string, role: string, app?: string, fresh = false) {
118
194
  const old = host.agents.get(name);
119
195
  if (old) { old.unsubscribe(); old.session.dispose(); }
120
- const dir = path.join(STATE, "sessions", name);
196
+ const dir = path.join(DATA, "sessions", name);
121
197
  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 });
198
+ // pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
199
+ // SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
200
+ const settingsManager = SettingsManager.create(WORKSPACE, AGENT, { projectTrusted: false });
125
201
  // 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,
202
+ // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in agent/mcp.json, with
203
+ // codemode and tool_search for servers whose tools aren't direct. Sign-ins: agent/mcp-auth.json (not versioned).
204
+ const resourceLoader = new DefaultResourceLoader({ cwd: WORKSPACE, agentDir: AGENT, settingsManager, noContextFiles: true,
129
205
  extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
130
- createMcpExtension({ logPath: path.join(STATE, "mcp.log") })] });
206
+ createMcpExtension({ logPath: path.join(DATA, "mcp.log") })] });
131
207
  await resourceLoader.reload();
132
208
  for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
133
209
  const { session } = await createAgentSession({
134
- cwd: WORK, agentDir: APP, resourceLoader, settingsManager,
210
+ cwd: WORKSPACE, agentDir: AGENT, resourceLoader, settingsManager,
135
211
  // the saved conversation, or a new one
136
- sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
212
+ sessionManager: fresh ? SessionManager.create(WORKSPACE, dir) : SessionManager.continueRecent(WORKSPACE, dir),
137
213
  });
138
214
  if (session.sessionName !== name) session.setSessionName(name);
215
+ const empty = session.sessionManager.buildSessionContext().messages.length === 0;
139
216
  // starts the extensions' session (session_start). onError, besides showing extensions' errors: pi 1.4.0's reload()
140
217
  // sends session_start again only to a session with a binding, and without it pi's MCP doesn't reconnect after a reload
141
218
  await session.bindExtensions({ onError: (e) => {
142
219
  console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
143
220
  emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
144
221
  } });
145
- const model = session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
222
+ // the model: the one picked with /login or /model, else the MODEL_BASE_URL endpoint (both: extensions/model).
223
+ // CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
224
+ const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.model?.modelFor(name, session);
225
+ const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
146
226
  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);
227
+ // thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
228
+ // conversation starts at "medium". Set here: pi restores it only when it knows the model then, and ours (the
229
+ // model extension) comes later, so pi would say "off"
230
+ session.setThinkingLevel(empty ? "medium" : session.sessionManager.buildSessionContext().thinkingLevel);
150
231
  const a: Agent = { name, role, app, session, unsubscribe: () => {} };
151
232
  host.agents.set(name, a);
152
233
  a.unsubscribe = session.subscribe((ev: any) => {
@@ -180,58 +261,71 @@ function file(res: http.ServerResponse, p: string) {
180
261
  fs.createReadStream(p).pipe(res);
181
262
  }
182
263
 
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,
264
+ // ---- the version history: one git repo for agent/ and built/ (inHistory), kept in data/version/git ----
265
+ // Outside the folders it keeps, so an agent's own git commands can't rewrite it. Git through isomorphic-git (pure JS).
266
+ // The version extension commits and rolls back with this; /recover too, so it works when that extension is broken.
267
+ // repo isomorphic-git's {fs, dir: HOME, gitdir}
268
+ // stage() git add -A for what inHistory keeps (.gitignore files play no part); returns the changed paths
269
+ const REPO = { fs, dir: HOME, gitdir: path.join(DATA, "version", "git") };
270
+ const KEPT = { filepaths: ["agent", "built"], ignored: true, filter: inHistory }; // statusMatrix's walk: only these
271
+ async function stage() {
272
+ const changed = [];
273
+ for (const [filepath, head, workdir, index] of await git.statusMatrix({ ...REPO, ...KEPT })) {
274
+ if (head === 1 && workdir === 1 && index === 1) continue; // unchanged
275
+ if (workdir === 0) { if (index !== 0) await git.remove({ ...REPO, filepath }); }
276
+ else await git.add({ ...REPO, filepath, force: true });
277
+ changed.push(filepath);
278
+ }
279
+ return changed;
280
+ }
281
+ Object.assign(host, { history: { repo: REPO, kept: KEPT, stage } });
282
+
283
+ // ---- recovery: the safety net for when an agent breaks the page (or the version extension, so its panel) ----
284
+ // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever agent/ holds.
285
+ // GET /recover the saved versions, newest first; "the page worked" = the page took its picture of that
286
+ // version (data/version/shots/<sha>.png); the newest too (a broken version extension doesn't
287
+ // save the edit that broke it)
288
+ // POST /recover/<sha> that version's agent/ and built/ back, as a new commit (as the version panel's rollback,
189
289
  // without its extras), then every agent's extensions load again; back to the page
190
290
  // 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
291
  const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
193
292
 
194
293
  async function recoverPage(res: http.ServerResponse) {
195
- const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO) : [];
294
+ // the history's folder comes before its first commit: right after a start there may be no versions yet
295
+ const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO).catch((e) => e.code === "NotFoundError" ? [] : Promise.reject(e)) : [];
196
296
  const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
197
297
  <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>
298
+ <td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
199
299
  <td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
200
300
  const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
201
301
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
202
302
  <style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
203
303
  <h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
204
304
  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("")}
305
+ ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(AGENT, e.path))}: ${esc(String(e.error))}</p>`).join("")}
206
306
  <table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
207
307
  }
208
308
 
209
309
  async function recover(sha: string, res: http.ServerResponse) {
210
310
  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
311
+ await host.shared.get("version")?.queue; // after the versions extension's own work
312
+ try { await host.api.team?.stop(); } catch {} // the team extension may be the broken one
213
313
  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
314
  const author = { name: "user", email: "user@chalky" };
221
315
  const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
222
316
  await stage();
223
317
  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; }
318
+ await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true, filepaths: KEPT.filepaths });
319
+ const files = await stage();
320
+ for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) { // folders it emptied
321
+ try { fs.rmdirSync(path.join(HOME, d)); } catch { break; }
228
322
  }
229
323
  await git.commit({ ...REPO, message: `recover to ${subject}`, author });
230
324
  for (const name of host.agents.keys()) await host.reloadAgent(name);
231
- if (files.includes("ui/surfaces.json")) host.shared.get("ui")?.load?.();
325
+ if (files.includes("built/card.json")) { host.shared.get("card")?.load?.(); host.api.history?.add("user", `recovered to ${subject}`); }
232
326
  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.`);
327
+ host.api.team?.fyi("system", name, `The user recovered ${AGENT} and ${BUILT} to version ${subject} (the page was broken). `
328
+ + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/version). Check before relying on them.`);
235
329
  console.log(`recovered to ${subject}`);
236
330
  res.writeHead(303, { location: "/" }).end();
237
331
  }
@@ -240,6 +334,11 @@ const server = http.createServer((req, res) => {
240
334
  const url = new URL(req.url!, "http://x").pathname;
241
335
  if (url === "/recover") return void recoverPage(res);
242
336
  if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
337
+ if (url.startsWith("/built/")) { // what the agents made, at the same path as on disk (built/ holds no secrets)
338
+ let p = "";
339
+ try { p = path.join(HOME, path.posix.normalize(decodeURIComponent(url))); } catch {} // a bad %-escape: 404
340
+ return p.startsWith(BUILT + path.sep) ? file(res, p) : void res.writeHead(404).end(); // also a ".." out of built/
341
+ }
243
342
  let route = host.routes.get(url);
244
343
  for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
245
344
  if (route) return route(req, res);
@@ -257,7 +356,7 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
257
356
  const data = JSON.parse(String(raw));
258
357
  const kind = data.type ?? "chat";
259
358
  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();
359
+ else if (kind === "stop" && host.api.team) await host.api.team.stop();
261
360
  else if (kind === "stop") {
262
361
  for (const a of host.agents.values()) await host.abort(a.name);
263
362
  emit("stopped", { agent: "all" });
@@ -267,8 +366,8 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
267
366
 
268
367
  // agent1, then every agent saved by an earlier run (their conversations come back from their session files)
269
368
  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"));
369
+ for (const name of fs.readdirSync(path.join(DATA, "sessions")).filter((n) => n !== "agent1")) {
370
+ const { role, app } = JSON.parse(fs.readFileSync(path.join(DATA, "sessions", name, "role.json"), "utf8"));
272
371
  await host.createAgent(name, role, app);
273
372
  }
274
373
  const hostName = process.env.UI_HOST || "127.0.0.1";
@@ -1,10 +0,0 @@
1
- # App data (the appdata extension)
2
- An App ({code}/ui/apps/<name>/) keeps what the user makes in it (notes, lists, picks) with
3
- `import agents from "/ui/agents.js"`: `await agents.load()` gives the app's saved data ({} at first),
4
- `agents.save(data)` replaces it, and `agents.on("app_data", ({app, data}) => ...)` hears every save of any
5
- app (check app === your app's folder name). Saved on the server in {data}/app-data/<name>.json: it
6
- survives reloads, is the same in every browser, and you can read it (cat the file) when the user points at
7
- something in the app. The same app shown in several surfaces shares this data, so one surface's clicks
8
- update the others at once, with no agent in between. To change it yourself, PUT the whole value:
9
- curl -X PUT --data @new.json {page}/ui-api/app-data/<name> (the open pages update).
10
- Not localStorage: that stays in one browser, and you can't see it.
@@ -1,4 +0,0 @@
1
- # App data
2
- An App keeps what the user makes in it (notes, lists, picks) with /ui/agents.js (agents.load, agents.save), on the
3
- server and shared by every surface showing it; never localStorage (one browser only, and you can't see it).
4
- Before you build an App that keeps data, read README.md.
@@ -1,47 +0,0 @@
1
- // App data: a small shared store for apps (app/ui/apps/<name>/, shown by the App component), saved here, so an
2
- // app's data outlives the page, is the same in every browser, and the agents can read it (port of the Python
3
- // appdata plugin). One JSON value per app, in state/app-data/<name>.json. The same app shown in several surfaces
4
- // shares it: when one saves, every open page and app frame gets an "app_data" event.
5
- // The app side is app/ui/agents.js: agents.load(), agents.save(data), agents.on("app_data", fn).
6
- // GET /ui-api/app-data/<name> the app's data ({} if none yet)
7
- // PUT /ui-api/app-data/<name> replace it (JSON, at most MAX_BYTES); event "app_data" {app, data}
8
- // Whole value replaced each time: the last save wins (keep one app's data in one value). A version check (refuse a
9
- // save made from an older value) is the fix if lost saves show up; not added yet.
10
- import fs from "node:fs";
11
- import path from "node:path";
12
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
-
14
- const host = (globalThis as any).chalky;
15
- const DIR = path.join(host.state, "app-data");
16
- const MAX_BYTES = 1024 * 1024;
17
- const json = (res: any, v: unknown, status = 200) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(v));
18
-
19
- export default function (pi: ExtensionAPI) {
20
- const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
21
- .replace("README.md", path.join(import.meta.dirname, "README.md"));
22
- pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.appdata = guide; });
23
-
24
- host.routes.set("/ui-api/app-data/", (req: any, res: any) => {
25
- const name = decodeURIComponent(new URL(req.url, "http://x").pathname.slice("/ui-api/app-data/".length));
26
- if (!/^[\w-]+$/.test(name)) return json(res, { error: "app names are letters, digits, _ and -" }, 400);
27
- const p = path.join(DIR, name + ".json");
28
- if (req.method === "GET") {
29
- if (!fs.existsSync(p)) return json(res, {});
30
- return res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(fs.readFileSync(p));
31
- }
32
- if (req.method !== "PUT") return res.writeHead(405).end();
33
- const chunks: Buffer[] = [];
34
- let size = 0;
35
- req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
36
- req.on("end", () => {
37
- if (size > MAX_BYTES) return json(res, { error: `bad app name, or over ${MAX_BYTES / 1024} KB` }, 400);
38
- let data;
39
- try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch (e: any) { return json(res, { error: `not JSON: ${e.message}` }, 400); }
40
- fs.mkdirSync(DIR, { recursive: true });
41
- fs.writeFileSync(p + ".tmp", JSON.stringify(data));
42
- fs.renameSync(p + ".tmp", p);
43
- host.broadcast("app_data", { app: name, data });
44
- json(res, { saved: true });
45
- });
46
- });
47
- }
@@ -1,15 +0,0 @@
1
- # Doodles (the doodle extension)
2
- The user can draw on the page (lines, arrows, circles, words in handwriting), click through it while drawing,
3
- then send it all with a few words: "[doodles on the UI] <their words>", then the steps, in order. Each step
4
- is a picture of the screen as they saw it, with their drawing on it (cropped to the drawing; the pictures
5
- are attached to the message, in step order), which part of the page it shows (main-space px), the words they
6
- wrote on it (exactly), then what they clicked to get to the next step. The drawing is only in the picture.
7
- - Read it as a story: step 1 is how it is and what they marked; their clicks lead to step 2; and so on. A
8
- step with nothing drawn after clicks is how the screen looked after them: often the point ("after Save,
9
- this should show the date").
10
- - Look at every picture closely: the drawing is the message. A line from A to B links them ("this goes
11
- there", "this feeds that"); a circle or a box points at the thing to change; a cross removes; handwriting
12
- says what. Find what a mark is on from the picture (its text, its look) and the page you know.
13
- - To place something where they drew (on empty space): the picture's area maps to the page, so a mark's
14
- spot in the picture is a spot on the page; ui_render(..., place=<that box>).
15
- - If the drawing could mean two quite different things, propose one in a line and ask; otherwise do it.