@venturewild/workspace 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +75 -75
- package/server/bin/wild-workspace.mjs +725 -725
- package/server/src/agent.mjs +356 -356
- package/server/src/config.mjs +302 -302
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1330 -1330
- package/server/src/service.mjs +202 -32
- package/server/src/sync.mjs +248 -248
package/server/src/index.mjs
CHANGED
|
@@ -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
|
+
}
|