@venturewild/workspace 0.3.7 → 0.3.8

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 (53) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -83
  4. package/server/bin/wild-workspace.mjs +995 -995
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +579 -579
  10. package/server/src/bazaar/index.mjs +75 -75
  11. package/server/src/bazaar/mcp-server.mjs +328 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
  24. package/server/src/canvas/core.mjs +446 -421
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -0
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +2551 -2475
  34. package/server/src/logpaths.mjs +98 -98
  35. package/server/src/observability.mjs +45 -45
  36. package/server/src/operator.mjs +92 -92
  37. package/server/src/pairing.mjs +137 -137
  38. package/server/src/service.mjs +515 -515
  39. package/server/src/session-reporter.mjs +201 -201
  40. package/server/src/settings.mjs +145 -145
  41. package/server/src/share.mjs +182 -182
  42. package/server/src/skills.mjs +213 -213
  43. package/server/src/supervisor.mjs +647 -647
  44. package/server/src/support-consent.mjs +133 -133
  45. package/server/src/sync.mjs +248 -248
  46. package/server/src/transcript.mjs +121 -121
  47. package/server/src/turn-mcp.mjs +46 -46
  48. package/server/src/usage.mjs +405 -405
  49. package/web/dist/assets/index-B44y93r4.js +91 -0
  50. package/web/dist/assets/index-NXZN2LU2.css +1 -0
  51. package/web/dist/index.html +2 -2
  52. package/web/dist/assets/index-BxRx8EsD.js +0 -91
  53. package/web/dist/assets/index-DoOPBr3s.css +0 -1
@@ -1,421 +1,446 @@
1
- // Canvas core — the store + validator for AGENT-MADE custom blocks.
2
- //
3
- // The block canvas (UX §3.3) lets the agent build the user a widget on request
4
- // ("make me a block that shows today's signups"). The differentiator: the canvas
5
- // stops being a fixed menu of widgets and becomes "anything the agent can build".
6
- //
7
- // SECURITY POSTURE — declarative, not code. The agent does NOT ship a React
8
- // component or any code to eval. It computes the data itself (Read/Bash in its own
9
- // turn) and emits a *spec* drawn from a small, fixed vocabulary of presentation
10
- // primitives (metric · list · table · markdown). The browser renders that spec with
11
- // trusted primitives (React-escaped text; markdown via react-markdown with NO raw
12
- // HTML). So an agent-made block adds NO new execution surface and cannot escalate a
13
- // read-only share-link viewer's privileges. "Live" = the agent re-pushes via
14
- // update_block (principle #4: no idle polling), not a server-run refresh command.
15
- // A shell-bound live refresh is a deliberate, owner-gated follow-up (see NOTES).
16
- //
17
- // State lives ENTIRELY under ~/.wild-workspace/canvas/ (absolute, OUTSIDE the
18
- // user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main server
19
- // (to serve /api/canvas/blocks) and the spawned MCP server (to make/update blocks),
20
- // so there is a single file-backed source of truth and no port handshake:
21
- // - blocks.json the array of custom block specs. MCP writes, main reads.
22
-
23
- import fs from 'node:fs';
24
- import path from 'node:path';
25
- import os from 'node:os';
26
- import crypto from 'node:crypto';
27
-
28
- // The presentation primitives the agent may choose from. Anything else is rejected
29
- // at the boundary — the renderer only knows these.
30
- export const KINDS = ['metric', 'list', 'table', 'markdown'];
31
-
32
- // The named theme tokens an agent (or a Bazaar theme) may override — the EXTENSION
33
- // surface for "make my terminal look how I want". Each is ONLY ever a hex color
34
- // (validated below), so a theme is DATA, never CSS: it cannot inject url()/
35
- // expression()/selector-escapes. Keep in lockstep with TOKEN_VARS in web/src/theme.js.
36
- export const TOKEN_KEYS = [
37
- 'bg', 'bgElev', 'surface', 'border', 'text', 'textMuted', 'canvas1', 'canvas2', 'canvas3',
38
- ];
39
-
40
- const HEX = /^#[0-9a-f]{6}$/i;
41
- export function isHex(v) {
42
- return typeof v === 'string' && HEX.test(v.trim());
43
- }
44
- function hexOr(v, fallback = null) {
45
- return isHex(v) ? v.trim().toLowerCase() : fallback;
46
- }
47
-
48
- const DEFAULT_ICON = { metric: '📈', list: '📋', table: '🗂️', markdown: '📝' };
49
- const TRENDS = ['up', 'down', 'flat'];
50
-
51
- // Caps — bound every field so a runaway tool call can't bloat the store or the UI.
52
- const CAP = {
53
- blocks: 60, // keep the most-recent N specs
54
- title: 80,
55
- icon: 8,
56
- note: 240,
57
- value: 48,
58
- label: 48,
59
- delta: 24,
60
- spark: 60, // sparkline points
61
- listItems: 50,
62
- itemLabel: 80,
63
- itemValue: 40,
64
- columns: 12,
65
- colName: 40,
66
- rows: 50,
67
- cell: 80,
68
- markdown: 6000,
69
- };
70
-
71
- // ~/.wild-workspace/canvas — mirrors logpaths.globalDir() but kept dependency-free
72
- // here so the MCP child can import this module standalone (same trick as bazaar).
73
- export function defaultCanvasDir(env = process.env) {
74
- const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
75
- return path.join(base, 'canvas');
76
- }
77
-
78
- function rid() {
79
- return crypto.randomUUID().slice(0, 12);
80
- }
81
-
82
- function readJsonSafe(file, fallback) {
83
- try {
84
- return JSON.parse(fs.readFileSync(file, 'utf8'));
85
- } catch {
86
- return fallback;
87
- }
88
- }
89
-
90
- function writeJsonAtomic(file, value) {
91
- const tmp = `${file}.${process.pid}.tmp`;
92
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
93
- fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
94
- }
95
-
96
- // --- spec normalization (the security boundary) ---------------------------
97
-
98
- function str(v, max) {
99
- if (v === null || v === undefined) return '';
100
- const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
101
- return s.slice(0, max);
102
- }
103
-
104
- function nonEmptyStr(v, max, fallback = '') {
105
- const s = str(v, max).trim();
106
- return s || fallback;
107
- }
108
-
109
- function numArray(v, max) {
110
- if (!Array.isArray(v)) return [];
111
- return v
112
- .map((n) => Number(n))
113
- .filter((n) => Number.isFinite(n))
114
- .slice(0, max);
115
- }
116
-
117
- function normalizeData(kind, raw = {}) {
118
- switch (kind) {
119
- case 'metric': {
120
- const data = { value: nonEmptyStr(raw.value, CAP.value, '—') };
121
- const label = nonEmptyStr(raw.label, CAP.label);
122
- if (label) data.label = label;
123
- const delta = nonEmptyStr(raw.delta, CAP.delta);
124
- if (delta) data.delta = delta;
125
- if (TRENDS.includes(raw.trend)) data.trend = raw.trend;
126
- const spark = numArray(raw.spark, CAP.spark);
127
- if (spark.length) data.spark = spark;
128
- return data;
129
- }
130
- case 'list': {
131
- const src = Array.isArray(raw.items) ? raw.items : [];
132
- const items = src.slice(0, CAP.listItems).map((it) => {
133
- // Accept {label,value} objects OR bare strings (the agent's convenience).
134
- if (it && typeof it === 'object') {
135
- const item = { label: nonEmptyStr(it.label ?? it.name ?? it.key, CAP.itemLabel, '—') };
136
- const value = nonEmptyStr(it.value ?? it.amount ?? it.count, CAP.itemValue);
137
- if (value) item.value = value;
138
- return item;
139
- }
140
- return { label: nonEmptyStr(it, CAP.itemLabel, '—') };
141
- });
142
- return { items };
143
- }
144
- case 'table': {
145
- const columns = (Array.isArray(raw.columns) ? raw.columns : [])
146
- .slice(0, CAP.columns)
147
- .map((c) => nonEmptyStr(c, CAP.colName, ''));
148
- const width = columns.length || CAP.columns;
149
- const rows = (Array.isArray(raw.rows) ? raw.rows : [])
150
- .slice(0, CAP.rows)
151
- .map((r) => (Array.isArray(r) ? r : [r]).slice(0, width).map((cell) => str(cell, CAP.cell)));
152
- return { columns, rows };
153
- }
154
- case 'markdown':
155
- default:
156
- return { text: nonEmptyStr(raw.markdown ?? raw.text ?? raw.body, CAP.markdown) };
157
- }
158
- }
159
-
160
- /**
161
- * Validate + normalize a raw spec (from the agent's flat tool input) into the
162
- * stored shape. Throws on an unsupported `kind` so the MCP tool returns an error.
163
- * Everything else degrades gracefully (capped strings, dropped junk) — the agent
164
- * gets a usable block even if it over- or mis-specifies.
165
- */
166
- export function normalizeSpec(raw = {}) {
167
- const kind = String(raw.kind || '').toLowerCase();
168
- if (!KINDS.includes(kind)) {
169
- throw new Error(`unsupported kind "${raw.kind}". Use one of: ${KINDS.join(', ')}`);
170
- }
171
- const spec = {
172
- title: nonEmptyStr(raw.title, CAP.title, 'Untitled'),
173
- icon: nonEmptyStr(raw.icon, CAP.icon, DEFAULT_ICON[kind]),
174
- kind,
175
- data: normalizeData(kind, raw),
176
- };
177
- const note = nonEmptyStr(raw.note, CAP.note);
178
- if (note) spec.note = note;
179
- return spec;
180
- }
181
-
182
- // --- theme normalization (the same security boundary, for the look) --------
183
-
184
- /**
185
- * Validate + normalize a raw theme (from the agent's flat set_theme input) into the
186
- * stored shape: { mode, accent, hot?, name?, tokens: {…hex} }. Hex-only, allowlisted
187
- * tokens — anything else is dropped. Never throws; a junk theme degrades to the
188
- * mode default (the picker/stylesheet still produces a usable look).
189
- */
190
- export function normalizeTheme(raw = {}) {
191
- const theme = {
192
- mode: raw.mode === 'dark' ? 'dark' : 'light',
193
- accent: hexOr(raw.accent, '#0891b2'),
194
- };
195
- const hot = hexOr(raw.hot);
196
- if (hot) theme.hot = hot;
197
- const name = nonEmptyStr(raw.name, 60);
198
- if (name) theme.name = name;
199
- const tokens = {};
200
- for (const key of TOKEN_KEYS) {
201
- const hex = hexOr(raw[key] ?? raw.tokens?.[key]);
202
- if (hex) tokens[key] = hex;
203
- }
204
- theme.tokens = tokens;
205
- return theme;
206
- }
207
-
208
- // --- store ----------------------------------------------------------------
209
-
210
- export function createCanvas({ baseDir } = {}) {
211
- const dir = baseDir || defaultCanvasDir();
212
- const blocksFile = path.join(dir, 'blocks.json');
213
- const themeFile = path.join(dir, 'theme.json');
214
- // Server-side persistence for canvas state (layout, templates, user-theme).
215
- // Fixes the localStorage-per-origin divergence bug (req-1 gave the user two
216
- // origins → two divergent canvas states for the same workspace).
217
- const layoutFile = path.join(dir, 'layout.json');
218
- const templatesFile = path.join(dir, 'templates.json');
219
- const userThemeFile = path.join(dir, 'user-theme.json');
220
-
221
- function ensureDir() {
222
- try {
223
- fs.mkdirSync(dir, { recursive: true });
224
- } catch {
225
- /* read-only fs degrades to no persistence */
226
- }
227
- }
228
-
229
- function listBlocks() {
230
- const v = readJsonSafe(blocksFile, []);
231
- return Array.isArray(v) ? v : [];
232
- }
233
-
234
- function getBlock(id) {
235
- return listBlocks().find((b) => b.id === id) || null;
236
- }
237
-
238
- function save(blocks) {
239
- ensureDir();
240
- // Bound the store: keep the most-recent N (a user can always remove more).
241
- const trimmed = blocks.slice(-CAP.blocks);
242
- try {
243
- writeJsonAtomic(blocksFile, trimmed);
244
- } catch {
245
- /* persistence best-effort */
246
- }
247
- return trimmed;
248
- }
249
-
250
- function addBlock(raw) {
251
- const spec = normalizeSpec(raw); // throws on bad kind
252
- const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
253
- save([...listBlocks(), block]);
254
- return block;
255
- }
256
-
257
- // Update an existing block's content by id. Only the fields the agent supplies
258
- // change; the rest (incl. kind unless re-specified) are preserved. Returns the
259
- // updated block, or null if the id is unknown.
260
- function updateBlock(id, raw = {}) {
261
- const blocks = listBlocks();
262
- const idx = blocks.findIndex((b) => b.id === id);
263
- if (idx < 0) return null;
264
- const prev = blocks[idx];
265
- const merged = normalizeSpec({
266
- kind: raw.kind || prev.kind,
267
- title: raw.title ?? prev.title,
268
- icon: raw.icon ?? prev.icon,
269
- note: raw.note ?? prev.note,
270
- // Data fields: if the agent re-specifies the data for this kind, use the new
271
- // ones; otherwise re-feed the previous data so nothing is lost.
272
- ...flattenData(prev),
273
- ...raw,
274
- });
275
- const next = { ...prev, ...merged, updatedAt: Date.now() };
276
- blocks[idx] = next;
277
- save(blocks);
278
- return next;
279
- }
280
-
281
- function removeBlock(id) {
282
- const blocks = listBlocks();
283
- const next = blocks.filter((b) => b.id !== id);
284
- if (next.length === blocks.length) return false;
285
- save(next);
286
- return true;
287
- }
288
-
289
- // --- theme (the agent-set look) — one file, last-write-wins ---------------
290
-
291
- function getTheme() {
292
- const v = readJsonSafe(themeFile, null);
293
- return v && typeof v === 'object' ? v : null;
294
- }
295
-
296
- function setTheme(raw) {
297
- const theme = { ...normalizeTheme(raw), ts: Date.now() };
298
- ensureDir();
299
- try {
300
- writeJsonAtomic(themeFile, theme);
301
- } catch {
302
- /* persistence best-effort */
303
- }
304
- return theme;
305
- }
306
-
307
- // --- canvas layout (which blocks exist and where) — one file per workspace ----
308
-
309
- function getLayout() {
310
- const v = readJsonSafe(layoutFile, null);
311
- return v && typeof v === 'object' ? v : null;
312
- }
313
-
314
- function saveLayout(layout) {
315
- // Validate: blocks is an array, layouts has an lg array.
316
- const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
317
- const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
318
- const data = { blocks, layouts: { lg }, ts: Date.now() };
319
- ensureDir();
320
- try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
321
- return data;
322
- }
323
-
324
- // --- user templates (saved layouts) — global per install ----
325
-
326
- function getTemplates() {
327
- const v = readJsonSafe(templatesFile, null);
328
- return Array.isArray(v) ? v : [];
329
- }
330
-
331
- function saveTemplates(templates) {
332
- const data = Array.isArray(templates) ? templates : [];
333
- ensureDir();
334
- try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
335
- return data;
336
- }
337
-
338
- // --- user theme (mode + accent from the picker) — separate from the agent theme ---
339
- // The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
340
- // theme holds the user's own mode+accent choice from the ThemePicker. They merge
341
- // at render time (mergeAgentTheme in theme.js).
342
-
343
- function getUserTheme() {
344
- const v = readJsonSafe(userThemeFile, null);
345
- return v && typeof v === 'object' ? v : null;
346
- }
347
-
348
- function saveUserTheme(raw) {
349
- // Only persist allowlisted fieldsmode, accent, hot, accentManual, tokens.
350
- const accent = hexOr(raw?.accent);
351
- const theme = {
352
- mode: raw?.mode === 'dark' ? 'dark' : 'light',
353
- accent: accent || '#0891b2',
354
- hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
355
- accentManual: !!raw?.accentManual,
356
- tokens: {},
357
- };
358
- if (raw?.tokens && typeof raw.tokens === 'object') {
359
- for (const key of TOKEN_KEYS) {
360
- const hex = hexOr(raw.tokens[key]);
361
- if (hex) theme.tokens[key] = hex;
362
- }
363
- }
364
- ensureDir();
365
- try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
366
- return theme;
367
- }
368
-
369
- // Darken helper (standalone — can't import from theme.js which is client-only)
370
- function darkenHex(hex, f = 0.16) {
371
- const h = hex.replace('#', '');
372
- const k = 1 - f;
373
- const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
374
- return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
375
- }
376
-
377
- // --- batch get/save (used by /api/canvas/state) ---
378
-
379
- function getState() {
380
- return {
381
- layout: getLayout(),
382
- templates: getTemplates(),
383
- userTheme: getUserTheme(),
384
- };
385
- }
386
-
387
- function saveState(updates) {
388
- const result = {};
389
- if (updates.layout) result.layout = saveLayout(updates.layout);
390
- if (updates.templates) result.templates = saveTemplates(updates.templates);
391
- if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
392
- return result;
393
- }
394
-
395
- return {
396
- dir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
397
- listBlocks, getBlock, addBlock, updateBlock, removeBlock,
398
- getTheme, setTheme,
399
- getLayout, saveLayout, getTemplates, saveTemplates,
400
- getUserTheme, saveUserTheme,
401
- getState, saveState,
402
- };
403
- }
404
-
405
- // Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
406
- // so updateBlock can merge partial changes over the previous content.
407
- function flattenData(block) {
408
- const d = block?.data || {};
409
- switch (block?.kind) {
410
- case 'metric':
411
- return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
412
- case 'list':
413
- return { items: d.items };
414
- case 'table':
415
- return { columns: d.columns, rows: d.rows };
416
- case 'markdown':
417
- return { markdown: d.text };
418
- default:
419
- return {};
420
- }
421
- }
1
+ // Canvas core — the store + validator for AGENT-MADE custom blocks.
2
+ //
3
+ // The block canvas (UX §3.3) lets the agent build the user a widget on request
4
+ // ("make me a block that shows today's signups"). The differentiator: the canvas
5
+ // stops being a fixed menu of widgets and becomes "anything the agent can build".
6
+ //
7
+ // SECURITY POSTURE — declarative, not code. The agent does NOT ship a React
8
+ // component or any code to eval. It computes the data itself (Read/Bash in its own
9
+ // turn) and emits a *spec* drawn from a small, fixed vocabulary of presentation
10
+ // primitives (metric · list · table · markdown). The browser renders that spec with
11
+ // trusted primitives (React-escaped text; markdown via react-markdown with NO raw
12
+ // HTML). So an agent-made block adds NO new execution surface and cannot escalate a
13
+ // read-only share-link viewer's privileges. "Live" = the agent re-pushes via
14
+ // update_block (principle #4: no idle polling), not a server-run refresh command.
15
+ // A shell-bound live refresh is a deliberate, owner-gated follow-up (see NOTES).
16
+ //
17
+ // State lives ENTIRELY under ~/.wild-workspace/canvas/ (absolute, OUTSIDE the
18
+ // user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main server
19
+ // (to serve /api/canvas/blocks) and the spawned MCP server (to make/update blocks),
20
+ // so there is a single file-backed source of truth and no port handshake:
21
+ // - blocks.json the array of custom block specs. MCP writes, main reads.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+ import crypto from 'node:crypto';
27
+
28
+ // The presentation primitives the agent may choose from. Anything else is rejected
29
+ // at the boundary — the renderer only knows these.
30
+ export const KINDS = ['metric', 'list', 'table', 'markdown'];
31
+
32
+ // The named theme tokens an agent (or a Bazaar theme) may override — the EXTENSION
33
+ // surface for "make my terminal look how I want". Each is ONLY ever a hex color
34
+ // (validated below), so a theme is DATA, never CSS: it cannot inject url()/
35
+ // expression()/selector-escapes. Keep in lockstep with TOKEN_VARS in web/src/theme.js.
36
+ export const TOKEN_KEYS = [
37
+ 'bg', 'bgElev', 'surface', 'border', 'text', 'textMuted', 'canvas1', 'canvas2', 'canvas3',
38
+ ];
39
+
40
+ const HEX = /^#[0-9a-f]{6}$/i;
41
+ export function isHex(v) {
42
+ return typeof v === 'string' && HEX.test(v.trim());
43
+ }
44
+ function hexOr(v, fallback = null) {
45
+ return isHex(v) ? v.trim().toLowerCase() : fallback;
46
+ }
47
+
48
+ const DEFAULT_ICON = { metric: '📈', list: '📋', table: '🗂️', markdown: '📝' };
49
+ const TRENDS = ['up', 'down', 'flat'];
50
+
51
+ // Caps — bound every field so a runaway tool call can't bloat the store or the UI.
52
+ const CAP = {
53
+ blocks: 60, // keep the most-recent N specs
54
+ title: 80,
55
+ icon: 8,
56
+ note: 240,
57
+ value: 48,
58
+ label: 48,
59
+ delta: 24,
60
+ spark: 60, // sparkline points
61
+ listItems: 50,
62
+ itemLabel: 80,
63
+ itemValue: 40,
64
+ columns: 12,
65
+ colName: 40,
66
+ rows: 50,
67
+ cell: 80,
68
+ markdown: 6000,
69
+ };
70
+
71
+ // ~/.wild-workspace/canvas — mirrors logpaths.globalDir() but kept dependency-free
72
+ // here so the MCP child can import this module standalone (same trick as bazaar).
73
+ export function defaultCanvasDir(env = process.env) {
74
+ const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
75
+ return path.join(base, 'canvas');
76
+ }
77
+
78
+ function rid() {
79
+ return crypto.randomUUID().slice(0, 12);
80
+ }
81
+
82
+ function readJsonSafe(file, fallback) {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ function writeJsonAtomic(file, value) {
91
+ const tmp = `${file}.${process.pid}.tmp`;
92
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
93
+ fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
94
+ }
95
+
96
+ // --- spec normalization (the security boundary) ---------------------------
97
+
98
+ function str(v, max) {
99
+ if (v === null || v === undefined) return '';
100
+ const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
101
+ return s.slice(0, max);
102
+ }
103
+
104
+ function nonEmptyStr(v, max, fallback = '') {
105
+ const s = str(v, max).trim();
106
+ return s || fallback;
107
+ }
108
+
109
+ function numArray(v, max) {
110
+ if (!Array.isArray(v)) return [];
111
+ return v
112
+ .map((n) => Number(n))
113
+ .filter((n) => Number.isFinite(n))
114
+ .slice(0, max);
115
+ }
116
+
117
+ function normalizeData(kind, raw = {}) {
118
+ switch (kind) {
119
+ case 'metric': {
120
+ const data = { value: nonEmptyStr(raw.value, CAP.value, '—') };
121
+ const label = nonEmptyStr(raw.label, CAP.label);
122
+ if (label) data.label = label;
123
+ const delta = nonEmptyStr(raw.delta, CAP.delta);
124
+ if (delta) data.delta = delta;
125
+ if (TRENDS.includes(raw.trend)) data.trend = raw.trend;
126
+ const spark = numArray(raw.spark, CAP.spark);
127
+ if (spark.length) data.spark = spark;
128
+ return data;
129
+ }
130
+ case 'list': {
131
+ const src = Array.isArray(raw.items) ? raw.items : [];
132
+ const items = src.slice(0, CAP.listItems).map((it) => {
133
+ // Accept {label,value} objects OR bare strings (the agent's convenience).
134
+ if (it && typeof it === 'object') {
135
+ const item = { label: nonEmptyStr(it.label ?? it.name ?? it.key, CAP.itemLabel, '—') };
136
+ const value = nonEmptyStr(it.value ?? it.amount ?? it.count, CAP.itemValue);
137
+ if (value) item.value = value;
138
+ return item;
139
+ }
140
+ return { label: nonEmptyStr(it, CAP.itemLabel, '—') };
141
+ });
142
+ return { items };
143
+ }
144
+ case 'table': {
145
+ const columns = (Array.isArray(raw.columns) ? raw.columns : [])
146
+ .slice(0, CAP.columns)
147
+ .map((c) => nonEmptyStr(c, CAP.colName, ''));
148
+ const width = columns.length || CAP.columns;
149
+ const rows = (Array.isArray(raw.rows) ? raw.rows : [])
150
+ .slice(0, CAP.rows)
151
+ .map((r) => (Array.isArray(r) ? r : [r]).slice(0, width).map((cell) => str(cell, CAP.cell)));
152
+ return { columns, rows };
153
+ }
154
+ case 'markdown':
155
+ default:
156
+ return { text: nonEmptyStr(raw.markdown ?? raw.text ?? raw.body, CAP.markdown) };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Validate + normalize a raw spec (from the agent's flat tool input) into the
162
+ * stored shape. Throws on an unsupported `kind` so the MCP tool returns an error.
163
+ * Everything else degrades gracefully (capped strings, dropped junk) — the agent
164
+ * gets a usable block even if it over- or mis-specifies.
165
+ */
166
+ export function normalizeSpec(raw = {}) {
167
+ const kind = String(raw.kind || '').toLowerCase();
168
+ if (!KINDS.includes(kind)) {
169
+ throw new Error(`unsupported kind "${raw.kind}". Use one of: ${KINDS.join(', ')}`);
170
+ }
171
+ const spec = {
172
+ title: nonEmptyStr(raw.title, CAP.title, 'Untitled'),
173
+ icon: nonEmptyStr(raw.icon, CAP.icon, DEFAULT_ICON[kind]),
174
+ kind,
175
+ data: normalizeData(kind, raw),
176
+ };
177
+ const note = nonEmptyStr(raw.note, CAP.note);
178
+ if (note) spec.note = note;
179
+ return spec;
180
+ }
181
+
182
+ // --- theme normalization (the same security boundary, for the look) --------
183
+
184
+ /**
185
+ * Validate + normalize a raw theme (from the agent's flat set_theme input) into the
186
+ * stored shape: { mode, accent, hot?, name?, tokens: {…hex} }. Hex-only, allowlisted
187
+ * tokens — anything else is dropped. Never throws; a junk theme degrades to the
188
+ * mode default (the picker/stylesheet still produces a usable look).
189
+ */
190
+ export function normalizeTheme(raw = {}) {
191
+ const theme = {
192
+ mode: raw.mode === 'dark' ? 'dark' : 'light',
193
+ accent: hexOr(raw.accent, '#0891b2'),
194
+ };
195
+ const hot = hexOr(raw.hot);
196
+ if (hot) theme.hot = hot;
197
+ const name = nonEmptyStr(raw.name, 60);
198
+ if (name) theme.name = name;
199
+ const tokens = {};
200
+ for (const key of TOKEN_KEYS) {
201
+ const hex = hexOr(raw[key] ?? raw.tokens?.[key]);
202
+ if (hex) tokens[key] = hex;
203
+ }
204
+ theme.tokens = tokens;
205
+ return theme;
206
+ }
207
+
208
+ // --- store ----------------------------------------------------------------
209
+
210
+ // A personKey scopes the user's canvas STATE files (layout/templates/user-theme)
211
+ // to one identity. It becomes a path segment, so harden it against traversal —
212
+ // accountId is a uuid and the role sentinels are safe, but defend in depth.
213
+ function sanitizePersonKey(key) {
214
+ const s = String(key || '')
215
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
216
+ .slice(0, 80);
217
+ return s && s !== '.' && s !== '..' ? s : 'local';
218
+ }
219
+
220
+ export function createCanvas({ baseDir, personKey } = {}) {
221
+ const dir = baseDir || defaultCanvasDir();
222
+ // Agent-made content is workspace-SHARED (the agent builds blocks / sets the
223
+ // theme for the workspace, not for one person), so it stays at the canvas root.
224
+ const blocksFile = path.join(dir, 'blocks.json');
225
+ const themeFile = path.join(dir, 'theme.json');
226
+ // The user's canvas STATE (layout, saved templates, user-theme) is PER-IDENTITY
227
+ // when a personKey is given: it lives under <dir>/people/<personKey>/ so two
228
+ // people sharing one host don't collide, and (via the rails) one person's layout
229
+ // follows them across hosts. Without a personKey it stays flat at <dir>/ — the
230
+ // legacy / no-account-install path, which also preserves the existing on-disk
231
+ // layout for migration. (Originally added to fix the localStorage-per-origin
232
+ // divergence bug — req-1 gave the user two origins → two divergent states.)
233
+ const stateDir = personKey ? path.join(dir, 'people', sanitizePersonKey(personKey)) : dir;
234
+ const layoutFile = path.join(stateDir, 'layout.json');
235
+ const templatesFile = path.join(stateDir, 'templates.json');
236
+ const userThemeFile = path.join(stateDir, 'user-theme.json');
237
+
238
+ function ensureDir() {
239
+ try {
240
+ fs.mkdirSync(dir, { recursive: true });
241
+ } catch {
242
+ /* read-only fs — degrades to no persistence */
243
+ }
244
+ }
245
+ // The per-identity state dir (same as `dir` when no personKey is set).
246
+ function ensureStateDir() {
247
+ try {
248
+ fs.mkdirSync(stateDir, { recursive: true });
249
+ } catch {
250
+ /* read-only fs — degrades to no persistence */
251
+ }
252
+ }
253
+
254
+ function listBlocks() {
255
+ const v = readJsonSafe(blocksFile, []);
256
+ return Array.isArray(v) ? v : [];
257
+ }
258
+
259
+ function getBlock(id) {
260
+ return listBlocks().find((b) => b.id === id) || null;
261
+ }
262
+
263
+ function save(blocks) {
264
+ ensureDir();
265
+ // Bound the store: keep the most-recent N (a user can always remove more).
266
+ const trimmed = blocks.slice(-CAP.blocks);
267
+ try {
268
+ writeJsonAtomic(blocksFile, trimmed);
269
+ } catch {
270
+ /* persistence best-effort */
271
+ }
272
+ return trimmed;
273
+ }
274
+
275
+ function addBlock(raw) {
276
+ const spec = normalizeSpec(raw); // throws on bad kind
277
+ const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
278
+ save([...listBlocks(), block]);
279
+ return block;
280
+ }
281
+
282
+ // Update an existing block's content by id. Only the fields the agent supplies
283
+ // change; the rest (incl. kind unless re-specified) are preserved. Returns the
284
+ // updated block, or null if the id is unknown.
285
+ function updateBlock(id, raw = {}) {
286
+ const blocks = listBlocks();
287
+ const idx = blocks.findIndex((b) => b.id === id);
288
+ if (idx < 0) return null;
289
+ const prev = blocks[idx];
290
+ const merged = normalizeSpec({
291
+ kind: raw.kind || prev.kind,
292
+ title: raw.title ?? prev.title,
293
+ icon: raw.icon ?? prev.icon,
294
+ note: raw.note ?? prev.note,
295
+ // Data fields: if the agent re-specifies the data for this kind, use the new
296
+ // ones; otherwise re-feed the previous data so nothing is lost.
297
+ ...flattenData(prev),
298
+ ...raw,
299
+ });
300
+ const next = { ...prev, ...merged, updatedAt: Date.now() };
301
+ blocks[idx] = next;
302
+ save(blocks);
303
+ return next;
304
+ }
305
+
306
+ function removeBlock(id) {
307
+ const blocks = listBlocks();
308
+ const next = blocks.filter((b) => b.id !== id);
309
+ if (next.length === blocks.length) return false;
310
+ save(next);
311
+ return true;
312
+ }
313
+
314
+ // --- theme (the agent-set look) — one file, last-write-wins ---------------
315
+
316
+ function getTheme() {
317
+ const v = readJsonSafe(themeFile, null);
318
+ return v && typeof v === 'object' ? v : null;
319
+ }
320
+
321
+ function setTheme(raw) {
322
+ const theme = { ...normalizeTheme(raw), ts: Date.now() };
323
+ ensureDir();
324
+ try {
325
+ writeJsonAtomic(themeFile, theme);
326
+ } catch {
327
+ /* persistence best-effort */
328
+ }
329
+ return theme;
330
+ }
331
+
332
+ // --- canvas layout (which blocks exist and where) one file per workspace ----
333
+
334
+ function getLayout() {
335
+ const v = readJsonSafe(layoutFile, null);
336
+ return v && typeof v === 'object' ? v : null;
337
+ }
338
+
339
+ function saveLayout(layout) {
340
+ // Validate: blocks is an array, layouts has an lg array.
341
+ const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
342
+ const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
343
+ const data = { blocks, layouts: { lg }, ts: Date.now() };
344
+ ensureStateDir();
345
+ try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
346
+ return data;
347
+ }
348
+
349
+ // --- user templates (saved layouts) global per install ----
350
+
351
+ function getTemplates() {
352
+ const v = readJsonSafe(templatesFile, null);
353
+ return Array.isArray(v) ? v : [];
354
+ }
355
+
356
+ function saveTemplates(templates) {
357
+ const data = Array.isArray(templates) ? templates : [];
358
+ ensureStateDir();
359
+ try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
360
+ return data;
361
+ }
362
+
363
+ // --- user theme (mode + accent from the picker) — separate from the agent theme ---
364
+ // The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
365
+ // theme holds the user's own mode+accent choice from the ThemePicker. They merge
366
+ // at render time (mergeAgentTheme in theme.js).
367
+
368
+ function getUserTheme() {
369
+ const v = readJsonSafe(userThemeFile, null);
370
+ return v && typeof v === 'object' ? v : null;
371
+ }
372
+
373
+ function saveUserTheme(raw) {
374
+ // Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
375
+ const accent = hexOr(raw?.accent);
376
+ const theme = {
377
+ mode: raw?.mode === 'dark' ? 'dark' : 'light',
378
+ accent: accent || '#0891b2',
379
+ hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
380
+ accentManual: !!raw?.accentManual,
381
+ tokens: {},
382
+ };
383
+ if (raw?.tokens && typeof raw.tokens === 'object') {
384
+ for (const key of TOKEN_KEYS) {
385
+ const hex = hexOr(raw.tokens[key]);
386
+ if (hex) theme.tokens[key] = hex;
387
+ }
388
+ }
389
+ ensureStateDir();
390
+ try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
391
+ return theme;
392
+ }
393
+
394
+ // Darken helper (standalone — can't import from theme.js which is client-only)
395
+ function darkenHex(hex, f = 0.16) {
396
+ const h = hex.replace('#', '');
397
+ const k = 1 - f;
398
+ const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
399
+ return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
400
+ }
401
+
402
+ // --- batch get/save (used by /api/canvas/state) ---
403
+
404
+ function getState() {
405
+ return {
406
+ layout: getLayout(),
407
+ templates: getTemplates(),
408
+ userTheme: getUserTheme(),
409
+ };
410
+ }
411
+
412
+ function saveState(updates) {
413
+ const result = {};
414
+ if (updates.layout) result.layout = saveLayout(updates.layout);
415
+ if (updates.templates) result.templates = saveTemplates(updates.templates);
416
+ if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
417
+ return result;
418
+ }
419
+
420
+ return {
421
+ dir, stateDir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
422
+ listBlocks, getBlock, addBlock, updateBlock, removeBlock,
423
+ getTheme, setTheme,
424
+ getLayout, saveLayout, getTemplates, saveTemplates,
425
+ getUserTheme, saveUserTheme,
426
+ getState, saveState,
427
+ };
428
+ }
429
+
430
+ // Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
431
+ // so updateBlock can merge partial changes over the previous content.
432
+ function flattenData(block) {
433
+ const d = block?.data || {};
434
+ switch (block?.kind) {
435
+ case 'metric':
436
+ return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
437
+ case 'list':
438
+ return { items: d.items };
439
+ case 'table':
440
+ return { columns: d.columns, rows: d.rows };
441
+ case 'markdown':
442
+ return { markdown: d.text };
443
+ default:
444
+ return {};
445
+ }
446
+ }