@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.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- 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
|
+
}
|