@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 +1 -1
- package/server/src/agent.mjs +15 -0
- package/server/src/canvas/core.mjs +36 -11
- package/server/src/canvas-rails.mjs +108 -0
- package/server/src/index.mjs +193 -7
- package/server/src/settings.mjs +145 -0
- package/server/src/skills.mjs +213 -0
- package/server/src/usage.mjs +405 -0
- package/web/dist/assets/index-B44y93r4.js +91 -0
- package/web/dist/assets/index-NXZN2LU2.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B7cOsWLt.js +0 -91
- package/web/dist/assets/index-Dl0VT5e6.css +0 -1
package/package.json
CHANGED
package/server/src/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -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 —
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
1808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|