@venturewild/workspace 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.3.0",
3
+ "version": "0.3.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,7 +5,7 @@
5
5
  // lookup chain so it works both in development (a locally built binary in
6
6
  // vendor/ or on PATH) and in a published install (the platform subpackage).
7
7
 
8
- import { existsSync } from 'node:fs';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import url from 'node:url';
11
11
  import { createRequire } from 'node:module';
@@ -68,3 +68,21 @@ export function resolveDaemonBinary({ env = process.env, vendorRoot, requireReso
68
68
  // 4. last resort — spawn by name and let PATH resolve it.
69
69
  return { path: binName, source: 'path' };
70
70
  }
71
+
72
+ /**
73
+ * The version of the INSTALLED per-platform daemon subpackage (e.g. `"0.1.3"`),
74
+ * or null when it can't be determined (resolved via PATH/vendor, or not found).
75
+ * Used by the supervisor to detect a stale running daemon after an auto-update
76
+ * (the daemon's own /health doesn't report a version), so it can recycle it.
77
+ */
78
+ export function resolveDaemonVersion({ env = process.env, requireResolve } = {}) {
79
+ const tag = platformTag();
80
+ const resolvePkg = requireResolve || ((id) => require.resolve(id));
81
+ try {
82
+ const pkgJson = resolvePkg(`@venturewild/workspace-daemon-${tag}/package.json`);
83
+ const parsed = JSON.parse(readFileSync(pkgJson, 'utf8'));
84
+ return typeof parsed.version === 'string' ? parsed.version : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
@@ -192,6 +192,21 @@ export class DaemonSupervisor {
192
192
  return { stopped: true, pid };
193
193
  }
194
194
 
195
+ /**
196
+ * Recycle the daemon so it loads a freshly-resolved binary (after an
197
+ * auto-update). Stop the running process, wait for it to release its API port,
198
+ * then spawn again. Returns the `spawnDaemon` result.
199
+ */
200
+ async recycle() {
201
+ await this.stop();
202
+ // Wait for the old process to exit + free :PORT, else the new one can't bind.
203
+ const deadline = Date.now() + 5000;
204
+ while (Date.now() < deadline && (await this.health()).running) {
205
+ await new Promise((r) => setTimeout(r, 200));
206
+ }
207
+ return this.spawnDaemon();
208
+ }
209
+
195
210
  /** Combined status for `wild-workspace daemon status`. */
196
211
  async status() {
197
212
  const { running } = await this.health();
@@ -36,7 +36,15 @@ import { ActivityBus } from './activity.mjs';
36
36
  import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
37
37
  import { probeAgentReadiness } from './agent-readiness.mjs';
38
38
  import { AutoUpdater, npmInstall, recordUpdate, loadUpdateSettings, PACKAGE_NAME } from './auto-update.mjs';
39
- import { consentStatus, revokeConsent, readAudit, OPERATE_TIER } from './support-consent.mjs';
39
+ import {
40
+ consentStatus,
41
+ revokeConsent,
42
+ readAudit,
43
+ grantConsent,
44
+ OPERATE_TIER,
45
+ MAX_TIER,
46
+ MAX_GRANT_MINUTES,
47
+ } from './support-consent.mjs';
40
48
  import { ClaudeLoginSession } from './agent-login.mjs';
41
49
  import { ErrorReporter } from './error-reporter.mjs';
42
50
  import { DaemonBridge } from './daemon.mjs';
@@ -1081,6 +1089,10 @@ export async function createServer(overrides = {}) {
1081
1089
  remainingMs: s.remainingMs,
1082
1090
  active,
1083
1091
  operate,
1092
+ // Whether THIS viewer may grant/revoke (owner only) — drives the banner's
1093
+ // one-tap "Allow VentureWild support" control so consent is never a CLI paste.
1094
+ canGrant: Boolean(ROLE_CAPABILITIES[c.get('role')]?.chatWrite),
1095
+ maxTier: MAX_TIER,
1084
1096
  lastAction: last ? { action: last.action, ts: last.ts, ok: last.ok } : null,
1085
1097
  });
1086
1098
  });
@@ -1092,6 +1104,31 @@ export async function createServer(overrides = {}) {
1092
1104
  return c.json({ audit: readAudit(globalDir(), { limit }) });
1093
1105
  });
1094
1106
 
1107
+ // Phase 4 (Pillar E): the owner grants time-boxed support consent with a UI tap —
1108
+ // so "yes, VentureWild may help" is a browser click, never a terminal paste (the
1109
+ // CLI `support allow` still works too). Owner-only. Operate tiers (3/4) require an
1110
+ // explicit confirmOperate so a stray tap can't hand over agent/shell control.
1111
+ app.post('/api/support/grant', async (c) => {
1112
+ const forbidden = require(c, 'chatWrite');
1113
+ if (forbidden) return forbidden;
1114
+ let body;
1115
+ try {
1116
+ body = await c.req.json();
1117
+ } catch {
1118
+ body = {};
1119
+ }
1120
+ const tier = Math.min(MAX_TIER, Math.max(1, Math.floor(Number(body.tier) || 1)));
1121
+ const minutes = Math.min(MAX_GRANT_MINUTES, Math.max(1, Math.floor(Number(body.minutes) || 30)));
1122
+ if (tier >= OPERATE_TIER && body.confirmOperate !== true) {
1123
+ return c.json({ error: 'operate-confirm-required', tier }, 400);
1124
+ }
1125
+ const rec = grantConsent(globalDir(), { tier, minutes });
1126
+ if (!rec) return c.json({ error: 'write-failed' }, 500);
1127
+ appendLine('operator', `support consent granted via UI tier=${rec.tier} minutes=${minutes}`);
1128
+ activityBus.publish({ type: 'support-granted', tier: rec.tier, at: Date.now() });
1129
+ return c.json({ granted: true, tier: rec.tier, expiresAt: rec.expiresAt });
1130
+ });
1131
+
1095
1132
  app.post('/api/support/revoke', (c) => {
1096
1133
  const forbidden = require(c, 'chatWrite');
1097
1134
  if (forbidden) return forbidden;
@@ -25,6 +25,7 @@ import fs from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import { resolveDaemonVersion } from './daemon-bin.mjs';
28
29
 
29
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
31
  const DEFAULT_SERVER_ENTRY = path.join(__dirname, 'index.mjs');
@@ -125,6 +126,14 @@ export class WorkspaceSupervisor {
125
126
  superviseDaemon = env.WILD_WORKSPACE_NO_DAEMON_SUPERVISION !== '1',
126
127
  daemonPollMs = 10000, // probe the daemon every 10s
127
128
  daemonSupervisorFactory = null, // test seam: (supervisor) => DaemonSupervisor-like
129
+ // Daemon version-drift restart (the daemon analog of RC1b): after an
130
+ // auto-update installs a new daemon binary, the long-lived daemon process
131
+ // keeps running the OLD code until something restarts it — so the support
132
+ // channel silently won't activate. We recycle the daemon when the installed
133
+ // subpackage version differs from the version the running daemon was spawned
134
+ // under (tracked in `daemon-runtime.json`, since the daemon's /health reports
135
+ // no version). Test seam: inject a version function.
136
+ daemonVersionImpl = () => resolveDaemonVersion({ env }),
128
137
  } = {}) {
129
138
  Object.assign(this, {
130
139
  serverEntry, workspaceDir, port, globalDir, node, pollMs,
@@ -132,13 +141,14 @@ export class WorkspaceSupervisor {
132
141
  crashLoopThreshold, diagnosticsImpl,
133
142
  autoRestartOnVersionDrift, versionImpl, installedVersionImpl,
134
143
  autoUpdate, updatePollMs, autoUpdaterFactory,
135
- superviseDaemon, daemonPollMs, daemonSupervisorFactory,
144
+ superviseDaemon, daemonPollMs, daemonSupervisorFactory, daemonVersionImpl,
136
145
  });
137
146
  this.autoUpdater = null;
138
147
  this.updateTimer = null;
139
148
  this.daemonSupervisor = null;
140
149
  this.daemonTimer = null;
141
150
  this._daemonTicking = false;
151
+ this.daemonRuntimeFile = path.join(globalDir, 'daemon-runtime.json');
142
152
  this.logFile = path.join(globalDir, 'supervisor.log');
143
153
  this.serverLogFile = path.join(globalDir, 'server.out.log');
144
154
  this.lockFile = path.join(globalDir, 'supervisor.lock');
@@ -390,14 +400,58 @@ export class WorkspaceSupervisor {
390
400
  * channel) must stay up even when the server is crashed/mid-upgrade. Re-entrancy
391
401
  * guarded so a slow spawn can't overlap the next tick. Never throws.
392
402
  */
403
+ /** The daemon version the currently-running daemon was spawned under, or null. */
404
+ readDaemonMarker() {
405
+ try {
406
+ const v = JSON.parse(fs.readFileSync(this.daemonRuntimeFile, 'utf8'))?.daemonVersion;
407
+ return typeof v === 'string' ? v : null;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ writeDaemonMarker(version) {
414
+ if (!version) return; // unknown installed version (PATH/vendor) — don't pin
415
+ try {
416
+ fs.mkdirSync(this.globalDir, { recursive: true });
417
+ fs.writeFileSync(this.daemonRuntimeFile, JSON.stringify({ daemonVersion: version }));
418
+ } catch {
419
+ /* best-effort */
420
+ }
421
+ }
422
+
393
423
  async daemonTick() {
394
424
  if (!this.daemonSupervisor || this._daemonTicking) return 'skip';
395
425
  this._daemonTicking = true;
396
426
  try {
427
+ const installed = this.daemonVersionImpl();
397
428
  const h = await this.daemonSupervisor.health();
398
- if (h && h.running) return 'healthy';
429
+ if (h && h.running) {
430
+ // Running — but is it the CURRENT binary? After an auto-update the daemon
431
+ // keeps the old code until recycled (RC1b analog). Recycle when the
432
+ // installed version differs from what we recorded at spawn (a null marker
433
+ // = spawned by a pre-drift-aware supervisor → treat as drift, recycle once).
434
+ if (installed && this.readDaemonMarker() !== installed && this.daemonSupervisor.recycle) {
435
+ this.log(
436
+ `daemon version drift (marker=${this.readDaemonMarker() || 'none'} installed=${installed}) — recycling`,
437
+ );
438
+ const r = await this.daemonSupervisor.recycle();
439
+ if (r && r.started) {
440
+ this.writeDaemonMarker(installed);
441
+ this.log(`daemon recycled to ${installed} (pid=${r.pid})`);
442
+ return 'recycled';
443
+ }
444
+ this.log(`daemon recycle failed: ${r?.error || 'unknown'}`);
445
+ return 'recycle-failed';
446
+ }
447
+ return 'healthy';
448
+ }
399
449
  const r = await this.daemonSupervisor.ensureRunning();
400
- if (r && r.started) { this.log(`daemon respawned (pid=${r.pid})`); return 'respawned'; }
450
+ if (r && r.started) {
451
+ this.writeDaemonMarker(installed);
452
+ this.log(`daemon respawned (pid=${r.pid})`);
453
+ return 'respawned';
454
+ }
401
455
  if (r && r.alreadyRunning) return 'healthy';
402
456
  this.log(`daemon down, respawn not started: ${r?.error || 'unknown'}`);
403
457
  return 'failed';