@venturewild/workspace 0.1.2 → 0.1.4

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