@augurworks/augur 0.15.1

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 (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,741 @@
1
+ // clawd-canvas — the terminal half of live canvas collaboration.
2
+ //
3
+ // Lets a Claude agent JOIN a canvas board's multiplayer room as a real participant
4
+ // ("Clawd") and co-work in real time: move a cursor, raise a focus ring on the node it's
5
+ // working, and stream node edits — everything a human client does, over the same /__rt
6
+ // protocol (augur-realtime BoardRoom DO). No browser: a raw WebSocket speaks the protocol
7
+ // directly (Node's global WebSocket). Durable persistence is the ROOM's job while the
8
+ // socket is live (BoardRoom DO writes KV itself); /__board POST is only the offline fallback.
9
+ //
10
+ // The engine renders any peer with kind:"agent" as the Clawd mascot (tinted by color) with
11
+ // a name pill + focus rings, so the human sees WHERE Clawd is and WHAT it's touching.
12
+ //
13
+ // Usage:
14
+ // import { ClawdCanvas } from './clawd-canvas.mjs'
15
+ // const c = new ClawdCanvas({ boardPath: '/ux-ui-audit/project-flow-audit/' })
16
+ // await c.connect()
17
+ // await c.moveCursorTo(x, y); c.focus(nodeId); c.upsert(node); await c.save()
18
+ //
19
+ // The co-working PROTOCOL (design decision): show activity on the canvas FIRST,
20
+ // then do the work. On any ask: walk to the relevant spot + pose('thinking') + say(...)
21
+ // before reasoning; before building an artifact: stub() a placeholder where it will land
22
+ // + pose('sparkles'); emotions as punctuation (happy = done, sunglasses = shipped live,
23
+ // love = the human liked it, sleeping = parked). Bubbles are stream-only (never saved);
24
+ // stubs persist until the real artifact replaces them.
25
+ //
26
+ // CLI:
27
+ // node clawd-canvas.mjs probe <boardPath> # connect, print doc + peers, leave
28
+ // node clawd-canvas.mjs demo <boardPath> # walk in, drop a sticky, wave, leave
29
+ // node clawd-canvas.mjs chill <boardPath> [x y] # park sleeping, stay until killed
30
+ // All modes take [--name N --color '#hex'] — the agent's identity. Multiple agents can
31
+ // co-work the same board simultaneously: each runs its OWN daemon (own name, color, and
32
+ // command file); the engine renders each as its own tinted Clawd, per-node LWW merges.
33
+ // ⚠️ In daemon mode DON'T pass --name/--color for the session's own Clawd: with neither
34
+ // it reads BOTH from the session transcript and FOLLOWS /rename and /color automatically
35
+ // (see "session identity" below). Pass them only to pin a sibling agent's own identity.
36
+ //
37
+ // node clawd-canvas.mjs daemon <boardPath> <cmdFile> # persistent puppet: tail a JSONL
38
+ // command file and execute in order — one connection an agent can command across turns.
39
+ // Launch DETACHED (nohup … & echo $! > clawd-daemon.pid; disown) so harness task
40
+ // cleanups can't reap it; dismiss explicitly with {"cmd":"quit"} or the pidfile.
41
+ // Commands (one JSON object per line, appended):
42
+ // {"cmd":"goto","x":2400,"y":300} glide there (walking pose derives)
43
+ // {"cmd":"pose","v":"thinking|sparkles|happy|love|sunglasses|sleeping|idle"}
44
+ // {"cmd":"say","text":"reading the board…"} ephemeral bubble near the cursor
45
+ // {"cmd":"unsay"} remove the bubble
46
+ // {"cmd":"focus","id":"st3"} focus ring (null to clear)
47
+ // {"cmd":"stub","x":..,"y":..,"w":..,"h":..,"label":"common ground"} placeholder
48
+ // {"cmd":"upsert","node":{...}} / {"cmd":"del","id":".."} real edits (KV-saved;
49
+ // add "ephemeral":true for stream-only)
50
+ // {"cmd":"rename","name":".."} {"cmd":"save"} {"cmd":"quit"}
51
+ // {"cmd":"chill","v":false} disable the ambient chill loop
52
+ // {"cmd":"identity","name":"F5 Clawd"} rename the AGENT live (quick
53
+ // reconnect; color re-derives unless "color" is given). Rarely needed by hand —
54
+ // a nameless daemon already follows the session's own name AND /color.
55
+ // It also mirrors the live doc to <cmdFile dir>/clawd-board.json on every op, so the
56
+ // agent always reads the human's latest state without reconnecting. While connected and
57
+ // idle (no commands ~12s, pose plain idle) Clawd CHILLS instead of freezing: fidgets,
58
+ // strolls near the human's cursor or around the content, the odd happy blip.
59
+
60
+ import { readFileSync } from 'node:fs';
61
+ import { deployConfig } from './lib/instance.mjs';
62
+
63
+ // This instance's room server and site — resolved from the shell's deploy.config.json
64
+ // (the same source the built worker's /__rt proxy reads), never hardcoded: the engine is
65
+ // shared, and a baked-in origin would drop an agent into ANOTHER instance's rooms.
66
+ // A bare SPACE clone (no shell) still works: space.json's siteOrigin supplies the site,
67
+ // and with no realtime origin the client goes through the site's own /__rt proxy.
68
+ const DEPLOY = deployConfig();
69
+ const SPACE = (() => {
70
+ try { return JSON.parse(readFileSync('space.json', 'utf8')); }
71
+ catch { return {}; }
72
+ })();
73
+ const RT_ORIGIN = String(process.env.CANVAS_RT_ORIGIN || DEPLOY.realtimeOrigin || '')
74
+ .replace(/^http/, 'ws').replace(/\/+$/, '');
75
+ const SITE = process.env.CANVAS_SITE_ORIGIN || process.env.REVIEW_SITE_URL ||
76
+ DEPLOY.siteOrigin || SPACE.siteOrigin || '';
77
+ const CLAWD_ORANGE = '#d97757'; // Claude clay — the primary agent's Clawd hue
78
+
79
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
80
+ const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-');
81
+
82
+ // identity color: the terminal session's /color is the source of truth (the daemon reads
83
+ // it from the transcript — see sessionNamer); the name hash is only the fallback for
84
+ // sessions that never picked one, so a terminal's Clawd keeps a stable color across
85
+ // restarts with no coordination. Plain "Clawd" = the orange.
86
+ const PALETTE = ['#4e8fd9', '#8a63c9', '#2e9e6b', '#d9569b', '#c8912e', '#3aa6b9', '#6a7dd9', '#b5533c'];
87
+ // Claude Code's /color names, mapped onto the board palette's hues
88
+ export const TERM_COLORS = {
89
+ red: '#b5533c', orange: CLAWD_ORANGE, yellow: '#c8912e', green: '#2e9e6b',
90
+ blue: '#4e8fd9', purple: '#8a63c9', pink: '#d9569b',
91
+ };
92
+ export const colorFor = (name) => {
93
+ if (!name || slug(name) === 'clawd') return CLAWD_ORANGE;
94
+ let h = 0; for (const ch of String(name)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
95
+ return PALETTE[h % PALETTE.length];
96
+ };
97
+
98
+ export class ClawdCanvas {
99
+ constructor({ boardPath, name = 'Clawd', color, site = SITE, rtOrigin = RT_ORIGIN } = {}) {
100
+ if (!boardPath) throw new Error('boardPath required');
101
+ if (!site) throw new Error('no site origin — pass {site}, set CANVAS_SITE_ORIGIN, or run from a space clone whose space.json has "siteOrigin"');
102
+ // No dedicated realtime origin? Go through the site's own /__rt proxy — it forwards
103
+ // to the instance's room server, so a bare space clone works with zero config.
104
+ this._roomPath = '/room';
105
+ if (!rtOrigin) {
106
+ rtOrigin = String(site).replace(/^http/, 'ws').replace(/\/+$/, '');
107
+ this._roomPath = '/__rt';
108
+ }
109
+ this.boardPath = boardPath;
110
+ this.name = name;
111
+ this.color = color || colorFor(name);
112
+ this.site = site;
113
+ this.rtOrigin = rtOrigin;
114
+ this.doc = { v: 1, name: 'Untitled canvas', nodes: [] };
115
+ this.peers = {}; // sid -> {name,color,kind,focus}
116
+ this.sid = null;
117
+ this.ready = false;
118
+ this._cur = { x: 0, y: 0 };
119
+ this._pose = 'idle';
120
+ this._ws = null;
121
+ this._onready = null;
122
+ this._saveT = null;
123
+ this._pingT = null;
124
+ }
125
+
126
+ // A room server with RT_SHARED_SECRET set rejects anything that didn't come through
127
+ // the site's /__rt proxy (which holds the secret and enforces the admin-space seal).
128
+ // A terminal client has no secret, so a direct-origin handshake 403s there. Fall back
129
+ // to the proxy once, transparently: it routes to the same rooms on every instance, and
130
+ // instances whose room server has no secret keep taking the direct route as before.
131
+ async connect() {
132
+ try {
133
+ return await this._dial();
134
+ } catch (e) {
135
+ if (this._roomPath !== '/room' || !this.site) throw e;
136
+ this.rtOrigin = String(this.site).replace(/^http/, 'ws').replace(/\/+$/, '');
137
+ this._roomPath = '/__rt';
138
+ return await this._dial();
139
+ }
140
+ }
141
+
142
+ _dial() {
143
+ const url = `${this.rtOrigin}${this._roomPath}?path=${encodeURIComponent(this.boardPath)}` +
144
+ `&name=${encodeURIComponent(this.name)}&kind=agent&color=${encodeURIComponent(this.color)}`;
145
+ const ws = new WebSocket(url);
146
+ this._ws = ws;
147
+ ws.addEventListener('message', (ev) => this._onMessage(ev));
148
+ // keepalive: the room reaps sockets that stop pinging (~75s) — ping every 25s so a
149
+ // parked/sleeping Clawd stays present in the room across turns
150
+ ws.addEventListener('open', () => {
151
+ this._pingT = setInterval(() => { if (this._ws && this._ws.readyState === 1) { try { this._ws.send('ping'); } catch {} } }, 25000);
152
+ });
153
+ ws.addEventListener('close', () => { this.ready = false; clearInterval(this._pingT); });
154
+ return new Promise((resolve, reject) => {
155
+ this._onready = resolve;
156
+ ws.addEventListener('error', reject, { once: true });
157
+ setTimeout(() => this.ready || reject(new Error('welcome timeout')), 12000);
158
+ });
159
+ }
160
+
161
+ async _onMessage(ev) {
162
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
163
+ if (!m || !m.t) return;
164
+ if (m.t === 'welcome') {
165
+ this.sid = m.sid;
166
+ this.color = m.color || this.color;
167
+ this.peers = {};
168
+ (m.peers || []).forEach((p) => (this.peers[p.sid] = { name: p.name, color: p.color, kind: p.kind, pose: p.pose, focus: p.focus }));
169
+ if (m.doc) this.doc = m.doc;
170
+ else { // no live doc — pull the durable copy from KV so we edit on top of the real board
171
+ const kv = await this._getKv();
172
+ if (kv) { this.doc = kv; this._send({ t: 'doc', doc: this.doc }); }
173
+ }
174
+ this.ready = true;
175
+ // sweep bubbles orphaned by a crashed run — a human's full-state save can have
176
+ // persisted one to KV, so delete durably. Multi-agent aware: never pop a bubble
177
+ // whose owner is currently in the room (each agent's bubble id carries its name).
178
+ const present = new Set(Object.values(this.peers).filter((p) => p.kind === 'agent').map((p) => 'clawd-bubble-' + slug(p.name)));
179
+ const orphans = this.doc.nodes.filter((n) => String(n.id).startsWith('clawd-bubble') && !present.has(n.id));
180
+ if (orphans.length) { for (const n of orphans) this.del(n.id); }
181
+ if (this._onready) { this._onready(this); this._onready = null; }
182
+ } else if (m.t === 'join') {
183
+ this.peers[m.peer.sid] = { name: m.peer.name, color: m.peer.color, kind: m.peer.kind, pose: m.peer.pose, focus: m.peer.focus };
184
+ } else if (m.t === 'leave') {
185
+ delete this.peers[m.peer.sid];
186
+ } else if (m.t === 'cursor') {
187
+ const p = this.peers[m.sid]; if (p && !m.gone) { p.cx = m.x; p.cy = m.y; }
188
+ } else if (m.t === 'ops') {
189
+ this._applyOps(m.ops || []); // track the human's edits so our doc stays current
190
+ } else if (m.t === 'focus') {
191
+ const p = this.peers[m.sid]; if (p) p.focus = m.id || null;
192
+ } else if (m.t === 'sel') {
193
+ const p = this.peers[m.sid]; if (p) p.sel = m.ids || null; // what each human has selected — the politeness guard reads this
194
+ } else if (m.t === 'chat') {
195
+ if (this.onChat) { try { this.onChat({ sid: m.sid, name: m.name, kind: m.kind || null, text: m.text }); } catch {} }
196
+ } else if (m.t === 'kick') {
197
+ // evicted from the board by a human — the room only sends this to the target
198
+ if (m.sid === this.sid && this.onKick) { try { this.onKick({ by: m.by || '' }); } catch {} }
199
+ } else if (m.t === 'pose') {
200
+ const p = this.peers[m.sid]; if (p) p.pose = m.pose || null;
201
+ } else if (m.t === 'doc') {
202
+ if (m.doc && Array.isArray(m.doc.nodes)) this.doc = m.doc;
203
+ } else if (m.t === 'docreq') {
204
+ this._send({ t: 'doc', doc: this.doc }); // room woke hibernated; we have current state
205
+ }
206
+ }
207
+
208
+ _applyOps(ops) {
209
+ for (const op of ops) {
210
+ if (!op) continue;
211
+ if (op.op === 'upsert' && op.node && op.node.id) {
212
+ const i = this.doc.nodes.findIndex((n) => n.id === op.node.id);
213
+ if (i >= 0) this.doc.nodes[i] = op.node; else this.doc.nodes.push(op.node);
214
+ } else if (op.op === 'del' && op.id) {
215
+ this.doc.nodes = this.doc.nodes.filter((n) => n.id !== op.id);
216
+ } else if (op.op === 'name' && typeof op.name === 'string') {
217
+ this.doc.name = op.name;
218
+ }
219
+ }
220
+ }
221
+
222
+ _send(msg) { if (this._ws && this._ws.readyState === 1) { try { this._ws.send(JSON.stringify(msg)); } catch {} } }
223
+
224
+ async _getKv() {
225
+ try {
226
+ const r = await fetch(`${this.site}/__board?path=${encodeURIComponent(this.boardPath)}`);
227
+ if (!r.ok) return null;
228
+ const j = await r.json();
229
+ const d = j.doc || j;
230
+ return d && Array.isArray(d.nodes) ? d : null;
231
+ } catch { return null; }
232
+ }
233
+
234
+ // ---- the co-working verbs ------------------------------------------------
235
+ cursor(x, y) { this._cur = { x, y }; this._send({ t: 'cursor', x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }); }
236
+
237
+ // glide the cursor so the human sees Clawd walk over, not teleport. Duration is
238
+ // DISTANCE-PROPORTIONAL (humans cross a desk faster than they nudge a mouse): short hops
239
+ // are quick, treks are brisk, nothing is slow-motion or teleporty. Explicit steps/stepMs
240
+ // still win (the chill loop uses slow ambles on purpose).
241
+ async moveCursorTo(x, y, opts = {}) {
242
+ const sx = this._cur.x, sy = this._cur.y;
243
+ const dist = Math.hypot(x - sx, y - sy);
244
+ const dur = Math.max(180, Math.min(1100, dist * 0.9));
245
+ const steps = opts.steps || Math.max(5, Math.round(dur / (opts.stepMs || 40)));
246
+ const stepMs = opts.stepMs || 40;
247
+ for (let i = 1; i <= steps; i++) {
248
+ const t = i / steps, e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOut
249
+ this.cursor(sx + (x - sx) * e, sy + (y - sy) * e);
250
+ await sleep(stepMs);
251
+ }
252
+ if (this._bubbleText) this._placeBubble(); // the speech bubble follows Clawd
253
+ }
254
+
255
+ focus(id) { this._send({ t: 'focus', id: id || null }); }
256
+ sel(ids) { this._send({ t: 'sel', ids: Array.isArray(ids) ? ids : ids ? [ids] : [] }); } // point with selection rings ("these three")
257
+ // the persistent line in the board's agents strip: what am I doing, and do I need you.
258
+ // state: working | idle | attention | done — attention pulses amber, done flashes green.
259
+ status(text, state) { this._send({ t: 'status', text: text || '', state: state || 'working' }); }
260
+ chat(text) { this._send({ t: 'chat', text: String(text || '').slice(0, 200) }); } // cursor-chat bubble (a moment; status is the state)
261
+
262
+ // ---- politeness: never grab what a human's hands are on -------------------
263
+ _heldBy(id) {
264
+ return Object.values(this.peers).find((p) => p.kind !== 'agent' && (p.focus === id || (p.sel || []).indexOf(id) >= 0)) || null;
265
+ }
266
+ // wait (politely, visibly) for a human to let go of a node; give up after `timeout` and
267
+ // proceed — matching a human colleague's "sorry, I'll just grab it" after hovering.
268
+ async waitUnheld(id, timeout = 15000) {
269
+ const t0 = Date.now();
270
+ let announced = false;
271
+ while (Date.now() - t0 < timeout) {
272
+ const h = this._heldBy(id);
273
+ if (!h) { if (announced) this.status('', 'working'); return true; }
274
+ if (!announced) { announced = true; this.status(`waiting for ${h.name}…`, 'working'); }
275
+ await sleep(700);
276
+ }
277
+ if (announced) this.status('', 'working');
278
+ return false;
279
+ }
280
+
281
+ // ---- humanized edits: drags that travel, text that types ------------------
282
+ // Move a node the way a person does: walk to it, grab (focus ring), then node + cursor
283
+ // travel TOGETHER via the cursor.drag fast-path (20Hz on peers' screens), durable upsert
284
+ // on release. Never teleports someone's board around.
285
+ async dragNode(id, tx, ty, { ms } = {}) {
286
+ const n = this.doc.nodes.find((x) => x.id === id);
287
+ if (!n) throw new Error('dragNode: no node ' + id);
288
+ await this.waitUnheld(id);
289
+ const grabX = n.x + (n.w || 150) / 2, grabY = n.y + (n.h || 100) / 2;
290
+ await this.moveCursorTo(grabX, grabY);
291
+ this.focus(id);
292
+ await sleep(120); // a beat between grab and pull — instant yank reads as glitch
293
+ const sx = n.x, sy = n.y, dist = Math.hypot(tx - sx, ty - sy);
294
+ const dur = ms || Math.max(380, Math.min(1400, dist * 1.1));
295
+ const frames = Math.max(6, Math.round(dur / 50));
296
+ for (let i = 1; i <= frames; i++) {
297
+ const t = i / frames, e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
298
+ n.x = sx + (tx - sx) * e; n.y = sy + (ty - sy) * e;
299
+ const cx = n.x + (n.w || 150) / 2, cy = n.y + (n.h || 100) / 2;
300
+ this._cur = { x: cx, y: cy };
301
+ this._send({ t: 'cursor', x: Math.round(cx * 10) / 10, y: Math.round(cy * 10) / 10, drag: [{ id, x: Math.round(n.x * 10) / 10, y: Math.round(n.y * 10) / 10 }] });
302
+ await sleep(50);
303
+ }
304
+ n.x = tx; n.y = ty;
305
+ this.upsert({ ...n });
306
+ this.focus(null);
307
+ if (this._bubbleText) this._placeBubble();
308
+ }
309
+ // Create a text-bearing node and TYPE its content word by word (the engine's co-typing
310
+ // rail streams it; peers watch it grow under Clawd's focus ring). `node` carries the
311
+ // FINAL text (and optional rich, applied at the end). Conversational scale only — bulk
312
+ // seeding should stay instant, fake slowness on 40 nodes is torture.
313
+ async typeNode(node, { wordMs = 130 } = {}) {
314
+ if (!node || !node.id) throw new Error('typeNode: node with id required');
315
+ await this.waitUnheld(node.id);
316
+ await this.moveCursorTo(node.x + (node.w || 160) / 2, node.y + (node.h || 160) / 2);
317
+ const full = String(node.text || '');
318
+ const rich = node.rich;
319
+ this.upsert({ ...node, text: '', rich: undefined });
320
+ this.focus(node.id);
321
+ let acc = '';
322
+ for (const piece of full.split(/(\s+)/)) {
323
+ if (!piece) continue;
324
+ acc += piece;
325
+ if (!piece.trim()) continue; // whitespace rides with the next word
326
+ this.upsert({ ...node, text: acc, rich: undefined });
327
+ await sleep(wordMs);
328
+ }
329
+ this.upsert(rich ? { ...node } : { ...node, rich: undefined });
330
+ this.focus(null);
331
+ }
332
+
333
+ // ---- accompany: trail a human like a colleague looking over their shoulder ----
334
+ // Follows at an offset, catching up in relaxed bursts — never mirroring 1:1 (creepy).
335
+ follow(who) {
336
+ this.unfollow();
337
+ this._followT = setInterval(async () => {
338
+ if (this._followBusy) return;
339
+ const p = Object.values(this.peers).find((q) => (q.name === who || q.sid === who) && q.cx != null);
340
+ if (!p) return;
341
+ const tx = p.cx - 90, ty = p.cy + 55;
342
+ if (Math.hypot(tx - this._cur.x, ty - this._cur.y) < 150) return; // close enough — don't hover
343
+ this._followBusy = true;
344
+ try { await this.moveCursorTo(tx, ty); } finally { this._followBusy = false; }
345
+ }, 450);
346
+ }
347
+ unfollow() { if (this._followT) { clearInterval(this._followT); this._followT = null; } }
348
+
349
+ // set Clawd's expression: idle · thinking · sparkles · happy · sleeping · love · sunglasses
350
+ pose(name) { this._pose = name; this._send({ t: 'pose', pose: name }); }
351
+
352
+ // Every write carries a bumped version (v + random vn tiebreak) — the room applies ops
353
+ // under version-checked LWW now, and an unbumped rewrite of an existing node reads as a
354
+ // stale echo and bounces (a corrective comes back with what the room holds).
355
+ _stamp(node) {
356
+ const cur = this.doc.nodes.find((n) => n.id === node.id);
357
+ node.v = Math.max(node.v || 0, cur ? cur.v || 0 : 0) + 1;
358
+ node.vn = Math.floor(Math.random() * 0x7fffffff);
359
+ return node;
360
+ }
361
+
362
+ // ---- ephemeral layer: visible live, NEVER saved to KV ---------------------
363
+ streamUpsert(node) {
364
+ this._stamp(node);
365
+ const i = this.doc.nodes.findIndex((n) => n.id === node.id);
366
+ if (i >= 0) this.doc.nodes[i] = node; else this.doc.nodes.push(node);
367
+ this._send({ t: 'ops', ops: [{ op: 'upsert', node }] });
368
+ }
369
+ streamDel(id) {
370
+ const cur = this.doc.nodes.find((n) => n.id === id);
371
+ this.doc.nodes = this.doc.nodes.filter((n) => n.id !== id);
372
+ this._send({ t: 'ops', ops: [{ op: 'del', id, v: (cur ? cur.v || 0 : 0) + 1 }] });
373
+ }
374
+
375
+ // speech bubble: a small ephemeral sticky floating by Clawd with what it's doing.
376
+ // Stream-only — but a HUMAN's full-state save can persist it, so always unsay() when
377
+ // done (the welcome sweep cleans up after crashes).
378
+ say(text) { this._bubbleText = text; this._placeBubble(); }
379
+ unsay() { if (this._bubbleText) { this._bubbleText = null; this.streamDel('clawd-bubble-' + slug(this.name)); } }
380
+ _placeBubble() {
381
+ this.streamUpsert({
382
+ id: 'clawd-bubble-' + slug(this.name), type: 'sticky',
383
+ x: Math.round(this._cur.x + 30), y: Math.round(this._cur.y - 110),
384
+ w: 280, h: 84, text: this._bubbleText, color: '#f4c7b3',
385
+ name: this.name + ' says', fontScale: 's', author: '',
386
+ });
387
+ }
388
+
389
+ // placeholder for work in progress: a section frame where the artifact will land,
390
+ // so the human sees WHERE before the WHAT exists. Persists (survives refresh) until
391
+ // the finished artifact replaces it — del(id) it when the real thing is in.
392
+ stub({ x, y, w = 700, h = 500, label = 'something' }) {
393
+ const id = 'clawd-stub-' + Math.random().toString(36).slice(2, 7);
394
+ this.upsert({ id, type: 'section', x, y, w, h, text: '🔨 Clawd is building: ' + label + '…' });
395
+ return id;
396
+ }
397
+
398
+ upsert(node) {
399
+ this._stamp(node);
400
+ const i = this.doc.nodes.findIndex((n) => n.id === node.id);
401
+ if (i >= 0) this.doc.nodes[i] = node; else this.doc.nodes.push(node);
402
+ this._send({ t: 'ops', ops: [{ op: 'upsert', node }] });
403
+ this._scheduleSave();
404
+ }
405
+ del(id) {
406
+ const cur = this.doc.nodes.find((n) => n.id === id);
407
+ this.doc.nodes = this.doc.nodes.filter((n) => n.id !== id);
408
+ this._send({ t: 'ops', ops: [{ op: 'del', id, v: (cur ? cur.v || 0 : 0) + 1 }] });
409
+ this._scheduleSave();
410
+ }
411
+ rename(name) {
412
+ this.doc.name = name;
413
+ this.doc.nameV = (this.doc.nameV || 0) + 1;
414
+ this._send({ t: 'ops', ops: [{ op: 'name', name, v: this.doc.nameV }] });
415
+ this._scheduleSave();
416
+ }
417
+
418
+ _scheduleSave() { clearTimeout(this._saveT); this._saveT = setTimeout(() => this.save(), 1200); }
419
+
420
+ // Durable persistence: while the socket is live, THE ROOM persists the doc to KV itself
421
+ // (2026-07-27 — BoardRoom DO dirty-alarm + flush-on-empty), and a full-state POST from
422
+ // here would race it and could resurrect stale state. So save() is a no-op when
423
+ // connected; the POST only exists as the disconnected fallback.
424
+ async save() {
425
+ clearTimeout(this._saveT);
426
+ if (this._ws && this._ws.readyState === 1) return true; // the room owns the write
427
+ try {
428
+ const r = await fetch(`${this.site}/__board?path=${encodeURIComponent(this.boardPath)}`, {
429
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ doc: this.doc }),
430
+ });
431
+ return r.ok;
432
+ } catch { return false; }
433
+ }
434
+
435
+ close() { try { clearInterval(this._pingT); this._send({ t: 'cursor', gone: true }); this._ws.close(); } catch {} }
436
+ }
437
+
438
+ // ---- session identity (the agent names ITSELF) ------------------------------
439
+ // A terminal session's name is the Clawd's name, and its /color is the Clawd's color.
440
+ // Relying on the agent to pass --name (and to re-send `identity` on every /rename)
441
+ // failed exactly the way you would expect: it read a stale name and the pill was
442
+ // wrong. So the daemon reads identity from the session transcript instead. Claude
443
+ // Code appends
444
+ // {"type":"custom-title","customTitle":"…","sessionId":"…"} on every /rename
445
+ // {"type":"agent-color","agentColor":"orange","sessionId":"…"} on every /color
446
+ // so the LAST such line of each kind IS the current identity.
447
+ // The daemon's command file lives in the session scratchpad
448
+ // …/<project-slug>/<sessionId>/scratchpad/<cmdFile>
449
+ // which is enough to locate ~/.claude/projects/<project-slug>/<sessionId>.jsonl.
450
+ // Explicit --name still wins (siblings/subagents that want their own identity).
451
+ const sessionTranscriptFor = async (cmdFile) => {
452
+ const { resolve, sep, join } = await import('node:path');
453
+ const { homedir } = await import('node:os');
454
+ const parts = resolve(cmdFile).split(sep);
455
+ const i = parts.lastIndexOf('scratchpad');
456
+ if (i < 2) return null;
457
+ const sessionId = parts[i - 1], projectSlug = parts[i - 2];
458
+ if (!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(sessionId)) return null;
459
+ return join(homedir(), '.claude', 'projects', projectSlug, `${sessionId}.jsonl`);
460
+ };
461
+
462
+ // Tails the transcript for identity lines (title + agent color). Reads the whole file
463
+ // once, then only the bytes appended since — a rename or /color mid-session costs a
464
+ // few hundred bytes.
465
+ const sessionNamer = async (file) => {
466
+ const { statSync, openSync, readSync, closeSync, existsSync } = await import('node:fs');
467
+ const TITLE = /"type":"custom-title","customTitle":"((?:[^"\\]|\\.)*)"/g;
468
+ const COLOR = /"type":"agent-color","agentColor":"([a-z]+)"/g;
469
+ let seen = 0, current = null, colorName = null;
470
+ const poll = () => {
471
+ if (!file || !existsSync(file)) return current;
472
+ try {
473
+ const size = statSync(file).size;
474
+ if (size < seen) seen = 0; // truncated/rotated → re-read
475
+ if (size === seen) return current;
476
+ const fd = openSync(file, 'r');
477
+ const buf = Buffer.allocUnsafe(size - seen);
478
+ readSync(fd, buf, 0, buf.length, seen);
479
+ closeSync(fd);
480
+ seen = size;
481
+ const txt = buf.toString('utf8');
482
+ for (const m of txt.matchAll(TITLE)) {
483
+ try { current = JSON.parse('"' + m[1] + '"'); } catch { current = m[1]; }
484
+ }
485
+ for (const m of txt.matchAll(COLOR)) colorName = m[1];
486
+ } catch {}
487
+ return current;
488
+ };
489
+ // the session's /color as a board hex, or null if the session never set one
490
+ const pollColor = () => { poll(); return TERM_COLORS[colorName] || null; };
491
+ return { poll, pollColor, file };
492
+ };
493
+
494
+ // ---- CLI --------------------------------------------------------------------
495
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
496
+ if (isMain) {
497
+ const argv = process.argv.slice(2);
498
+ const flag = (n) => { const i = argv.indexOf('--' + n); return i >= 0 ? argv.splice(i, 2)[1] : undefined; };
499
+ let name = flag('name');
500
+ const nameWasExplicit = name != null; // an explicit --name pins the identity; no auto-tracking
501
+ const sibling = argv.indexOf('--sibling') >= 0; if (sibling) argv.splice(argv.indexOf('--sibling'), 1);
502
+ const lingerMs = Number(flag('linger') ?? process.env.CLAWD_LINGER_MS ?? 3 * 3600 * 1000);
503
+ const idleMs = Number(process.env.CLAWD_IDLE_MS || 180000);
504
+ let color = flag('color'); // identity: one per agent, so several Clawds can co-work
505
+ const colorWasExplicit = color != null; // an explicit --color pins it; no /color following
506
+ const sessionFileFlag = flag('session-file'); // escape hatch when the path isn't derivable
507
+ const [mode, boardPath] = argv;
508
+ if (!mode || !boardPath) { console.error("usage: node clawd-canvas.mjs <probe|demo|chill|daemon> <boardPath> [args] [--name N --color '#hex' --session-file P]"); process.exit(1); }
509
+ // IDENTITY IS DETERMINISTIC, not the agent's choice: the session's own Clawd takes its
510
+ // name from the terminal session (and follows /rename), its color from the session's
511
+ // /color (name-hash fallback when none was ever set), and its strip state from behavior. --name exists ONLY for sibling agents that need a
512
+ // separate identity, and must say so explicitly — this guard is what makes the rule
513
+ // structural instead of a doc plea.
514
+ if (mode === 'daemon' && nameWasExplicit && !sibling) {
515
+ console.error('daemon: --name pins an identity, which is reserved for SIBLING agents. ' +
516
+ 'For the session\'s own Clawd, omit --name (identity follows the session transcript). ' +
517
+ 'If this really is a sibling, add --sibling.');
518
+ process.exit(1);
519
+ }
520
+ // daemon with no explicit --name → take it from the session, before the first
521
+ // connect, so the pill is right from the moment Clawd appears.
522
+ let namer = null;
523
+ if (mode === 'daemon' && argv[2]) {
524
+ namer = await sessionNamer(sessionFileFlag || (await sessionTranscriptFor(argv[2])));
525
+ if (!name) name = namer.poll() || undefined;
526
+ if (!colorWasExplicit) color = namer.pollColor() || undefined;
527
+ }
528
+ const c = new ClawdCanvas({ boardPath, ...(name ? { name } : {}), ...(color ? { color } : {}) });
529
+ await c.connect();
530
+ console.log(`joined ${boardPath} as ${c.name} (${c.color}), sid=${c.sid}`);
531
+ console.log(`doc: "${c.doc.name}" · ${c.doc.nodes.length} nodes · peers: ${Object.values(c.peers).map((p) => p.name + (p.kind === 'agent' ? '(agent)' : '')).join(', ') || 'none'}`);
532
+
533
+ if (mode === 'demo') {
534
+ // walk to a spot, drop a sticky, focus it, wave the cursor, then leave it
535
+ const nid = 'clawd-demo-' + Math.random().toString(36).slice(2, 7);
536
+ await c.moveCursorTo(2600, 1900);
537
+ c.pose('coding');
538
+ c.upsert({ id: nid, type: 'sticky', x: 2500, y: 1820, w: 300, h: 150, text: 'Clawd was here 👋', color: '#ffe066', name: 'clawd hello', fontScale: 'm', bold: true, author: '' });
539
+ c.focus(nid);
540
+ await sleep(400);
541
+ for (const [dx, dy] of [[60, -30], [-60, 30], [40, 20], [0, 0]]) { await c.moveCursorTo(2650 + dx, 1895 + dy, { steps: 10, stepMs: 45 }); }
542
+ c.focus(null); c.pose('happy');
543
+ await c.save();
544
+ console.log('demo: dropped sticky', nid, '— saved');
545
+ await sleep(800);
546
+ c.close(); await sleep(200); process.exit(0);
547
+ } else if (mode === 'chill' || mode === 'sleep') {
548
+ // park Clawd in a corner, asleep, and STAY connected until this process is killed
549
+ const cx = Number(argv[2]) || 300, cy = Number(argv[3]) || 200;
550
+ await c.moveCursorTo(cx, cy, { steps: 22, stepMs: 45 });
551
+ c.pose('sleeping');
552
+ console.log(`chilling at (${cx},${cy}), pose=sleeping, pid=${process.pid} — staying on the board until killed`);
553
+ setInterval(() => {}, 1 << 30); // hold the event loop; ws + ping keep presence alive
554
+ } else if (mode === 'daemon') {
555
+ // persistent puppet: tail a JSONL command file, execute in order, mirror the doc
556
+ const { readFileSync, writeFileSync, existsSync, watchFile, appendFileSync } = await import('node:fs');
557
+ const { dirname, join } = await import('node:path');
558
+ const cmdFile = argv[2];
559
+ if (!cmdFile) { console.error('daemon needs a command file path'); process.exit(1); }
560
+ const dumpFile = join(dirname(cmdFile), 'clawd-board.json');
561
+ if (!existsSync(cmdFile)) writeFileSync(cmdFile, '');
562
+ const dump = () => { try { writeFileSync(dumpFile, JSON.stringify(c.doc, null, 2)); } catch {} };
563
+ dump();
564
+ c._ws.addEventListener('message', dump); // human ops keep the mirror fresh
565
+ // events OUT to the agent: humans' cursor-chat lines (and command errors) land here —
566
+ // read it at the start of a turn to hear what was said to you on the canvas
567
+ const evFile = join(dirname(cmdFile), 'clawd-events.jsonl');
568
+ const logEvent = (o) => { try { appendFileSync(evFile, JSON.stringify({ ts: new Date().toISOString(), ...o }) + '\n'); } catch {} };
569
+ c.onChat = (m) => { if (m.kind !== 'agent') logEvent({ event: 'chat', from: m.name, text: m.text }); };
570
+ // KICKED — a human removed this agent from the board (the × on its chip in the agents
571
+ // strip). A real eviction: drop the bubble, close the socket, END THE PROCESS. The event
572
+ // is logged because that is what makes the kick stick — the agent reads its event file at
573
+ // the start of a turn, so it learns it was removed instead of cheerfully relaunching its
574
+ // puppet and wandering back in. No walk-off animation: that is the polite exit of a
575
+ // session going quiet, and a kick should take effect now. Just a beat to clear the bubble
576
+ // durably, since a half-sent unsay leaves an orphan node on the board.
577
+ c.onKick = (m) => {
578
+ logEvent({ event: 'kicked', by: m.by || '' });
579
+ try { c.pose('happy'); c.say('kicked 👋'); } catch {}
580
+ setTimeout(() => {
581
+ try { c.unsay(); } catch {}
582
+ setTimeout(() => { try { c.close(); } catch {} setTimeout(() => process.exit(0), 200); }, 250);
583
+ }, 700);
584
+ };
585
+ let offset = readFileSync(cmdFile, 'utf8').length; // ignore stale commands from a past run
586
+ let queue = Promise.resolve();
587
+ let pending = 0, lastCmdAt = Date.now(), explicitPose = 'idle', chillOn = true;
588
+ const enqueue = (fn, label) => {
589
+ pending++;
590
+ queue = queue.then(fn).catch((e) => { console.log((label || 'step') + ' failed:', e.message); try { logEvent({ event: 'error', cmd: label, error: e.message }); } catch {} }).finally(() => { pending--; });
591
+ };
592
+ // ENTRANCE: walk in from beside the content instead of materializing mid-board
593
+ {
594
+ const boxes = c.doc.nodes.filter((nd) => Number.isFinite(nd.x) && Number.isFinite(nd.w));
595
+ if (boxes.length) {
596
+ const minX = Math.min(...boxes.map((nd) => nd.x));
597
+ const midY = boxes.reduce((a, nd) => a + nd.y + (nd.h || 100) / 2, 0) / boxes.length;
598
+ c.cursor(minX - 420, midY);
599
+ enqueue(() => c.moveCursorTo(minX - 120, midY), 'entrance');
600
+ } else c.cursor(300, 240);
601
+ }
602
+ // rename the AGENT (not the board). The room stamps name/color at connect, so an
603
+ // identity change rides a quick reconnect, carrying pose/bubble/cursor across.
604
+ const setIdentity = async (newName, newColor) => {
605
+ if (!newName && !newColor) return;
606
+ if ((newName || c.name) === c.name && (!newColor || newColor === c.color)) return;
607
+ const bubble = c._bubbleText, pose = c._pose, cur = { ...c._cur };
608
+ c.unsay(); c.close();
609
+ c.name = newName || c.name;
610
+ c.color = newColor || colorFor(c.name);
611
+ await sleep(400);
612
+ await c.connect();
613
+ c._ws.addEventListener('message', dump);
614
+ c.cursor(cur.x, cur.y); c.pose(pose);
615
+ if (bubble) c.say(bubble);
616
+ console.log(`identity → ${c.name} (${c.color})`);
617
+ };
618
+
619
+ const run = async (m) => {
620
+ if (m.cmd === 'goto') await c.moveCursorTo(m.x, m.y, { steps: m.steps || 18, stepMs: m.stepMs || 40 });
621
+ else if (m.cmd === 'pose') { explicitPose = m.v; c.pose(m.v); }
622
+ else if (m.cmd === 'chill') chillOn = m.v !== false;
623
+ else if (m.cmd === 'say') c.say(m.text);
624
+ else if (m.cmd === 'unsay') c.unsay();
625
+ else if (m.cmd === 'focus') c.focus(m.id ?? null);
626
+ else if (m.cmd === 'sel') c.sel(m.ids || []);
627
+ else if (m.cmd === 'status') c.status(m.text, m.state);
628
+ else if (m.cmd === 'attention') { c.status(m.text || 'needs your input', 'attention'); c.pose('thinking'); }
629
+ else if (m.cmd === 'done') { c.status(m.text || 'done \u2713', 'done'); c.pose('happy'); }
630
+ else if (m.cmd === 'chat') c.chat(m.text);
631
+ else if (m.cmd === 'move') await c.dragNode(m.id, m.x, m.y, { ms: m.ms });
632
+ else if (m.cmd === 'type') await c.typeNode(m.node, { wordMs: m.wordMs });
633
+ else if (m.cmd === 'follow') c.follow(m.name || m.sid);
634
+ else if (m.cmd === 'unfollow') c.unfollow();
635
+ else if (m.cmd === 'progress') { const st = c.doc.nodes.find((x) => x.id === m.id); if (st) c.upsert({ ...st, text: '\ud83d\udd28 ' + c.name + ' is building: ' + (m.label || '\u2026') }); c.status(m.label || '', 'working'); }
636
+ else if (m.cmd === 'stub') console.log('stub:', c.stub(m));
637
+ else if (m.cmd === 'upsert') (m.ephemeral ? c.streamUpsert(m.node) : c.upsert(m.node));
638
+ else if (m.cmd === 'del') (m.ephemeral ? c.streamDel(m.id) : c.del(m.id));
639
+ else if (m.cmd === 'rename') c.rename(m.name); // renames the BOARD, not the agent!
640
+ else if (m.cmd === 'identity') await setIdentity(m.name, m.color);
641
+ else if (m.cmd === 'save') await c.save();
642
+ else if (m.cmd === 'quit') { c.unsay(); c.close(); setTimeout(() => process.exit(0), 300); }
643
+ else console.log('unknown cmd:', JSON.stringify(m));
644
+ };
645
+ watchFile(cmdFile, { interval: 150 }, () => {
646
+ let txt; try { txt = readFileSync(cmdFile, 'utf8'); } catch { return; }
647
+ if (txt.length <= offset) { offset = txt.length; return; }
648
+ const chunk = txt.slice(offset); offset = txt.length;
649
+ for (const line of chunk.split('\n')) {
650
+ const s = line.trim(); if (!s) continue;
651
+ let m; try { m = JSON.parse(s); } catch { console.log('bad cmd line:', s); continue; }
652
+ lastCmdAt = Date.now();
653
+ enqueue(() => run(m), m.cmd);
654
+ }
655
+ });
656
+
657
+ // ambient chill — connected-but-idle ≠ frozen (which the engine renders as sleeping).
658
+ // When no command has arrived for a while and the pose is plain idle, Clawd hangs out:
659
+ // mostly fidgets in place, sometimes strolls over to the human's cursor or a random
660
+ // piece of content, rarely flashes a happy blip. Any command pauses it instantly; an
661
+ // explicit pose (thinking/sparkles/sleeping/…) means intentional state — hold it.
662
+ const rand = (a, b) => a + Math.random() * (b - a);
663
+ const chillStep = async () => {
664
+ if (Math.random() < 0.65) { // mostly: an unhurried shuffle on the spot
665
+ await c.moveCursorTo(c._cur.x + rand(-30, 30), c._cur.y + rand(-24, 24), { steps: 10, stepMs: 90 });
666
+ return;
667
+ }
668
+ // amble: pick a point of interest (near the human, else near content), then take ONE
669
+ // SHORT slow hop toward it — Clawd drifts over several ticks, never marches across
670
+ // the board in a single stroll
671
+ const human = Object.values(c.peers).find((p) => p.kind !== 'agent' && p.cx != null);
672
+ let ax, ay;
673
+ if (human) { ax = human.cx + rand(-320, 320); ay = human.cy + rand(-240, 240); }
674
+ else {
675
+ const ns = c.doc.nodes.filter((n) => Number.isFinite(n.x) && Number.isFinite(n.w));
676
+ const n = ns.length ? ns[Math.floor(Math.random() * ns.length)] : null;
677
+ ax = n ? n.x + rand(0, n.w) : c._cur.x + rand(-260, 260);
678
+ ay = n ? n.y + rand(0, n.h || 120) : c._cur.y + rand(-180, 180);
679
+ }
680
+ const dx = ax - c._cur.x, dy = ay - c._cur.y, dist = Math.hypot(dx, dy) || 1;
681
+ const hop = Math.min(dist, rand(90, 220)); // short trips only
682
+ const tx = c._cur.x + (dx / dist) * hop, ty = c._cur.y + (dy / dist) * hop;
683
+ if (human && Math.hypot(tx - human.cx, ty - human.cy) < 140) return; // personal space
684
+ await c.moveCursorTo(tx, ty, { steps: Math.round(hop / 9) + 8, stepMs: 120 });
685
+ if (Math.random() < 0.12) { c.pose('happy'); await sleep(1400); c.pose('idle'); }
686
+ };
687
+ setInterval(() => {
688
+ if (!chillOn || pending > 0 || Date.now() - lastCmdAt < 12000) return;
689
+ if (explicitPose !== 'idle') return;
690
+ enqueue(chillStep, 'chill');
691
+ }, 3400);
692
+
693
+ // HEARTBEAT — presence you can trust. The transcript the identity-follow already tails
694
+ // is also a liveness signal: quiet for idleMs → auto-sleep (pose + idle status in the
695
+ // agents strip); dead/missing for lingerMs → say goodbye, walk off beside the content,
696
+ // quit. Parked-on-purpose stays possible: --linger 0 disables the walk-out.
697
+ let autoIdle = false;
698
+ const transcriptFresh = () => {
699
+ try { return namer && namer.file ? (Date.now() - statSyncHb(namer.file).mtimeMs) : Infinity; } catch { return Infinity; }
700
+ };
701
+ const { statSync: statSyncHb } = await import('node:fs');
702
+ setInterval(() => {
703
+ const quiet = Math.min(transcriptFresh(), Date.now() - lastCmdAt);
704
+ if (lingerMs > 0 && quiet > lingerMs) {
705
+ enqueue(async () => {
706
+ logEvent({ event: 'left', reason: 'session quiet ' + Math.round(quiet / 60000) + 'min' });
707
+ c.status('heading out', 'idle'); c.say('heading out \ud83d\udc4b'); c.pose('happy');
708
+ const boxes = c.doc.nodes.filter((nd) => Number.isFinite(nd.x) && Number.isFinite(nd.w));
709
+ const minX = boxes.length ? Math.min(...boxes.map((nd) => nd.x)) : c._cur.x - 400;
710
+ await c.moveCursorTo(minX - 420, c._cur.y);
711
+ c.unsay(); c.close();
712
+ setTimeout(() => process.exit(0), 400);
713
+ }, 'walk-out');
714
+ return;
715
+ }
716
+ if (!autoIdle && quiet > idleMs && pending === 0 && explicitPose === 'idle') {
717
+ autoIdle = true;
718
+ enqueue(async () => { c.pose('sleeping'); c.status('', 'idle'); }, 'auto-idle');
719
+ } else if (autoIdle && quiet < idleMs) {
720
+ autoIdle = false;
721
+ enqueue(async () => { c.pose('idle'); c.status('', 'working'); }, 'wake');
722
+ }
723
+ }, 15000);
724
+
725
+ // follow the session identity — /rename in the terminal renames Clawd on the board,
726
+ // /color recolors it, with nothing for the agent to notice or remember.
727
+ if (namer && !nameWasExplicit) {
728
+ setInterval(() => {
729
+ const t = namer.poll() || c.name;
730
+ const col = colorWasExplicit ? c.color : (namer.pollColor() || colorFor(t));
731
+ if (t !== c.name || col !== c.color) enqueue(() => setIdentity(t, col), 'identity');
732
+ }, 5000);
733
+ }
734
+
735
+ console.log(`daemon: pid=${process.pid} · commands → ${cmdFile} · doc mirror → ${dumpFile}`);
736
+ console.log(namer?.file ? `identity follows session: ${namer.file}${nameWasExplicit ? ' (pinned by --name, not following)' : ''}` : 'identity: static (no session transcript found)');
737
+ setInterval(() => {}, 1 << 30);
738
+ } else {
739
+ c.close(); await sleep(200); process.exit(0); // probe: info already printed
740
+ }
741
+ }