@venturewild/workspace 0.3.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -246,6 +246,21 @@ export class AgentSession extends EventEmitter {
246
246
  this._handleStreamEvent(evt.event);
247
247
  return;
248
248
  case 'assistant':
249
+ // The assistant event carries the authoritative per-turn `usage` + `model`
250
+ // (verified: input/cache_creation/cache_read/output + model on message). We
251
+ // surface a lightweight `context` chunk for the working-memory gauge (§8) —
252
+ // additive, independent of text rendering. Arrives even with stream events on.
253
+ if (evt.message?.usage && evt.message?.model) {
254
+ this.emit('chunk', {
255
+ type: 'context',
256
+ usage: {
257
+ input_tokens: evt.message.usage.input_tokens || 0,
258
+ cache_creation_input_tokens: evt.message.usage.cache_creation_input_tokens || 0,
259
+ cache_read_input_tokens: evt.message.usage.cache_read_input_tokens || 0,
260
+ },
261
+ model: evt.message.model,
262
+ });
263
+ }
249
264
  // Redundant with stream_event when --include-partial-messages is on
250
265
  // (it always is). Only the fallback path when partials are disabled.
251
266
  if (!this._sawStreamEvent) this._handleAssistantFallback(evt.message);
@@ -207,16 +207,33 @@ export function normalizeTheme(raw = {}) {
207
207
 
208
208
  // --- store ----------------------------------------------------------------
209
209
 
210
- export function createCanvas({ baseDir } = {}) {
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 } = {}) {
211
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.
212
224
  const blocksFile = path.join(dir, 'blocks.json');
213
225
  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');
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');
220
237
 
221
238
  function ensureDir() {
222
239
  try {
@@ -225,6 +242,14 @@ export function createCanvas({ baseDir } = {}) {
225
242
  /* read-only fs — degrades to no persistence */
226
243
  }
227
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
+ }
228
253
 
229
254
  function listBlocks() {
230
255
  const v = readJsonSafe(blocksFile, []);
@@ -316,7 +341,7 @@ export function createCanvas({ baseDir } = {}) {
316
341
  const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
317
342
  const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
318
343
  const data = { blocks, layouts: { lg }, ts: Date.now() };
319
- ensureDir();
344
+ ensureStateDir();
320
345
  try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
321
346
  return data;
322
347
  }
@@ -330,7 +355,7 @@ export function createCanvas({ baseDir } = {}) {
330
355
 
331
356
  function saveTemplates(templates) {
332
357
  const data = Array.isArray(templates) ? templates : [];
333
- ensureDir();
358
+ ensureStateDir();
334
359
  try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
335
360
  return data;
336
361
  }
@@ -361,7 +386,7 @@ export function createCanvas({ baseDir } = {}) {
361
386
  if (hex) theme.tokens[key] = hex;
362
387
  }
363
388
  }
364
- ensureDir();
389
+ ensureStateDir();
365
390
  try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
366
391
  return theme;
367
392
  }
@@ -393,7 +418,7 @@ export function createCanvas({ baseDir } = {}) {
393
418
  }
394
419
 
395
420
  return {
396
- dir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
421
+ dir, stateDir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
397
422
  listBlocks, getBlock, addBlock, updateBlock, removeBlock,
398
423
  getTheme, setTheme,
399
424
  getLayout, saveLayout, getTemplates, saveTemplates,
@@ -0,0 +1,108 @@
1
+ // CanvasRails — per-identity canvas state on the rails (multi-host step 1).
2
+ //
3
+ // WHY: canvas layout/templates/user-theme used to be per-INSTALL (one folder on
4
+ // one machine). Multi-host (docs/multi-host-workspaces-design.md §8.1) makes them
5
+ // per-(workspace, person) on VW's rails, so a person's arrangement follows them
6
+ // across hosts/devices/reinstalls. The rails (bmo-sync) are the source of truth;
7
+ // the local ~/.wild-workspace/canvas/people/<personKey>/ files become a
8
+ // read-through + offline cache — the same "server is truth, cache below it"
9
+ // pattern lifted one level up.
10
+ //
11
+ // This is a thin client over bmo-sync's POST /api/canvas-state/{pull,push}
12
+ // (account-token self-authed, exactly like /api/telemetry). person_id and the
13
+ // workspace_slug are DERIVED server-side from the account token today — a caller
14
+ // can only touch its own account's row (no IDOR). Modeled on session-reporter.mjs.
15
+ //
16
+ // degrade-never-throw: every method fails soft (pull → {ok:false}, push → false)
17
+ // so the canvas keeps working from the local cache when the rails are down or the
18
+ // install has no account. `pull` distinguishes "rails answered, empty" (ok:true,
19
+ // state:null → migration may fire) from "rails unreachable" (ok:false → serve the
20
+ // local cache, do NOT migrate).
21
+
22
+ const DEFAULT_TIMEOUT_MS = 3000;
23
+
24
+ export class CanvasRails {
25
+ constructor({
26
+ bmoSyncUrl,
27
+ accountToken,
28
+ slug = null,
29
+ timeoutMs = DEFAULT_TIMEOUT_MS,
30
+ fetchImpl = (...a) => globalThis.fetch(...a),
31
+ } = {}) {
32
+ this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
33
+ this.accountToken = accountToken || null;
34
+ this.slug = slug;
35
+ this.timeoutMs = timeoutMs;
36
+ this.fetchImpl = fetchImpl;
37
+ // Inert without a token (can't key it) or without a server URL. Unlike
38
+ // telemetry we do NOT exclude localhost — the e2e test points a local
39
+ // wild-workspace at a local bmo-sync, and dev installs have no account
40
+ // token anyway, so they stay inert without an extra guard.
41
+ this.capable = Boolean(this.accountToken) && Boolean(this.bmoSyncUrl);
42
+ }
43
+
44
+ async _post(path, body) {
45
+ if (!this.capable) return null;
46
+ const ctrl = new AbortController();
47
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
48
+ if (timer.unref) timer.unref();
49
+ try {
50
+ const r = await this.fetchImpl(`${this.bmoSyncUrl}${path}`, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ account_token: this.accountToken, ...body }),
54
+ signal: ctrl.signal,
55
+ });
56
+ if (!r || !r.ok) return null;
57
+ return await r.json().catch(() => null);
58
+ } catch {
59
+ return null; // network / abort / parse — caller degrades to the local cache
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Pull this person's canvas state from the rails.
67
+ * @returns {Promise<{ok: boolean, state: object|null}>}
68
+ * ok:true → the rails answered. state is the {layout,templates,userTheme}
69
+ * object, or null when the rails have nothing yet (→ migration may fire).
70
+ * ok:false → the rails were unreachable / not configured (→ serve the local cache).
71
+ */
72
+ async pull(workspaceSlug = this.slug) {
73
+ const resp = await this._post('/api/canvas-state/pull', { workspace_slug: workspaceSlug });
74
+ if (!resp || resp.ok !== true) return { ok: false, state: null };
75
+ // resp.json is the stored blob as a STRING (or null/absent when empty).
76
+ if (resp.json == null || resp.json === '') return { ok: true, state: null };
77
+ try {
78
+ const state = JSON.parse(resp.json);
79
+ return { ok: true, state: state && typeof state === 'object' ? state : null };
80
+ } catch {
81
+ return { ok: true, state: null }; // corrupt row → treat as empty
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Push this person's FULL canvas snapshot to the rails (last-write-wins; Node
87
+ * owns the merge, so the rails always receive the complete {layout,templates,
88
+ * userTheme}). Best-effort.
89
+ * @returns {Promise<boolean>} true iff the rails accepted the write.
90
+ */
91
+ async push(snapshot, workspaceSlug = this.slug) {
92
+ const resp = await this._post('/api/canvas-state/push', {
93
+ workspace_slug: workspaceSlug,
94
+ json: JSON.stringify(snapshot ?? {}),
95
+ });
96
+ return Boolean(resp && resp.ok === true);
97
+ }
98
+ }
99
+
100
+ /** Build the rails client from server config + account (or null when not logged in). */
101
+ export function createCanvasRails(config, account, fetchImpl) {
102
+ return new CanvasRails({
103
+ bmoSyncUrl: config?.bmoSyncServerUrl,
104
+ accountToken: account?.accountToken,
105
+ slug: account?.slug || config?.account?.slug || null,
106
+ fetchImpl,
107
+ });
108
+ }
@@ -55,6 +55,10 @@ import { SyncControl } from './sync.mjs';
55
55
  import { detectPreviewPorts, checkPort } from './preview.mjs';
56
56
  import { createBazaar } from './bazaar/core.mjs';
57
57
  import { createCanvas } from './canvas/core.mjs';
58
+ import { createCanvasRails } from './canvas-rails.mjs';
59
+ import { createUsageService } from './usage.mjs';
60
+ import { createPowers } from './skills.mjs';
61
+ import { createSettings, resolveAutonomyMode } from './settings.mjs';
58
62
  import { matchCandidates } from './bazaar/mock-tickup.mjs';
59
63
  import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
60
64
  import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
@@ -313,6 +317,74 @@ export async function createServer(overrides = {}) {
313
317
  // ~/.wild-workspace/canvas (OUTSIDE the repo). The agent builds blocks via the
314
318
  // canvas MCP server we hand it per turn; the UI reads them from /api/canvas/blocks.
315
319
  const canvas = createCanvas(overrides.canvasDir ? { baseDir: overrides.canvasDir } : {});
320
+ // Multi-host step 1 (docs/multi-host-workspaces-design.md §8.1): the user's canvas
321
+ // STATE (layout/templates/user-theme) is per-IDENTITY, with VW's rails (bmo-sync)
322
+ // as the source of truth and the local per-person dir as a read-through/offline
323
+ // cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
324
+ // agent theme). Person-scoped state stores are built lazily + cached per personKey.
325
+ const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
326
+ const canvasStores = new Map();
327
+ function canvasFor(personKey) {
328
+ let store = canvasStores.get(personKey);
329
+ if (!store) {
330
+ store = createCanvas({ baseDir: canvas.dir, personKey });
331
+ canvasStores.set(personKey, store);
332
+ }
333
+ return store;
334
+ }
335
+ // Today every authed identity is the host owner (Google sign-in requires the
336
+ // owner's email; there's no membership yet), so personKey collapses to the
337
+ // account (or 'local' when not logged in). The `session.person` hook is where
338
+ // step 2 (membership) will route non-owner teammates to their own state.
339
+ function personKeyFor(session) {
340
+ if (session?.person) return String(session.person);
341
+ return config.account?.accountId || 'local';
342
+ }
343
+ // Is a getState() result effectively blank? Used to gate the one-time migration
344
+ // of the legacy per-install (flat) canvas into the owner's person store.
345
+ function isEmptyCanvasState(s) {
346
+ const hasLayout = Boolean(s?.layout?.blocks?.length || s?.layout?.layouts?.lg?.length);
347
+ const hasTemplates = Array.isArray(s?.templates) && s.templates.length > 0;
348
+ const hasTheme = Boolean(s?.userTheme && typeof s.userTheme === 'object');
349
+ return !hasLayout && !hasTemplates && !hasTheme;
350
+ }
351
+ // One-time forward-migration: the pre-multi-host flat files (canvas/{layout,…}.json)
352
+ // belong to today's single user = the owner. Seed them into the person store iff it
353
+ // is still empty (never clobber a real arrangement). Returns true if it seeded.
354
+ function seedPersonFromLegacy(store) {
355
+ if (!isEmptyCanvasState(store.getState())) return false;
356
+ const legacy = canvas.getState();
357
+ if (isEmptyCanvasState(legacy)) return false;
358
+ store.saveState(legacy);
359
+ return true;
360
+ }
361
+ // The usage service — plan utilization (from Anthropic's OAuth usage endpoint) +
362
+ // the local context gauge (§8). onUpdate pushes a `usage` WS frame, the SAME push
363
+ // path the bazaar meter uses (no client polling). The poll timer only starts off
364
+ // the test path (mirrors daemonAutostart) so the suite never hits real Anthropic.
365
+ const usage = createUsageService({
366
+ env: overrides.usageEnv || process.env,
367
+ home: overrides.usageHome, // tests point credential resolution at a temp dir
368
+ baseDir: overrides.usageDir,
369
+ fetchImpl: overrides.usageFetch, // tests inject a mock; undefined → global fetch
370
+ pollMs: overrides.usagePollMs,
371
+ onUpdate: (state) => broadcastChat({ type: 'usage', state }),
372
+ });
373
+ const usageAutostart =
374
+ overrides.usageAutostart ??
375
+ (process.env.VITEST !== 'true' && config.nodeEnv !== 'test');
376
+ if (usageAutostart) usage.start();
377
+ // The Powers block (§7) — user-curated skills discovered by scanning the workspace's
378
+ // and the user's .claude/skills (+ .claude/commands). The "from Bazaar" zone stays
379
+ // locked (Class-D gate not built). Curation persists under ~/.wild-workspace/powers.
380
+ const powers = createPowers({
381
+ baseDir: overrides.powersDir,
382
+ workspaceDir: config.workspaceDir,
383
+ home: overrides.powersHome || config.home,
384
+ });
385
+ // Settings store (§8 Surface B) — the autonomy dial + the /rename conversation
386
+ // title. The autonomy resolver's guardrail is applied per user turn (runChatTurn).
387
+ const settings = createSettings({ baseDir: overrides.settingsDir, home: config.home });
316
388
  const turnMcpConfig = writeTurnMcpConfig({
317
389
  baseDir: bazaar.dir,
318
390
  globalDir: path.dirname(bazaar.dir),
@@ -366,6 +438,11 @@ export async function createServer(overrides = {}) {
366
438
  currentTurn.session.close(); // a user send supersedes what's running
367
439
  currentTurn = null;
368
440
  }
441
+ // Apply the autonomy dial (§8). Auto-wake is exempt — it's the system consent
442
+ // boundary and stays in the plan mode it was given. For a USER turn we resolve
443
+ // the stored level + the per-message toggle through resolveAutonomyMode, whose
444
+ // guardrail guarantees an unbuilt/unknown level can NEVER become bypassPermissions.
445
+ if (!auto) mode = resolveAutonomyMode(settings.getAutonomyLevel(), mode);
369
446
  const id = messageId || nanoid(8);
370
447
  broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
371
448
  activityBus.publish({
@@ -382,6 +459,13 @@ export async function createServer(overrides = {}) {
382
459
  currentTurn = { session, messageId: id };
383
460
  let sawError = false;
384
461
  session.on('chunk', (chunk) => {
462
+ // The per-turn context signal (working-memory gauge, §8) feeds the usage
463
+ // service, which pushes a `usage` WS frame. It's server-internal metadata —
464
+ // keep it OFF the chat stream so it never renders as an empty bubble.
465
+ if (chunk.type === 'context' && chunk.usage) {
466
+ usage.setContext(chunk.usage, chunk.model);
467
+ return;
468
+ }
385
469
  if (chunk.type === 'error') sawError = true;
386
470
  broadcastChat({ type: 'chunk', messageId: id, chunk });
387
471
  activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
@@ -755,6 +839,10 @@ export async function createServer(overrides = {}) {
755
839
  workspace: workspaceSummary(config.workspaceDir),
756
840
  workspaceId: config.workspaceId,
757
841
  session,
842
+ // The identity that scopes this person's canvas state (multi-host step 1).
843
+ // The client folds it into its localStorage cache key so two people on one
844
+ // browser don't share a cache.
845
+ personKey: personKeyFor(session),
758
846
  agent: activeAgent
759
847
  ? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
760
848
  : null,
@@ -1764,6 +1852,74 @@ export async function createServer(overrides = {}) {
1764
1852
  if (forbidden) return forbidden;
1765
1853
  return c.json(bazaar.computeState());
1766
1854
  });
1855
+
1856
+ // --- usage / context (§8) -------------------------------------------------
1857
+ // Plan utilization (Session/Weekly %) from Anthropic's OAuth usage endpoint +
1858
+ // the local working-memory gauge. Read-gated like the bazaar state. The snapshot
1859
+ // carries `disclosed` so the UI knows whether to show the one-time disclosure
1860
+ // before the block activates. Live updates ride the `usage` WS frame (no polling).
1861
+ app.get('/api/usage/state', (c) => {
1862
+ const forbidden = require(c, 'chat');
1863
+ if (forbidden) return forbidden;
1864
+ return c.json(usage.snapshot());
1865
+ });
1866
+ // One-time disclosure ack — stored server-side under ~/.wild-workspace (per user,
1867
+ // not per browser; CLAUDE.md #1). chatWrite-gated: a read-only viewer can't ack on
1868
+ // the owner's behalf. After ack, the client refreshes /api/usage/state.
1869
+ app.post('/api/usage/ack', (c) => {
1870
+ const forbidden = require(c, 'chatWrite');
1871
+ if (forbidden) return forbidden;
1872
+ usage.acknowledge();
1873
+ return c.json({ ok: true, disclosed: true });
1874
+ });
1875
+ // Force a refresh (e.g. right after the user acks, so they don't wait for the
1876
+ // timer). Degrade-safe — never throws. chatWrite-gated.
1877
+ app.post('/api/usage/refresh', async (c) => {
1878
+ const forbidden = require(c, 'chatWrite');
1879
+ if (forbidden) return forbidden;
1880
+ const state = await usage.refresh();
1881
+ return c.json(state);
1882
+ });
1883
+
1884
+ // --- powers (§7) — user-curated skill toolbelt ----------------------------
1885
+ // Read-gated like the canvas/bazaar state. Returns discovered skills (project >
1886
+ // user precedence) merged with the user's curation, plus the locked Bazaar zone.
1887
+ app.get('/api/powers/state', (c) => {
1888
+ const forbidden = require(c, 'chat');
1889
+ if (forbidden) return forbidden;
1890
+ return c.json(powers.state());
1891
+ });
1892
+ // Save the user's arrangement (pinned order + hidden set). chatWrite-gated — a
1893
+ // read-only viewer can browse the toolbelt but not re-curate the owner's.
1894
+ app.post('/api/powers/curation', async (c) => {
1895
+ const forbidden = require(c, 'chatWrite');
1896
+ if (forbidden) return forbidden;
1897
+ const body = await c.req.json().catch(() => ({}));
1898
+ const saved = powers.saveCuration(body);
1899
+ return c.json({ ok: true, curation: { order: saved.order, hidden: saved.hidden } });
1900
+ });
1901
+
1902
+ // --- settings (§8 Surface B) — the /config menu's writable state ----------
1903
+ // Read-gated. Returns the autonomy level (+ which tiers are selectable / coming
1904
+ // soon) and the /rename conversation title. The other /config knobs (theme,
1905
+ // account, model, consent) keep their own surfaces; /config just aggregates them.
1906
+ app.get('/api/settings', (c) => {
1907
+ const forbidden = require(c, 'chat');
1908
+ if (forbidden) return forbidden;
1909
+ return c.json(settings.getAll());
1910
+ });
1911
+ // Update the autonomy level and/or the conversation title. chatWrite-gated — a
1912
+ // read-only viewer can't change the owner's agent autonomy. The store REFUSES any
1913
+ // non-selectable autonomy level (e.g. the unbuilt 'work' middle tier), so the
1914
+ // guardrail can't be bypassed via the API either.
1915
+ app.post('/api/settings', async (c) => {
1916
+ const forbidden = require(c, 'chatWrite');
1917
+ if (forbidden) return forbidden;
1918
+ const body = await c.req.json().catch(() => ({}));
1919
+ if ('autonomyLevel' in body) settings.setAutonomyLevel(body.autonomyLevel);
1920
+ if ('conversationTitle' in body) settings.setConversationTitle(body.conversationTitle);
1921
+ return c.json({ ok: true, ...settings.getAll() });
1922
+ });
1767
1923
  // Apply a theme from the shelf: records the three-way moment (producer earns) and
1768
1924
  // returns the validated bundle for the browser to apply. chatWrite-gated — a
1769
1925
  // read-only viewer can browse the shelf but not record a use against it.
@@ -1801,20 +1957,50 @@ export async function createServer(overrides = {}) {
1801
1957
  return c.json({ theme: canvas.getTheme() });
1802
1958
  });
1803
1959
 
1804
- // Canvas state — the source of truth for layout, templates, and user-theme.
1805
- // Fixes the localStorage-per-origin divergence (req-1 gave two origins for the
1806
- // same workspace divergent canvas). Server stores the canonical state; client
1807
- // uses localStorage as a read-through cache.
1808
- app.get('/api/canvas/state', (c) => {
1960
+ // Canvas state — layout, saved templates, and user-theme, PER IDENTITY.
1961
+ // Source-of-truth order: VW's rails (bmo-sync, keyed by (workspace, person))
1962
+ // the local per-person dir (read-through + offline cache). Lifts the original
1963
+ // "server is truth, localStorage is the cache" one level up so a person's
1964
+ // arrangement follows them across hosts/devices (multi-host step 1). The rails
1965
+ // degrade-never-throw: when they're unreachable or the install has no account,
1966
+ // the local cache answers exactly as before.
1967
+ app.get('/api/canvas/state', async (c) => {
1809
1968
  const forbidden = require(c, 'chat');
1810
1969
  if (forbidden) return forbidden;
1811
- return c.json(canvas.getState());
1970
+ const store = canvasFor(personKeyFor(c.get('session')));
1971
+ if (config.account && canvasRails.capable) {
1972
+ const pulled = await canvasRails.pull(config.account.slug);
1973
+ if (pulled.ok) {
1974
+ if (pulled.state) {
1975
+ // Rails are the truth → refresh the local cache (re-validated by
1976
+ // saveState) and serve it.
1977
+ store.saveState(pulled.state);
1978
+ return c.json(store.getState());
1979
+ }
1980
+ // Rails answered EMPTY (first time for this person) → migrate any legacy
1981
+ // per-install layout up, then serve.
1982
+ if (seedPersonFromLegacy(store)) {
1983
+ canvasRails.push(store.getState(), config.account.slug).catch(() => {});
1984
+ }
1985
+ return c.json(store.getState());
1986
+ }
1987
+ // Rails unreachable → fall through to the local cache (offline tier).
1988
+ }
1989
+ seedPersonFromLegacy(store); // no-account / offline: still inherit legacy once
1990
+ return c.json(store.getState());
1812
1991
  });
1813
1992
  app.post('/api/canvas/state', async (c) => {
1814
1993
  const forbidden = require(c, 'chatWrite');
1815
1994
  if (forbidden) return forbidden;
1995
+ const store = canvasFor(personKeyFor(c.get('session')));
1816
1996
  const body = await c.req.json().catch(() => ({}));
1817
- return c.json({ ok: true, ...canvas.saveState(body) });
1997
+ const result = store.saveState(body); // apply the subset to the local cache
1998
+ // Push the FULL current snapshot to the rails (Node owns the merge → a clean
1999
+ // last-write-wins row). Best-effort; the local write already succeeded.
2000
+ if (config.account && canvasRails.capable) {
2001
+ canvasRails.push(store.getState(), config.account.slug).catch(() => {});
2002
+ }
2003
+ return c.json({ ok: true, ...result });
1818
2004
  });
1819
2005
 
1820
2006
  // The built site, served SAME-ORIGIN through this (already authed) server — no
@@ -0,0 +1,145 @@
1
+ // Settings store + the autonomy-dial resolver (§8 "Surface B"). Two things live here:
2
+ // 1. resolveAutonomyMode() — the SAFETY-CRITICAL mapping from the user-facing
3
+ // autonomy level to the agent's permission mode. This is the guardrail.
4
+ // 2. createSettings() — a file-backed store under ~/.wild-workspace/settings/ for
5
+ // the autonomy level + the conversation title (/rename override of the derived
6
+ // presence label). CLAUDE.md #1: never the synced repo, never localStorage.
7
+ //
8
+ // THE AUTONOMY DIAL — and why the middle tier is "coming soon", not built (read this):
9
+ //
10
+ // The §8 build-risk is explicit: "Work, then show me" is NOT one native flag. It is
11
+ // acceptEdits PLUS our own confirm-gate on dangerous non-edit ops (deploy/delete/
12
+ // send/spend). A TRUE inline confirm-gate needs to intercept a tool call mid-turn,
13
+ // round-trip a confirmation to the browser, and resume — which our architecture
14
+ // can't do cleanly tonight: we wrap `claude -p` per turn (AR-17: no Agent SDK, so no
15
+ // `canUseTool` callback), and headless `-p` has no interactive permission prompt, so
16
+ // `default`/`acceptEdits` modes can't "ask" — they just deny. Building that
17
+ // round-trip is a real piece of infra, not a flag.
18
+ //
19
+ // So per the pre-approved fallback we ship only the TWO UNAMBIGUOUS tiers and leave
20
+ // the middle DISABLED ("coming soon"):
21
+ // • 'check' (Check with me) → 'plan' — read-only; the agent proposes, you approve.
22
+ // • 'full' (Full autonomy) → honors the Build/Plan toggle (Build → bypass).
23
+ // The middle ('work') and ANY unknown value SAFELY fall back to 'plan' — NEVER
24
+ // bypassPermissions. That is the guardrail the build-risk warns about: an unbuilt or
25
+ // unrecognized autonomy level must never silently behave like Full autonomy.
26
+ //
27
+ // DEFAULT = UNSET. An install with no chosen level keeps today's behavior exactly
28
+ // (the per-message Build/Plan toggle drives the mode — Build is already bypass). The
29
+ // dial is purely ADDITIVE: it lets a nervous user LOCK to read-only, which they
30
+ // couldn't before. We did NOT change the product's existing default, and we did NOT
31
+ // map the middle tier to Full. (Whether the default SHOULD become a guarded
32
+ // auto-build once the confirm-gate ships is a product call flagged for Tuan.)
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import os from 'node:os';
37
+
38
+ // The three user-facing tiers. 'work' is DEFINED (so the UI can render it) but NOT
39
+ // BUILD-BACKED — see resolveAutonomyMode (it falls back to the safe 'plan').
40
+ export const AUTONOMY_LEVELS = ['check', 'work', 'full'];
41
+ // Which tiers a user may actually SELECT today. 'work' is excluded — "coming soon".
42
+ export const SELECTABLE_AUTONOMY = ['check', 'full'];
43
+
44
+ /**
45
+ * Map an autonomy level + the per-message Build/Plan toggle to the agent permission
46
+ * mode buildClaudeArgs understands ('plan' | 'build'). THE GUARDRAIL lives here:
47
+ * - unknown / 'work' (middle, not built) → 'plan' (read-only — NEVER bypass)
48
+ * - 'check' → 'plan' (hard read-only lock)
49
+ * - 'full' → honors the toggle (Build → bypass)
50
+ * - null/undefined (unset) → honors the toggle (today's behavior)
51
+ * The invariant tested in settings.test.mjs: NOTHING except 'full'/unset+Build ever
52
+ * yields 'build' (which is the only path to bypassPermissions).
53
+ */
54
+ export function resolveAutonomyMode(level, requestedMode) {
55
+ const toggle = requestedMode === 'plan' ? 'plan' : 'build';
56
+ switch (level) {
57
+ case 'full':
58
+ return toggle; // Full autonomy → honor the toggle (Build = bypass)
59
+ case 'check':
60
+ return 'plan'; // Check with me → always read-only propose
61
+ case null:
62
+ case undefined:
63
+ case '':
64
+ return toggle; // unset → unchanged product behavior
65
+ default:
66
+ // 'work' (middle, not built) AND any unrecognized value → SAFE read-only.
67
+ // This is the anti-regression: an unbuilt guardrail never becomes Full.
68
+ return 'plan';
69
+ }
70
+ }
71
+
72
+ function defaultSettingsDir(env = process.env, home = os.homedir()) {
73
+ const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(home, '.wild-workspace');
74
+ return path.join(base, 'settings');
75
+ }
76
+
77
+ function readJsonSafe(file, fallback) {
78
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
79
+ }
80
+
81
+ const TITLE_CAP = 120;
82
+
83
+ export function createSettings({ baseDir, env = process.env, home = os.homedir() } = {}) {
84
+ const dir = baseDir || defaultSettingsDir(env, home);
85
+ const file = path.join(dir, 'settings.json');
86
+
87
+ function read() {
88
+ const v = readJsonSafe(file, null);
89
+ return v && typeof v === 'object' ? v : {};
90
+ }
91
+
92
+ function write(patch) {
93
+ const next = { ...read(), ...patch, ts: Date.now() };
94
+ try {
95
+ fs.mkdirSync(dir, { recursive: true });
96
+ const tmp = `${file}.${process.pid}.tmp`;
97
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2));
98
+ fs.renameSync(tmp, file);
99
+ } catch { /* read-only fs — degrade to not-persisted */ }
100
+ return next;
101
+ }
102
+
103
+ // autonomyLevel: 'check' | 'full' | null. We REFUSE to persist 'work' (not
104
+ // selectable) or any unknown value — storing it would be a latent foot-gun. null
105
+ // clears back to the unset/default behavior.
106
+ function getAutonomyLevel() {
107
+ const lvl = read().autonomyLevel;
108
+ return SELECTABLE_AUTONOMY.includes(lvl) ? lvl : null;
109
+ }
110
+ function setAutonomyLevel(level) {
111
+ const clean = SELECTABLE_AUTONOMY.includes(level) ? level : null;
112
+ write({ autonomyLevel: clean });
113
+ return clean;
114
+ }
115
+
116
+ function getConversationTitle() {
117
+ const t = read().conversationTitle;
118
+ return typeof t === 'string' && t.trim() ? t.trim() : null;
119
+ }
120
+ function setConversationTitle(title) {
121
+ const clean = typeof title === 'string' ? title.trim().slice(0, TITLE_CAP) : '';
122
+ write({ conversationTitle: clean || null });
123
+ return clean || null;
124
+ }
125
+
126
+ function getAll() {
127
+ return {
128
+ autonomyLevel: getAutonomyLevel(),
129
+ conversationTitle: getConversationTitle(),
130
+ // Surface what's selectable + which is build-backed, so the UI doesn't hardcode it.
131
+ autonomy: {
132
+ levels: AUTONOMY_LEVELS,
133
+ selectable: SELECTABLE_AUTONOMY,
134
+ comingSoon: AUTONOMY_LEVELS.filter((l) => !SELECTABLE_AUTONOMY.includes(l)),
135
+ },
136
+ };
137
+ }
138
+
139
+ return {
140
+ dir, file,
141
+ getAutonomyLevel, setAutonomyLevel,
142
+ getConversationTitle, setConversationTitle,
143
+ getAll,
144
+ };
145
+ }