@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.
package/README.md CHANGED
@@ -2,12 +2,23 @@
2
2
 
3
3
  A Replit/Lovable-style **chat-first** browser UI that wraps the AI agent already installed on your machine (Claude Code by default; Gemini / GLM / Codex if present).
4
4
 
5
- > v0.1.0initial scaffold, implements PRD §5.5 (workspace-platform-prd.md v0.10).
5
+ > v0.1.1chat-first workspace + bmo-sync daemon (npm) + per-user proxy (b-ii) live. Implements PRD §5.5 (workspace-platform-prd.md v0.10).
6
6
 
7
7
  ## Quick start
8
8
 
9
9
  ```bash
10
- # install agent dependencies & web build
10
+ # recommended global install from npm
11
+ npm i -g @venturewild/workspace
12
+ wild-workspace
13
+ ```
14
+
15
+ A global install pulls the matching **bmo-sync daemon** binary automatically for
16
+ win32-x64 / darwin-x64 / darwin-arm64 / linux-x64 (shipped as `optionalDependencies`).
17
+
18
+ To build from source instead (dev path):
19
+
20
+ ```bash
21
+ # install dependencies & web build
11
22
  npm install
12
23
  npm run build
13
24
 
@@ -30,18 +41,23 @@ wild-workspace/
30
41
  │ ├── index.mjs # server bootstrap
31
42
  │ ├── config.mjs # config + role definitions
32
43
  │ ├── agent.mjs # claude / gemini / glm / codex subprocess wrapper
44
+ │ ├── account.mjs # login / account + slug claim
33
45
  │ ├── share.mjs # JWT share-token mint + verify
34
46
  │ ├── inbox.mjs # .wild/inbox.md watcher
35
47
  │ ├── fs.mjs # workspace file tree (collapsed by default)
36
48
  │ ├── activity.mjs # AI activity event bus
37
49
  │ ├── preview.mjs # dev-server port detection
50
+ │ ├── sync.mjs # bmo-sync wiring
51
+ │ ├── daemon-bin.mjs # resolves the platform daemon binary
38
52
  │ └── routes/ # REST + WS endpoints
39
53
  ├── web/ # React + Vite frontend
40
54
  │ └── src/
41
55
  │ ├── App.jsx # role-flagged React tree (partner / viewer / client)
42
- │ ├── components/ # Chat, Preview, FileTree, Terminal, ShareDialog…
56
+ │ ├── components/ # Chat, Preview, FileTree, Onboarding, ShareDialog…
43
57
  │ └── state/ # session + chat stores
44
- └── docs/ # design notes
58
+ ├── vw-proxy/ # Cloudflare Worker — public *.venturewild.llc front door
59
+ ├── landing/ # marketing / landing page (Cloudflare Pages)
60
+ └── docs/ # design notes (incl. b-ii-proxy-plan.md / -design.md)
45
61
  ```
46
62
 
47
63
  ## The three roles (AR-19)
@@ -49,6 +65,7 @@ wild-workspace/
49
65
  | Role | URL pattern | What they see |
50
66
  |---|---|---|
51
67
  | **partner** | `http://localhost:5173` | Chat + preview + file tree + terminal toggle + inbox + share + deploy |
68
+ | **partner (published)** | `https://<user>.venturewild.llc` | Your workspace, live on your claimed per-user subdomain (e.g. `tuananh.venturewild.llc`) |
52
69
  | **viewer** | `https://<user>.venturewild.llc/<wsid>?t=<token>` | Chat history (read-only) + preview + presence + activity stream |
53
70
  | **client** | `https://workspace.<client>.com` | Chat + preview + "request changes" only |
54
71
 
@@ -56,7 +73,29 @@ Same React tree, role-gated visibility (AR-19).
56
73
 
57
74
  ## AR-17: wrap don't embed
58
75
 
59
- We don't ship an AI agent. `server/src/agent.mjs` spawns `claude` (or `gemini` / `glm` / `codex` if installed) as a subprocess and pipes stdout/stderr through WebSocket to the chat UI. The wrapped agent's modes mirror automatically (AR-18).
76
+ We don't ship an AI agent. `server/src/agent.mjs` spawns `claude` (or `gemini` / `glm` / `codex` if installed) as a subprocess and pipes stdout/stderr through WebSocket to the chat UI. The wrapped agent's modes mirror automatically (AR-18). (The one native binary we *do* bundle is the bmo-sync daemon — see below.)
77
+
78
+ ## bmo-sync daemon + per-user URLs
79
+
80
+ The workspace bundles the **bmo-sync daemon** (run as a subprocess) which links your
81
+ local workspace to a per-user subdomain `https://<user>.venturewild.llc`:
82
+
83
+ ```
84
+ browser → vw-proxy (Cloudflare Worker on *.venturewild.llc)
85
+ → bmo-sync (Fly, bmo-sync.fly.dev) → your daemon → local workspace server
86
+ ```
87
+
88
+ The Worker reads the `Host` header, resolves the slug via bmo-sync, and forwards to
89
+ the bmo-sync Fly origin, which proxies to your most-recently-connected daemon. A
90
+ **claimed** slug serves your live workspace (e.g. `tuananh.venturewild.llc` → 200); an
91
+ **unclaimed** slug 302-redirects to the landing page (e.g. `apple.venturewild.llc`).
92
+
93
+ The daemon ships via npm `optionalDependencies` (`@venturewild/workspace-daemon-*@0.1.0`
94
+ for win32-x64 / darwin-x64 / darwin-arm64 / linux-x64), resolved at launch by
95
+ `server/src/daemon-bin.mjs`. Shipped + verified live as of 2026-05-30; see
96
+ [`docs/b-ii-proxy-plan.md`](docs/b-ii-proxy-plan.md),
97
+ [`docs/b-ii-proxy-design.md`](docs/b-ii-proxy-design.md), and
98
+ [`docs/ECOSYSTEM.md`](docs/ECOSYSTEM.md).
60
99
 
61
100
  ## Rich chat rendering
62
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import path from 'node:path';
7
7
  import url from 'node:url';
8
+ import os from 'node:os';
8
9
  import { createServer } from '../src/index.mjs';
9
10
  import { APP_VERSION, buildConfig } from '../src/config.mjs';
10
11
  import { DaemonSupervisor } from '../src/daemon-supervisor.mjs';
@@ -15,6 +16,13 @@ import {
15
16
  loadAccount,
16
17
  clearAccount,
17
18
  } from '../src/account.mjs';
19
+ import { readFileSync } from 'node:fs';
20
+ import { WorkspaceSupervisor, probeHealth } from '../src/supervisor.mjs';
21
+ import { installService, uninstallService, serviceStatus, globalDir } from '../src/service.mjs';
22
+ import { appendLine, listLogs, tailFile } from '../src/logpaths.mjs';
23
+ import { runDoctor, renderDoctor, writeDoctorBundle } from '../src/doctor.mjs';
24
+ import { enableOperator, disableOperator, operatorStatus } from '../src/operator.mjs';
25
+ import { loadObservabilityConsent, setObservabilityConsent } from '../src/observability.mjs';
18
26
 
19
27
  const __filename = url.fileURLToPath(import.meta.url);
20
28
  const __dirname = path.dirname(__filename);
@@ -38,6 +46,15 @@ Usage:
38
46
  wild-workspace logout clear the bound account (slug + token)
39
47
  wild-workspace whoami show the currently-bound account
40
48
  wild-workspace rotate-token mint a new account token; invalidates the old one
49
+ wild-workspace doctor [--share] diagnose this machine's install (✅/⚠️/❌ + logs)
50
+ wild-workspace logs [name] [--tail N] list logs, or tail one (cli/server/daemon/…)
51
+ wild-workspace operator enable let the wild-workspace team help with your install (mints a token)
52
+ wild-workspace operator disable revoke the support token
53
+ wild-workspace operator status is the support channel on?
54
+ wild-workspace observability [on|off|status] share session + install health so we can help (default on; never chat content)
55
+ wild-workspace service install keep your workspace always-on (starts at login, no admin)
56
+ wild-workspace service uninstall turn always-on off
57
+ wild-workspace service status show always-on status (installed? supervisor? server?)
41
58
  wild-workspace install (info) how the sync daemon is managed
42
59
  wild-workspace --help this message
43
60
  wild-workspace --version print version
@@ -64,6 +81,11 @@ function parseArgs(argv) {
64
81
  else if (arg === '--port') { opts.port = Number(argv[++i]); }
65
82
  else if (arg === '--host') { opts.host = argv[++i]; }
66
83
  else if (arg === '--workspace') { opts.workspaceDir = argv[++i]; }
84
+ else if (arg === '--tail') { opts.tail = Number(argv[++i]); }
85
+ else if (arg === '--share') { opts.share = true; }
86
+ else if (arg === '--rotate') { opts.rotate = true; }
87
+ else if (arg === '--kind') { opts.kind = argv[++i]; }
88
+ else if (arg === '--limit') { opts.limit = Number(argv[++i]); }
67
89
  else if (arg.startsWith('--')) {
68
90
  // ignore unknown flags
69
91
  } else {
@@ -233,6 +255,16 @@ async function runLoginCommand(args) {
233
255
  console.log(` saved to : ${config.dataDir}/account.json`);
234
256
  console.log('');
235
257
  console.log('Run `wild-workspace` in any folder to start your workspace.');
258
+
259
+ // Arm always-on so the workspace comes back on its own (best-effort — never
260
+ // blocks login). On a platform without autostart yet, just nudge the user.
261
+ try {
262
+ const svc = await installService({
263
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
264
+ });
265
+ if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
266
+ else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
267
+ } catch { /* never block login */ }
236
268
  }
237
269
 
238
270
  async function runLogoutCommand() {
@@ -324,7 +356,279 @@ async function runWhoamiCommand() {
324
356
  console.log(` loggedIn : ${new Date(account.loggedInAt).toISOString()}`);
325
357
  }
326
358
 
359
+ // `wild-workspace doctor [--share]` — one diagnostic of this machine's install.
360
+ // Prints ✅/⚠️/❌ per check + where the logs are, and writes a JSON bundle under
361
+ // ~/.wild-workspace/diagnostics/. Exits non-zero if any check failed.
362
+ async function runDoctorCommand(opts) {
363
+ const config = buildConfig(opts);
364
+ const report = await runDoctor({ config });
365
+ console.log(renderDoctor(report));
366
+ const bundle = writeDoctorBundle(report);
367
+ if (bundle) {
368
+ console.log('');
369
+ console.log(`Full report: ${bundle}`);
370
+ }
371
+ if (opts.share) {
372
+ const shared = await shareDoctor(config, report);
373
+ console.log(
374
+ shared.ok
375
+ ? '✓ shared with the wild-workspace team — they can see this diagnostic now.'
376
+ : `Couldn't auto-share (${shared.reason}). Send the file above instead.`,
377
+ );
378
+ }
379
+ process.exitCode = report.summary.fail > 0 ? 1 : 0;
380
+ }
381
+
382
+ // Upload a doctor report to bmo-sync. `--share` is an explicit user action, so it
383
+ // goes even if the passive observability feed is off — but still respects the
384
+ // hard kill switch and needs an account to key it to.
385
+ async function shareDoctor(config, report) {
386
+ if (!config.accountToken) return { ok: false, reason: 'not logged in' };
387
+ if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return { ok: false, reason: 'telemetry disabled' };
388
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
389
+ const ctrl = new AbortController();
390
+ const t = setTimeout(() => ctrl.abort(), 5000);
391
+ try {
392
+ const res = await fetch(url, {
393
+ method: 'POST',
394
+ headers: { 'content-type': 'application/json' },
395
+ body: JSON.stringify({
396
+ account_token: config.accountToken,
397
+ slug: config.account?.slug || null,
398
+ workspace_id: config.workspaceId,
399
+ kind: 'doctor-share',
400
+ doctor: report,
401
+ sent_at: Math.floor(Date.now() / 1000),
402
+ }),
403
+ signal: ctrl.signal,
404
+ });
405
+ return { ok: res.ok, reason: res.ok ? null : `HTTP ${res.status}` };
406
+ } catch (e) {
407
+ return { ok: false, reason: String(e?.message || e) };
408
+ } finally {
409
+ clearTimeout(t);
410
+ }
411
+ }
412
+
413
+ // `wild-workspace logs [name] [--tail N]` — list the logs, or tail one by name.
414
+ async function runLogsCommand(opts) {
415
+ const name = opts.positional[1];
416
+ const n = opts.tail || 40;
417
+ const logs = listLogs();
418
+ if (!name) {
419
+ console.log(`logs dir: ${path.dirname(logs[0].file)}`);
420
+ for (const l of logs) {
421
+ console.log(` ${l.name.padEnd(10)} ${l.exists ? `${l.size} bytes` : '(none yet)'} ${l.file}`);
422
+ }
423
+ console.log('');
424
+ console.log(`Show one: wild-workspace logs <name> [--tail N] (names: ${logs.map((l) => l.name).join(', ')})`);
425
+ return;
426
+ }
427
+ const match = logs.find((l) => l.name === name);
428
+ if (!match) {
429
+ console.log(`unknown log "${name}". names: ${logs.map((l) => l.name).join(', ')}`);
430
+ process.exitCode = 1;
431
+ return;
432
+ }
433
+ console.log(`# ${match.name} — ${match.file} (last ${n} lines)`);
434
+ console.log(tailFile(match.file, n) || '(empty)');
435
+ }
436
+
437
+ // `wild-workspace ops <slug>` — OPERATOR read of a user's observability feed.
438
+ // Reads the bmo-sync admin endpoint with the admin key at ~/.bmo-sync-admin-key
439
+ // (so it only works on an operator's machine). This is how we see a stuck/broken
440
+ // user without them having to ask. Filters: --kind feed|install-down|transcript,
441
+ // --limit N.
442
+ async function runOpsCommand(opts) {
443
+ const slug = opts.positional[1];
444
+ if (!slug) {
445
+ console.error('usage: wild-workspace ops <slug> [--kind feed|install-down|transcript] [--limit N]');
446
+ process.exitCode = 1;
447
+ return;
448
+ }
449
+ const config = buildConfig(opts);
450
+ const keyPath = path.join(os.homedir(), '.bmo-sync-admin-key');
451
+ let adminKey;
452
+ try {
453
+ adminKey = readFileSync(keyPath, 'utf8').trim();
454
+ } catch {
455
+ console.error(`No admin key at ${keyPath} — \`ops\` is an operator-only command.`);
456
+ process.exitCode = 1;
457
+ return;
458
+ }
459
+ const params = new URLSearchParams();
460
+ if (opts.kind) params.set('kind', opts.kind);
461
+ params.set('limit', String(opts.limit || 50));
462
+ const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/admin/accounts/${encodeURIComponent(slug)}/activity?${params}`;
463
+ let res;
464
+ try {
465
+ res = await fetch(url, { headers: { 'x-admin-key': adminKey } });
466
+ } catch (e) {
467
+ console.error(`Couldn't reach ${config.bmoSyncServerUrl}: ${e.message || e}`);
468
+ process.exitCode = 2;
469
+ return;
470
+ }
471
+ if (res.status === 404) {
472
+ console.log(`No account/slug "${slug}" yet (unclaimed, or it hasn't reported).`);
473
+ return;
474
+ }
475
+ if (res.status === 403 || res.status === 401) {
476
+ console.error('admin key rejected.');
477
+ process.exitCode = 1;
478
+ return;
479
+ }
480
+ if (!res.ok) {
481
+ console.error(`HTTP ${res.status}`);
482
+ process.exitCode = 1;
483
+ return;
484
+ }
485
+ const data = await res.json();
486
+ console.log(`account ${data.account_id}${data.slug ? ` (${data.slug})` : ''} — ${data.count} recent event(s)`);
487
+ for (const r of data.rows || []) {
488
+ const when = new Date((r.reported_at || 0) * 1000).toISOString();
489
+ let detail = '';
490
+ if (r.kind === 'feed' && Array.isArray(r.payload?.events)) {
491
+ const counts = {};
492
+ for (const e of r.payload.events) counts[e.type] = (counts[e.type] || 0) + 1;
493
+ detail = Object.entries(counts).map(([k, v]) => `${k}×${v}`).join(' ');
494
+ } else if ((r.kind === 'install-down' || r.kind === 'doctor-share') && r.payload?.doctor?.summary) {
495
+ const s = r.payload.doctor.summary;
496
+ detail = `doctor: ${s.fail} fail / ${s.warn} warn`;
497
+ } else if (r.kind === 'transcript') {
498
+ detail = `transcript ${r.payload?.date || ''} (${(r.payload?.markdown || '').length} chars)`;
499
+ }
500
+ console.log(` ${when} [${r.kind}]${r.os ? ` ${r.os}` : ''} ${detail}`);
501
+ }
502
+ }
503
+
504
+ // `wild-workspace observability [on|off|status]` — the consented session +
505
+ // install-health feed (default ON). Streams WHAT happened + install health to the
506
+ // Venturewild team so they can help if something breaks — never your chat words
507
+ // (that's the separate transcript channel). See observability.mjs.
508
+ async function runObservabilityCommand(action = 'status', opts = {}) {
509
+ const config = buildConfig(opts);
510
+ if (action === 'on' || action === 'off') {
511
+ const rec = setObservabilityConsent(config.dataDir, action === 'on');
512
+ console.log(
513
+ rec.enabled
514
+ ? '✓ observability ON — the Venturewild team can see how your workspace is doing'
515
+ : '✓ observability OFF — no session/health feed leaves this machine.',
516
+ );
517
+ if (rec.enabled) {
518
+ console.log(' (events + install health only — never your chat content)');
519
+ }
520
+ console.log(' Applies the next time your workspace starts (or toggle it live in the app).');
521
+ return;
522
+ }
523
+ const rec = loadObservabilityConsent(config.dataDir);
524
+ console.log(`observability: ${rec.enabled ? 'ON' : 'OFF'}${rec.decidedAt ? '' : ' (default)'}`);
525
+ console.log(' what : events + install health → lets us help if something breaks (never chat content)');
526
+ console.log(' toggle: wild-workspace observability on | off');
527
+ return;
528
+ }
529
+
530
+ // `wild-workspace operator [enable|disable|status]` — the consented support
531
+ // channel (docs/SECURITY.md). OFF by default; `enable` mints a token to hand to
532
+ // the wild-workspace team so they can diagnose + run a fixed set of safe fixes.
533
+ async function runOperatorCommand(action = 'status', opts = {}) {
534
+ const config = buildConfig(opts);
535
+ if (action === 'enable') {
536
+ const token = enableOperator(config.dataDir, { rotate: opts.rotate });
537
+ if (!token) {
538
+ console.error(`Could not enable operator channel (couldn't write to ${config.dataDir}).`);
539
+ process.exitCode = 1;
540
+ return;
541
+ }
542
+ const slug = config.account?.slug;
543
+ console.log('✓ operator channel enabled (consented support access).');
544
+ console.log(` token : ${token}`);
545
+ console.log(' Share this token with the wild-workspace team so they can help with your install.');
546
+ if (slug) console.log(` reach : https://${slug}.venturewild.llc/api/operator/diag (Authorization: Bearer <token>)`);
547
+ console.log(' off : wild-workspace operator disable');
548
+ console.log('');
549
+ console.log(' Scope: read diagnostics + a fixed set of safe fixes (restart sync, re-detect');
550
+ console.log(' Claude, re-link your account, reinstall the sync daemon). It cannot run');
551
+ console.log(' arbitrary commands or drive your agent, and every action is logged.');
552
+ return;
553
+ }
554
+ if (action === 'disable') {
555
+ const removed = disableOperator(config.dataDir);
556
+ console.log(removed ? '✓ operator channel disabled (token revoked).' : 'operator channel was not enabled.');
557
+ return;
558
+ }
559
+ if (action === 'status') {
560
+ const s = operatorStatus(config.dataDir);
561
+ console.log(`operator channel: ${s.enabled ? 'ENABLED' : 'disabled'}`);
562
+ console.log(` token file: ${s.file}`);
563
+ console.log(s.enabled ? ' disable : wild-workspace operator disable' : ' enable : wild-workspace operator enable');
564
+ return;
565
+ }
566
+ console.log(`unknown operator action: ${action} (use enable | disable | status)`);
567
+ }
568
+
569
+ // `wild-workspace service [install|uninstall|status|run]` — the always-on
570
+ // autostart (docs/always-on-design.md). `run` is the HIDDEN supervisor entry
571
+ // the per-OS launcher invokes at login; the others manage registration. All
572
+ // per-user, no admin.
573
+ async function runServiceCommand(action = 'status', opts = {}) {
574
+ const config = buildConfig(opts);
575
+
576
+ if (action === 'install') {
577
+ const r = await installService({
578
+ node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
579
+ });
580
+ if (!r.installed) {
581
+ console.log(`service install: ${r.message || 'not supported on this platform'}`);
582
+ process.exitCode = 1;
583
+ return;
584
+ }
585
+ console.log('✓ always-on enabled — your workspace starts automatically at login.');
586
+ console.log(` mechanism : ${r.mechanism} (per-user, no admin)`);
587
+ console.log(` launcher : ${r.vbs}`);
588
+ console.log(` workspace : ${config.workspaceDir}`);
589
+ console.log(' disable : wild-workspace service uninstall');
590
+ return;
591
+ }
592
+
593
+ if (action === 'uninstall') {
594
+ const r = await uninstallService();
595
+ if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
596
+ console.log(`✓ always-on disabled${r.removedKey ? '' : ' (no autostart entry was set)'}${r.stoppedPid ? ` — stopped supervisor pid ${r.stoppedPid}` : ''}.`);
597
+ return;
598
+ }
599
+
600
+ if (action === 'status') {
601
+ const s = await serviceStatus({ port: config.port }, { probeImpl: (p) => probeHealth(p) });
602
+ if (s.supported === false) { console.log(`always-on: autostart not implemented for ${s.platform} yet (run \`wild-workspace\` manually)`); return; }
603
+ console.log(`always-on : ${s.installed ? 'installed' : 'NOT installed'}`);
604
+ if (s.runValue) console.log(` run entry : ${s.runValue}`);
605
+ console.log(` supervisor: ${s.supervisorAlive ? `running (pid ${s.supervisorPid})` : 'not running'}`);
606
+ console.log(` server : ${s.serverUp ? `up on http://127.0.0.1:${config.port}` : 'down'}`);
607
+ return;
608
+ }
609
+
610
+ if (action === 'run') {
611
+ // Hidden entrypoint launched by the autostart VBS at login. The OS gives us
612
+ // an arbitrary cwd, so read the workspace dir persisted at install time.
613
+ let svc = {};
614
+ try { svc = JSON.parse(readFileSync(path.join(globalDir(), 'service.json'), 'utf8')); } catch { /* fall back to config */ }
615
+ const sup = new WorkspaceSupervisor({
616
+ workspaceDir: svc.workspaceDir || config.workspaceDir,
617
+ port: svc.port || config.port,
618
+ });
619
+ const r = sup.start();
620
+ if (!r.started) process.exit(0); // another supervisor already owns the lock
621
+ // else: the supervision interval keeps this process alive.
622
+ return;
623
+ }
624
+
625
+ console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
626
+ }
627
+
327
628
  async function main() {
629
+ // First-run capture: log every invocation BEFORE doing anything, so even a
630
+ // crash-on-start leaves a trace we (or `wild-workspace doctor`) can read.
631
+ appendLine('cli', `invoke argv=[${process.argv.slice(2).join(' ')}] cwd=${process.cwd()} node=${process.version} v${APP_VERSION}`);
328
632
  const opts = parseArgs(process.argv.slice(2));
329
633
  if (opts.help) return printUsage();
330
634
  if (opts.version) { console.log(APP_VERSION); return; }
@@ -345,6 +649,24 @@ async function main() {
345
649
  if (opts.positional[0] === 'rotate-token') {
346
650
  return runRotateTokenCommand();
347
651
  }
652
+ if (opts.positional[0] === 'doctor') {
653
+ return runDoctorCommand(opts);
654
+ }
655
+ if (opts.positional[0] === 'logs') {
656
+ return runLogsCommand(opts);
657
+ }
658
+ if (opts.positional[0] === 'operator') {
659
+ return runOperatorCommand(opts.positional[1], opts);
660
+ }
661
+ if (opts.positional[0] === 'observability') {
662
+ return runObservabilityCommand(opts.positional[1], opts);
663
+ }
664
+ if (opts.positional[0] === 'ops') {
665
+ return runOpsCommand(opts);
666
+ }
667
+ if (opts.positional[0] === 'service') {
668
+ return runServiceCommand(opts.positional[1], opts);
669
+ }
348
670
 
349
671
  if (opts.positional[0] === 'install') {
350
672
  console.log('wild-workspace manages the bmo-sync sync daemon for you.');
@@ -397,6 +719,7 @@ async function main() {
397
719
  }
398
720
 
399
721
  main().catch((err) => {
722
+ appendLine('cli', `FATAL ${err?.stack || err}`);
400
723
  console.error('wild-workspace failed:', err);
401
724
  process.exit(1);
402
725
  });
@@ -0,0 +1,200 @@
1
+ // Agent readiness — "is the wrapped agent actually able to talk?"
2
+ //
3
+ // THE GAP THIS CLOSES: detectAgents() only proves the `claude` binary is on the
4
+ // PATH. It does NOT prove the user has signed in. A brand-new user who installed
5
+ // the CLI but never ran `claude auth login` has a binary that resolves but every
6
+ // `claude -p` turn fails with an auth error. Onboarding's folder-peek (step 2)
7
+ // would then surface a scary error bubble — the exact "makes them feel stupid"
8
+ // failure docs/user-experience.md §4 forbids, and the open question in §3.2.
9
+ //
10
+ // THE SECOND GAP (added 2026-06-01): being signed in is NOT the same as being
11
+ // ABLE to run turns. A free claude.ai account can complete `claude auth login`
12
+ // (so `loggedIn:true`) yet Claude Code still refuses every prompt — a paid plan
13
+ // (Pro/Max/Team/Enterprise) or API billing is required. If we only checked
14
+ // `loggedIn` we'd wave a free user straight into the folder-peek, where the
15
+ // FIRST `claude -p` blows up with a billing error = the same scary bubble. So we
16
+ // also read `subscriptionType` / `apiProvider` and raise a distinct `subscribe`
17
+ // verdict ("you're in — you just need an active plan") instead.
18
+ //
19
+ // THE PROBE: `claude auth status` — a built-in, ZERO-TOKEN, cross-platform check
20
+ // (verified on Claude Code 2.1.158). It reads whatever credential store the
21
+ // platform uses (macOS Keychain, Windows ~/.claude/.credentials.json, Linux
22
+ // config), so a naive credentials-file check (which would false-negative on a
23
+ // signed-in Mac user like Mel) is wrong — we ask the CLI itself. It prints JSON:
24
+ // - signed in → exit 0, {"loggedIn":true,"authMethod":"claude.ai",
25
+ // "apiProvider":"firstParty","email":"…",
26
+ // "subscriptionType":"max",…}
27
+ // - signed out → exit 1, {"loggedIn":false,"authMethod":"none",…}
28
+ // We parse the JSON (authoritative) and fall back to the exit code if the shape
29
+ // ever changes.
30
+ //
31
+ // API-KEY / TOKEN BILLING: if ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN /
32
+ // CLAUDE_CODE_OAUTH_TOKEN) is set, `claude -p` bills through that and turns run
33
+ // even without a claude.ai OAuth session — so we treat a configured key as
34
+ // `ready` regardless of `auth status` (the sponsored / managed-key path).
35
+ //
36
+ // Only `claude` has this contract today. Other agents (gemini/glm/codex) report
37
+ // `unknown` — we never block on a readiness we can't measure (fail-open), so a
38
+ // Codex user is never gated by a Claude-shaped check.
39
+
40
+ import { execFile as execFileCb } from 'node:child_process';
41
+ import { promisify } from 'node:util';
42
+
43
+ const execFile = promisify(execFileCb);
44
+
45
+ // `claude auth status` is local-only (no network), but give it room on a cold
46
+ // cache / slow disk. Comfortably under the 5s onboarding "feels instant" budget.
47
+ const AUTH_PROBE_TIMEOUT_MS = 8000;
48
+
49
+ // Subscription tiers that CAN run Claude Code turns vs. ones that cannot. Lower-
50
+ // cased for comparison. An unknown, non-empty tier on a firstParty account is
51
+ // treated leniently (assumed paid) so a future tier name never false-gates a
52
+ // paying user behind a non-skippable wall — the empty/known-free cases are the
53
+ // only ones we positively block.
54
+ const PAID_TIERS = new Set(['pro', 'max', 'team', 'enterprise']);
55
+ const UNPAID_TIERS = new Set(['free', 'none', 'inactive', 'expired', 'trial_expired']);
56
+
57
+ // An API key / long-lived token configures billing directly — turns will run
58
+ // even with no OAuth session. Checked before the auth probe.
59
+ function hasApiBillingKey(env) {
60
+ const keys = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
61
+ return keys.some((k) => typeof env?.[k] === 'string' && env[k].trim() !== '');
62
+ }
63
+
64
+ // Given a parsed `auth status` object for a signed-in user, can turns actually
65
+ // run? true = yes (paid plan or API billing), false = no (free / no plan),
66
+ // null = unknown shape → caller treats as ready (lenient, never false-gates).
67
+ export function canRunTurns(parsed) {
68
+ if (!parsed || typeof parsed !== 'object') return null;
69
+ const sub =
70
+ typeof parsed.subscriptionType === 'string' ? parsed.subscriptionType.toLowerCase().trim() : '';
71
+ const provider =
72
+ typeof parsed.apiProvider === 'string' ? parsed.apiProvider.toLowerCase().trim() : '';
73
+ // A non-firstParty provider (console / bedrock / vertex) bills via API → runs.
74
+ if (provider && provider !== 'firstparty') return true;
75
+ if (PAID_TIERS.has(sub)) return true;
76
+ if (UNPAID_TIERS.has(sub)) return false;
77
+ // Signed in via claude.ai with NO subscription field at all → no active plan
78
+ // (the current CLI emits subscriptionType for paid accounts, so absence here
79
+ // is the free-account fingerprint).
80
+ if (sub === '') return false;
81
+ // Unknown, non-empty tier on a firstParty account — lean paid (don't gate).
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Readiness verdict for one agent.
87
+ * status: 'ready' — installed AND able to run turns (signed-in + plan, or API key).
88
+ * 'subscribe' — installed AND signed in, but no active plan → the user
89
+ * needs a Claude Pro (or higher) plan before turns run.
90
+ * 'login' — installed but NOT signed in; the user must auth.
91
+ * 'missing' — binary not on PATH (detect step already knew).
92
+ * 'unknown' — installed, but this agent has no auth-status probe
93
+ * we understand → never gate on it (fail-open).
94
+ * email: parsed sign-in identity when known (claude only), else null.
95
+ * hint: a short, human, NON-technical line the UI can show as-is.
96
+ */
97
+ export async function probeClaudeAuth(agent, runner = defaultRunner, env = process.env) {
98
+ if (!agent || !agent.available) {
99
+ return {
100
+ status: 'missing',
101
+ email: null,
102
+ hint: 'Claude Code isn’t installed yet. Install it, then come back.',
103
+ };
104
+ }
105
+ // Sponsored / managed billing: a configured key runs turns regardless of the
106
+ // OAuth session state, so don't gate.
107
+ if (hasApiBillingKey(env)) {
108
+ return { status: 'ready', email: null, hint: null };
109
+ }
110
+ const command = agent.resolvedPath || agent.binary;
111
+ try {
112
+ const { code, stdout } = await runner(command, ['auth', 'status']);
113
+ const parsed = parseAuthStatus(stdout);
114
+ // Authoritative: the JSON `loggedIn` flag. Fall back to the exit code
115
+ // (0 = signed in) only if the JSON is unparseable / shape-changed.
116
+ const loggedIn = parsed ? parsed.loggedIn === true : code === 0;
117
+ if (loggedIn) {
118
+ // Signed in — but can they actually run a turn? A free account can't.
119
+ if (canRunTurns(parsed) === false) {
120
+ return {
121
+ status: 'subscribe',
122
+ email: parsed?.email || null,
123
+ hint: 'You’re signed in — you just need an active Claude plan (Pro or higher) so your agent can run.',
124
+ };
125
+ }
126
+ return { status: 'ready', email: parsed?.email || null, hint: null };
127
+ }
128
+ // Signed out (or any non-ready shape). Treat as "needs login" — safer than
129
+ // claiming ready and then failing the first real turn mid-onboarding.
130
+ return {
131
+ status: 'login',
132
+ email: null,
133
+ hint: 'One quick thing: sign in to Claude so your agent can think.',
134
+ };
135
+ } catch (err) {
136
+ // The probe itself failed to run (timeout, spawn error, unexpected CLI
137
+ // version with no `auth` subcommand). Don't hard-block onboarding on our
138
+ // own probe breaking — report unknown and let the turn-runner be the
139
+ // ground truth. Surfaced for logging, not shown as a scary error.
140
+ return {
141
+ status: 'unknown',
142
+ email: null,
143
+ hint: null,
144
+ error: String(err?.message || err),
145
+ };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Readiness for the active agent. Claude gets the real probe; every other agent
151
+ * is 'unknown' (we have no auth-status contract for them, so we never gate).
152
+ */
153
+ export async function probeAgentReadiness(agent, runner = defaultRunner, env = process.env) {
154
+ if (agent && agent.id === 'claude') {
155
+ return probeClaudeAuth(agent, runner, env);
156
+ }
157
+ return { status: 'unknown', email: null, hint: null };
158
+ }
159
+
160
+ // Parse `claude auth status` stdout. It prints a JSON object; tolerate leading/
161
+ // trailing noise by extracting the first {...} span. Returns null if nothing
162
+ // parseable is found (caller falls back to the exit code).
163
+ export function parseAuthStatus(stdout) {
164
+ const text = String(stdout || '');
165
+ const start = text.indexOf('{');
166
+ const end = text.lastIndexOf('}');
167
+ if (start === -1 || end === -1 || end <= start) return null;
168
+ try {
169
+ const obj = JSON.parse(text.slice(start, end + 1));
170
+ return obj && typeof obj === 'object' ? obj : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // execFile wrapper that resolves (never rejects) on a non-zero exit, so the
177
+ // caller can branch on { code, stdout } uniformly. Only a genuine spawn/timeout
178
+ // failure rejects — that's the 'unknown' path above.
179
+ function defaultRunner(command, args) {
180
+ return new Promise((resolve, reject) => {
181
+ execFile(
182
+ command,
183
+ args,
184
+ { timeout: AUTH_PROBE_TIMEOUT_MS, windowsHide: true },
185
+ (err, stdout, stderr) => {
186
+ if (err && typeof err.code !== 'number') {
187
+ // No numeric exit code => process never ran to completion
188
+ // (ENOENT, ETIMEDOUT, killed). That's a probe failure, not a verdict.
189
+ reject(err);
190
+ return;
191
+ }
192
+ resolve({
193
+ code: err ? err.code : 0,
194
+ stdout: stdout || '',
195
+ stderr: stderr || '',
196
+ });
197
+ },
198
+ );
199
+ });
200
+ }