@venturewild/workspace 0.1.1 → 0.1.2

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.
@@ -8,6 +8,7 @@ import crypto from 'node:crypto';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import { loadAccount } from './account.mjs';
11
+ import { loadOperatorToken } from './operator.mjs';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
 
@@ -74,6 +75,8 @@ export const ROLES = Object.freeze({
74
75
  PARTNER: 'partner',
75
76
  VIEWER: 'viewer',
76
77
  CLIENT: 'client',
78
+ // The consented support/operator channel (off by default — see operator.mjs).
79
+ OPERATOR: 'operator',
77
80
  });
78
81
 
79
82
  export const ROLE_CAPABILITIES = Object.freeze({
@@ -88,6 +91,7 @@ export const ROLE_CAPABILITIES = Object.freeze({
88
91
  sync: true,
89
92
  deploy: true,
90
93
  requestChanges: false,
94
+ operate: true, // the owner can also drive the operator allowlist locally
91
95
  },
92
96
  viewer: {
93
97
  chat: true,
@@ -100,6 +104,7 @@ export const ROLE_CAPABILITIES = Object.freeze({
100
104
  sync: false,
101
105
  deploy: false,
102
106
  requestChanges: false,
107
+ operate: false,
103
108
  },
104
109
  client: {
105
110
  chat: true,
@@ -112,6 +117,24 @@ export const ROLE_CAPABILITIES = Object.freeze({
112
117
  sync: false,
113
118
  deploy: false,
114
119
  requestChanges: true,
120
+ operate: false,
121
+ },
122
+ // Operator: remote diagnose + a curated remediation allowlist. Read-only on
123
+ // chat (can SEE the conversation to help, cannot drive the agent — chatWrite
124
+ // stays false), plus the `operate` capability the /api/operator/* routes gate
125
+ // on. Reachable only with the dedicated operator token (operator.mjs).
126
+ operator: {
127
+ chat: true,
128
+ chatWrite: false,
129
+ preview: true,
130
+ fileTree: true,
131
+ terminal: false,
132
+ inbox: false,
133
+ share: false,
134
+ sync: false,
135
+ deploy: false,
136
+ requestChanges: false,
137
+ operate: true,
115
138
  },
116
139
  });
117
140
 
@@ -231,6 +254,13 @@ export function buildConfig(overrides = {}) {
231
254
  overrides.shareSecret ||
232
255
  env.WILD_WORKSPACE_SHARE_SECRET ||
233
256
  secrets().shareSecret,
257
+ // The operator-channel token — null unless the user explicitly enabled the
258
+ // channel (`wild-workspace operator enable`). Off by default. Server-side
259
+ // only; never broadcast to the browser.
260
+ operatorToken:
261
+ overrides.operatorToken ??
262
+ env.WILD_WORKSPACE_OPERATOR_TOKEN ??
263
+ loadOperatorToken(dataDir),
234
264
  workspaceId:
235
265
  overrides.workspaceId ||
236
266
  env.WILD_WORKSPACE_ID ||
@@ -36,9 +36,13 @@ export function daemonBinaryName() {
36
36
  *
37
37
  * @param {{ env?: NodeJS.ProcessEnv, vendorRoot?: string }} [opts]
38
38
  */
39
- export function resolveDaemonBinary({ env = process.env, vendorRoot } = {}) {
39
+ export function resolveDaemonBinary({ env = process.env, vendorRoot, requireResolve } = {}) {
40
40
  const binName = daemonBinaryName();
41
41
  const tag = platformTag();
42
+ // Injected seam: lets a test simulate "the platform subpackage isn't
43
+ // installed" deterministically, regardless of what's in this machine's
44
+ // node_modules (the win32-x64 subpackage IS present on a Windows dev box).
45
+ const resolvePkg = requireResolve || ((id) => require.resolve(id));
42
46
 
43
47
  // 1. explicit override — if set but missing, that's an error, not a miss.
44
48
  const override = env.WILD_WORKSPACE_DAEMON_BIN;
@@ -49,7 +53,7 @@ export function resolveDaemonBinary({ env = process.env, vendorRoot } = {}) {
49
53
  // 2. per-platform npm subpackage — resolve via its package.json so the
50
54
  // lookup doesn't depend on an `exports` map for the binary file.
51
55
  try {
52
- const pkgJson = require.resolve(`@venturewild/workspace-daemon-${tag}/package.json`);
56
+ const pkgJson = resolvePkg(`@venturewild/workspace-daemon-${tag}/package.json`);
53
57
  const candidate = path.join(path.dirname(pkgJson), binName);
54
58
  if (existsSync(candidate)) return { path: candidate, source: 'subpackage' };
55
59
  } catch {
@@ -0,0 +1,246 @@
1
+ // `wild-workspace doctor` — one pre/post-flight diagnostic for a real user's
2
+ // machine. The riskiest moment for a brand-new (non-technical) user is the
3
+ // install itself: no Claude yet, wrong Node, a busy port, a daemon binary that
4
+ // didn't resolve, an unclaimed slug. When something breaks we need to SEE it —
5
+ // ideally fix it — without making them feel stupid (docs/user-experience.md §5).
6
+ //
7
+ // runDoctor() returns a structured report (every check is { id, label, status,
8
+ // detail, hint }); the CLI renders it with ✅/⚠️/❌ and the operator channel
9
+ // serves the same JSON. Every external touch-point (agent detect, auth probe,
10
+ // daemon resolve, port check, account load, service status, registry fetch) is
11
+ // an injected seam so the test suite never spawns a process or hits the network.
12
+
13
+ import os from 'node:os';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ import { buildConfig, APP_VERSION } from './config.mjs';
18
+ import { detectAgents, pickDefaultAgent } from './agent.mjs';
19
+ import { probeAgentReadiness } from './agent-readiness.mjs';
20
+ import { resolveDaemonBinary } from './daemon-bin.mjs';
21
+ import { checkPort } from './preview.mjs';
22
+ import { loadAccount } from './account.mjs';
23
+ import { serviceStatus } from './service.mjs';
24
+ import { probeHealth } from './supervisor.mjs';
25
+ import { listLogs, diagnosticsDir } from './logpaths.mjs';
26
+
27
+ const STATUS_ICON = { ok: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' };
28
+
29
+ // Native installer is Claude's canonical path today (npm i -g still works as a
30
+ // fallback). Shown verbatim to the user, so keep it copy-pasteable.
31
+ const CLAUDE_INSTALL_HINT =
32
+ 'Install Claude Code: curl -fsSL https://claude.ai/install.sh | bash (Windows: irm https://claude.ai/install.ps1 | iex)';
33
+
34
+ function nodeMajor(version = process.version) {
35
+ const m = /^v?(\d+)/.exec(String(version));
36
+ return m ? Number(m[1]) : 0;
37
+ }
38
+
39
+ // Reach the bmo-sync registry: resolve the user's slug if linked, else /health.
40
+ async function probeRegistry(config, fetchImpl) {
41
+ const base = String(config.bmoSyncServerUrl || '').replace(/\/$/, '');
42
+ const slug = config.account?.slug || null;
43
+ const url = slug
44
+ ? `${base}/api/slug/resolve/${encodeURIComponent(slug)}`
45
+ : `${base}/api/health`;
46
+ const ctrl = new AbortController();
47
+ const timer = setTimeout(() => ctrl.abort(), 5000);
48
+ try {
49
+ const res = await fetchImpl(url, { signal: ctrl.signal });
50
+ return { reachable: true, status: res.status, slug, url };
51
+ } catch (e) {
52
+ return { reachable: false, error: String(e?.message || e), slug, url };
53
+ } finally {
54
+ clearTimeout(timer);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Run every diagnostic check. All deps are injectable for testing.
60
+ * @returns {{version,generatedAt,platform,summary,checks,logs}}
61
+ */
62
+ export async function runDoctor(opts = {}, deps = {}) {
63
+ const config = opts.config || deps.config || buildConfig({});
64
+ const env = deps.env || process.env;
65
+ const d = {
66
+ detectAgents: deps.detectAgents || detectAgents,
67
+ probeReadiness: deps.probeAgentReadiness || probeAgentReadiness,
68
+ resolveDaemon: deps.resolveDaemonBinary || resolveDaemonBinary,
69
+ checkPort: deps.checkPort || checkPort,
70
+ loadAccount: deps.loadAccount || loadAccount,
71
+ serviceStatus: deps.serviceStatus || serviceStatus,
72
+ listLogs: deps.listLogs || listLogs,
73
+ fetchImpl: deps.fetchImpl || ((...a) => globalThis.fetch(...a)),
74
+ };
75
+ const checks = [];
76
+ const add = (c) => checks.push(c);
77
+ // Run a check body, capturing a thrown error as a non-fatal 'warn' so one bad
78
+ // check never aborts the whole report.
79
+ const guarded = async (id, label, body) => {
80
+ try {
81
+ add({ id, label, ...(await body()) });
82
+ } catch (e) {
83
+ add({ id, label, status: 'warn', detail: `check errored: ${String(e?.message || e)}`, hint: null });
84
+ }
85
+ };
86
+
87
+ // 1. Node runtime
88
+ await guarded('node', 'Node.js runtime', async () => {
89
+ const major = nodeMajor();
90
+ const detail = `${process.version} on ${os.platform()}-${os.arch()}`;
91
+ return major >= 18
92
+ ? { status: 'ok', detail, hint: null }
93
+ : { status: 'fail', detail, hint: 'Claude Code + wild-workspace need Node 18 or newer. Update Node, then retry.' };
94
+ });
95
+
96
+ // 2. Claude installed?
97
+ let claude = null;
98
+ await guarded('agent', 'Claude Code installed', async () => {
99
+ const agents = await d.detectAgents();
100
+ claude = (agents || []).find((a) => a.id === 'claude' && a.available) || null;
101
+ if (!claude) {
102
+ const fallback = pickDefaultAgent(agents || []);
103
+ if (fallback?.available) {
104
+ return { status: 'info', detail: `Claude not found; using ${fallback.label}.`, hint: null };
105
+ }
106
+ return { status: 'fail', detail: 'no `claude` on PATH', hint: CLAUDE_INSTALL_HINT };
107
+ }
108
+ return { status: 'ok', detail: claude.resolvedPath || claude.binary, hint: null };
109
+ });
110
+
111
+ // 3. Claude signed in AND able to run turns?
112
+ if (claude) {
113
+ await guarded('agentAuth', 'Claude ready to think', async () => {
114
+ const v = await d.probeReadiness(claude, undefined, env);
115
+ switch (v.status) {
116
+ case 'ready':
117
+ return { status: 'ok', detail: v.email ? `signed in as ${v.email}` : 'signed in', hint: null };
118
+ case 'subscribe':
119
+ return {
120
+ status: 'warn',
121
+ detail: v.email ? `signed in as ${v.email}, no active plan` : 'signed in, no active plan',
122
+ hint: 'Claude Code needs a Claude Pro plan (or higher). Subscribe at claude.ai, then retry.',
123
+ };
124
+ case 'login':
125
+ return { status: 'fail', detail: 'not signed in', hint: 'Run `claude auth login`, sign in, then retry.' };
126
+ case 'missing':
127
+ return { status: 'fail', detail: 'Claude not installed', hint: CLAUDE_INSTALL_HINT };
128
+ default:
129
+ return { status: 'info', detail: `readiness unknown (${v.status})`, hint: 'Will be confirmed on the first agent turn.' };
130
+ }
131
+ });
132
+ }
133
+
134
+ // 4. bmo-sync daemon binary resolvable?
135
+ await guarded('daemonBinary', 'Sync daemon binary', async () => {
136
+ const r = d.resolveDaemon({ env });
137
+ if (!r) {
138
+ return { status: 'fail', detail: 'WILD_WORKSPACE_DAEMON_BIN is set but the file is missing', hint: 'Unset it or point it at a real binary.' };
139
+ }
140
+ if (r.source === 'path') {
141
+ return {
142
+ status: 'warn',
143
+ detail: 'no bundled daemon found — relying on PATH; cross-device sync may be off',
144
+ hint: 'Reinstall: npm i -g @venturewild/workspace (pulls the daemon for your platform).',
145
+ };
146
+ }
147
+ return { status: 'ok', detail: `${r.path} (${r.source})`, hint: null };
148
+ });
149
+
150
+ // 5. Workspace port
151
+ await guarded('port', `Workspace port :${config.port}`, async () => {
152
+ const inUse = await d.checkPort(config.port);
153
+ return inUse
154
+ ? { status: 'info', detail: 'in use — your workspace is likely already running (or another app holds it)', hint: null }
155
+ : { status: 'ok', detail: 'free', hint: null };
156
+ });
157
+
158
+ // 6. Account linked (slug)
159
+ let account = null;
160
+ await guarded('account', 'Workspace account linked', async () => {
161
+ account = d.loadAccount(config.dataDir);
162
+ if (account?.slug) {
163
+ return { status: 'ok', detail: `${account.slug} (${account.email || 'no email'}) → https://${account.slug}.venturewild.llc`, hint: null };
164
+ }
165
+ return { status: 'warn', detail: 'not linked yet', hint: 'Run `wild-workspace login <blob>` with the code from workspace.venturewild.llc.' };
166
+ });
167
+
168
+ // 7. Registry reachable + slug status
169
+ await guarded('registry', 'Sync server reachable', async () => {
170
+ const r = await probeRegistry({ ...config, account: account || config.account }, d.fetchImpl);
171
+ if (!r.reachable) {
172
+ return { status: 'fail', detail: `can't reach ${r.url}: ${r.error}`, hint: 'Check the internet connection, then retry.' };
173
+ }
174
+ if (r.slug) {
175
+ if (r.status === 200) return { status: 'ok', detail: `slug "${r.slug}" is claimed`, hint: null };
176
+ if (r.status === 404) return { status: 'warn', detail: `slug "${r.slug}" is not claimed on the server`, hint: 'Re-run the claim, or `wild-workspace login` with a fresh blob.' };
177
+ return { status: 'warn', detail: `slug resolve returned HTTP ${r.status}`, hint: null };
178
+ }
179
+ return r.status < 500
180
+ ? { status: 'ok', detail: `reachable (HTTP ${r.status})`, hint: null }
181
+ : { status: 'warn', detail: `server returned HTTP ${r.status}`, hint: null };
182
+ });
183
+
184
+ // 8. Always-on / autostart
185
+ await guarded('service', 'Always-on (autostart)', async () => {
186
+ const s = await d.serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
187
+ if (s.supported === false) {
188
+ return { status: 'info', detail: `not yet on ${s.platform} — run \`wild-workspace\` to start it`, hint: null };
189
+ }
190
+ const bits = [`installed=${s.installed ? 'yes' : 'no'}`, `supervisor=${s.supervisorAlive ? 'up' : 'down'}`, `server=${s.serverUp ? 'up' : 'down'}`];
191
+ return { status: s.installed ? 'ok' : 'info', detail: bits.join(' '), hint: s.installed ? null : 'Enable with `wild-workspace service install`.' };
192
+ });
193
+
194
+ const logs = d.listLogs(env);
195
+ const summary = checks.reduce(
196
+ (acc, c) => ((acc[c.status] = (acc[c.status] || 0) + 1), acc),
197
+ { ok: 0, warn: 0, fail: 0, info: 0 },
198
+ );
199
+
200
+ return {
201
+ version: APP_VERSION,
202
+ generatedAt: new Date().toISOString(),
203
+ platform: `${os.platform()}-${os.arch()}`,
204
+ summary,
205
+ checks,
206
+ logs,
207
+ };
208
+ }
209
+
210
+ // Render a report to a human string (used by the CLI). The operator channel
211
+ // sends the JSON instead.
212
+ export function renderDoctor(report) {
213
+ const lines = [];
214
+ lines.push(`wild-workspace doctor — v${report.version} (${report.platform})`);
215
+ lines.push('');
216
+ for (const c of report.checks) {
217
+ lines.push(`${STATUS_ICON[c.status] || '•'} ${c.label}: ${c.detail}`);
218
+ if (c.hint && (c.status === 'fail' || c.status === 'warn')) {
219
+ lines.push(` → ${c.hint}`);
220
+ }
221
+ }
222
+ lines.push('');
223
+ const { ok, warn, fail } = report.summary;
224
+ lines.push(`Summary: ${ok} ok · ${warn} warning${warn === 1 ? '' : 's'} · ${fail} problem${fail === 1 ? '' : 's'}`);
225
+ lines.push('');
226
+ lines.push('Logs:');
227
+ for (const l of report.logs) {
228
+ lines.push(` ${l.exists ? '·' : ' '} ${l.name.padEnd(10)} ${l.file}${l.exists ? ` (${l.size} bytes)` : ' (none yet)'}`);
229
+ }
230
+ return lines.join('\n');
231
+ }
232
+
233
+ // Persist the JSON report under ~/.wild-workspace/diagnostics/. Returns the
234
+ // file path (or null if it couldn't be written). Best-effort.
235
+ export function writeDoctorBundle(report, env = process.env) {
236
+ try {
237
+ const dir = diagnosticsDir(env);
238
+ fs.mkdirSync(dir, { recursive: true });
239
+ const stamp = report.generatedAt.replace(/[:.]/g, '-');
240
+ const file = path.join(dir, `doctor-${stamp}.json`);
241
+ fs.writeFileSync(file, JSON.stringify(report, null, 2));
242
+ return file;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
@@ -25,11 +25,19 @@ import { listDir, readFile, fullTree, workspaceSummary, safeResolve } from './fs
25
25
  import { InboxWatcher } from './inbox.mjs';
26
26
  import { ActivityBus } from './activity.mjs';
27
27
  import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
28
+ import { probeAgentReadiness } from './agent-readiness.mjs';
28
29
  import { ErrorReporter } from './error-reporter.mjs';
29
30
  import { DaemonBridge } from './daemon.mjs';
30
31
  import { DaemonSupervisor } from './daemon-supervisor.mjs';
31
32
  import { SyncControl } from './sync.mjs';
32
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';
33
41
  import { nanoid } from 'nanoid';
34
42
 
35
43
  const __filename = url.fileURLToPath(import.meta.url);
@@ -166,6 +174,73 @@ export async function createServer(overrides = {}) {
166
174
  enabled: process.env.WILD_WORKSPACE_NO_TELEMETRY !== '1',
167
175
  });
168
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
+
169
244
  // --- chat turn orchestration ----------------------------------------------
170
245
  // One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
171
246
  // Both user sends and auto-wake turns thread through one turn-runner so they
@@ -358,6 +433,12 @@ export async function createServer(overrides = {}) {
358
433
  if (token === config.partnerToken) {
359
434
  return { role: ROLES.PARTNER, sub: 'partner', source: 'partner-token' };
360
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
+ }
361
442
  const payload = await verifyShareToken(token, config.shareSecret);
362
443
  if (payload && !tokenRegistry.isRevoked(payload.sub)) {
363
444
  return {
@@ -456,6 +537,9 @@ export async function createServer(overrides = {}) {
456
537
  // with the actual <slug>.venturewild.llc URL. accountToken is NOT
457
538
  // exposed — it stays in server-side config only.
458
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 },
459
543
  });
460
544
  });
461
545
 
@@ -467,6 +551,33 @@ export async function createServer(overrides = {}) {
467
551
  return c.json({ identity, tones: TONES });
468
552
  });
469
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
+
470
581
  app.post('/api/agent/identity', async (c) => {
471
582
  const forbidden = require(c, 'chatWrite');
472
583
  if (forbidden) return forbidden;
@@ -499,6 +610,20 @@ export async function createServer(overrides = {}) {
499
610
  }
500
611
  });
501
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
+
502
627
  // --- onboarding step 2: agent peeks at a folder ---
503
628
  // The browser sends a small sample of the chosen folder's contents — file
504
629
  // names + a short head of each text file — and we ask the agent to react
@@ -637,6 +762,109 @@ export async function createServer(overrides = {}) {
637
762
  return c.json({ ok: true, active: activeAgent.id });
638
763
  });
639
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
+
640
868
  // --- workspace files ---
641
869
  app.get('/api/workspace/tree', async (c) => {
642
870
  if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
@@ -1053,11 +1281,14 @@ export async function createServer(overrides = {}) {
1053
1281
  daemonSupervisor,
1054
1282
  daemonReady,
1055
1283
  syncControl,
1284
+ sessionReporter,
1056
1285
  detectedAgents,
1057
1286
  getActiveAgent: () => activeAgent,
1058
1287
  async stop() {
1059
1288
  try { clearTimeout(autoWakeTimer); } catch {}
1060
1289
  try { currentTurn?.session.close(); } catch {}
1290
+ try { sessionReporter.stop(); } catch {}
1291
+ try { transcriptRecorder.stop(); } catch {}
1061
1292
  try { inboxWatcher.stop(); } catch {}
1062
1293
  try { daemonBridge?.stop(); } catch {}
1063
1294
  // The daemon is deliberately NOT stopped here — it is detached so sync