@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 +1 -1
- package/server/bin/wild-workspace.mjs +31 -7
- package/server/src/agent.mjs +19 -0
- package/server/src/index.mjs +33 -8
- package/server/src/service.mjs +70 -0
- package/server/src/support/mcp-server.mjs +1 -1
- package/server/src/support/ticket-sync.mjs +44 -0
- package/web/dist/assets/index-CUcUGKJC.css +32 -0
- package/web/dist/assets/index-Ccr8fqed.js +131 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-ABxdF5Rs.css +0 -32
- package/web/dist/assets/index-CPnHHTR6.js +0 -131
package/package.json
CHANGED
|
@@ -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
|
|
287
|
-
//
|
|
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)
|
|
293
|
-
|
|
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() {
|
package/server/src/agent.mjs
CHANGED
|
@@ -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
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -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
|
|
2803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2857
|
+
syncTicketOnce(rec);
|
|
2833
2858
|
auditAction(c, 'tickets.update', `id=${rec.id} status=${rec.status}`);
|
|
2834
2859
|
return c.json({ ticket: rec });
|
|
2835
2860
|
});
|
package/server/src/service.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|