@chalky/chalky 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +16 -5
  2. package/agent/SYSTEM.md +36 -0
  3. package/agent/extensions/app/README.md +72 -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/app/skills/test-your-app/SKILL.md +23 -0
  9. package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
  10. package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
  11. package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
  12. package/agent/extensions/card/README.md +88 -0
  13. package/agent/extensions/card/blocks/Button.js +34 -0
  14. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
  15. package/agent/extensions/card/blocks/Markdown.js +37 -0
  16. package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
  17. package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
  18. package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
  19. package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -129
  20. package/agent/extensions/card/guide.md +13 -0
  21. package/agent/extensions/card/index.ts +370 -0
  22. package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
  23. package/agent/extensions/command/guide.md +2 -0
  24. package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
  25. package/agent/extensions/doodle/README.md +57 -0
  26. package/agent/extensions/doodle/browser.js +1013 -0
  27. package/agent/extensions/doodle/guide.md +22 -0
  28. package/agent/extensions/doodle/index.ts +274 -0
  29. package/agent/extensions/file/browser.js +182 -0
  30. package/agent/extensions/file/guide.md +5 -0
  31. package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
  32. package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
  33. package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
  34. package/agent/extensions/frame/guide.md +3 -0
  35. package/agent/extensions/frame/index.ts +90 -0
  36. package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
  37. package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -33
  38. package/{app → agent}/extensions/mcp/browser.js +1 -1
  39. package/agent/extensions/mcp/guide.md +3 -0
  40. package/{app → agent}/extensions/mcp/index.ts +2 -4
  41. package/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
  42. package/{app → agent}/extensions/model/index.ts +8 -4
  43. package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
  44. package/agent/extensions/note/README.md +37 -0
  45. package/agent/extensions/note/browser.js +382 -0
  46. package/agent/extensions/note/guide.md +9 -0
  47. package/agent/extensions/note/index.ts +215 -0
  48. package/agent/extensions/page/browser.js +453 -0
  49. package/{app → agent}/extensions/page/index.ts +72 -3
  50. package/{app → agent}/extensions/page/page.html +102 -49
  51. package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
  52. package/agent/extensions/prompt/index.ts +38 -0
  53. package/{app → agent}/extensions/reload/index.ts +3 -3
  54. package/{app → agent}/extensions/screen/browser.js +10 -23
  55. package/{app → agent}/extensions/screen/index.ts +11 -11
  56. package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
  57. package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
  58. package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
  59. package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
  60. package/{app/extensions/versions → agent/extensions/version}/browser.js +19 -10
  61. package/agent/extensions/version/index.ts +205 -0
  62. package/{app → agent}/extensions/viewer/README.md +4 -4
  63. package/{app → agent}/extensions/viewer/browser.js +8 -8
  64. package/agent/extensions/viewer/guide.md +3 -0
  65. package/{app → agent}/extensions/viewer/index.ts +22 -17
  66. package/{app → agent}/extensions/voice/browser.js +4 -3
  67. package/{app → agent}/extensions/voice/index.ts +8 -6
  68. package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
  69. package/agent/settings.json +8 -0
  70. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
  71. package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
  72. package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +15 -15
  73. package/agent/skills/extension-map/SKILL.md +56 -0
  74. package/agent/skills/show-me/SKILL.md +66 -0
  75. package/{app → agent}/skills/write-skill/SKILL.md +1 -1
  76. package/bin/chalky.mjs +2 -2
  77. package/package.json +9 -8
  78. package/server.ts +313 -58
  79. package/app/SYSTEM.md +0 -31
  80. package/app/extensions/appdata/README.md +0 -10
  81. package/app/extensions/appdata/guide.md +0 -4
  82. package/app/extensions/appdata/index.ts +0 -47
  83. package/app/extensions/commands/guide.md +0 -3
  84. package/app/extensions/doodle/README.md +0 -15
  85. package/app/extensions/doodle/browser.js +0 -398
  86. package/app/extensions/doodle/guide.md +0 -6
  87. package/app/extensions/doodle/index.ts +0 -54
  88. package/app/extensions/files/browser.js +0 -96
  89. package/app/extensions/files/guide.md +0 -7
  90. package/app/extensions/frames/guide.md +0 -7
  91. package/app/extensions/frames/index.ts +0 -85
  92. package/app/extensions/mcp/guide.md +0 -5
  93. package/app/extensions/point/README.md +0 -22
  94. package/app/extensions/point/browser.js +0 -247
  95. package/app/extensions/point/guide.md +0 -6
  96. package/app/extensions/point/index.ts +0 -112
  97. package/app/extensions/prompt/index.ts +0 -19
  98. package/app/extensions/ui/README.md +0 -69
  99. package/app/extensions/ui/components/App.js +0 -28
  100. package/app/extensions/ui/components/Button.js +0 -28
  101. package/app/extensions/ui/components/Markdown.js +0 -37
  102. package/app/extensions/ui/guide.md +0 -7
  103. package/app/extensions/ui/index.ts +0 -291
  104. package/app/extensions/versions/index.ts +0 -168
  105. package/app/extensions/viewer/guide.md +0 -5
  106. package/app/mcp.json +0 -3
  107. package/app/settings.json +0 -3
  108. package/app/skills/plugin-map/SKILL.md +0 -49
  109. package/app/ui/apps/.keep +0 -0
  110. package/node_modules/xlsx/CHANGELOG.md +0 -361
  111. package/node_modules/xlsx/LICENSE +0 -201
  112. package/node_modules/xlsx/README.md +0 -41
  113. package/node_modules/xlsx/bin/xlsx.njs +0 -310
  114. package/node_modules/xlsx/bower.json +0 -22
  115. package/node_modules/xlsx/dist/LICENSE +0 -201
  116. package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
  117. package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
  118. package/node_modules/xlsx/dist/cpexcel.js +0 -1506
  119. package/node_modules/xlsx/dist/shim.min.js +0 -2
  120. package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
  121. package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
  122. package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
  123. package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
  124. package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
  125. package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
  126. package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
  127. package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
  128. package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
  129. package/node_modules/xlsx/dist/zahl.d.ts +0 -4
  130. package/node_modules/xlsx/package.json +0 -183
  131. package/node_modules/xlsx/types/index.d.ts +0 -1065
  132. package/node_modules/xlsx/types/tsconfig.json +0 -15
  133. package/node_modules/xlsx/xlsx.js +0 -28103
  134. package/node_modules/xlsx/xlsx.mjs +0 -28228
  135. package/node_modules/xlsx/xlsxworker.js +0 -14
  136. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
  137. /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
package/server.ts CHANGED
@@ -1,8 +1,11 @@
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);
8
+ // - brings a new release of this package into the home's agent/, merged with what the agents changed (host.update);
6
9
  // - hands each agent's pi events to the extensions that asked (host.eventHandlers), and tells them when a page
7
10
  // opens (host.connectHandlers). The page extension turns pi's messages into the page's and replays them.
8
11
  // Extensions reach the host through globalThis.chalky (pi loads them with jiti, so they don't share our imports).
@@ -15,44 +18,118 @@ import path from "node:path";
15
18
  import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, SessionManager,
16
19
  SettingsManager } from "@earendil-works/pi-coding-agent";
17
20
  import git from "isomorphic-git";
21
+ import diff3Merge from "diff3";
18
22
  import { WebSocketServer } from "ws";
19
23
 
20
- const ROOT = import.meta.dirname; // this package: server.ts and the starting app/
21
- // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder
24
+ const ROOT = import.meta.dirname; // this package: server.ts and the starting agent/
25
+ // HOME: where this Chalky keeps its things. CHALKY_HOME (bin/chalky.mjs, an installed copy: ~/.chalky), else this folder.
26
+ // Four places, one job each (the prompt gives them as {agent}, {built}, {data}, {workspace}):
22
27
  const HOME = process.env.CHALKY_HOME ? path.resolve(process.env.CHALKY_HOME) : ROOT;
23
- const APP = path.join(HOME, "app"); // agent-editable, pi's agentDir: extensions/, skills/, ui/ (what they built)
24
- const STATE = path.join(HOME, "state"); // conversations and data, not code
28
+ const AGENT = path.join(HOME, "agent"); // what the agents ARE, pi's agentDir: SYSTEM.md, settings, extensions/, skills/
29
+ const BUILT = path.join(HOME, "built"); // what they MADE for the user: card.json, board.json, apps/; served at /built/
30
+ const DATA = path.join(HOME, "data"); // what is kept while running: conversations, typed data, notes (not versioned)
31
+ const WORKSPACE = path.join(HOME, "workspace"); // the agents' working folder (bash runs here); workspace/drops: the user's files
32
+ // The version history covers agent/ and built/ together (a rollback puts both back), but not the logins and endpoints
33
+ // (the model extension writes them, not the agents) or installed packages. Here, so /recover follows it too.
34
+ const NOT_IN_HISTORY = ["agent/auth.json", "agent/models.json", "agent/models-store.json", "agent/mcp-auth.json"];
35
+ const inHistory = (f: string) => /^(agent|built)\//.test(f) && !NOT_IN_HISTORY.includes(f) && !f.split("/").includes("node_modules");
36
+ // What an agent is made of (paths in agent/). A new home starts with these from this package (no logins, nothing
37
+ // built); anything else an agent puts in agent/ doesn't belong there (the version extension tells it so).
38
+ const AGENT_PARTS = ["extensions/", "skills/", "SYSTEM.md", "mcp.json", "settings.json"];
39
+ const isAgentPart = (rel: string) => !rel.split(path.sep).includes("node_modules")
40
+ && AGENT_PARTS.some((p) => (rel.replaceAll(path.sep, "/") + "/").startsWith(p) || rel === p);
41
+ const startAgent = () => fs.cpSync(path.join(ROOT, "agent"), AGENT, { recursive: true,
42
+ filter: (src) => { const rel = path.relative(path.join(ROOT, "agent"), src); return !rel || isAgentPart(rel); } });
25
43
  // our dependencies' folder (npm may put it above this package)
26
44
  const MODULES = path.resolve(createRequire(import.meta.url).resolve("ws/package.json"), "../..");
27
- if (!fs.existsSync(APP)) fs.cpSync(path.join(ROOT, "app"), APP, { recursive: true }); // first run: the starting code
28
- if (HOME !== ROOT) { // HOME/app's extensions import our dependencies: Node looks for a node_modules above them
45
+
46
+ // The layout before 2026-11 (app/: the agent and what it built; state/: the data), moved once into the four places.
47
+ // An old app/ (an installed home) gives up only the user's things: its code stays as app-before-reorg/, and agent/ is
48
+ // this package's (the old code expects the old layout). The old version history stays too: its paths were app/'s.
49
+ function migrate() {
50
+ const move = (from: string, to: string) => {
51
+ if (!fs.existsSync(from) || fs.existsSync(to)) return;
52
+ fs.mkdirSync(path.dirname(to), { recursive: true });
53
+ fs.renameSync(from, to);
54
+ console.log(`moved ${path.relative(HOME, from)} -> ${path.relative(HOME, to)}`);
55
+ };
56
+ const old = path.join(HOME, "app");
57
+ if (fs.existsSync(old)) {
58
+ if (!fs.existsSync(AGENT)) startAgent();
59
+ for (const f of [...NOT_IN_HISTORY.map((f) => path.basename(f)), "mcp.json", "settings.json"]) // the user's, not code
60
+ if (fs.existsSync(path.join(old, f))) { fs.rmSync(path.join(AGENT, f), { force: true }); move(path.join(old, f), path.join(AGENT, f)); }
61
+ }
62
+ for (const ui of [path.join(old, "ui"), path.join(AGENT, "ui")]) { // what they built: app/ui (installed), agent/ui (this repo)
63
+ const apps = path.join(ui, "apps");
64
+ if (fs.existsSync(apps)) for (const name of fs.readdirSync(apps)) if (name !== ".keep") move(path.join(apps, name), path.join(BUILT, "apps", name));
65
+ move(path.join(ui, "surfaces.json"), path.join(BUILT, "card.json"));
66
+ move(path.join(ui, "boards.json"), path.join(BUILT, "board.json"));
67
+ fs.rmSync(path.join(apps, ".keep"), { force: true });
68
+ for (const d of [apps, ui]) try { fs.rmdirSync(d); } catch {}
69
+ }
70
+ if (fs.existsSync(old)) move(old, path.join(HOME, "app-before-reorg"));
71
+ move(path.join(HOME, "state"), DATA);
72
+ for (const [from, to] of [["ui-data.json", "card.json"], ["ui-history.jsonl", "history.jsonl"], ["notes.json", "note.json"],
73
+ ["frames.json", "frame.json"], ["login.json", "model.json"], ["app-data", "app"], ["screens", "screen"], ["pointed", "viewer"],
74
+ ["shots", "version/shots"], ["versions.git", "version-before-reorg.git"]]) move(path.join(DATA, from), path.join(DATA, to));
75
+ // what was in them, in the new words: a card's "components" are its "blocks", each {"block": kind}; the Card block
76
+ // is Panel; a card that was one App block shows that app; the undo steps' "surfaces" are "cards"; apps use the new addresses
77
+ const blocks = (list: any[]) => list.map(({ component, ...b }: any) => ({ ...b, block: component === "Card" ? "Panel" : component }));
78
+ const content = ({ components, ...rest }: any) => components.length === 1 && components[0].component === "App"
79
+ ? { ...rest, app: `apps/${components[0].src}`, ...(components[0].height ? { height: components[0].height } : {}) }
80
+ : { ...rest, blocks: blocks(components) };
81
+ const card = (r: any) => {
82
+ if (!r.components) return r;
83
+ const out: any = content(r);
84
+ if (out.options) out.options = out.options.map((o: any) => o.components ? content(o) : o);
85
+ if (out.kinds) out.kinds = Object.fromEntries(Object.entries(out.kinds).filter(([k]) => k !== "App").map(([k, v]) => [k === "Card" ? "Panel" : k, v]));
86
+ return out;
87
+ };
88
+ const cards = (all: Record<string, any>) => Object.fromEntries(Object.entries(all).map(([id, r]) => [id, card(r)]));
89
+ const rewrite = (file: string, change: (text: string) => string) => {
90
+ const text = fs.readFileSync(file, "utf8"), now = change(text);
91
+ if (now !== text) { fs.writeFileSync(file, now); console.log(`updated ${path.relative(HOME, file)}`); }
92
+ };
93
+ 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);
94
+ if (fs.existsSync(path.join(DATA, "history.jsonl"))) rewrite(path.join(DATA, "history.jsonl"), (t) => !t.includes('"surfaces"') ? t
95
+ : 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"));
96
+ const apps = path.join(BUILT, "apps");
97
+ if (fs.existsSync(apps)) for (const f of fs.readdirSync(apps, { recursive: true }) as string[])
98
+ if (/\.(html|js|mjs)$/.test(f) && !f.split(path.sep).includes("node_modules")) rewrite(path.join(apps, f), (t) => t
99
+ .replaceAll("/ui/agents.js", "/app/agents.js").replaceAll("/ui/voice.js", "/voice/voice.js")
100
+ .replaceAll("/ui-api/app-data/", "/app/data/").replaceAll("/ui/apps/", "/built/apps/"));
101
+ }
102
+ migrate(); // (a new home's agent/ comes from the update below: the first release, merged into nothing)
103
+ if (HOME !== ROOT) { // HOME/agent's extensions import our dependencies: Node looks for a node_modules above them
104
+ fs.mkdirSync(HOME, { recursive: true }); // a new home: nothing made it yet
29
105
  const link = path.join(HOME, "node_modules");
30
106
  if (fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(link); // again each start: the package may have moved
31
107
  if (fs.existsSync(link)) throw new Error(`${link} is in the way: Chalky links it to ${MODULES}`);
32
108
  fs.symlinkSync(MODULES, link, "junction");
33
109
  }
34
110
  // pi's agent folder for the code that doesn't take our agentDir but calls getAgentDir() (its MCP extension: mcp.json
35
- // and mcp-auth.json): the same app/
36
- process.env.PI_CODING_AGENT_DIR = APP;
37
- const WORK = path.join(HOME, "workspace"); // the agents' working folder (bash runs here)
111
+ // and mcp-auth.json): the same agent/
112
+ process.env.PI_CODING_AGENT_DIR = AGENT;
38
113
  const PORT = Number(process.env.PORT || 3000); // the page's port
39
- for (const d of [STATE, WORK]) fs.mkdirSync(d, { recursive: true });
114
+ for (const d of [path.join(BUILT, "apps"), DATA, WORKSPACE]) fs.mkdirSync(d, { recursive: true });
40
115
 
41
116
  type Agent = { name: string; role: string; app?: string; session: any; unsubscribe: () => void;
42
117
  stopped?: boolean }; // stopped: this run was stopped on purpose (host.abort)
43
118
 
44
119
  const sockets = new Set<any>();
45
120
  const host = {
46
- root: ROOT, app: APP, state: STATE, work: WORK, modules: MODULES, port: PORT,
121
+ root: ROOT, home: HOME, agent: AGENT, built: BUILT, data: DATA, workspace: WORKSPACE, modules: MODULES, port: PORT,
122
+ inHistory, // inHistory(path in HOME): whether the version history keeps that file
123
+ isAgentPart, // isAgentPart(path in agent/): whether it is part of an agent
47
124
  agents: new Map<string, Agent>(),
48
125
  routes: new Map<string, (req: http.IncomingMessage, res: http.ServerResponse) => void>(), // path -> handler (a path ending in /: also every path under it)
49
126
  pageHandlers: new Map<string, (msg: any) => void>(), // one per extension (not per agent): key -> handler
50
127
  eventHandlers: new Map<string, (agent: string, ev: any) => void>(), // every pi event of every agent (session.subscribe:
51
128
  // also what pi.on misses, e.g. a message filed while idle); key -> handler
52
129
  connectHandlers: new Map<string, (send: (type: string, data: object) => void) => void>(), // a page opened: what it gets first
53
- shared: new Map<string, any>(), // state extensions share across agents (e.g. the surfaces)
130
+ shared: new Map<string, any>(), // state extensions share across agents (e.g. the cards)
54
131
  reloadAfterTurn: new Map<string, boolean>(), // agents whose extensions load again when their turn ends -> quiet
55
- api: {} as Record<string, any>, // services extensions offer by name (e.g. swarm); the newest load wins
132
+ api: {} as Record<string, any>, // services extensions offer by name (e.g. team); the newest load wins
56
133
  file, // file(res, path): send a file (type from its extension; 404 if none)
57
134
 
58
135
  broadcast(type: string, data: object) {
@@ -60,14 +137,14 @@ const host = {
60
137
  for (const s of sockets) s.send(text);
61
138
  },
62
139
 
63
- // Without the swarm extension: each agent idle or working, and messages go straight to a run
140
+ // Without the team extension: each agent idle or working, and messages go straight to a run
64
141
  status() {
65
- if (host.api.swarm) return host.api.swarm.status();
142
+ if (host.api.team) return host.api.team.status();
66
143
  return Object.fromEntries([...host.agents.values()].map((a) => [a.name, { role: a.role, app: a.app ?? null,
67
144
  state: a.session.isStreaming ? "working" : "idle", doing: "", since: 0, queued: 0 }]));
68
145
  },
69
146
  send(from: string, to: string, text: string) {
70
- if (host.api.swarm) return host.api.swarm.send(from, to, text);
147
+ if (host.api.team) return host.api.team.send(from, to, text);
71
148
  void host.prompt(to, `[message from ${from} · ${new Date().toTimeString().slice(0, 8)}]\n${text}`, from);
72
149
  },
73
150
 
@@ -118,23 +195,23 @@ const host = {
118
195
  async createAgent(name: string, role: string, app?: string, fresh = false) {
119
196
  const old = host.agents.get(name);
120
197
  if (old) { old.unsubscribe(); old.session.dispose(); }
121
- const dir = path.join(STATE, "sessions", name);
198
+ const dir = path.join(DATA, "sessions", name);
122
199
  fs.mkdirSync(dir, { recursive: true });
123
200
  // pi's settings: app/settings.json (e.g. compaction). projectTrusted false: pi ignores workspace/.pi/ (its
124
201
  // SYSTEM.md, extensions, skills, settings), so an agent's project files can't change it; app/ (versioned) is the only place
125
- const settingsManager = SettingsManager.create(WORK, APP, { projectTrusted: false });
202
+ const settingsManager = SettingsManager.create(WORKSPACE, AGENT, { projectTrusted: false });
126
203
  // agentDir = app/: pi finds app/extensions/<name>/index.ts and app/skills/* itself, again on every reload
127
- // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in app/mcp.json, with
128
- // codemode and tool_search for servers whose tools aren't direct. Sign-ins: app/mcp-auth.json (not versioned).
129
- const resourceLoader = new DefaultResourceLoader({ cwd: WORK, agentDir: APP, settingsManager, noContextFiles: true,
204
+ // pi's MCP (the CLI's built-in extensions, which SDK sessions don't load): the servers in agent/mcp.json, with
205
+ // codemode and tool_search for servers whose tools aren't direct. Sign-ins: agent/mcp-auth.json (not versioned).
206
+ const resourceLoader = new DefaultResourceLoader({ cwd: WORKSPACE, agentDir: AGENT, settingsManager, noContextFiles: true,
130
207
  extensionFactories: [createCodemodeExtension(), createToolSearchExtension(),
131
- createMcpExtension({ logPath: path.join(STATE, "mcp.log") })] });
208
+ createMcpExtension({ logPath: path.join(DATA, "mcp.log") })] });
132
209
  await resourceLoader.reload();
133
210
  for (const e of resourceLoader.getExtensions().errors) console.error(`${name}: extension ${e.path} failed: ${e.error}`);
134
211
  const { session } = await createAgentSession({
135
- cwd: WORK, agentDir: APP, resourceLoader, settingsManager,
212
+ cwd: WORKSPACE, agentDir: AGENT, resourceLoader, settingsManager,
136
213
  // the saved conversation, or a new one
137
- sessionManager: fresh ? SessionManager.create(WORK, dir) : SessionManager.continueRecent(WORK, dir),
214
+ sessionManager: fresh ? SessionManager.create(WORKSPACE, dir) : SessionManager.continueRecent(WORKSPACE, dir),
138
215
  });
139
216
  if (session.sessionName !== name) session.setSessionName(name);
140
217
  const empty = session.sessionManager.buildSessionContext().messages.length === 0;
@@ -144,9 +221,9 @@ const host = {
144
221
  console.error(`${name}: extension ${e.extensionPath} (${e.event}): ${e.error}`);
145
222
  emit("error", { agent: name, text: `extension ${e.extensionPath} (${e.event}): ${e.error}` });
146
223
  } });
147
- // the model: the one picked with /login or /model (extensions/login), else the MODEL_BASE_URL endpoint (extensions/model).
224
+ // the model: the one picked with /login or /model, else the MODEL_BASE_URL endpoint (both: extensions/model).
148
225
  // CHALKY_LOGIN=off (a cloud deployment, the backend's endpoint): always that endpoint, whatever an extension says
149
- const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.login?.modelFor(name, session);
226
+ const picked = process.env.CHALKY_LOGIN === "off" ? undefined : host.api.model?.modelFor(name, session);
150
227
  const model = picked ?? session.modelRuntime.getModel("chalky", process.env.MODEL_NAME || "default");
151
228
  if (model) await session.setModel(model);
152
229
  // thinking level: the agent's own (the /thinking command), which pi keeps in its session file; an empty
@@ -186,58 +263,226 @@ function file(res: http.ServerResponse, p: string) {
186
263
  fs.createReadStream(p).pipe(res);
187
264
  }
188
265
 
189
- // ---- recovery: the safety net for when an agent breaks the page (or the versions extension, so its panel) ----
190
- // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever app/ holds.
191
- // GET /recover the saved versions (state/versions.git, written by the versions extension), newest first;
192
- // "the page worked" = the page took its picture of that version (state/shots/<sha>.png);
193
- // the newest too (a broken versions extension doesn't save the edit that broke it)
194
- // POST /recover/<sha> that version's files back into app/, as a new commit (as the versions panel's rollback,
266
+ // ---- the version history: one git repo for agent/ and built/ (inHistory), kept in data/version/git ----
267
+ // Outside the folders it keeps, so an agent's own git commands can't rewrite it. Git through isomorphic-git (pure JS).
268
+ // The version extension commits and rolls back with this; /recover too, so it works when that extension is broken.
269
+ // repo isomorphic-git's {fs, dir: HOME, gitdir}
270
+ // stage() git add -A for what inHistory keeps (.gitignore files play no part); returns the changed paths
271
+ // restore(oid) that version's agent/ and built/ into the folders and the index, removing files it doesn't have
272
+ // (the branch stays where it is; what the history doesn't keep, logins and endpoints, isn't touched)
273
+ // versions() the user's versions, newest first, as git.log gives them ({oid, commit}): main's own line, without
274
+ // the releases an update merged in (see "updates" below)
275
+ const REPO = { fs, dir: HOME, gitdir: path.join(DATA, "version", "git") };
276
+ const KEPT = { filepaths: ["agent", "built"], ignored: true, filter: inHistory }; // statusMatrix's walk: only these
277
+ // isomorphic-git 1.44.2 takes a file whose size and mtime (whole seconds) match the index as unchanged: a same-size
278
+ // change in the second after it was staged would be missed (and an update would write over it). Real git checks
279
+ // those "racy" files; here every file that looks unchanged is checked by its content. Its status also misses a change
280
+ // of the x bit alone: checked too.
281
+ async function stage() {
282
+ const changed = [], staged = new Map<string, string>(); // the index: path -> "oid mode"
283
+ await git.walk({ ...REPO, trees: [git.STAGE()], map: async (f, [e]) => {
284
+ if (e && f !== "." && (await e.type()) === "blob") staged.set(f, `${await e.oid()} ${await e.mode()}`);
285
+ return true;
286
+ } });
287
+ const now = async (f: string) => { const p = path.join(HOME, f); // the file as git would stage it
288
+ return `${(await git.hashBlob({ object: fs.readFileSync(p) })).oid} ${fs.statSync(p).mode & 0o111 ? 0o100755 : 0o100644}`; };
289
+ for (const [filepath, head, workdir, index] of await git.statusMatrix({ ...REPO, ...KEPT })) {
290
+ if (head === 1 && workdir === 1 && index === 1 && await now(filepath) === staged.get(filepath)) continue; // unchanged
291
+ if (workdir === 0) { if (index !== 0) await git.remove({ ...REPO, filepath }); }
292
+ else await git.add({ ...REPO, filepath, force: true });
293
+ changed.push(filepath);
294
+ }
295
+ return changed;
296
+ }
297
+ // Only the folders that either version has files in: for a folder neither has (built/ before anything was made, still
298
+ // on disk as built/apps/), isomorphic-git 1.44.2's checkout takes it for a file to delete and throws EISDIR.
299
+ async function restore(oid: string) {
300
+ const has = async (ref: string, dir: string) => (await git.listFiles({ ...REPO, ref })).some((f) => f.startsWith(`${dir}/`));
301
+ const head = await git.resolveRef({ ...REPO, ref: "HEAD" });
302
+ const filepaths = [];
303
+ for (const dir of KEPT.filepaths) if (await has(oid, dir) || await has(head, dir)) filepaths.push(dir);
304
+ if (filepaths.length) await git.checkout({ ...REPO, ref: oid, noUpdateHead: true, force: true, filepaths });
305
+ }
306
+ Object.assign(host, { history: { repo: REPO, kept: KEPT, stage, restore } });
307
+
308
+ // ---- updates: each release of this package is a commit on the history's "package" branch, merged into main ----
309
+ // The package's agent/ is a release; the home's agent/ is what runs, and the agents change it. At start, a release
310
+ // not merged yet is committed on "package" (its agent/ only) and merged into main, file by file. The base is the
311
+ // release merged last (git's merge base), so for each file:
312
+ // only the release changed it -> the release's only the home changed it -> the home's
313
+ // both, in different lines -> both changes (diff3)
314
+ // both, in the same lines, or one side deleted it -> the home's stays; the release's goes next to it as
315
+ // <file>.new, and agent1 is asked to merge them (host.update.conflicts)
316
+ // First a "Before update" commit of unsaved changes, then one commit with both parents: the version panel undoes an
317
+ // update like any change. A new home is the first release merged into nothing; a home from before this has no
318
+ // "package" yet: its base is its "original" version (the release it started from). If the update fails, the home
319
+ // runs as it was and the page says why (host.update.error). Not when the home is this folder (node server.ts here).
320
+ // agent/mcp.json is the user's own list of MCP servers: the package never ships it, so no release brings or removes it.
321
+ const RELEASE = `Chalky ${JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version}`;
322
+ const CHALKY = { name: "chalky", email: "chalky@chalky" };
323
+ const OWN = ["agent/mcp.json"];
324
+ const ref = (r: string) => git.resolveRef({ ...REPO, ref: r }).catch(() => undefined);
325
+ const blob = async (oid: string) => Buffer.from((await git.readBlob({ ...REPO, oid })).blob);
326
+
327
+ // The user's versions: from HEAD by first parents only (an update's first parent is the version before it; its
328
+ // second, the release), stopping at a release (a new home's first version comes after one). Not git.log: it walks
329
+ // both parents, so the releases would be listed as versions (going back to one would take the agents' changes and
330
+ // built/ away), the first release twice, and versions made in the same second out of order.
331
+ async function versions() {
332
+ const releases = new Set<string>(), original = await ref("refs/tags/original"); // a home from before: its "original" isn't one
333
+ for (let oid = await ref("package"); oid && oid !== original; oid = (await git.readCommit({ ...REPO, oid })).commit.parent[0])
334
+ releases.add(oid);
335
+ const out = [];
336
+ for (let oid = await ref("HEAD"); oid && !releases.has(oid); ) {
337
+ const c = await git.readCommit({ ...REPO, oid });
338
+ out.push(c);
339
+ oid = c.commit.parent[0];
340
+ }
341
+ return out;
342
+ }
343
+ Object.assign(host.history, { versions });
344
+
345
+ async function releaseTree(dir: string): Promise<string | null> { // the package's agent/ (what startAgent copies) as a git tree
346
+ const tree = [];
347
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
348
+ const p = path.join(dir, e.name);
349
+ const rel = path.relative(path.join(ROOT, "agent"), p);
350
+ if (!isAgentPart(rel) || OWN.includes(`agent/${rel}`)) continue;
351
+ if (e.isFile()) tree.push({ mode: fs.statSync(p).mode & 0o111 ? "100755" : "100644", path: e.name, type: "blob" as const,
352
+ oid: await git.writeBlob({ ...REPO, blob: fs.readFileSync(p) }) });
353
+ else if (e.isDirectory()) { const oid = await releaseTree(p); if (oid) tree.push({ mode: "040000", path: e.name, type: "tree" as const, oid }); }
354
+ }
355
+ return tree.length ? git.writeTree({ ...REPO, tree }) : null;
356
+ }
357
+
358
+ async function filesOf(commit?: string) { // agent/'s files in a commit: path -> {oid, mode}
359
+ const files = new Map<string, { oid: string; mode: number }>();
360
+ if (commit) await git.walk({ ...REPO, trees: [git.TREE({ ref: commit })], map: async (p, [e]) => {
361
+ if (p !== "." && p !== "agent" && !p.startsWith("agent/")) return null; // built/: not the release's
362
+ if (e && (await e.type()) === "blob") files.set(p, { oid: await e.oid(), mode: await e.mode() });
363
+ return true;
364
+ } });
365
+ return files;
366
+ }
367
+
368
+ function merge3(base: Buffer, ours: Buffer, theirs: Buffer) { // both changes, or null if they touch the same lines
369
+ if ([base, ours, theirs].some((b) => b.includes(0))) return null; // not text
370
+ const lines = (b: Buffer) => b.toString("utf8").match(/^.*(\r?\n|$)/gm) ?? [];
371
+ const parts = diff3Merge(lines(ours), lines(base), lines(theirs));
372
+ return parts.some((p: any) => p.conflict) ? null : parts.flatMap((p: any) => p.ok).join("");
373
+ }
374
+
375
+ async function update() {
376
+ let main = await ref("main"), pkg = await ref("package");
377
+ const last = pkg && (await git.readCommit({ ...REPO, oid: pkg })).commit.message.trim();
378
+ if (last === RELEASE && main && (await git.findMergeBase({ ...REPO, oids: [main, pkg!] }))[0] === pkg) return; // merged
379
+ await git.init({ ...REPO, defaultBranch: "main" }); // (does nothing if there is one)
380
+ if ((await stage()).length) main = await git.commit({ ...REPO, message: `Before update to ${RELEASE}`, author: CHALKY });
381
+ if (last !== RELEASE) {
382
+ const agent = await releaseTree(path.join(ROOT, "agent"));
383
+ const tree = await git.writeTree({ ...REPO, tree: agent ? [{ mode: "040000", path: "agent", type: "tree", oid: agent }] : [] });
384
+ const parent = pkg ?? (main && await ref("refs/tags/original")); // a home from before this: the release it started from
385
+ pkg = await git.commit({ ...REPO, ref: "refs/heads/package", tree, parent: parent ? [parent] : [], message: RELEASE, author: CHALKY });
386
+ }
387
+ const [base] = main ? await git.findMergeBase({ ...REPO, oids: [main, pkg!] }) : [];
388
+ const [B, O, T] = await Promise.all([base, main, pkg].map(filesOf));
389
+ const writes: [string, Buffer | null, number?][] = [], conflicts: string[] = []; // [path in HOME, content (null: delete), mode]
390
+ for (const f of new Set([...O.keys(), ...T.keys()])) {
391
+ if (OWN.includes(f)) continue;
392
+ const [b, o, t] = [B.get(f), O.get(f), T.get(f)], same = (x?: typeof b, y?: typeof b) => x?.oid === y?.oid && x?.mode === y?.mode;
393
+ if (same(o, t) || same(b, t)) continue; // the same, or the release didn't change it: the home's
394
+ if (same(b, o)) { writes.push([f, t ? await blob(t.oid) : null, t?.mode]); continue; } // only the release changed it
395
+ const merged = b && o && t && merge3(await blob(b.oid), await blob(o.oid), await blob(t.oid)); // (and the x bit: whose changed)
396
+ if (merged != null) { writes.push([f, Buffer.from(merged), b!.mode === t!.mode ? o!.mode : t!.mode]); continue; }
397
+ if (t) writes.push([`${f}.new`, await blob(t.oid), t.mode]);
398
+ conflicts.push(!o ? `${f}: you had deleted it; this release changed it (its version: ${f}.new)`
399
+ : !t ? `${f}: this release removed it; you had changed it (yours is kept)`
400
+ : `${f}: you and this release changed the same lines (its version: ${f}.new)`);
401
+ }
402
+ try { // (then saved: main moves only when every file is written)
403
+ for (const [f, content, mode] of writes) {
404
+ const p = path.join(HOME, f);
405
+ if (!content) { // and the folders that leaves empty
406
+ fs.rmSync(p, { force: true });
407
+ for (let d = path.dirname(p); d !== AGENT && fs.existsSync(d) && !fs.readdirSync(d).length; d = path.dirname(d)) fs.rmdirSync(d);
408
+ continue;
409
+ }
410
+ fs.mkdirSync(path.dirname(p), { recursive: true });
411
+ fs.writeFileSync(p, content);
412
+ if (mode) fs.chmodSync(p, mode & 0o777);
413
+ }
414
+ await stage();
415
+ await git.commit({ ...REPO, message: main ? `Updated to ${RELEASE}` : `Started with ${RELEASE}`, author: CHALKY,
416
+ parent: main ? [main, pkg!] : [pkg!] });
417
+ } catch (e) { // each file written goes back as main has it (contents and x bit), or away (.new files too)
418
+ for (const [f] of writes) {
419
+ const p = path.join(HOME, f), o = O.get(f);
420
+ try {
421
+ if (!o) { fs.rmSync(p, { force: true }); continue; }
422
+ fs.mkdirSync(path.dirname(p), { recursive: true });
423
+ fs.writeFileSync(p, await blob(o.oid));
424
+ fs.chmodSync(p, o.mode & 0o777);
425
+ } catch (e2) { console.error(`could not put ${f} back:`, e2); }
426
+ }
427
+ throw e;
428
+ }
429
+ const files = writes.length - writes.filter(([f]) => f.endsWith(".new")).length;
430
+ if (main) return { release: RELEASE, files, conflicts }; // (a new home: nothing to tell)
431
+ }
432
+
433
+ if (HOME !== ROOT) try { Object.assign(host, { update: await update() }); } catch (e: any) { // run as it was: main didn't move
434
+ console.error(`update to ${RELEASE} failed:`, e);
435
+ const main = await ref("main");
436
+ if (main) await restore(main).catch((e) => console.error("could not put agent/ and built/ back as main has them:", e)); // and the index
437
+ Object.assign(host, { update: { release: RELEASE, error: String(e?.message ?? e) } });
438
+ }
439
+
440
+ // ---- recovery: the safety net for when an agent breaks the page (or the version extension, so its panel) ----
441
+ // Here, not in an extension, so agents can't break it too. Plain HTML forms, no script: it works whatever agent/ holds.
442
+ // GET /recover the saved versions, newest first; "the page worked" = the page took its picture of that
443
+ // version (data/version/shots/<sha>.png); the newest too (a broken version extension doesn't
444
+ // save the edit that broke it)
445
+ // POST /recover/<sha> that version's agent/ and built/ back, as a new commit (as the version panel's rollback,
195
446
  // without its extras), then every agent's extensions load again; back to the page
196
447
  // Also shown at / when no extension serves the page (the page extension failed to load).
197
- const REPO = { fs, dir: APP, gitdir: path.join(STATE, "versions.git") };
198
448
  const esc = (s: string) => s.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
199
449
 
200
450
  async function recoverPage(res: http.ServerResponse) {
201
- const log = fs.existsSync(REPO.gitdir) ? await git.log(REPO) : [];
451
+ // the history's folder comes before its first commit: right after a start there may be no versions yet
452
+ const log = await versions(); // (none yet: [])
202
453
  const rows = log.map(({ oid, commit: c }, i) => `<tr><td>${new Date(c.author.timestamp * 1000).toLocaleString()}</td>
203
454
  <td>${esc(c.author.name)}</td><td>${esc(c.message.split("\n")[0])}</td>
204
- <td>${fs.existsSync(path.join(STATE, "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
455
+ <td>${fs.existsSync(path.join(DATA, "version", "shots", `${oid}.png`)) ? "the page worked" : ""}</td>
205
456
  <td><form method="post" action="/recover/${oid}"><button>go back to this</button></form>${i ? "" : " (the last saved)"}</td></tr>`);
206
457
  const failed = host.agents.get("agent1")?.session.resourceLoader.getExtensions().errors ?? []; // what didn't load
207
458
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><title>Recover</title>
208
459
  <style>body{font:14px system-ui;margin:2em}td{padding:4px 10px;border-bottom:1px solid #ddd}</style>
209
460
  <h1>Recover</h1><p>If an agent broke the page, go back to a version where it worked. Nothing is lost: the current
210
461
  files are saved first, and going back is itself a new version. <a href="/">Back to the page</a></p>
211
- ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(APP, e.path))}: ${esc(String(e.error))}</p>`).join("")}
462
+ ${failed.map((e: any) => `<p style="color:#b00">Failed to load: ${esc(path.relative(AGENT, e.path))}: ${esc(String(e.error))}</p>`).join("")}
212
463
  <table>${rows.join("") || "<tr><td>No versions yet.</td></tr>"}</table>`);
213
464
  }
214
465
 
215
466
  async function recover(sha: string, res: http.ServerResponse) {
216
467
  try { sha = await git.expandOid({ ...REPO, oid: sha }); } catch { return res.writeHead(404).end(); }
217
- await host.shared.get("versions")?.queue; // after the versions extension's own work
218
- try { await host.api.swarm?.stop(); } catch {} // the swarm extension may be the broken one
468
+ await host.shared.get("version")?.queue; // after the versions extension's own work
469
+ try { await host.api.team?.stop(); } catch {} // the team extension may be the broken one
219
470
  for (const name of host.agents.keys()) await host.abort(name);
220
- const stage = async () => { // git add -A (new ignored files skipped: statusMatrix misses info/exclude, see versions)
221
- for (const [f, h, w, i] of await git.statusMatrix(REPO)) {
222
- if ((h === 1 && w === 1 && i === 1) || (h === 0 && i === 0 && await git.isIgnored({ ...REPO, filepath: f }))) continue;
223
- if (w === 0) { if (i !== 0) await git.remove({ ...REPO, filepath: f }); } else await git.add({ ...REPO, filepath: f });
224
- }
225
- };
226
471
  const author = { name: "user", email: "user@chalky" };
227
472
  const subject = `${sha.slice(0, 7)} ${(await git.readCommit({ ...REPO, oid: sha })).commit.message.split("\n")[0]}`;
228
473
  await stage();
229
474
  try { await git.commit({ ...REPO, message: "uncommitted changes before recovery", author, disallowEmpty: true }); } catch {}
230
- await git.checkout({ ...REPO, ref: sha, noUpdateHead: true, force: true });
231
- const files = (await git.statusMatrix(REPO)).filter(([, h, w, i]) => !(h === 1 && w === 1 && i === 1)).map(([f]) => f);
232
- for (const f of files) for (let d = path.dirname(f); d !== "."; d = path.dirname(d)) { // folders it emptied
233
- try { fs.rmdirSync(path.join(APP, d)); } catch { break; }
475
+ await restore(sha);
476
+ const files = await stage();
477
+ for (const f of files) for (let d = path.dirname(f); !["agent", "built", "."].includes(d); d = path.dirname(d)) { // folders it emptied
478
+ try { fs.rmdirSync(path.join(HOME, d)); } catch { break; }
234
479
  }
235
480
  await git.commit({ ...REPO, message: `recover to ${subject}`, author });
236
481
  for (const name of host.agents.keys()) await host.reloadAgent(name);
237
- if (files.includes("ui/surfaces.json")) host.shared.get("ui")?.load?.();
482
+ if (files.includes("built/card.json")) { host.shared.get("card")?.load?.(); host.api.history?.add("user", `recovered to ${subject}`); }
238
483
  for (const name of host.agents.keys())
239
- host.api.swarm?.fyi("system", name, `The user recovered ${APP} to version ${subject} (the page was broken). `
240
- + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/versions). Check before relying on them.`);
484
+ host.api.team?.fyi("system", name, `The user recovered ${AGENT} and ${BUILT} to version ${subject} (the page was broken). `
485
+ + `Later changes are undone (still in the history: GET http://127.0.0.1:${PORT}/version). Check before relying on them.`);
241
486
  console.log(`recovered to ${subject}`);
242
487
  res.writeHead(303, { location: "/" }).end();
243
488
  }
@@ -246,6 +491,11 @@ const server = http.createServer((req, res) => {
246
491
  const url = new URL(req.url!, "http://x").pathname;
247
492
  if (url === "/recover") return void recoverPage(res);
248
493
  if (url.startsWith("/recover/") && req.method === "POST") return void recover(url.slice(9), res);
494
+ if (url.startsWith("/built/")) { // what the agents made, at the same path as on disk (built/ holds no secrets)
495
+ let p = "";
496
+ try { p = path.join(HOME, path.posix.normalize(decodeURIComponent(url))); } catch {} // a bad %-escape: 404
497
+ return p.startsWith(BUILT + path.sep) ? file(res, p) : void res.writeHead(404).end(); // also a ".." out of built/
498
+ }
249
499
  let route = host.routes.get(url);
250
500
  for (let p = url; !route && p.lastIndexOf("/") > 0; route = host.routes.get(p + "/")) p = p.slice(0, p.lastIndexOf("/"));
251
501
  if (route) return route(req, res);
@@ -263,7 +513,7 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
263
513
  const data = JSON.parse(String(raw));
264
514
  const kind = data.type ?? "chat";
265
515
  if (kind === "chat") host.send("user", data.to === "main" || !host.agents.has(data.to) ? "agent1" : data.to, data.text);
266
- else if (kind === "stop" && host.api.swarm) await host.api.swarm.stop();
516
+ else if (kind === "stop" && host.api.team) await host.api.team.stop();
267
517
  else if (kind === "stop") {
268
518
  for (const a of host.agents.values()) await host.abort(a.name);
269
519
  emit("stopped", { agent: "all" });
@@ -273,8 +523,13 @@ new WebSocketServer({ server, path: "/ws" }).on("connection", (sock) => {
273
523
 
274
524
  // agent1, then every agent saved by an earlier run (their conversations come back from their session files)
275
525
  await host.createAgent("agent1", "the user's main agent");
276
- for (const name of fs.readdirSync(path.join(STATE, "sessions")).filter((n) => n !== "agent1")) {
277
- const { role, app } = JSON.parse(fs.readFileSync(path.join(STATE, "sessions", name, "role.json"), "utf8"));
526
+ const conflicts: string[] = (host as any).update?.conflicts ?? []; // the update's files to merge: agent1's task
527
+ if (conflicts.length) host.send("chalky", "agent1", `Chalky was updated to ${(host as any).update.release}. These files `
528
+ + `were changed both by you (or another agent) and by the update, so yours were kept:\n${conflicts.map((c) => `- ${c}`).join("\n")}\n`
529
+ + "For each, read both versions and make one that keeps what the user had and adds what the update brings, then "
530
+ + "delete the .new file. If you can't tell what the user would want, keep theirs and tell them what the update had.");
531
+ for (const name of fs.readdirSync(path.join(DATA, "sessions")).filter((n) => n !== "agent1")) {
532
+ const { role, app } = JSON.parse(fs.readFileSync(path.join(DATA, "sessions", name, "role.json"), "utf8"));
278
533
  await host.createAgent(name, role, app);
279
534
  }
280
535
  const hostName = process.env.UI_HOST || "127.0.0.1";
package/app/SYSTEM.md DELETED
@@ -1,31 +0,0 @@
1
- You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (UI
2
- surfaces and apps) fills the screen, and the conversation is a side panel. The user says what they want; you put a
3
- working first version on screen; then they point at things on it and leave notes, and you change exactly that.
4
- - Build, don't describe. A first version they can point at beats questions you could answer by building.
5
- - Your final reply is the only text they read in full: one or two sentences on what changed.
6
- - The page around your work is the user's: change it only when asked. Build inside the main space.
7
-
8
- What follows this: the skills you can load, your working folder (<cwd>: bash runs there), one short section for
9
- each part of you that has something to say (each comes from an extension), and <paths>: where your folders
10
- ({code}, {work}, {data}), the host and pi's docs are. In these texts and the README.md files, {code}/..., {work}/...
11
- and {data}/... are paths in those folders. A section that names a README.md is the short version of its
12
- extension: read that file when the section says, once (it stays in your conversation).
13
-
14
- Your tools:
15
- - read to look at files and pictures (not cat or sed); bash for ls, grep, find and running things.
16
- - edit for precise changes: each edits[].oldText must match the file exactly and only once; keep it as small as
17
- that allows. Several changes in one file: one edit call with several entries, each matched against the original
18
- file, none overlapping. An edit whose newText equals its oldText changes nothing and is refused.
19
- - write only for new files or complete rewrites.
20
- - Name files by their full path when you mention them.
21
-
22
- Changing yourself: when the work needs it, you can change any part of you, the page included. You are pi
23
- extensions, one folder each: {code}/extensions/<name>/. In it, index.ts is `export default function (pi:
24
- ExtensionAPI) { ... }`, which registers tools (pi.registerTool), prompt sections and event handlers (pi.on); and as
25
- the extension needs them, guide.md (its short prompt section), README.md (the whole of it), browser.js (its part
26
- of the page) and skills/. The page itself is the page extension; this text is {code}/SYSTEM.md; pi's settings (e.g. compaction) are
27
- {code}/settings.json (pi's docs/settings.md). Extensions reach
28
- shared state and the web server through globalThis.chalky: the host, the fixed core under them (don't edit it).
29
- Read pi's extension docs (<paths>) before writing or changing an extension. After editing, call the reload tool:
30
- your extensions load again when your turn ends, and your conversation is kept. A changed page or browser.js shows
31
- when the page reloads, after your turn.
@@ -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,3 +0,0 @@
1
- # Commands
2
- The user's "/" commands for an agent (e.g. /compact) are pi commands in {code}/extensions/commands/index.ts; the
3
- page lists them by itself. To add one when asked: pi.registerCommand there, calling what pi's built-in calls.