@venturewild/workspace 0.6.23 → 0.6.26

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.6.23",
3
+ "version": "0.6.26",
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": {
@@ -18,7 +18,7 @@ import {
18
18
  } from '../src/account.mjs';
19
19
  import { readFileSync } from 'node:fs';
20
20
  import { WorkspaceSupervisor, probeHealth } from '../src/supervisor.mjs';
21
- import { installService, uninstallService, serviceStatus, globalDir } from '../src/service.mjs';
21
+ import { installService, startService, uninstallService, serviceStatus, globalDir } from '../src/service.mjs';
22
22
  import { appendLine, listLogs, tailFile } from '../src/logpaths.mjs';
23
23
  import { runDoctor, renderDoctor, writeDoctorBundle } from '../src/doctor.mjs';
24
24
  import { enableOperator, disableOperator, operatorStatus } from '../src/operator.mjs';
@@ -281,16 +281,28 @@ async function runLoginCommand(args) {
281
281
  console.log(` url : https://${saved.slug}.venturewild.llc (once the tunnel is configured)`);
282
282
  console.log(` saved to : ${config.dataDir}/account.json`);
283
283
  console.log('');
284
- console.log('Run `wild-workspace` in any folder to start your workspace.');
285
284
 
286
- // Arm always-on so the workspace comes back on its own (best-effort — never
287
- // blocks login). On a platform without autostart yet, just nudge the user.
285
+ // Arm always-on AND start it now (best-effort — never blocks login) so a brand-new
286
+ // user gets a LIVE workspace without logging out/in. installService writes the
287
+ // autostart entry; startService brings the supervisor up immediately (idempotent —
288
+ // no-ops if one already holds the lock). Everything is driven by absolute paths, so
289
+ // the bare `wild-workspace` command never needs to be on PATH.
288
290
  try {
289
291
  const svc = await installService({
290
292
  node: process.execPath, cli: __filename, workspaceDir: config.workspaceDir, port: config.port, version: APP_VERSION,
291
293
  });
292
- if (svc.installed) console.log(' always-on : enabled — starts at login (disable: wild-workspace service uninstall)');
293
- else if (svc.supported === false) console.log(` always-on : not yet on ${svc.platform} — run \`wild-workspace\` to start it`);
294
+ if (svc.installed) {
295
+ const started = await startService({}).catch(() => ({ started: false }));
296
+ if (started.started || started.alreadyRunning) {
297
+ console.log(' always-on : ON — starting your workspace now; it relaunches at every login.');
298
+ console.log('');
299
+ console.log(`Your workspace is coming up at https://${saved.slug}.venturewild.llc — give it a few seconds.`);
300
+ } else {
301
+ console.log(' always-on : enabled — starts at your next login (disable: wild-workspace service uninstall)');
302
+ }
303
+ } else if (svc.supported === false) {
304
+ console.log(` always-on : not yet automated on ${svc.platform}.`);
305
+ }
294
306
  } catch { /* never block login */ }
295
307
  }
296
308
 
@@ -926,6 +938,18 @@ async function runServiceCommand(action = 'status', opts = {}) {
926
938
  return;
927
939
  }
928
940
 
941
+ if (action === 'start') {
942
+ // Bring always-on UP now (the bootstrap installer calls this after `install` so a
943
+ // brand-new user gets a live workspace without logging out/in). Idempotent.
944
+ const r = await startService({});
945
+ if (r.supported === false) { console.log(`service: start not supported on ${r.platform} yet — run \`wild-workspace\` manually`); return; }
946
+ if (r.alreadyRunning) { console.log('always-on: already running.'); return; }
947
+ if (r.started) { console.log(`✓ always-on started (${r.method}) — your workspace is coming up now.`); return; }
948
+ console.log(`service start: couldn't start${r.reason ? ` — ${r.reason}` : r.error ? ` — ${r.error}` : ''} (run \`wild-workspace service install\` first?).`);
949
+ process.exitCode = 1;
950
+ return;
951
+ }
952
+
929
953
  if (action === 'uninstall') {
930
954
  const r = await uninstallService();
931
955
  if (r.supported === false) { console.log(`service: nothing to do — autostart not implemented for ${r.platform} yet`); return; }
@@ -958,7 +982,7 @@ async function runServiceCommand(action = 'status', opts = {}) {
958
982
  return;
959
983
  }
960
984
 
961
- console.log(`unknown service action: ${action} (use install | uninstall | status | run)`);
985
+ console.log(`unknown service action: ${action} (use install | start | uninstall | status | run)`);
962
986
  }
963
987
 
964
988
  async function main() {
@@ -466,3 +466,22 @@ export function pickDefaultAgent(detected) {
466
466
  if (anyAvailable) return anyAvailable;
467
467
  return detected[0] || DEFAULT_AGENTS[0];
468
468
  }
469
+
470
+ // Re-pick the active agent from a FRESH detection, used by the readiness gate's
471
+ // "I installed it / signed in" retry. THE BUG THIS FIXES: detection runs once at
472
+ // startup, so a brand-new user who installs Claude AFTER the workspace booted was
473
+ // stuck — the gate kept saying "missing"/"login" until a full restart, because the
474
+ // frozen `activeAgent` stayed `available:false`. Re-detecting on retry flips it.
475
+ //
476
+ // We REFRESH the currently-active agent (so a deliberate switch to GLM/Codex isn't
477
+ // clobbered back to the default) and only re-pick the default when the current
478
+ // agent is gone or still unavailable. Never invents availability — a still-missing
479
+ // agent stays missing, so the gate can't false-pass.
480
+ export function refreshActiveAgent(detected, current) {
481
+ const list = Array.isArray(detected) ? detected : [];
482
+ if (current) {
483
+ const same = list.find((a) => a.id === current.id);
484
+ if (same && same.available) return same;
485
+ }
486
+ return pickDefaultAgent(list);
487
+ }
@@ -21,7 +21,7 @@ import {
21
21
  assertSecureBinding,
22
22
  isLocalhost,
23
23
  } from './config.mjs';
24
- import { detectAgents, AgentSession, pickDefaultAgent } from './agent.mjs';
24
+ import { detectAgents, AgentSession, pickDefaultAgent, refreshActiveAgent } from './agent.mjs';
25
25
  import {
26
26
  mintShareToken,
27
27
  mintDeviceToken,
@@ -87,6 +87,7 @@ import { runDoctor } from './doctor.mjs';
87
87
  import { appendLine, tailFile, logFile, listLogs, TAILABLE, globalDir } from './logpaths.mjs';
88
88
  import { createBackgroundTasks } from './background-tasks.mjs';
89
89
  import { createTickets } from './support/tickets.mjs';
90
+ import { createTicketSyncer } from './support/ticket-sync.mjs';
90
91
  import { SessionReporter } from './session-reporter.mjs';
91
92
  import { TranscriptRecorder } from './transcript.mjs';
92
93
  import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
@@ -648,6 +649,14 @@ export async function createServer(overrides = {}) {
648
649
  .finally(() => clearTimeout(t));
649
650
  }
650
651
 
652
+ // Dedup'd push to the team's rails. Swept at every turn end AND by the Requests-block
653
+ // poll — so a ticket the agent files via mcp__tickets__file_ticket (a separate process
654
+ // that only writes the local store, never calls syncTicket) still reaches the team
655
+ // without the user having the Requests block open. See support/ticket-sync.mjs.
656
+ const ticketSync = createTicketSyncer({ send: syncTicket, list: () => tickets.list() });
657
+ const syncTicketOnce = (rec) => ticketSync.syncOnce(rec);
658
+ const syncUnsyncedTickets = () => ticketSync.syncAll();
659
+
651
660
  /**
652
661
  * Run one chat turn: spawn the agent, stream every chunk to every chat
653
662
  * client, and persist the resulting session id so the next turn resumes it.
@@ -810,6 +819,10 @@ export async function createServer(overrides = {}) {
810
819
  } catch { /* best-effort — never break a turn on a registry hiccup */ }
811
820
  broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
812
821
  activityBus.publish({ type: 'chat-end', messageId: id, code });
822
+ // A user turn may have filed/updated a ticket via mcp__tickets__file_ticket
823
+ // (a separate process that only writes the local store). Sweep + push them to
824
+ // the team now, so agent-filed tickets sync without the user opening Requests.
825
+ if (!auto) syncUnsyncedTickets();
813
826
  });
814
827
  session.send(prompt, {
815
828
  cwd: workspace.dir,
@@ -1541,6 +1554,20 @@ export async function createServer(overrides = {}) {
1541
1554
  if (!fresh && _readinessCache && now - _readinessCache.at < READINESS_TTL_MS) {
1542
1555
  return c.json({ agent: agentTag(activeAgent), ...(_readinessCache.verdict) });
1543
1556
  }
1557
+ // On a forced refresh (the gate's "I installed it / signed in" button), RE-DETECT
1558
+ // agents BEFORE probing. A brand-new user installs Claude AFTER the workspace
1559
+ // booted; detection is otherwise frozen at startup, so the gate stayed stuck on
1560
+ // "missing"/"login" until a full restart (the exact "I installed it but it still
1561
+ // says missing" trap). refreshActiveAgent keeps a deliberately-chosen agent and
1562
+ // only flips a now-installed binary to available — it never false-passes.
1563
+ if (fresh) {
1564
+ try {
1565
+ const redetected = (await operatorDeps.detectAgents()) || [];
1566
+ activeAgent = refreshActiveAgent(redetected, activeAgent);
1567
+ } catch (e) {
1568
+ log('[onboarding]', `readiness re-detect failed: ${e?.message || e}`);
1569
+ }
1570
+ }
1544
1571
  const verdict = await probeAgentReadiness(activeAgent);
1545
1572
  _readinessCache = { at: now, verdict };
1546
1573
  log('[onboarding]', `readiness agent=${activeAgent?.id || '-'} status=${verdict.status}${verdict.email ? ` email=${verdict.email}` : ''}`);
@@ -2799,15 +2826,13 @@ export async function createServer(overrides = {}) {
2799
2826
  // --- Feature/bug tickets (item 7) — the Requests block + the agent's file_ticket --
2800
2827
  // The agent files via the support MCP (mcp__tickets__file_ticket) into the same
2801
2828
  // store; the user files/updates here. Owner-gated. Tickets sync to the team's tracker
2802
- // best-effort, once each (a runaway list-poll never re-POSTs the same ticket).
2803
- const syncedTicketIds = new Set();
2829
+ // best-effort, once each via syncTicketOnce/syncUnsyncedTickets (defined up top, also
2830
+ // swept at every turn end so agent-filed tickets sync without opening this block).
2804
2831
  app.get('/api/workspace/tickets', (c) => {
2805
2832
  const forbidden = require(c, 'chatWrite');
2806
2833
  if (forbidden) return forbidden;
2807
2834
  const list = tickets.list();
2808
- for (const t of list) {
2809
- if (!syncedTicketIds.has(t.id)) { syncedTicketIds.add(t.id); syncTicket(t); }
2810
- }
2835
+ for (const t of list) syncTicketOnce(t);
2811
2836
  return c.json({ tickets: list });
2812
2837
  });
2813
2838
  app.post('/api/workspace/tickets', async (c) => {
@@ -2819,7 +2844,7 @@ export async function createServer(overrides = {}) {
2819
2844
  title: body.title, desc: body.desc, category: body.category, severity: body.severity,
2820
2845
  workspaceId: workspaceFor(c).id, source: 'user',
2821
2846
  });
2822
- syncedTicketIds.add(rec.id); syncTicket(rec);
2847
+ syncTicketOnce(rec);
2823
2848
  auditAction(c, 'tickets.file', `id=${rec.id} cat=${rec.category}`);
2824
2849
  return c.json({ ticket: rec });
2825
2850
  });
@@ -2829,7 +2854,7 @@ export async function createServer(overrides = {}) {
2829
2854
  const body = await c.req.json().catch(() => ({}));
2830
2855
  const rec = tickets.update(c.req.param('id'), { status: body.status, note: body.note });
2831
2856
  if (!rec) return c.json({ error: 'not_found' }, 404);
2832
- syncTicket(rec);
2857
+ syncTicketOnce(rec);
2833
2858
  auditAction(c, 'tickets.update', `id=${rec.id} status=${rec.status}`);
2834
2859
  return c.json({ ticket: rec });
2835
2860
  });
@@ -453,6 +453,76 @@ export async function installService(opts = {}, deps = {}) {
453
453
  return unsupported(platform, 'installed');
454
454
  }
455
455
 
456
+ // Bring the installed always-on supervisor UP NOW, rather than waiting for the next
457
+ // login. `installService` deliberately does NOT auto-start (a manually-run
458
+ // `wild-workspace` could already hold :5173, and starting a second server collides).
459
+ // But the BOOTSTRAP installer runs in a CLEAN context — nothing on the port — so it
460
+ // follows `service install` with `service start` so a brand-new user gets a live URL
461
+ // without logging out/in. IDEMPOTENT: if a supervisor already holds the singleton
462
+ // lock we no-op, so a redundant start (or an existing user) never double-starts.
463
+ export async function startService(opts = {}, deps = {}) {
464
+ const platform = deps.platform || process.platform;
465
+ const dir = deps.dir || globalDir();
466
+ const { supervisorAlive } = supervisorLiveness(dir);
467
+ if (supervisorAlive) return { started: false, alreadyRunning: true, platform };
468
+ if (platform === 'darwin') {
469
+ return macStart(
470
+ { uid: deps.uid ?? currentUid() },
471
+ { launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(), execFileImpl: deps.execFileImpl || execFileP },
472
+ );
473
+ }
474
+ if (platform === 'linux') {
475
+ return linuxStart({}, { execFileImpl: deps.execFileImpl || execFileP });
476
+ }
477
+ if (platform === 'win32') {
478
+ return winStart({ dir }, { spawnImpl: deps.spawnImpl || spawn });
479
+ }
480
+ return unsupported(platform, 'started');
481
+ }
482
+
483
+ async function macStart({ uid }, { launchAgentsDir, execFileImpl }) {
484
+ const plist = plistPath(launchAgentsDir);
485
+ if (!fs.existsSync(plist)) return { started: false, reason: 'not-installed', platform: 'darwin' };
486
+ const domain = `gui/${uid}`;
487
+ // bootstrap LOADS the agent → RunAtLoad starts it. If it's already loaded from a
488
+ // prior login, bootstrap errors → kickstart it instead to force a (re)start.
489
+ try {
490
+ await execFileImpl('launchctl', ['bootstrap', domain, plist]);
491
+ return { started: true, method: 'launchctl-bootstrap', platform: 'darwin' };
492
+ } catch {
493
+ try {
494
+ await execFileImpl('launchctl', ['kickstart', '-k', `${domain}/${LAUNCHD_LABEL}`]);
495
+ return { started: true, method: 'launchctl-kickstart', platform: 'darwin' };
496
+ } catch (e) {
497
+ return { started: false, error: String(e?.message || e).split('\n')[0], platform: 'darwin' };
498
+ }
499
+ }
500
+ }
501
+
502
+ async function linuxStart(_opts, { execFileImpl }) {
503
+ try {
504
+ await execFileImpl('systemctl', ['--user', 'start', SYSTEMD_UNIT]);
505
+ return { started: true, method: 'systemctl-start', platform: 'linux' };
506
+ } catch (e) {
507
+ return { started: false, error: String(e?.message || e).split('\n')[0], platform: 'linux' };
508
+ }
509
+ }
510
+
511
+ function winStart({ dir }, { spawnImpl }) {
512
+ // Launch the same hidden VBS that HKCU\Run uses, detached, so the supervisor comes
513
+ // up now AND outlives this process. (At next login HKCU\Run launches another; the
514
+ // singleton lock makes it exit — no respawn loop, since HKCU\Run is one-shot.)
515
+ const vbs = path.join(dir, 'launch-hidden.vbs');
516
+ if (!fs.existsSync(vbs)) return { started: false, reason: 'not-installed', platform: 'win32' };
517
+ try {
518
+ const child = spawnImpl('wscript.exe', [vbs], { detached: true, stdio: 'ignore', windowsHide: true });
519
+ if (child && typeof child.unref === 'function') child.unref();
520
+ return { started: true, method: 'wscript-vbs', platform: 'win32' };
521
+ } catch (e) {
522
+ return { started: false, error: String(e?.message || e).split('\n')[0], platform: 'win32' };
523
+ }
524
+ }
525
+
456
526
  export async function uninstallService(deps = {}) {
457
527
  const platform = deps.platform || process.platform;
458
528
  if (platform === 'win32') {
@@ -80,7 +80,7 @@ async function callTool(name, args = {}) {
80
80
  });
81
81
  return textContent({
82
82
  kind: 'ticket', op: 'file', ticket: rec,
83
- note: "Request filed. It's on the user's Requests block + synced to VentureWild. Tell them in one short line what you logged.",
83
+ note: "Request filed to the user's Requests block; the server reports it to the VentureWild team when this turn ends. Tell them in one short line what you logged.",
84
84
  });
85
85
  }
86
86
  case 'list_tickets': {
@@ -0,0 +1,44 @@
1
+ // Dedup wrapper around the best-effort "push a ticket to the team's rails" send.
2
+ //
3
+ // The bug this fixes: a ticket the agent files via mcp__tickets__file_ticket lands in
4
+ // the local store from a SEPARATE process that never calls the server's syncTicket — so
5
+ // it only reached the rails when the web Requests block happened to poll. This syncer is
6
+ // swept at the end of every chat turn (and from the poll), so agent-filed tickets sync
7
+ // without the user opening anything.
8
+ //
9
+ // Dedup key = id + updatedAt + status + note-count, so each ticket VERSION is sent at
10
+ // most once, but a later change re-sends. We include status/note-count (not just
11
+ // updatedAt) because two writes can land in the SAME millisecond — an agent that files
12
+ // then immediately updates a ticket would otherwise collide on updatedAt and the change
13
+ // would never sync. Re-sending is safe: the rails ingest upserts by ticket_id. The
14
+ // `sent` set is in-memory — a server restart re-syncs the store on the next sweep.
15
+
16
+ export function createTicketSyncer({ send, list }) {
17
+ const sent = new Set();
18
+
19
+ function sig(rec) {
20
+ const notes = Array.isArray(rec.notes) ? rec.notes.length : 0;
21
+ return `${rec.id}:${rec.updatedAt || rec.createdAt || 0}:${rec.status || ''}:${notes}`;
22
+ }
23
+
24
+ // Send one ticket if this version hasn't been sent yet. Returns true if it sent.
25
+ function syncOnce(rec) {
26
+ if (!rec || typeof rec.id !== 'string') return false;
27
+ const key = sig(rec);
28
+ if (sent.has(key)) return false;
29
+ sent.add(key);
30
+ try { send(rec); } catch { /* best-effort — the local store is the source of truth */ }
31
+ return true;
32
+ }
33
+
34
+ // Sweep the whole store, sending every not-yet-sent version. Returns how many sent.
35
+ function syncAll() {
36
+ let recs;
37
+ try { recs = list() || []; } catch { return 0; }
38
+ let n = 0;
39
+ for (const r of recs) { if (syncOnce(r)) n += 1; }
40
+ return n;
41
+ }
42
+
43
+ return { syncOnce, syncAll };
44
+ }