@venturewild/workspace 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,635 +1,1099 @@
1
- // wild-workspace server bootstrap.
2
- // Three processes per AR-17:
3
- // - this Node server (Hono): REST + WebSocket + frontend bundle
4
- // - AI agent subprocess: spawned per chat session via agent.mjs
5
- // - bmo-sync daemon (v1.x — out of scope for this scaffold)
6
-
7
- import { Hono } from 'hono';
8
- import { serveStatic } from '@hono/node-server/serve-static';
9
- import { serve } from '@hono/node-server';
10
- import { WebSocketServer } from 'ws';
11
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
12
- import path from 'node:path';
13
- import url from 'node:url';
14
- import {
15
- buildConfig,
16
- ROLES,
17
- ROLE_CAPABILITIES,
18
- APP_VERSION,
19
- DEFAULT_AGENTS,
20
- assertSecureBinding,
21
- } from './config.mjs';
22
- import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
23
- import { mintShareToken, verifyShareToken, buildShareUrl, TokenRegistry } from './share.mjs';
24
- import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
25
- import { InboxWatcher } from './inbox.mjs';
26
- import { ActivityBus } from './activity.mjs';
27
- import { DaemonBridge } from './daemon.mjs';
28
- import { SyncControl } from './sync.mjs';
29
- import { detectPreviewPorts, checkPort } from './preview.mjs';
30
- import { nanoid } from 'nanoid';
31
-
32
- const __filename = url.fileURLToPath(import.meta.url);
33
- const __dirname = path.dirname(__filename);
34
-
35
- export async function createServer(overrides = {}) {
36
- const config = buildConfig(overrides);
37
- // Refuse to start on a public bind with a forgeable default secret. (C1/C2)
38
- assertSecureBinding(config);
39
- if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
40
-
41
- const activityBus = new ActivityBus();
42
- const tokenRegistry = new TokenRegistry();
43
- const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
44
- inboxWatcher.on('change', (payload) => {
45
- activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
46
- });
47
-
48
- // Bridge the bmo-sync daemon's event feed into the ActivityBus. The daemon
49
- // is a separate process and may be absent — the bridge retries quietly.
50
- // `overrides.daemonBridge: false` disables it (used by tests).
51
- const daemonBridge =
52
- overrides.daemonBridge === false
53
- ? null
54
- : new DaemonBridge(activityBus, { url: config.daemonUrl }).start();
55
-
56
- // Control plane for bmo-sync folder sharing (pair / detach / invite).
57
- // `overrides.syncControl` is a test seam.
58
- const syncControl =
59
- overrides.syncControl ||
60
- new SyncControl({
61
- daemonHttpUrl: config.daemonHttpUrl,
62
- bmoSyncServerUrl: config.bmoSyncServerUrl,
63
- adminKey: config.bmoSyncAdminKey,
64
- });
65
-
66
- // `overrides.agents` / `overrides.activeAgent` are a test/embedding seam:
67
- // a caller can inject agent definitions instead of probing PATH.
68
- const detectedAgents = overrides.agents || (await detectAgents());
69
- let activeAgent = overrides.activeAgent || pickDefaultAgent(detectedAgents);
70
-
71
- const app = new Hono();
72
-
73
- // --- auth + role resolution ---
74
- async function resolveRole(c) {
75
- const auth = c.req.header('authorization');
76
- if (auth?.startsWith('Bearer ')) {
77
- const token = auth.slice('Bearer '.length).trim();
78
- if (token === config.partnerToken) {
79
- return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
80
- }
81
- const payload = await verifyShareToken(token, config.shareSecret);
82
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
83
- return {
84
- role: payload.role,
85
- sub: payload.sub,
86
- workspaceId: payload.workspaceId,
87
- source: 'share-jwt',
88
- exp: payload.exp,
89
- };
90
- }
91
- }
92
- const queryToken = c.req.query('t');
93
- if (queryToken) {
94
- // A browser opening the workspace URL can only carry a token in the
95
- // query string, not an Authorization header — so the partner token is
96
- // accepted here too, mirroring the WebSocket upgrade handler.
97
- if (queryToken === config.partnerToken) {
98
- return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token-query' };
99
- }
100
- const payload = await verifyShareToken(queryToken, config.shareSecret);
101
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
102
- return {
103
- role: payload.role,
104
- sub: payload.sub,
105
- workspaceId: payload.workspaceId,
106
- source: 'share-jwt-query',
107
- exp: payload.exp,
108
- };
109
- }
110
- }
111
- // Default for local partner UX — same machine, no token expected.
112
- if (!config.publicMode) {
113
- return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
114
- }
115
- // Public mode with no valid token: deny. No anonymous viewer access —
116
- // a share JWT or the partner token is required. (Concern C1.)
117
- return { role: null, sub: 'anon', source: 'unauth', denied: true };
118
- }
119
-
120
- function require(c, capability) {
121
- const cap = ROLE_CAPABILITIES[c.get('role')];
122
- if (!cap || !cap[capability]) {
123
- return c.json({ error: 'forbidden', capability, role: c.get('role') }, 403);
124
- }
125
- return null;
126
- }
127
-
128
- app.use('*', async (c, next) => {
129
- const session = await resolveRole(c);
130
- c.set('role', session.role);
131
- c.set('session', session);
132
- // Block the API for denied (non-localhost, unauthenticated) requests, but
133
- // let static assets and the health check through so the SPA can still
134
- // load and prompt for a token. (Concern C1.)
135
- if (session.denied && c.req.path.startsWith('/api/') && c.req.path !== '/api/health') {
136
- return c.json({ error: 'unauthorized' }, 401);
137
- }
138
- await next();
139
- });
140
-
141
- // --- meta ---
142
- app.get('/api/health', (c) =>
143
- c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
144
- );
145
-
146
- app.get('/api/session', (c) => {
147
- const session = c.get('session');
148
- const role = c.get('role');
149
- return c.json({
150
- version: APP_VERSION,
151
- role,
152
- capabilities: ROLE_CAPABILITIES[role],
153
- workspace: workspaceSummary(config.workspaceDir),
154
- workspaceId: config.workspaceId,
155
- session,
156
- agent: activeAgent
157
- ? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
158
- : null,
159
- });
160
- });
161
-
162
- app.get('/api/agents', (c) =>
163
- c.json({
164
- available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
165
- id,
166
- label,
167
- description,
168
- available,
169
- resolvedPath,
170
- })),
171
- active: activeAgent?.id,
172
- }),
173
- );
174
-
175
- app.post('/api/agents/select', async (c) => {
176
- const forbidden = require(c, 'chatWrite');
177
- if (forbidden) return forbidden;
178
- const body = await c.req.json().catch(() => ({}));
179
- const next = detectedAgents.find((a) => a.id === body.id);
180
- if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
181
- activeAgent = next;
182
- activityBus.publish({ type: 'agent-changed', agentId: next.id });
183
- return c.json({ ok: true, active: activeAgent.id });
184
- });
185
-
186
- // --- workspace files ---
187
- app.get('/api/workspace/tree', async (c) => {
188
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
189
- return c.json({ error: 'forbidden' }, 403);
190
- }
191
- try {
192
- const tree = await fullTree(config.workspaceDir, 3);
193
- return c.json({ root: config.workspaceDir, entries: tree });
194
- } catch (e) {
195
- return c.json({ error: String(e.message || e) }, 500);
196
- }
197
- });
198
-
199
- app.get('/api/workspace/list', async (c) => {
200
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
201
- return c.json({ error: 'forbidden' }, 403);
202
- }
203
- const p = c.req.query('path') || '';
204
- try {
205
- const items = await listDir(config.workspaceDir, p);
206
- if (items == null) return c.json({ error: 'not-a-directory' }, 400);
207
- return c.json({ path: p, items });
208
- } catch (e) {
209
- return c.json({ error: String(e.message || e) }, 400);
210
- }
211
- });
212
-
213
- app.get('/api/workspace/file', async (c) => {
214
- if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
215
- return c.json({ error: 'forbidden' }, 403);
216
- }
217
- const p = c.req.query('path');
218
- if (!p) return c.json({ error: 'path-required' }, 400);
219
- try {
220
- const result = await readFile(config.workspaceDir, p);
221
- return c.json({ path: p, ...result });
222
- } catch (e) {
223
- return c.json({ error: String(e.message || e) }, 400);
224
- }
225
- });
226
-
227
- // --- component inbox ---
228
- app.get('/api/inbox', async (c) => {
229
- const snapshot = await inboxWatcher.snapshot();
230
- return c.json(snapshot);
231
- });
232
-
233
- // --- live preview port detection ---
234
- app.get('/api/preview/ports', async (c) => {
235
- const ports = await detectPreviewPorts();
236
- return c.json({ ports });
237
- });
238
-
239
- app.get('/api/preview/check', async (c) => {
240
- const port = Number(c.req.query('port'));
241
- if (!port) return c.json({ error: 'port-required' }, 400);
242
- const host = c.req.query('host') || '127.0.0.1';
243
- return c.json({ port, host, listening: await checkPort(port, host) });
244
- });
245
-
246
- // --- activity stream snapshot (WebSocket carries live updates) ---
247
- app.get('/api/activity', (c) => c.json(activityBus.snapshot()));
248
-
249
- // --- share-by-URL (AR-20) ---
250
- app.post('/api/share', async (c) => {
251
- const forbidden = require(c, 'share');
252
- if (forbidden) return forbidden;
253
- const body = await c.req.json().catch(() => ({}));
254
- const role = body.role === 'client' ? 'client' : 'viewer';
255
- const ttlSeconds = Number(body.ttlSeconds) || 60 * 60 * 24;
256
- const label = body.label || (role === 'client' ? 'Client portal' : 'Viewer');
257
- try {
258
- const minted = await mintShareToken({
259
- secret: config.shareSecret,
260
- workspaceId: config.workspaceId,
261
- role,
262
- ttlSeconds,
263
- });
264
- tokenRegistry.add({
265
- ...minted,
266
- label,
267
- createdAt: Date.now(),
268
- });
269
- const shareUrl = buildShareUrl({
270
- shareBaseUrl: config.shareBaseUrl,
271
- workspaceId: config.workspaceId,
272
- token: minted.token,
273
- });
274
- activityBus.publish({
275
- type: 'share-issued',
276
- role,
277
- sub: minted.sub,
278
- exp: minted.exp,
279
- label,
280
- });
281
- return c.json({ ...minted, shareUrl, label });
282
- } catch (e) {
283
- return c.json({ error: String(e.message || e) }, 400);
284
- }
285
- });
286
-
287
- app.get('/api/share', (c) => {
288
- const forbidden = require(c, 'share');
289
- if (forbidden) return forbidden;
290
- return c.json({ tokens: tokenRegistry.list() });
291
- });
292
-
293
- app.delete('/api/share/:sub', (c) => {
294
- const forbidden = require(c, 'share');
295
- if (forbidden) return forbidden;
296
- const sub = c.req.param('sub');
297
- tokenRegistry.revoke(sub);
298
- activityBus.publish({ type: 'share-revoked', sub });
299
- return c.json({ ok: true, sub });
300
- });
301
-
302
- // --- bmo-sync folder sharing ---
303
- // Pairing / detaching a folder and minting invites all run through the
304
- // bmo-sync daemon (and, for invites, the central server). Partner-only.
305
- app.get('/api/sync/status', async (c) => {
306
- const forbidden = require(c, 'sync');
307
- if (forbidden) return forbidden;
308
- const status = await syncControl.status();
309
- return c.json({
310
- ...status,
311
- workspaceDir: config.workspaceDir,
312
- workspaceName: path.basename(config.workspaceDir),
313
- });
314
- });
315
-
316
- app.post('/api/sync/pair', async (c) => {
317
- const forbidden = require(c, 'sync');
318
- if (forbidden) return forbidden;
319
- const body = await c.req.json().catch(() => ({}));
320
- try {
321
- const workspace = await syncControl.pair(body.inviteCode, config.workspaceDir);
322
- activityBus.publish({
323
- type: 'sync-paired',
324
- workspaceId: workspace.workspaceId,
325
- projectName: workspace.projectName,
326
- });
327
- return c.json({ ok: true, workspace });
328
- } catch (e) {
329
- return c.json({ error: String(e.message || e) }, 400);
330
- }
331
- });
332
-
333
- app.post('/api/sync/detach', async (c) => {
334
- const forbidden = require(c, 'sync');
335
- if (forbidden) return forbidden;
336
- const body = await c.req.json().catch(() => ({}));
337
- try {
338
- const result = await syncControl.detach(body.workspaceId);
339
- activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
340
- return c.json({ ok: true, ...result });
341
- } catch (e) {
342
- return c.json({ error: String(e.message || e) }, 400);
343
- }
344
- });
345
-
346
- app.post('/api/sync/invite', async (c) => {
347
- const forbidden = require(c, 'sync');
348
- if (forbidden) return forbidden;
349
- const body = await c.req.json().catch(() => ({}));
350
- try {
351
- const invite = await syncControl.createInvite({
352
- projectCode: body.projectCode,
353
- displayName: body.displayName,
354
- expiresHours: body.expiresHours,
355
- });
356
- return c.json({ ok: true, invite });
357
- } catch (e) {
358
- return c.json({ error: String(e.message || e) }, 400);
359
- }
360
- });
361
-
362
- // --- chat REST fallback (for clients that can't WebSocket) ---
363
- app.post('/api/chat', async (c) => {
364
- const forbidden = require(c, 'chatWrite');
365
- if (forbidden) return forbidden;
366
- const body = await c.req.json().catch(() => ({}));
367
- const prompt = (body.prompt || '').trim();
368
- if (!prompt) return c.json({ error: 'prompt-required' }, 400);
369
- const session = new AgentSession(activeAgent);
370
- return new Promise((resolve) => {
371
- const chunks = [];
372
- session.on('chunk', (chunk) => chunks.push(chunk));
373
- session.on('end', ({ code }) => {
374
- resolve(c.json({ ok: true, exitCode: code, chunks }));
375
- });
376
- session.on('error', (err) => {
377
- resolve(c.json({ ok: false, error: String(err.message || err) }, 500));
378
- });
379
- session.send(prompt, { cwd: config.workspaceDir, mode: body.mode });
380
- });
381
- });
382
-
383
- // --- request-changes (client role) ---
384
- const changeRequests = [];
385
- app.post('/api/request-changes', async (c) => {
386
- const forbidden = require(c, 'requestChanges');
387
- if (forbidden) return forbidden;
388
- const body = await c.req.json().catch(() => ({}));
389
- const text = (body.text || '').trim();
390
- if (!text) return c.json({ error: 'text-required' }, 400);
391
- const session = c.get('session');
392
- const entry = {
393
- id: nanoid(12),
394
- text,
395
- from: session.sub || 'client',
396
- ts: Date.now(),
397
- };
398
- changeRequests.push(entry);
399
- activityBus.publish({ type: 'request-changes', entry });
400
- return c.json({ ok: true, entry });
401
- });
402
-
403
- app.get('/api/request-changes', (c) => c.json({ requests: changeRequests }));
404
-
405
- // --- frontend bundle (built by `npm run build:web`) ---
406
- if (existsSync(config.webDir)) {
407
- app.use(
408
- '/*',
409
- serveStatic({
410
- root: path.relative(process.cwd(), config.webDir),
411
- }),
412
- );
413
- // SPA fallback
414
- app.notFound((c) => {
415
- const indexHtmlPath = path.join(config.webDir, 'index.html');
416
- if (existsSync(indexHtmlPath)) {
417
- return new Response(readFileSync(indexHtmlPath), {
418
- headers: { 'content-type': 'text/html' },
419
- });
420
- }
421
- return c.text('wild-workspace: frontend not built; run `npm run build`', 200);
422
- });
423
- } else {
424
- app.notFound((c) =>
425
- c.text(
426
- 'wild-workspace API ready. Frontend bundle missing — run `npm run build` first.',
427
- 200,
428
- ),
429
- );
430
- }
431
-
432
- const httpServer = serve({
433
- fetch: app.fetch,
434
- port: config.port,
435
- hostname: config.host,
436
- });
437
- // wait until the server is actually listening before continuing
438
- await new Promise((resolve, reject) => {
439
- if (httpServer.listening) return resolve();
440
- httpServer.once('listening', resolve);
441
- httpServer.once('error', reject);
442
- });
443
-
444
- // --- websocket bridge ---
445
- const wss = new WebSocketServer({ noServer: true });
446
- httpServer.on('upgrade', async (req, socket, head) => {
447
- const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
448
- const supported = ['/ws/chat', '/ws/activity'];
449
- if (!supported.includes(reqUrl.pathname)) {
450
- socket.destroy();
451
- return;
452
- }
453
- const tokenFromQuery = reqUrl.searchParams.get('t');
454
- let role = null;
455
- let sub = 'anon';
456
- if (tokenFromQuery === config.partnerToken) {
457
- role = ROLES.PARTNER;
458
- sub = 'partner';
459
- } else if (tokenFromQuery) {
460
- const payload = await verifyShareToken(tokenFromQuery, config.shareSecret);
461
- if (payload && !tokenRegistry.isRevoked(payload.sub)) {
462
- role = payload.role;
463
- sub = payload.sub;
464
- }
465
- } else if (!config.publicMode) {
466
- role = ROLES.PARTNER;
467
- sub = 'local-partner';
468
- }
469
- // Deny: public mode with no token, or any invalid/revoked token. An
470
- // invalid token must NOT silently fall back to partner. (Concern C1.)
471
- if (!role) {
472
- socket.destroy();
473
- return;
474
- }
475
- wss.handleUpgrade(req, socket, head, (ws) => {
476
- ws._wsRole = role;
477
- ws._wsSub = sub;
478
- wss.emit('connection', ws, req, reqUrl.pathname);
479
- });
480
- });
481
-
482
- wss.on('connection', (ws, req, route) => {
483
- if (route === '/ws/activity') return wireActivityWs(ws);
484
- if (route === '/ws/chat') return wireChatWs(ws);
485
- });
486
-
487
- function wireActivityWs(ws) {
488
- const presence = activityBus.joinPresence({
489
- sessionId: nanoid(10),
490
- role: ws._wsRole,
491
- label: ws._wsRole,
492
- });
493
- ws.send(
494
- JSON.stringify({
495
- type: 'snapshot',
496
- snapshot: activityBus.snapshot(),
497
- you: presence,
498
- }),
499
- );
500
- const onEvent = (evt) => {
501
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt));
502
- };
503
- activityBus.on('event', onEvent);
504
- ws.on('message', (raw) => {
505
- try {
506
- const msg = JSON.parse(raw.toString());
507
- if (msg.type === 'focus') {
508
- activityBus.updateFocus(presence.sessionId, msg.focus || null);
509
- }
510
- } catch {}
511
- });
512
- ws.on('close', () => {
513
- activityBus.off('event', onEvent);
514
- activityBus.leavePresence(presence.sessionId);
515
- });
516
- }
517
-
518
- function wireChatWs(ws) {
519
- const cap = ROLE_CAPABILITIES[ws._wsRole];
520
- let activeSession = null;
521
- ws.send(JSON.stringify({ type: 'hello', role: ws._wsRole, agent: activeAgent?.id }));
522
- ws.on('message', (raw) => {
523
- let msg;
524
- try {
525
- msg = JSON.parse(raw.toString());
526
- } catch {
527
- ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
528
- return;
529
- }
530
- if (msg.type === 'send') {
531
- if (!cap.chatWrite) {
532
- ws.send(
533
- JSON.stringify({ type: 'error', message: 'role not permitted to send' }),
534
- );
535
- return;
536
- }
537
- if (activeSession) activeSession.close();
538
- const messageId = msg.messageId || nanoid(8);
539
- activityBus.publish({
540
- type: 'chat-user',
541
- messageId,
542
- role: ws._wsRole,
543
- text: msg.text,
544
- });
545
- activeSession = new AgentSession(activeAgent);
546
- activeSession.on('chunk', (chunk) => {
547
- ws.send(JSON.stringify({ type: 'chunk', messageId, chunk }));
548
- activityBus.publish({ type: 'chat-stream', messageId, chunk });
549
- // Surface the turn's token/cost totals so the activity bar can show
550
- // running usage — the ActivityBus accumulates events typed 'usage'.
551
- if (chunk.type === 'usage' && chunk.usage) {
552
- activityBus.publish({ type: 'usage', usage: chunk.usage });
553
- }
554
- });
555
- activeSession.on('stderr', (text) => {
556
- ws.send(JSON.stringify({ type: 'stderr', messageId, text }));
557
- });
558
- activeSession.on('end', ({ code }) => {
559
- ws.send(JSON.stringify({ type: 'end', messageId, code }));
560
- activityBus.publish({ type: 'chat-end', messageId, code });
561
- activeSession = null;
562
- });
563
- activeSession.on('error', (err) => {
564
- ws.send(
565
- JSON.stringify({
566
- type: 'error',
567
- messageId,
568
- message: String(err.message || err),
569
- }),
570
- );
571
- activeSession = null;
572
- });
573
- activeSession.send(msg.text, {
574
- cwd: config.workspaceDir,
575
- mode: msg.mode,
576
- });
577
- } else if (msg.type === 'cancel') {
578
- if (activeSession) activeSession.close();
579
- activeSession = null;
580
- }
581
- });
582
- ws.on('close', () => {
583
- if (activeSession) activeSession.close();
584
- });
585
- }
586
-
587
- return {
588
- config,
589
- app,
590
- httpServer,
591
- wss,
592
- activityBus,
593
- inboxWatcher,
594
- tokenRegistry,
595
- daemonBridge,
596
- syncControl,
597
- detectedAgents,
598
- getActiveAgent: () => activeAgent,
599
- async stop() {
600
- try { inboxWatcher.stop(); } catch {}
601
- try { daemonBridge?.stop(); } catch {}
602
- try { wss.close(); } catch {}
603
- await new Promise((resolve) => httpServer.close(resolve));
604
- },
605
- };
606
- }
607
-
608
- // Standalone entry — runs when executed directly (node server/src/index.mjs).
609
- const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === __filename;
610
- if (isDirectRun) {
611
- createServer().then(async (s) => {
612
- const { config } = s;
613
- console.log(`\n wild-workspace v${APP_VERSION}`);
614
- console.log(` workspace : ${config.workspaceDir}`);
615
- console.log(` url : http://${config.host}:${config.port}`);
616
- console.log(` agent : ${s.getActiveAgent()?.label || '(none detected)'}`);
617
- if (config.publicMode) {
618
- // Public mode: no anonymous access. Partner must authenticate.
619
- console.log(` mode : PUBLIC — anonymous requests denied`);
620
- console.log(` partner : append ?t=${config.partnerToken} to the URL`);
621
- }
622
- console.log('');
623
- if (config.openBrowser) {
624
- try {
625
- const open = (await import('open')).default;
626
- open(`http://${config.host}:${config.port}`);
627
- } catch (e) {
628
- // browser is best-effort; not having one isn't fatal
629
- }
630
- }
631
- }).catch((err) => {
632
- console.error('wild-workspace failed to start:', err);
633
- process.exit(1);
634
- });
635
- }
1
+ // wild-workspace server bootstrap.
2
+ // Three processes per AR-17:
3
+ // - this Node server (Hono): REST + WebSocket + frontend bundle
4
+ // - AI agent subprocess: spawned per chat session via agent.mjs
5
+ // - bmo-sync daemon (v1.x — out of scope for this scaffold)
6
+
7
+ import { Hono } from 'hono';
8
+ import { serveStatic } from '@hono/node-server/serve-static';
9
+ import { serve } from '@hono/node-server';
10
+ import { WebSocketServer } from 'ws';
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import url from 'node:url';
14
+ import {
15
+ buildConfig,
16
+ ROLES,
17
+ ROLE_CAPABILITIES,
18
+ APP_VERSION,
19
+ DEFAULT_AGENTS,
20
+ assertSecureBinding,
21
+ } from './config.mjs';
22
+ import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
23
+ import { mintShareToken, verifyShareToken, buildShareUrl, TokenRegistry } from './share.mjs';
24
+ import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs.mjs';
25
+ import { InboxWatcher } from './inbox.mjs';
26
+ import { ActivityBus } from './activity.mjs';
27
+ import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
28
+ import { ErrorReporter } from './error-reporter.mjs';
29
+ import { DaemonBridge } from './daemon.mjs';
30
+ import { DaemonSupervisor } from './daemon-supervisor.mjs';
31
+ import { SyncControl } from './sync.mjs';
32
+ import { detectPreviewPorts, checkPort } from './preview.mjs';
33
+ import { nanoid } from 'nanoid';
34
+
35
+ const __filename = url.fileURLToPath(import.meta.url);
36
+ const __dirname = path.dirname(__filename);
37
+
38
+ // --- structured logging ---------------------------------------------------
39
+ // Single helper used everywhere so log lines are uniformly tagged + timestamped.
40
+ // Goes to stdout; the launcher redirects stdout/stderr to a file. Categories:
41
+ // [http], [ws], [chat], [onboarding], [identity], [auth].
42
+ function log(tag, ...args) {
43
+ const ts = new Date().toISOString();
44
+ const line = args
45
+ .map((a) =>
46
+ typeof a === 'string'
47
+ ? a
48
+ : a instanceof Error
49
+ ? a.stack || String(a)
50
+ : JSON.stringify(a),
51
+ )
52
+ .join(' ');
53
+ process.stdout.write(`${ts} ${tag} ${line}\n`);
54
+ }
55
+
56
+ // --- chat session persistence ---------------------------------------------
57
+ // The conversation's claude session id, stored in the workspace's gitignored
58
+ // .wild-workspace/ dir. Persisting it means a browser reload — or a server
59
+ // restart — doesn't wipe the agent's memory of the conversation.
60
+ function chatSessionPath(dataDir) {
61
+ return path.join(dataDir, 'chat-session.json');
62
+ }
63
+ function loadChatSessionId(dataDir) {
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(chatSessionPath(dataDir), 'utf8'));
66
+ return typeof parsed.sessionId === 'string' ? parsed.sessionId : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+ function saveChatSessionId(dataDir, sessionId) {
72
+ try {
73
+ writeFileSync(
74
+ chatSessionPath(dataDir),
75
+ JSON.stringify({ sessionId: sessionId || null }, null, 2),
76
+ );
77
+ } catch {
78
+ /* read-only fs continuity degrades to in-memory for this run */
79
+ }
80
+ }
81
+
82
+ // Directory names already under .wild/imports/ — the auto-wake baseline.
83
+ function scanImports(workspaceDir) {
84
+ try {
85
+ return readdirSync(path.join(workspaceDir, '.wild', 'imports'), {
86
+ withFileTypes: true,
87
+ })
88
+ .filter((e) => e.isDirectory())
89
+ .map((e) => e.name);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ export async function createServer(overrides = {}) {
96
+ const config = buildConfig(overrides);
97
+ // Refuse to start on a public bind with a forgeable default secret. (C1/C2)
98
+ assertSecureBinding(config);
99
+ if (!existsSync(config.dataDir)) mkdirSync(config.dataDir, { recursive: true });
100
+
101
+ const activityBus = new ActivityBus();
102
+ const tokenRegistry = new TokenRegistry();
103
+ const inboxWatcher = new InboxWatcher(config.workspaceDir).start();
104
+ inboxWatcher.on('change', (payload) => {
105
+ activityBus.publish({ type: 'inbox-change', snapshot: payload.snapshot });
106
+ });
107
+
108
+ // Bridge the bmo-sync daemon's event feed into the ActivityBus. The daemon
109
+ // is a separate process and may be absent — the bridge retries quietly.
110
+ // `overrides.daemonBridge: false` disables it (used by tests).
111
+ const daemonBridge =
112
+ overrides.daemonBridge === false
113
+ ? null
114
+ : new DaemonBridge(activityBus, { url: config.daemonUrl }).start();
115
+
116
+ // Owns the bmo-sync daemon's lifecycle: starts it (detached + window-hidden)
117
+ // if it isn't already running, so sync just works whenever wild-workspace is
118
+ // used. The daemon outlives the server by design — not stopped in stop().
119
+ // `overrides.daemonSupervisor: false` disables it; an object injects test
120
+ // seams. Autostart is gated by config.daemonAutostart (off under tests).
121
+ const daemonSupervisor =
122
+ overrides.daemonSupervisor === false
123
+ ? null
124
+ : new DaemonSupervisor({
125
+ httpBase: config.daemonHttpUrl,
126
+ // b-ii: hand the daemon the account token + relay so it opens the
127
+ // proxy link (lights up <slug>.venturewild.llc). Null when logged out.
128
+ accountToken: config.accountToken,
129
+ serverUrl: config.bmoSyncServerUrl,
130
+ ...(typeof overrides.daemonSupervisor === 'object'
131
+ ? overrides.daemonSupervisor
132
+ : {}),
133
+ });
134
+ const daemonReady =
135
+ daemonSupervisor && config.daemonAutostart
136
+ ? daemonSupervisor
137
+ .ensureRunning()
138
+ .catch((e) => ({ started: false, error: String(e?.message || e) }))
139
+ : Promise.resolve({ started: false, skipped: true });
140
+
141
+ // Control plane for bmo-sync folder sharing (pair / detach / invite).
142
+ // `overrides.syncControl` is a test seam.
143
+ const syncControl =
144
+ overrides.syncControl ||
145
+ new SyncControl({
146
+ daemonHttpUrl: config.daemonHttpUrl,
147
+ bmoSyncServerUrl: config.bmoSyncServerUrl,
148
+ adminKey: config.bmoSyncAdminKey,
149
+ });
150
+
151
+ // `overrides.agents` / `overrides.activeAgent` are a test/embedding seam:
152
+ // a caller can inject agent definitions instead of probing PATH.
153
+ const detectedAgents = overrides.agents || (await detectAgents());
154
+ let activeAgent = overrides.activeAgent || pickDefaultAgent(detectedAgents);
155
+
156
+ // Error telemetry — forwards agent crashes etc. to bmo-sync-server so
157
+ // support can diagnose client-machine issues. Off via
158
+ // WILD_WORKSPACE_NO_TELEMETRY=1 or overrides.errorReporter = false.
159
+ const errorReporter =
160
+ overrides.errorReporter === false
161
+ ? { report: () => {} }
162
+ : overrides.errorReporter ||
163
+ new ErrorReporter({
164
+ bmoSyncUrl: config.bmoSyncServerUrl,
165
+ workspaceId: config.workspaceId,
166
+ enabled: process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1',
167
+ });
168
+
169
+ // --- chat turn orchestration ----------------------------------------------
170
+ // One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
171
+ // Both user sends and auto-wake turns thread through one turn-runner so they
172
+ // share the agent's memory and never run two claude processes at once.
173
+ let chatSessionId = loadChatSessionId(config.dataDir);
174
+ const chatClients = new Set(); // every connected /ws/chat socket
175
+ let currentTurn = null; // { session, messageId } at most one at a time
176
+
177
+ function broadcastChat(obj) {
178
+ const data = JSON.stringify(obj);
179
+ for (const ws of chatClients) {
180
+ if (ws.readyState === ws.OPEN) ws.send(data);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Run one chat turn: spawn the agent, stream every chunk to every chat
186
+ * client, and persist the resulting session id so the next turn resumes it.
187
+ * - `userText` / `note`: optional lines shown before the agent reply (a
188
+ * user bubble, or an auto-wake system note).
189
+ * - `auto`: an automated (auto-wake) turn never interrupts a live turn,
190
+ * and retries once if the run fails (PRD §13 A8).
191
+ * Returns false if the turn could not start (an auto turn while busy).
192
+ */
193
+ function runChatTurn({ prompt, mode, messageId, userText, note, auto = false }) {
194
+ if (currentTurn) {
195
+ if (auto) return false; // auto-wake yields to a live turn
196
+ currentTurn.session.close(); // a user send supersedes what's running
197
+ currentTurn = null;
198
+ }
199
+ const id = messageId || nanoid(8);
200
+ broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
201
+ activityBus.publish({
202
+ type: 'chat-user',
203
+ messageId: id,
204
+ text: userText || note || prompt,
205
+ });
206
+
207
+ let retried = false;
208
+ const startTurn = () => {
209
+ const startedAt = Date.now();
210
+ log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
211
+ const session = new AgentSession(activeAgent);
212
+ currentTurn = { session, messageId: id };
213
+ let sawError = false;
214
+ session.on('chunk', (chunk) => {
215
+ if (chunk.type === 'error') sawError = true;
216
+ broadcastChat({ type: 'chunk', messageId: id, chunk });
217
+ activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
218
+ // Surface the turn's token/cost totals so the activity bar can show
219
+ // running usage — the ActivityBus accumulates events typed 'usage'.
220
+ if (chunk.type === 'usage' && chunk.usage) {
221
+ activityBus.publish({ type: 'usage', usage: chunk.usage });
222
+ }
223
+ });
224
+ session.on('stderr', (text) => {
225
+ const trimmed = String(text || '').trim();
226
+ if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
227
+ broadcastChat({ type: 'stderr', messageId: id, text });
228
+ });
229
+ session.on('error', (err) => {
230
+ sawError = true;
231
+ const msg = String(err?.message || err);
232
+ log('[chat]', `error id=${id}: ${msg}`);
233
+ errorReporter.report({
234
+ category: 'agent',
235
+ message: msg,
236
+ stack: err?.stack,
237
+ agentLabel: activeAgent?.label,
238
+ });
239
+ broadcastChat({
240
+ type: 'error',
241
+ messageId: id,
242
+ message: msg,
243
+ });
244
+ currentTurn = null;
245
+ });
246
+ session.on('end', ({ code }) => {
247
+ currentTurn = null;
248
+ const elapsed = Date.now() - startedAt;
249
+ log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
250
+ // Silent agent crash → telemetry. A non-zero exit (or signal-kill =
251
+ // code null) that wasn't user-cancelled is exactly the failure mode
252
+ // we want to see in the central log. Skip the user-cancelled and
253
+ // clean-exit cases.
254
+ if (!session.closed && (code !== 0 || sawError)) {
255
+ errorReporter.report({
256
+ category: 'agent',
257
+ message:
258
+ code === null
259
+ ? `agent subprocess killed by signal after ${elapsed}ms (no chunks)`
260
+ : `agent exited code=${code} after ${elapsed}ms sawError=${sawError}`,
261
+ agentLabel: activeAgent?.label,
262
+ });
263
+ }
264
+ // A turn closed on purpose — cancelled by the user, or superseded by
265
+ // the next turn — never reached a clean finish: it must not retry and
266
+ // must not persist its session id (that would clobber a reset, or
267
+ // resurrect a turn the user just stopped).
268
+ if (!session.closed) {
269
+ // An automated turn retries once on a failed run — `claude -p`
270
+ // spawned non-interactively hits transient API resets (PRD §13 A8).
271
+ if (auto && !retried && (sawError || code !== 0)) {
272
+ retried = true;
273
+ setTimeout(startTurn, 700);
274
+ return;
275
+ }
276
+ if (session.sessionId) {
277
+ chatSessionId = session.sessionId;
278
+ saveChatSessionId(config.dataDir, chatSessionId);
279
+ }
280
+ }
281
+ broadcastChat({ type: 'end', messageId: id, code });
282
+ activityBus.publish({ type: 'chat-end', messageId: id, code });
283
+ });
284
+ session.send(prompt, {
285
+ cwd: config.workspaceDir,
286
+ mode,
287
+ resumeSessionId: chatSessionId,
288
+ });
289
+ };
290
+ startTurn();
291
+ return true;
292
+ }
293
+
294
+ function resetChat() {
295
+ if (currentTurn) {
296
+ currentTurn.session.close();
297
+ currentTurn = null;
298
+ }
299
+ chatSessionId = null;
300
+ saveChatSessionId(config.dataDir, null);
301
+ broadcastChat({ type: 'reset' });
302
+ }
303
+
304
+ // --- auto-wake on import (AR-23) ------------------------------------------
305
+ // When `wild add` (or a bmo-sync delivery) drops a new component into
306
+ // .wild/imports/, wake the agent in PLAN mode to PROPOSE the integration.
307
+ // Plan mode is the consent boundary — it cannot edit files, so auto-wake
308
+ // only ever proposes; the user's reply applies it in Build mode.
309
+ const autoWakeMs = overrides.autoWakeDebounceMs ?? 1500;
310
+ const autoWakeEnabled = overrides.autoWake !== false;
311
+ let knownImports = new Set(scanImports(config.workspaceDir)); // startup baseline
312
+ let pendingWake = new Set();
313
+ let autoWakeTimer = null;
314
+
315
+ if (autoWakeEnabled) {
316
+ inboxWatcher.on('change', ({ snapshot }) => {
317
+ const current = new Set(snapshot.imports || []);
318
+ for (const name of current) {
319
+ if (!knownImports.has(name)) pendingWake.add(name);
320
+ }
321
+ knownImports = current;
322
+ if (pendingWake.size === 0) return;
323
+ // Debounce: `wild add` writes several files; collapse the burst.
324
+ clearTimeout(autoWakeTimer);
325
+ autoWakeTimer = setTimeout(fireAutoWake, autoWakeMs);
326
+ });
327
+ }
328
+
329
+ function fireAutoWake() {
330
+ const names = [...pendingWake];
331
+ if (names.length === 0) return;
332
+ pendingWake = new Set();
333
+ const list = names.join(', ');
334
+ const note = `📦 Imported ${list} — proposing an integration plan…`;
335
+ const prompt =
336
+ `A new wild component was just imported into this workspace: ` +
337
+ `${names.map((n) => `.wild/imports/${n}/`).join(', ')}. ` +
338
+ `You are in Plan mode, so you cannot modify files — only propose. ` +
339
+ `Read each component's README.md, look at the existing workspace, then ` +
340
+ `lay out how to integrate it: where the files should go, whether to ` +
341
+ `merge / overwrite / namespace, and any risks. Then stop so I can choose.`;
342
+ const started = runChatTurn({ prompt, mode: 'plan', note, auto: true });
343
+ if (!started) {
344
+ // The chat was busy — re-queue so the import isn't silently dropped.
345
+ for (const n of names) pendingWake.add(n);
346
+ clearTimeout(autoWakeTimer);
347
+ autoWakeTimer = setTimeout(fireAutoWake, 3000);
348
+ }
349
+ }
350
+
351
+ const app = new Hono();
352
+
353
+ // --- auth + role resolution ---
354
+ async function resolveRole(c) {
355
+ const auth = c.req.header('authorization');
356
+ if (auth?.startsWith('Bearer ')) {
357
+ const token = auth.slice('Bearer '.length).trim();
358
+ if (token === config.partnerToken) {
359
+ return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
360
+ }
361
+ const payload = await verifyShareToken(token, config.shareSecret);
362
+ if (payload && !tokenRegistry.isRevoked(payload.sub)) {
363
+ return {
364
+ role: payload.role,
365
+ sub: payload.sub,
366
+ workspaceId: payload.workspaceId,
367
+ source: 'share-jwt',
368
+ exp: payload.exp,
369
+ };
370
+ }
371
+ }
372
+ const queryToken = c.req.query('t');
373
+ if (queryToken) {
374
+ // A browser opening the workspace URL can only carry a token in the
375
+ // query string, not an Authorization header — so the partner token is
376
+ // accepted here too, mirroring the WebSocket upgrade handler.
377
+ if (queryToken === config.partnerToken) {
378
+ return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token-query' };
379
+ }
380
+ const payload = await verifyShareToken(queryToken, config.shareSecret);
381
+ if (payload && !tokenRegistry.isRevoked(payload.sub)) {
382
+ return {
383
+ role: payload.role,
384
+ sub: payload.sub,
385
+ workspaceId: payload.workspaceId,
386
+ source: 'share-jwt-query',
387
+ exp: payload.exp,
388
+ };
389
+ }
390
+ }
391
+ // Default for local partner UX — same machine, no token expected.
392
+ if (!config.publicMode) {
393
+ return { role: ROLES.PARTNER, sub: 'local-partner', source: 'localhost' };
394
+ }
395
+ // Public mode with no valid token: deny. No anonymous viewer access —
396
+ // a share JWT or the partner token is required. (Concern C1.)
397
+ return { role: null, sub: 'anon', source: 'unauth', denied: true };
398
+ }
399
+
400
+ function require(c, capability) {
401
+ const cap = ROLE_CAPABILITIES[c.get('role')];
402
+ if (!cap || !cap[capability]) {
403
+ return c.json({ error: 'forbidden', capability, role: c.get('role') }, 403);
404
+ }
405
+ return null;
406
+ }
407
+
408
+ app.use('*', async (c, next) => {
409
+ const session = await resolveRole(c);
410
+ c.set('role', session.role);
411
+ c.set('session', session);
412
+ // Block the API for denied (non-localhost, unauthenticated) requests, but
413
+ // let static assets and the health check through so the SPA can still
414
+ // load and prompt for a token. (Concern C1.)
415
+ if (session.denied && c.req.path.startsWith('/api/') && c.req.path !== '/api/health') {
416
+ log('[auth]', `denied ${c.req.method} ${c.req.path} src=${session.source}`);
417
+ return c.json({ error: 'unauthorized' }, 401);
418
+ }
419
+ await next();
420
+ });
421
+
422
+ // Lightweight HTTP request log — every /api/* call, with status + duration.
423
+ // Static asset traffic is noisy and uninteresting, so we skip it.
424
+ app.use('/api/*', async (c, next) => {
425
+ const t0 = Date.now();
426
+ await next();
427
+ const ms = Date.now() - t0;
428
+ const role = c.get('role') || 'anon';
429
+ log('[http]', `${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms role=${role}`);
430
+ });
431
+
432
+ // --- meta ---
433
+ app.get('/api/health', (c) =>
434
+ c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
435
+ );
436
+
437
+ app.get('/api/session', (c) => {
438
+ const session = c.get('session');
439
+ const role = c.get('role');
440
+ const identity = loadIdentity(config.dataDir);
441
+ return c.json({
442
+ version: APP_VERSION,
443
+ role,
444
+ capabilities: ROLE_CAPABILITIES[role],
445
+ workspace: workspaceSummary(config.workspaceDir),
446
+ workspaceId: config.workspaceId,
447
+ session,
448
+ agent: activeAgent
449
+ ? { id: activeAgent.id, label: activeAgent.label, available: activeAgent.available }
450
+ : null,
451
+ identity,
452
+ onboarded: Boolean(identity?.onboardedAt),
453
+ shareBaseUrl: config.shareBaseUrl,
454
+ // `account` is set after the user runs `wild-workspace login`. The UI
455
+ // uses it to show "you are <slug>" and to seed step 4 of onboarding
456
+ // with the actual <slug>.venturewild.llc URL. accountToken is NOT
457
+ // exposed — it stays in server-side config only.
458
+ account: config.account,
459
+ });
460
+ });
461
+
462
+ // --- agent identity (onboarding) ---
463
+ // Persisted to <dataDir>/agent-identity.json. Absence of this file is the
464
+ // signal the UI uses to launch the 5-step onboarding flow.
465
+ app.get('/api/agent/identity', (c) => {
466
+ const identity = loadIdentity(config.dataDir);
467
+ return c.json({ identity, tones: TONES });
468
+ });
469
+
470
+ app.post('/api/agent/identity', async (c) => {
471
+ const forbidden = require(c, 'chatWrite');
472
+ if (forbidden) return forbidden;
473
+ const body = await c.req.json().catch(() => ({}));
474
+ try {
475
+ const saved = saveIdentity(config.dataDir, {
476
+ name: body.name,
477
+ tone: body.tone,
478
+ color: body.color,
479
+ connectedServices: body.connectedServices,
480
+ });
481
+ log('[identity]', `saved name=${saved.name} tone=${saved.tone} color=${saved.color}`);
482
+ activityBus.publish({ type: 'identity-changed', name: saved.name, tone: saved.tone });
483
+ return c.json({ identity: saved });
484
+ } catch (e) {
485
+ return c.json({ error: String(e.message || e) }, 400);
486
+ }
487
+ });
488
+
489
+ app.post('/api/agent/onboarded', (c) => {
490
+ const forbidden = require(c, 'chatWrite');
491
+ if (forbidden) return forbidden;
492
+ try {
493
+ const saved = markOnboarded(config.dataDir);
494
+ log('[onboarding]', `complete name=${saved.name}`);
495
+ activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
496
+ return c.json({ identity: saved });
497
+ } catch (e) {
498
+ return c.json({ error: String(e.message || e) }, 400);
499
+ }
500
+ });
501
+
502
+ // --- onboarding step 2: agent peeks at a folder ---
503
+ // The browser sends a small sample of the chosen folder's contents — file
504
+ // names + a short head of each text file — and we ask the agent to react
505
+ // in one or two sentences. Runs through the normal turn-runner; the browser
506
+ // supplies the messageId so the onboarding overlay can subscribe to /ws/chat
507
+ // and stream the reaction back into a bubble next to the dropzone — the
508
+ // "agent reacts in ~2s" beat the locked plan calls the highest-converting moment.
509
+ app.post('/api/onboarding/peek', async (c) => {
510
+ const forbidden = require(c, 'chatWrite');
511
+ if (forbidden) return forbidden;
512
+ const body = await c.req.json().catch(() => ({}));
513
+ const files = Array.isArray(body.files) ? body.files.slice(0, 80) : [];
514
+ const folderName = (body.folderName || 'this folder').slice(0, 80);
515
+ if (files.length === 0) return c.json({ error: 'no-files' }, 400);
516
+ const sample = files
517
+ .map((f) => {
518
+ const head = typeof f.head === 'string' ? f.head.slice(0, 600) : '';
519
+ return head
520
+ ? `--- ${f.path}\n${head}`
521
+ : `--- ${f.path}`;
522
+ })
523
+ .join('\n');
524
+ const identity = loadIdentity(config.dataDir);
525
+ const youAre = identity?.name
526
+ ? `You are ${identity.name}, a ${identity.tone || 'concise'} AI assistant just meeting your human for the first time.`
527
+ : `You are an AI assistant just meeting your human for the first time.`;
528
+ const prompt =
529
+ `${youAre} They just showed you a folder called "${folderName}" with ` +
530
+ `${files.length} file${files.length === 1 ? '' : 's'}. Below is a quick ` +
531
+ `sample of what's inside. In ONE or TWO short sentences, react: name ` +
532
+ `what you see, then propose ONE specific, concrete thing you could do ` +
533
+ `with it that would be useful. Be specific reference real filenames ` +
534
+ `or content. Don't ask permission, don't list options, don't introduce ` +
535
+ `yourself. Just react like a smart friend who just glanced at the desk.\n\n` +
536
+ sample;
537
+ const messageId =
538
+ typeof body.messageId === 'string' && body.messageId.trim()
539
+ ? body.messageId.trim().slice(0, 64)
540
+ : undefined;
541
+ log('[onboarding]', `peek folder=${folderName} files=${files.length} sampleBytes=${sample.length} mid=${messageId || '(auto)'}`);
542
+ const started = runChatTurn({
543
+ prompt,
544
+ mode: 'plan',
545
+ messageId,
546
+ note: `👀 ${identity?.name || 'Your agent'} is looking at ${folderName}…`,
547
+ auto: true,
548
+ });
549
+ return c.json({ ok: true, sampled: files.length, started: started !== false });
550
+ });
551
+
552
+ // --- onboarding step 5: kick off the user's first real job ---
553
+ // The browser picks one of three known job kinds; the server builds the
554
+ // matching prompt incorporating the agent's tone + the optional peek context
555
+ // so the long instruction shape stays server-side (the user sees a clean
556
+ // "Started: …" note, not the raw prompt). Same WS streaming contract as
557
+ // peek — the browser supplies the messageId.
558
+ app.post('/api/onboarding/start-job', async (c) => {
559
+ const forbidden = require(c, 'chatWrite');
560
+ if (forbidden) return forbidden;
561
+ const body = await c.req.json().catch(() => ({}));
562
+ const kind = typeof body.kind === 'string' ? body.kind : '';
563
+ const messageId =
564
+ typeof body.messageId === 'string' && body.messageId.trim()
565
+ ? body.messageId.trim().slice(0, 64)
566
+ : undefined;
567
+ const peekFolder =
568
+ typeof body.peekFolderName === 'string'
569
+ ? body.peekFolderName.slice(0, 80)
570
+ : null;
571
+ const identity = loadIdentity(config.dataDir);
572
+ const tone = identity?.tone || 'concise';
573
+ const name = identity?.name || 'your agent';
574
+ const youAre = `You are ${name}, a ${tone} AI assistant. Your human just finished a 5-step onboarding and picked their first job. Stay in character.`;
575
+ let prompt;
576
+ let note;
577
+ if (kind === 'survey') {
578
+ prompt =
579
+ `${youAre} Look at the wild-workspace folder this server runs in — ` +
580
+ `read CLAUDE.md, README.md, and any package.json or top-level docs ` +
581
+ `you find. In ONE short paragraph, summarize what this project is ` +
582
+ `and what's notable about it. Be ${tone}. Don't ask permission ` +
583
+ `first just go. Finish with a single concrete next-step question.`;
584
+ note = `🔎 First job — ${name} is reading your workspace…`;
585
+ } else if (kind === 'startup') {
586
+ const folderHint = peekFolder
587
+ ? ` They showed you a folder called "${peekFolder}" earlier — feel free to reference it.`
588
+ : '';
589
+ prompt =
590
+ `${youAre} Your human wants to start a new project but hasn't said ` +
591
+ `what yet.${folderHint} In ONE or TWO sentences, ask the single ` +
592
+ `most useful question that will help you understand what they want ` +
593
+ `to build today. Be ${tone}, warm, and concrete — no list of options.`;
594
+ note = `🚀 First job — ${name} is figuring out what to build with you…`;
595
+ } else if (kind === 'chat') {
596
+ prompt =
597
+ `${youAre} Your human picked the "just chat" option — they want to ` +
598
+ `get to know you, no agenda yet. Say a brief hello, then ask ONE ` +
599
+ `short question that will help you find a job for them today. Be ` +
600
+ `${tone}. Don't introduce yourself by name (they already named you).`;
601
+ note = `💬 First job — ${name} is settling in…`;
602
+ } else {
603
+ return c.json({ error: 'unknown-job-kind' }, 400);
604
+ }
605
+ log('[onboarding]', `start-job kind=${kind} mid=${messageId || '(auto)'} peek=${peekFolder || '-'}`);
606
+ const started = runChatTurn({
607
+ prompt,
608
+ mode: 'build',
609
+ messageId,
610
+ note,
611
+ auto: true,
612
+ });
613
+ return c.json({ ok: true, started: started !== false });
614
+ });
615
+
616
+ app.get('/api/agents', (c) =>
617
+ c.json({
618
+ available: detectedAgents.map(({ id, label, description, available, resolvedPath }) => ({
619
+ id,
620
+ label,
621
+ description,
622
+ available,
623
+ resolvedPath,
624
+ })),
625
+ active: activeAgent?.id,
626
+ }),
627
+ );
628
+
629
+ app.post('/api/agents/select', async (c) => {
630
+ const forbidden = require(c, 'chatWrite');
631
+ if (forbidden) return forbidden;
632
+ const body = await c.req.json().catch(() => ({}));
633
+ const next = detectedAgents.find((a) => a.id === body.id);
634
+ if (!next) return c.json({ error: 'unknown-agent', id: body.id }, 400);
635
+ activeAgent = next;
636
+ activityBus.publish({ type: 'agent-changed', agentId: next.id });
637
+ return c.json({ ok: true, active: activeAgent.id });
638
+ });
639
+
640
+ // --- workspace files ---
641
+ app.get('/api/workspace/tree', async (c) => {
642
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
643
+ return c.json({ error: 'forbidden' }, 403);
644
+ }
645
+ try {
646
+ const tree = await fullTree(config.workspaceDir, 3);
647
+ return c.json({ root: config.workspaceDir, entries: tree });
648
+ } catch (e) {
649
+ return c.json({ error: String(e.message || e) }, 500);
650
+ }
651
+ });
652
+
653
+ app.get('/api/workspace/list', async (c) => {
654
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
655
+ return c.json({ error: 'forbidden' }, 403);
656
+ }
657
+ const p = c.req.query('path') || '';
658
+ try {
659
+ const items = await listDir(config.workspaceDir, p);
660
+ if (items == null) return c.json({ error: 'not-a-directory' }, 400);
661
+ return c.json({ path: p, items });
662
+ } catch (e) {
663
+ return c.json({ error: String(e.message || e) }, 400);
664
+ }
665
+ });
666
+
667
+ app.get('/api/workspace/file', async (c) => {
668
+ if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
669
+ return c.json({ error: 'forbidden' }, 403);
670
+ }
671
+ const p = c.req.query('path');
672
+ if (!p) return c.json({ error: 'path-required' }, 400);
673
+ try {
674
+ const result = await readFile(config.workspaceDir, p);
675
+ return c.json({ path: p, ...result });
676
+ } catch (e) {
677
+ return c.json({ error: String(e.message || e) }, 400);
678
+ }
679
+ });
680
+
681
+ // --- component inbox ---
682
+ app.get('/api/inbox', async (c) => {
683
+ const snapshot = await inboxWatcher.snapshot();
684
+ return c.json(snapshot);
685
+ });
686
+
687
+ // --- live preview port detection ---
688
+ app.get('/api/preview/ports', async (c) => {
689
+ const ports = await detectPreviewPorts();
690
+ return c.json({ ports });
691
+ });
692
+
693
+ app.get('/api/preview/check', async (c) => {
694
+ const port = Number(c.req.query('port'));
695
+ if (!port) return c.json({ error: 'port-required' }, 400);
696
+ const host = c.req.query('host') || '127.0.0.1';
697
+ return c.json({ port, host, listening: await checkPort(port, host) });
698
+ });
699
+
700
+ // --- activity stream snapshot (WebSocket carries live updates) ---
701
+ app.get('/api/activity', (c) => c.json(activityBus.snapshot()));
702
+
703
+ // --- share-by-URL (AR-20) ---
704
+ app.post('/api/share', async (c) => {
705
+ const forbidden = require(c, 'share');
706
+ if (forbidden) return forbidden;
707
+ const body = await c.req.json().catch(() => ({}));
708
+ const role = body.role === 'client' ? 'client' : 'viewer';
709
+ const ttlSeconds = Number(body.ttlSeconds) || 60 * 60 * 24;
710
+ const label = body.label || (role === 'client' ? 'Client portal' : 'Viewer');
711
+ try {
712
+ const minted = await mintShareToken({
713
+ secret: config.shareSecret,
714
+ workspaceId: config.workspaceId,
715
+ role,
716
+ ttlSeconds,
717
+ });
718
+ tokenRegistry.add({
719
+ ...minted,
720
+ label,
721
+ createdAt: Date.now(),
722
+ });
723
+ const shareUrl = buildShareUrl({
724
+ shareBaseUrl: config.shareBaseUrl,
725
+ workspaceId: config.workspaceId,
726
+ token: minted.token,
727
+ });
728
+ activityBus.publish({
729
+ type: 'share-issued',
730
+ role,
731
+ sub: minted.sub,
732
+ exp: minted.exp,
733
+ label,
734
+ });
735
+ return c.json({ ...minted, shareUrl, label });
736
+ } catch (e) {
737
+ return c.json({ error: String(e.message || e) }, 400);
738
+ }
739
+ });
740
+
741
+ app.get('/api/share', (c) => {
742
+ const forbidden = require(c, 'share');
743
+ if (forbidden) return forbidden;
744
+ return c.json({ tokens: tokenRegistry.list() });
745
+ });
746
+
747
+ app.delete('/api/share/:sub', (c) => {
748
+ const forbidden = require(c, 'share');
749
+ if (forbidden) return forbidden;
750
+ const sub = c.req.param('sub');
751
+ tokenRegistry.revoke(sub);
752
+ activityBus.publish({ type: 'share-revoked', sub });
753
+ return c.json({ ok: true, sub });
754
+ });
755
+
756
+ // --- bmo-sync folder sharing ---
757
+ // Pairing / detaching a folder and minting invites all run through the
758
+ // bmo-sync daemon (and, for invites, the central server). Partner-only.
759
+ app.get('/api/sync/status', async (c) => {
760
+ const forbidden = require(c, 'sync');
761
+ if (forbidden) return forbidden;
762
+ const status = await syncControl.status();
763
+ return c.json({
764
+ ...status,
765
+ workspaceDir: config.workspaceDir,
766
+ workspaceName: path.basename(config.workspaceDir),
767
+ });
768
+ });
769
+
770
+ app.post('/api/sync/pair', async (c) => {
771
+ const forbidden = require(c, 'sync');
772
+ if (forbidden) return forbidden;
773
+ const body = await c.req.json().catch(() => ({}));
774
+ try {
775
+ const workspace = await syncControl.pair(body.inviteCode, config.workspaceDir);
776
+ activityBus.publish({
777
+ type: 'sync-paired',
778
+ workspaceId: workspace.workspaceId,
779
+ projectName: workspace.projectName,
780
+ });
781
+ return c.json({ ok: true, workspace });
782
+ } catch (e) {
783
+ return c.json({ error: String(e.message || e) }, 400);
784
+ }
785
+ });
786
+
787
+ app.post('/api/sync/detach', async (c) => {
788
+ const forbidden = require(c, 'sync');
789
+ if (forbidden) return forbidden;
790
+ const body = await c.req.json().catch(() => ({}));
791
+ try {
792
+ const result = await syncControl.detach(body.workspaceId);
793
+ activityBus.publish({ type: 'sync-detached', workspaceId: body.workspaceId });
794
+ return c.json({ ok: true, ...result });
795
+ } catch (e) {
796
+ return c.json({ error: String(e.message || e) }, 400);
797
+ }
798
+ });
799
+
800
+ app.post('/api/sync/invite', async (c) => {
801
+ const forbidden = require(c, 'sync');
802
+ if (forbidden) return forbidden;
803
+ const body = await c.req.json().catch(() => ({}));
804
+ try {
805
+ const invite = await syncControl.createInvite({
806
+ projectCode: body.projectCode,
807
+ displayName: body.displayName,
808
+ expiresHours: body.expiresHours,
809
+ });
810
+ return c.json({ ok: true, invite });
811
+ } catch (e) {
812
+ return c.json({ error: String(e.message || e) }, 400);
813
+ }
814
+ });
815
+
816
+ // --- C12-e conflict surface ---
817
+ // The daemon detects local-vs-peer divergence and stores both versions
818
+ // in its back-office. The agent (and the human-fallback badge) drives
819
+ // resolution through these routes.
820
+ app.get('/api/conflicts', async (c) => {
821
+ const forbidden = require(c, 'sync');
822
+ if (forbidden) return forbidden;
823
+ const conflicts = await syncControl.listConflicts();
824
+ return c.json({ conflicts });
825
+ });
826
+
827
+ app.get('/api/conflicts/view', async (c) => {
828
+ const forbidden = require(c, 'sync');
829
+ if (forbidden) return forbidden;
830
+ const workspaceId = c.req.query('workspaceId');
831
+ const filePath = c.req.query('path');
832
+ if (!workspaceId || !filePath) {
833
+ return c.json({ error: 'workspaceId and path are required' }, 400);
834
+ }
835
+ try {
836
+ const view = await syncControl.viewConflict(workspaceId, filePath);
837
+ if (!view) return c.json({ error: 'not found' }, 404);
838
+ return c.json(view);
839
+ } catch (e) {
840
+ return c.json({ error: String(e.message || e) }, 400);
841
+ }
842
+ });
843
+
844
+ app.post('/api/conflicts/resolve', async (c) => {
845
+ const forbidden = require(c, 'sync');
846
+ if (forbidden) return forbidden;
847
+ const body = await c.req.json().catch(() => ({}));
848
+ try {
849
+ await syncControl.resolveConflict(body.workspaceId, body.path, body.action);
850
+ activityBus.publish({
851
+ type: 'sync-conflict-resolved',
852
+ workspaceId: body.workspaceId,
853
+ path: body.path,
854
+ action: body.action,
855
+ });
856
+ return c.json({ ok: true });
857
+ } catch (e) {
858
+ return c.json({ error: String(e.message || e) }, 400);
859
+ }
860
+ });
861
+
862
+ // --- request-changes (client role) ---
863
+ const changeRequests = [];
864
+ app.post('/api/request-changes', async (c) => {
865
+ const forbidden = require(c, 'requestChanges');
866
+ if (forbidden) return forbidden;
867
+ const body = await c.req.json().catch(() => ({}));
868
+ const text = (body.text || '').trim();
869
+ if (!text) return c.json({ error: 'text-required' }, 400);
870
+ const session = c.get('session');
871
+ const entry = {
872
+ id: nanoid(12),
873
+ text,
874
+ from: session.sub || 'client',
875
+ ts: Date.now(),
876
+ };
877
+ changeRequests.push(entry);
878
+ activityBus.publish({ type: 'request-changes', entry });
879
+ return c.json({ ok: true, entry });
880
+ });
881
+
882
+ app.get('/api/request-changes', (c) => c.json({ requests: changeRequests }));
883
+
884
+ // --- frontend bundle (built by `npm run build:web`) ---
885
+ if (existsSync(config.webDir)) {
886
+ app.use(
887
+ '/*',
888
+ serveStatic({
889
+ root: path.relative(process.cwd(), config.webDir),
890
+ }),
891
+ );
892
+ // SPA fallback
893
+ app.notFound((c) => {
894
+ const indexHtmlPath = path.join(config.webDir, 'index.html');
895
+ if (existsSync(indexHtmlPath)) {
896
+ return new Response(readFileSync(indexHtmlPath), {
897
+ headers: { 'content-type': 'text/html' },
898
+ });
899
+ }
900
+ return c.text('wild-workspace: frontend not built; run `npm run build`', 200);
901
+ });
902
+ } else {
903
+ app.notFound((c) =>
904
+ c.text(
905
+ 'wild-workspace API ready. Frontend bundle missing — run `npm run build` first.',
906
+ 200,
907
+ ),
908
+ );
909
+ }
910
+
911
+ const httpServer = serve({
912
+ fetch: app.fetch,
913
+ port: config.port,
914
+ hostname: config.host,
915
+ });
916
+ // wait until the server is actually listening before continuing
917
+ await new Promise((resolve, reject) => {
918
+ if (httpServer.listening) return resolve();
919
+ httpServer.once('listening', resolve);
920
+ httpServer.once('error', reject);
921
+ });
922
+
923
+ // --- websocket bridge ---
924
+ const wss = new WebSocketServer({ noServer: true });
925
+ httpServer.on('upgrade', async (req, socket, head) => {
926
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
927
+ const supported = ['/ws/chat', '/ws/activity'];
928
+ if (!supported.includes(reqUrl.pathname)) {
929
+ socket.destroy();
930
+ return;
931
+ }
932
+ const tokenFromQuery = reqUrl.searchParams.get('t');
933
+ let role = null;
934
+ let sub = 'anon';
935
+ if (tokenFromQuery === config.partnerToken) {
936
+ role = ROLES.PARTNER;
937
+ sub = 'partner';
938
+ } else if (tokenFromQuery) {
939
+ const payload = await verifyShareToken(tokenFromQuery, config.shareSecret);
940
+ if (payload && !tokenRegistry.isRevoked(payload.sub)) {
941
+ role = payload.role;
942
+ sub = payload.sub;
943
+ }
944
+ } else if (!config.publicMode) {
945
+ role = ROLES.PARTNER;
946
+ sub = 'local-partner';
947
+ }
948
+ // Deny: public mode with no token, or any invalid/revoked token. An
949
+ // invalid token must NOT silently fall back to partner. (Concern C1.)
950
+ if (!role) {
951
+ log('[ws]', `denied ${reqUrl.pathname} (no valid token)`);
952
+ socket.destroy();
953
+ return;
954
+ }
955
+ wss.handleUpgrade(req, socket, head, (ws) => {
956
+ ws._wsRole = role;
957
+ ws._wsSub = sub;
958
+ log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub}`);
959
+ wss.emit('connection', ws, req, reqUrl.pathname);
960
+ });
961
+ });
962
+
963
+ wss.on('connection', (ws, req, route) => {
964
+ if (route === '/ws/activity') return wireActivityWs(ws);
965
+ if (route === '/ws/chat') return wireChatWs(ws);
966
+ });
967
+
968
+ function wireActivityWs(ws) {
969
+ const presence = activityBus.joinPresence({
970
+ sessionId: nanoid(10),
971
+ role: ws._wsRole,
972
+ label: ws._wsRole,
973
+ });
974
+ ws.send(
975
+ JSON.stringify({
976
+ type: 'snapshot',
977
+ snapshot: activityBus.snapshot(),
978
+ you: presence,
979
+ }),
980
+ );
981
+ const onEvent = (evt) => {
982
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(evt));
983
+ };
984
+ activityBus.on('event', onEvent);
985
+ ws.on('message', (raw) => {
986
+ try {
987
+ const msg = JSON.parse(raw.toString());
988
+ if (msg.type === 'focus') {
989
+ activityBus.updateFocus(presence.sessionId, msg.focus || null);
990
+ }
991
+ } catch {}
992
+ });
993
+ ws.on('close', () => {
994
+ activityBus.off('event', onEvent);
995
+ activityBus.leavePresence(presence.sessionId);
996
+ });
997
+ }
998
+
999
+ function wireChatWs(ws) {
1000
+ const cap = ROLE_CAPABILITIES[ws._wsRole];
1001
+ chatClients.add(ws);
1002
+ ws.send(JSON.stringify({ type: 'hello', role: ws._wsRole, agent: activeAgent?.id }));
1003
+ ws.on('message', (raw) => {
1004
+ let msg;
1005
+ try {
1006
+ msg = JSON.parse(raw.toString());
1007
+ } catch {
1008
+ ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
1009
+ return;
1010
+ }
1011
+ if (msg.type === 'send') {
1012
+ if (!cap.chatWrite) {
1013
+ ws.send(
1014
+ JSON.stringify({ type: 'error', message: 'role not permitted to send' }),
1015
+ );
1016
+ return;
1017
+ }
1018
+ // The turn-runner is server-level: it streams to every chat client and
1019
+ // resumes the persisted claude session, so the agent keeps its memory.
1020
+ runChatTurn({
1021
+ prompt: msg.text,
1022
+ mode: msg.mode,
1023
+ messageId: msg.messageId,
1024
+ userText: msg.text,
1025
+ });
1026
+ } else if (msg.type === 'cancel') {
1027
+ if (currentTurn) {
1028
+ currentTurn.session.close();
1029
+ currentTurn = null;
1030
+ }
1031
+ } else if (msg.type === 'reset') {
1032
+ // "New chat" — drop the resumed session so the next turn starts fresh.
1033
+ if (cap.chatWrite) resetChat();
1034
+ }
1035
+ });
1036
+ ws.on('close', () => {
1037
+ chatClients.delete(ws);
1038
+ log('[ws]', `close /ws/chat sub=${ws._wsSub} remaining=${chatClients.size}`);
1039
+ // The turn itself keeps running — it may have other watchers, and it
1040
+ // still needs to finish to persist the session id.
1041
+ });
1042
+ }
1043
+
1044
+ return {
1045
+ config,
1046
+ app,
1047
+ httpServer,
1048
+ wss,
1049
+ activityBus,
1050
+ inboxWatcher,
1051
+ tokenRegistry,
1052
+ daemonBridge,
1053
+ daemonSupervisor,
1054
+ daemonReady,
1055
+ syncControl,
1056
+ detectedAgents,
1057
+ getActiveAgent: () => activeAgent,
1058
+ async stop() {
1059
+ try { clearTimeout(autoWakeTimer); } catch {}
1060
+ try { currentTurn?.session.close(); } catch {}
1061
+ try { inboxWatcher.stop(); } catch {}
1062
+ try { daemonBridge?.stop(); } catch {}
1063
+ // The daemon is deliberately NOT stopped here — it is detached so sync
1064
+ // keeps running after wild-workspace closes. `wild-workspace daemon
1065
+ // stop` is the explicit off-switch.
1066
+ try { wss.close(); } catch {}
1067
+ await new Promise((resolve) => httpServer.close(resolve));
1068
+ },
1069
+ };
1070
+ }
1071
+
1072
+ // Standalone entry — runs when executed directly (node server/src/index.mjs).
1073
+ const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === __filename;
1074
+ if (isDirectRun) {
1075
+ createServer().then(async (s) => {
1076
+ const { config } = s;
1077
+ console.log(`\n wild-workspace v${APP_VERSION}`);
1078
+ console.log(` workspace : ${config.workspaceDir}`);
1079
+ console.log(` url : http://${config.host}:${config.port}`);
1080
+ console.log(` agent : ${s.getActiveAgent()?.label || '(none detected)'}`);
1081
+ if (config.publicMode) {
1082
+ // Public mode: no anonymous access. Partner must authenticate.
1083
+ console.log(` mode : PUBLIC — anonymous requests denied`);
1084
+ console.log(` partner : append ?t=${config.partnerToken} to the URL`);
1085
+ }
1086
+ console.log('');
1087
+ if (config.openBrowser) {
1088
+ try {
1089
+ const open = (await import('open')).default;
1090
+ open(`http://${config.host}:${config.port}`);
1091
+ } catch (e) {
1092
+ // browser is best-effort; not having one isn't fatal
1093
+ }
1094
+ }
1095
+ }).catch((err) => {
1096
+ console.error('wild-workspace failed to start:', err);
1097
+ process.exit(1);
1098
+ });
1099
+ }