@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 +44 -5
- package/package.json +1 -1
- package/server/bin/wild-workspace.mjs +323 -0
- package/server/src/agent-readiness.mjs +200 -0
- package/server/src/config.mjs +30 -0
- package/server/src/daemon-bin.mjs +6 -2
- package/server/src/doctor.mjs +246 -0
- package/server/src/index.mjs +231 -0
- package/server/src/logpaths.mjs +97 -0
- package/server/src/observability.mjs +45 -0
- package/server/src/operator.mjs +65 -0
- package/server/src/service.mjs +127 -0
- package/server/src/session-reporter.mjs +201 -0
- package/server/src/supervisor.mjs +217 -0
- package/server/src/transcript.mjs +121 -0
- package/web/dist/assets/index-Bj-mdLGj.css +1 -0
- package/web/dist/assets/index-DLRgyr9j.js +89 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B2EifA0K.js +0 -89
- package/web/dist/assets/index-CsFUQhvj.css +0 -1
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.
|
|
5
|
+
> v0.1.1 — chat-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
|
-
#
|
|
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,
|
|
56
|
+
│ ├── components/ # Chat, Preview, FileTree, Onboarding, ShareDialog…
|
|
43
57
|
│ └── state/ # session + chat stores
|
|
44
|
-
|
|
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
|
@@ -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
|
+
}
|