@venturewild/workspace 0.3.8 → 0.4.1
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 +101 -0
- package/server/src/index.mjs +529 -48
- package/server/src/workspace-registry.mjs +295 -0
- package/server/src/workspaces.mjs +135 -0
- package/web/dist/assets/index-DahRXN26.js +91 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-B44y93r4.js +0 -91
package/server/src/index.mjs
CHANGED
|
@@ -59,6 +59,18 @@ import { createCanvasRails } from './canvas-rails.mjs';
|
|
|
59
59
|
import { createUsageService } from './usage.mjs';
|
|
60
60
|
import { createPowers } from './skills.mjs';
|
|
61
61
|
import { createSettings, resolveAutonomyMode } from './settings.mjs';
|
|
62
|
+
import {
|
|
63
|
+
listWorkspaces,
|
|
64
|
+
getWorkspace,
|
|
65
|
+
createWorkspace as registryCreateWorkspace,
|
|
66
|
+
touchWorkspace,
|
|
67
|
+
removeWorkspace,
|
|
68
|
+
markShared,
|
|
69
|
+
setSyncProject,
|
|
70
|
+
seedDefault,
|
|
71
|
+
workspaceIdForDir,
|
|
72
|
+
} from './workspace-registry.mjs';
|
|
73
|
+
import { createWorkspaceRails } from './workspaces.mjs';
|
|
62
74
|
import { matchCandidates } from './bazaar/mock-tickup.mjs';
|
|
63
75
|
import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
|
|
64
76
|
import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
|
|
@@ -110,6 +122,9 @@ function loadChatSessionId(dataDir) {
|
|
|
110
122
|
}
|
|
111
123
|
function saveChatSessionId(dataDir, sessionId) {
|
|
112
124
|
try {
|
|
125
|
+
// A freshly-created workspace has no .wild-workspace/ dir yet — make it so the
|
|
126
|
+
// per-workspace conversation persists from the very first turn (lobby M1).
|
|
127
|
+
mkdirSync(dataDir, { recursive: true });
|
|
113
128
|
writeFileSync(
|
|
114
129
|
chatSessionPath(dataDir),
|
|
115
130
|
JSON.stringify({ sessionId: sessionId || null }, null, 2),
|
|
@@ -323,6 +338,12 @@ export async function createServer(overrides = {}) {
|
|
|
323
338
|
// cache. `canvas` above still owns the workspace-SHARED agent content (blocks +
|
|
324
339
|
// agent theme). Person-scoped state stores are built lazily + cached per personKey.
|
|
325
340
|
const canvasRails = overrides.canvasRails || createCanvasRails(config, config.account);
|
|
341
|
+
// Shared-workspace membership client (sharing slice — design §4). The account
|
|
342
|
+
// token is kept top-level on config (out of the broadcast `config.account`), so
|
|
343
|
+
// pass it explicitly, mirroring the CLI `wild-workspace workspace …`. Inert (and
|
|
344
|
+
// every call returns {ok:false, code:'not_configured'}) until the owner logs in.
|
|
345
|
+
const workspaceRails =
|
|
346
|
+
overrides.workspaceRails || createWorkspaceRails(config, { accountToken: config.accountToken });
|
|
326
347
|
const canvasStores = new Map();
|
|
327
348
|
function canvasFor(personKey) {
|
|
328
349
|
let store = canvasStores.get(personKey);
|
|
@@ -400,9 +421,29 @@ export async function createServer(overrides = {}) {
|
|
|
400
421
|
// One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
|
|
401
422
|
// Both user sends and auto-wake turns thread through one turn-runner so they
|
|
402
423
|
// share the agent's memory and never run two claude processes at once.
|
|
403
|
-
let chatSessionId = loadChatSessionId(config.dataDir);
|
|
404
424
|
const chatClients = new Set(); // every connected /ws/chat socket
|
|
405
|
-
|
|
425
|
+
// Per-workspace live turns (lobby M2): one in-flight agent turn PER workspace,
|
|
426
|
+
// keyed by workspace id, so a task started in workspace A keeps running when you
|
|
427
|
+
// hop to B (design §8.1 decision 6 — "switching keeps in-flight work alive"). A
|
|
428
|
+
// user send supersedes only its OWN workspace's turn. Capped at
|
|
429
|
+
// MAX_CONCURRENT_TURNS live workspaces (reject, don't queue — the user resends,
|
|
430
|
+
// auto-wake self-retries): a `claude -p` turn measured ~340 MB peak working set
|
|
431
|
+
// (full-context turn in this repo, 2026-06-13), so 3 concurrent ≈ 1 GB — safe
|
|
432
|
+
// headroom on any modern machine, while the cap stops an unbounded fan-out from
|
|
433
|
+
// OOMing a laptop. Raise/lower via WILD_WORKSPACE_MAX_CONCURRENT_TURNS.
|
|
434
|
+
const currentTurns = new Map(); // workspace.id → { session, messageId, workspaceId }
|
|
435
|
+
const MAX_CONCURRENT_TURNS =
|
|
436
|
+
overrides.maxConcurrentTurns ??
|
|
437
|
+
Math.max(1, Number(process.env.WILD_WORKSPACE_MAX_CONCURRENT_TURNS) || 3);
|
|
438
|
+
// Drop a workspace's turn entry ONLY if it still points at THIS session. A
|
|
439
|
+
// superseded turn's `end` fires a tick AFTER its proc is killed (the 'close'
|
|
440
|
+
// event), by which point the replacement turn may already own the slot — so an
|
|
441
|
+
// unconditional delete would evict the live replacement. The identity check
|
|
442
|
+
// prevents that race.
|
|
443
|
+
function clearTurnIf(workspaceId, session) {
|
|
444
|
+
const t = currentTurns.get(workspaceId);
|
|
445
|
+
if (t && t.session === session) currentTurns.delete(workspaceId);
|
|
446
|
+
}
|
|
406
447
|
|
|
407
448
|
// Per-connection chat rate limit (SECURITY.md S6 / concern C4). Every send
|
|
408
449
|
// spawns an agent subprocess that costs real API tokens, so cap the burst a
|
|
@@ -416,10 +457,16 @@ export async function createServer(overrides = {}) {
|
|
|
416
457
|
(Number(process.env.WILD_WORKSPACE_CHAT_RATE_WINDOW_MS) || 60_000),
|
|
417
458
|
};
|
|
418
459
|
|
|
419
|
-
|
|
460
|
+
// Broadcast a chat frame. When `workspaceId` is given, only sockets attached to
|
|
461
|
+
// that workspace receive it — so a tab viewing workspace B never renders a turn
|
|
462
|
+
// that belongs to A (lobby M1: per-workspace conversations over one WS pool).
|
|
463
|
+
// Frames with no workspaceId (e.g. the usage gauge, bazaar meter) go to all.
|
|
464
|
+
function broadcastChat(obj, workspaceId) {
|
|
420
465
|
const data = JSON.stringify(obj);
|
|
421
466
|
for (const ws of chatClients) {
|
|
422
|
-
if (ws.readyState
|
|
467
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
468
|
+
if (workspaceId && ws._wsWorkspaceId !== workspaceId) continue;
|
|
469
|
+
ws.send(data);
|
|
423
470
|
}
|
|
424
471
|
}
|
|
425
472
|
|
|
@@ -432,19 +479,40 @@ export async function createServer(overrides = {}) {
|
|
|
432
479
|
* and retries once if the run fails (PRD §13 A8).
|
|
433
480
|
* Returns false if the turn could not start (an auto turn while busy).
|
|
434
481
|
*/
|
|
435
|
-
function runChatTurn({ prompt, mode, messageId, userText, note, auto = false }) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
482
|
+
function runChatTurn({ prompt, mode, messageId, userText, note, auto = false, workspace = defaultWorkspace }) {
|
|
483
|
+
const id = messageId || nanoid(8);
|
|
484
|
+
// Supersede only THIS workspace's in-flight turn — a send in B must never kill
|
|
485
|
+
// a turn still running in A (lobby M2).
|
|
486
|
+
const live = currentTurns.get(workspace.id);
|
|
487
|
+
if (live) {
|
|
488
|
+
if (auto) return false; // auto-wake yields to a live turn in this workspace
|
|
489
|
+
live.session.close(); // a user send supersedes what's running here
|
|
490
|
+
currentTurns.delete(workspace.id);
|
|
491
|
+
}
|
|
492
|
+
// Concurrency cap (after the per-workspace supersede, before spawn): too many
|
|
493
|
+
// OTHER workspaces are already mid-turn. Reject — don't queue — and tell the
|
|
494
|
+
// tab so the user can resend (an auto-wake re-queues itself, so it stays quiet).
|
|
495
|
+
if (currentTurns.size >= MAX_CONCURRENT_TURNS) {
|
|
496
|
+
log('[chat]', `cap reached (${currentTurns.size}/${MAX_CONCURRENT_TURNS}) — rejecting ${auto ? 'auto' : 'user'} turn in ${workspace.id}`);
|
|
497
|
+
if (!auto) {
|
|
498
|
+
broadcastChat({
|
|
499
|
+
type: 'error',
|
|
500
|
+
messageId: id,
|
|
501
|
+
message: `${MAX_CONCURRENT_TURNS} workspaces are already working at once. Wait for one to finish, then resend.`,
|
|
502
|
+
}, workspace.id);
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
440
505
|
}
|
|
441
506
|
// Apply the autonomy dial (§8). Auto-wake is exempt — it's the system consent
|
|
442
507
|
// boundary and stays in the plan mode it was given. For a USER turn we resolve
|
|
443
508
|
// the stored level + the per-message toggle through resolveAutonomyMode, whose
|
|
444
509
|
// guardrail guarantees an unbuilt/unknown level can NEVER become bypassPermissions.
|
|
445
510
|
if (!auto) mode = resolveAutonomyMode(settings.getAutonomyLevel(), mode);
|
|
446
|
-
|
|
447
|
-
|
|
511
|
+
// Per-workspace conversation (lobby M1): resume THIS workspace's claude session
|
|
512
|
+
// from its own dataDir, and tag every frame with the workspace so it streams
|
|
513
|
+
// only to tabs viewing it.
|
|
514
|
+
const resumeId = loadChatSessionId(workspace.dataDir);
|
|
515
|
+
broadcastChat({ type: 'turn-begin', messageId: id, userText, note }, workspace.id);
|
|
448
516
|
activityBus.publish({
|
|
449
517
|
type: 'chat-user',
|
|
450
518
|
messageId: id,
|
|
@@ -456,7 +524,7 @@ export async function createServer(overrides = {}) {
|
|
|
456
524
|
const startedAt = Date.now();
|
|
457
525
|
log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
|
|
458
526
|
const session = new AgentSession(activeAgent);
|
|
459
|
-
|
|
527
|
+
currentTurns.set(workspace.id, { session, messageId: id, workspaceId: workspace.id });
|
|
460
528
|
let sawError = false;
|
|
461
529
|
session.on('chunk', (chunk) => {
|
|
462
530
|
// The per-turn context signal (working-memory gauge, §8) feeds the usage
|
|
@@ -467,7 +535,7 @@ export async function createServer(overrides = {}) {
|
|
|
467
535
|
return;
|
|
468
536
|
}
|
|
469
537
|
if (chunk.type === 'error') sawError = true;
|
|
470
|
-
broadcastChat({ type: 'chunk', messageId: id, chunk });
|
|
538
|
+
broadcastChat({ type: 'chunk', messageId: id, chunk }, workspace.id);
|
|
471
539
|
activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
|
|
472
540
|
// Surface the turn's token/cost totals so the activity bar can show
|
|
473
541
|
// running usage — the ActivityBus accumulates events typed 'usage'.
|
|
@@ -478,7 +546,7 @@ export async function createServer(overrides = {}) {
|
|
|
478
546
|
session.on('stderr', (text) => {
|
|
479
547
|
const trimmed = String(text || '').trim();
|
|
480
548
|
if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
|
|
481
|
-
broadcastChat({ type: 'stderr', messageId: id, text });
|
|
549
|
+
broadcastChat({ type: 'stderr', messageId: id, text }, workspace.id);
|
|
482
550
|
});
|
|
483
551
|
session.on('error', (err) => {
|
|
484
552
|
sawError = true;
|
|
@@ -494,11 +562,11 @@ export async function createServer(overrides = {}) {
|
|
|
494
562
|
type: 'error',
|
|
495
563
|
messageId: id,
|
|
496
564
|
message: msg,
|
|
497
|
-
});
|
|
498
|
-
|
|
565
|
+
}, workspace.id);
|
|
566
|
+
clearTurnIf(workspace.id, session);
|
|
499
567
|
});
|
|
500
568
|
session.on('end', ({ code }) => {
|
|
501
|
-
|
|
569
|
+
clearTurnIf(workspace.id, session);
|
|
502
570
|
const elapsed = Date.now() - startedAt;
|
|
503
571
|
log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
|
|
504
572
|
// Silent agent crash → telemetry. A non-zero exit (or signal-kill =
|
|
@@ -528,17 +596,16 @@ export async function createServer(overrides = {}) {
|
|
|
528
596
|
return;
|
|
529
597
|
}
|
|
530
598
|
if (session.sessionId) {
|
|
531
|
-
|
|
532
|
-
saveChatSessionId(config.dataDir, chatSessionId);
|
|
599
|
+
saveChatSessionId(workspace.dataDir, session.sessionId);
|
|
533
600
|
}
|
|
534
601
|
}
|
|
535
|
-
broadcastChat({ type: 'end', messageId: id, code });
|
|
602
|
+
broadcastChat({ type: 'end', messageId: id, code }, workspace.id);
|
|
536
603
|
activityBus.publish({ type: 'chat-end', messageId: id, code });
|
|
537
604
|
});
|
|
538
605
|
session.send(prompt, {
|
|
539
|
-
cwd:
|
|
606
|
+
cwd: workspace.dir,
|
|
540
607
|
mode,
|
|
541
|
-
resumeSessionId:
|
|
608
|
+
resumeSessionId: resumeId,
|
|
542
609
|
// Auto-wake (import integration, plan mode) stays bazaar-free; only the
|
|
543
610
|
// user's own turns get the marketplace tools + disposition.
|
|
544
611
|
...(auto ? {} : turnCtx),
|
|
@@ -548,14 +615,16 @@ export async function createServer(overrides = {}) {
|
|
|
548
615
|
return true;
|
|
549
616
|
}
|
|
550
617
|
|
|
551
|
-
function resetChat() {
|
|
552
|
-
if
|
|
553
|
-
|
|
554
|
-
|
|
618
|
+
function resetChat(workspace = defaultWorkspace) {
|
|
619
|
+
// Only stop the live turn if it belongs to THIS workspace — a "new chat" in B
|
|
620
|
+
// must never kill a turn still running in A (lobby M1/M2).
|
|
621
|
+
const live = currentTurns.get(workspace.id);
|
|
622
|
+
if (live) {
|
|
623
|
+
live.session.close();
|
|
624
|
+
currentTurns.delete(workspace.id);
|
|
555
625
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
broadcastChat({ type: 'reset' });
|
|
626
|
+
saveChatSessionId(workspace.dataDir, null);
|
|
627
|
+
broadcastChat({ type: 'reset' }, workspace.id);
|
|
559
628
|
}
|
|
560
629
|
|
|
561
630
|
// --- auto-wake on import (AR-23) ------------------------------------------
|
|
@@ -734,6 +803,36 @@ export async function createServer(overrides = {}) {
|
|
|
734
803
|
return null;
|
|
735
804
|
}
|
|
736
805
|
|
|
806
|
+
// --- multi-workspace re-root (lobby M1) ---
|
|
807
|
+
// One install can hold many workspaces; the ACTIVE one is per-REQUEST, carried
|
|
808
|
+
// by the `X-Workspace-Id` header that each browser tab sets (a cookie can't be
|
|
809
|
+
// per-tab — it's shared across tabs). `workspaceFor(c)` resolves it to
|
|
810
|
+
// {id, dir, dataDir, name}; absent/unknown falls back to the boot default. The
|
|
811
|
+
// boot/default workspace's id is COMPUTED from its dir (never persisted), so
|
|
812
|
+
// startup writes nothing and the registry holds only workspaces the user adds.
|
|
813
|
+
const registryEnv = overrides.registryEnv || process.env;
|
|
814
|
+
const defaultWorkspace = {
|
|
815
|
+
id: workspaceIdForDir(config.workspaceDir),
|
|
816
|
+
dir: config.workspaceDir,
|
|
817
|
+
dataDir: config.dataDir, // preserves any WILD_WORKSPACE_DATA_DIR override
|
|
818
|
+
name: config.workspaceId,
|
|
819
|
+
isDefault: true,
|
|
820
|
+
};
|
|
821
|
+
function resolveWorkspace(id) {
|
|
822
|
+
if (!id || id === defaultWorkspace.id) return defaultWorkspace;
|
|
823
|
+
const w = getWorkspace(id, registryEnv);
|
|
824
|
+
if (!w) return defaultWorkspace; // unknown id → safe fallback to the default
|
|
825
|
+
return {
|
|
826
|
+
id: w.id,
|
|
827
|
+
dir: w.dir,
|
|
828
|
+
dataDir: path.join(w.dir, '.wild-workspace'),
|
|
829
|
+
name: w.name,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
function workspaceFor(c) {
|
|
833
|
+
return c.get('workspace') || resolveWorkspace(c.req.header('x-workspace-id'));
|
|
834
|
+
}
|
|
835
|
+
|
|
737
836
|
// Persistent audit trail for privileged actions (SECURITY.md S8). The [http]
|
|
738
837
|
// line is ephemeral (stdout, rotated); this records WHO did WHAT to a durable
|
|
739
838
|
// log under ~/.wild-workspace (outside the synced repo) that doctor/logs and
|
|
@@ -803,6 +902,9 @@ export async function createServer(overrides = {}) {
|
|
|
803
902
|
const session = await resolveRole(c);
|
|
804
903
|
c.set('role', session.role);
|
|
805
904
|
c.set('session', session);
|
|
905
|
+
// Resolve the active workspace for this request (lobby M1). Per-tab via the
|
|
906
|
+
// X-Workspace-Id header; absent/unknown → the boot default.
|
|
907
|
+
c.set('workspace', resolveWorkspace(c.req.header('x-workspace-id')));
|
|
806
908
|
// Block the API for denied (non-localhost, unauthenticated) requests, but
|
|
807
909
|
// let static assets + the public endpoints through so the SPA can still
|
|
808
910
|
// load and prompt for sign-in. (Concern C1.)
|
|
@@ -831,13 +933,24 @@ export async function createServer(overrides = {}) {
|
|
|
831
933
|
app.get('/api/session', (c) => {
|
|
832
934
|
const session = c.get('session');
|
|
833
935
|
const role = c.get('role');
|
|
834
|
-
const
|
|
936
|
+
const ws = workspaceFor(c);
|
|
937
|
+
// A different agent per workspace (design §8.1 decision 4): the identity +
|
|
938
|
+
// onboarded flag are read from the ACTIVE workspace's own dataDir, so each
|
|
939
|
+
// workspace has its own named agent and "meet your agent" is a per-workspace
|
|
940
|
+
// first-entry moment. A brand-new workspace has no identity yet → onboarded
|
|
941
|
+
// is false → the client runs the meet-your-agent beat for it.
|
|
942
|
+
const identity = loadIdentity(ws.dataDir);
|
|
835
943
|
return c.json({
|
|
836
944
|
version: APP_VERSION,
|
|
837
945
|
role,
|
|
838
946
|
capabilities: ROLE_CAPABILITIES[role],
|
|
839
|
-
workspace: workspaceSummary(
|
|
947
|
+
workspace: workspaceSummary(ws.dir),
|
|
840
948
|
workspaceId: config.workspaceId,
|
|
949
|
+
// The active workspace for THIS request/tab (lobby M1). Distinct from the
|
|
950
|
+
// legacy `workspaceId` (the boot default's folder name, kept stable so the
|
|
951
|
+
// canvas localStorage cache key doesn't churn). The client echoes
|
|
952
|
+
// `currentWorkspace.id` back in X-Workspace-Id to stay on this workspace.
|
|
953
|
+
currentWorkspace: { id: ws.id, name: ws.name, isDefault: Boolean(ws.isDefault) },
|
|
841
954
|
session,
|
|
842
955
|
// The identity that scopes this person's canvas state (multi-host step 1).
|
|
843
956
|
// The client folds it into its localStorage cache key so two people on one
|
|
@@ -1157,7 +1270,7 @@ export async function createServer(overrides = {}) {
|
|
|
1157
1270
|
// Persisted to <dataDir>/agent-identity.json. Absence of this file is the
|
|
1158
1271
|
// signal the UI uses to launch the 5-step onboarding flow.
|
|
1159
1272
|
app.get('/api/agent/identity', (c) => {
|
|
1160
|
-
const identity = loadIdentity(
|
|
1273
|
+
const identity = loadIdentity(workspaceFor(c).dataDir);
|
|
1161
1274
|
return c.json({ identity, tones: TONES });
|
|
1162
1275
|
});
|
|
1163
1276
|
|
|
@@ -1458,7 +1571,7 @@ export async function createServer(overrides = {}) {
|
|
|
1458
1571
|
if (forbidden) return forbidden;
|
|
1459
1572
|
const body = await c.req.json().catch(() => ({}));
|
|
1460
1573
|
try {
|
|
1461
|
-
const saved = saveIdentity(
|
|
1574
|
+
const saved = saveIdentity(workspaceFor(c).dataDir, {
|
|
1462
1575
|
name: body.name,
|
|
1463
1576
|
tone: body.tone,
|
|
1464
1577
|
color: body.color,
|
|
@@ -1476,7 +1589,7 @@ export async function createServer(overrides = {}) {
|
|
|
1476
1589
|
const forbidden = require(c, 'chatWrite');
|
|
1477
1590
|
if (forbidden) return forbidden;
|
|
1478
1591
|
try {
|
|
1479
|
-
const saved = markOnboarded(
|
|
1592
|
+
const saved = markOnboarded(workspaceFor(c).dataDir);
|
|
1480
1593
|
log('[onboarding]', `complete name=${saved.name}`);
|
|
1481
1594
|
activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
|
|
1482
1595
|
return c.json({ identity: saved });
|
|
@@ -1521,7 +1634,7 @@ export async function createServer(overrides = {}) {
|
|
|
1521
1634
|
: `--- ${f.path}`;
|
|
1522
1635
|
})
|
|
1523
1636
|
.join('\n');
|
|
1524
|
-
const identity = loadIdentity(
|
|
1637
|
+
const identity = loadIdentity(workspaceFor(c).dataDir);
|
|
1525
1638
|
const youAre = identity?.name
|
|
1526
1639
|
? `You are ${identity.name}, a ${identity.tone || 'concise'} AI assistant just meeting your human for the first time.`
|
|
1527
1640
|
: `You are an AI assistant just meeting your human for the first time.`;
|
|
@@ -1545,6 +1658,7 @@ export async function createServer(overrides = {}) {
|
|
|
1545
1658
|
messageId,
|
|
1546
1659
|
note: `👀 ${identity?.name || 'Your agent'} is looking at ${folderName}…`,
|
|
1547
1660
|
auto: true,
|
|
1661
|
+
workspace: workspaceFor(c),
|
|
1548
1662
|
});
|
|
1549
1663
|
return c.json({ ok: true, sampled: files.length, started: started !== false });
|
|
1550
1664
|
});
|
|
@@ -1568,7 +1682,7 @@ export async function createServer(overrides = {}) {
|
|
|
1568
1682
|
typeof body.peekFolderName === 'string'
|
|
1569
1683
|
? body.peekFolderName.slice(0, 80)
|
|
1570
1684
|
: null;
|
|
1571
|
-
const identity = loadIdentity(
|
|
1685
|
+
const identity = loadIdentity(workspaceFor(c).dataDir);
|
|
1572
1686
|
const tone = identity?.tone || 'concise';
|
|
1573
1687
|
const name = identity?.name || 'your agent';
|
|
1574
1688
|
const youAre = `You are ${name}, a ${tone} AI assistant. Your human just finished a 5-step onboarding and picked their first job. Stay in character.`;
|
|
@@ -1609,6 +1723,7 @@ export async function createServer(overrides = {}) {
|
|
|
1609
1723
|
messageId,
|
|
1610
1724
|
note,
|
|
1611
1725
|
auto: true,
|
|
1726
|
+
workspace: workspaceFor(c),
|
|
1612
1727
|
});
|
|
1613
1728
|
return c.json({ ok: true, started: started !== false });
|
|
1614
1729
|
});
|
|
@@ -1767,13 +1882,367 @@ export async function createServer(overrides = {}) {
|
|
|
1767
1882
|
});
|
|
1768
1883
|
|
|
1769
1884
|
// --- workspace files ---
|
|
1885
|
+
// --- lobby: the per-install workspace list (lobby M1) ---
|
|
1886
|
+
// One install holds many local workspaces; the lobby lists them and the user
|
|
1887
|
+
// picks one to enter (the client then sends X-Workspace-Id to re-root the agent,
|
|
1888
|
+
// files, and chat). Creating is a HOST action — the folder lives on THIS machine
|
|
1889
|
+
// — so it's refused for remote/tunnel requests; entering an existing one works
|
|
1890
|
+
// from anywhere. Gated on `fileTree` (owner-level: never expose the host's other
|
|
1891
|
+
// projects to a shared-link viewer/client). Distinct origin from the bmo-sync
|
|
1892
|
+
// rails `/api/workspaces/*` (those live on the sync server).
|
|
1893
|
+
const lobbyBootTime = Date.now();
|
|
1894
|
+
function isHostRequest(c) {
|
|
1895
|
+
const src = c.get('session')?.source;
|
|
1896
|
+
return src === 'localhost' || src === 'loopback';
|
|
1897
|
+
}
|
|
1898
|
+
// The client-facing shape of a lobby card. Shared workspaces (sharing slice)
|
|
1899
|
+
// carry their rails identity + the local member's role so the lobby can badge
|
|
1900
|
+
// the card + gate owner-only actions (invite/un-share).
|
|
1901
|
+
function lobbyShape(w) {
|
|
1902
|
+
return {
|
|
1903
|
+
id: w.id,
|
|
1904
|
+
name: w.name,
|
|
1905
|
+
dir: w.dir,
|
|
1906
|
+
kind: w.kind,
|
|
1907
|
+
shared: w.kind === 'shared' && w.shared ? w.shared : undefined,
|
|
1908
|
+
lastOpenedAt: w.lastOpenedAt,
|
|
1909
|
+
isDefault: w.id === defaultWorkspace.id,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
function lobbyList() {
|
|
1913
|
+
const reg = listWorkspaces(registryEnv);
|
|
1914
|
+
const items = reg.map(lobbyShape);
|
|
1915
|
+
// The boot/default workspace is computed (never persisted) — inject it if it
|
|
1916
|
+
// isn't already an explicit registry entry, so the lobby is never blank.
|
|
1917
|
+
if (!reg.some((w) => w.id === defaultWorkspace.id)) {
|
|
1918
|
+
items.push({
|
|
1919
|
+
id: defaultWorkspace.id,
|
|
1920
|
+
name: defaultWorkspace.name,
|
|
1921
|
+
dir: defaultWorkspace.dir,
|
|
1922
|
+
kind: 'local',
|
|
1923
|
+
lastOpenedAt: lobbyBootTime, // "the one you booted into" → sorts to the top
|
|
1924
|
+
isDefault: true,
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
return items.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// The shared workspaces THIS account belongs to, from the rails (sharing slice).
|
|
1931
|
+
// Degrade-never: not logged in / rails down → []; the lobby still shows local
|
|
1932
|
+
// workspaces. This is how a workspace someone shared with you APPEARS in your
|
|
1933
|
+
// lobby — before you've joined it, it has no local folder yet.
|
|
1934
|
+
async function sharedFromRails() {
|
|
1935
|
+
if (!config.account?.email || !workspaceRails.capable) return [];
|
|
1936
|
+
const r = await workspaceRails.list();
|
|
1937
|
+
if (!r.ok || !Array.isArray(r.data?.workspaces)) return [];
|
|
1938
|
+
return r.data.workspaces; // [{ slug, name, role, owner_email }]
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Merge the local registry with the rails membership: shared workspaces I belong
|
|
1942
|
+
// to but haven't given a local home yet surface as `joinable` cards (no dir,
|
|
1943
|
+
// can't be entered until joined). Ones I already host locally are deduped by slug.
|
|
1944
|
+
async function lobbyListMerged() {
|
|
1945
|
+
const local = lobbyList();
|
|
1946
|
+
const localSlugs = new Set(local.filter((w) => w.shared?.slug).map((w) => w.shared.slug));
|
|
1947
|
+
const joinable = (await sharedFromRails())
|
|
1948
|
+
.filter((w) => !localSlugs.has(w.slug))
|
|
1949
|
+
.map((w) => ({
|
|
1950
|
+
id: `shared:${w.slug}`, // synthetic — no local folder to re-root into yet
|
|
1951
|
+
name: w.name,
|
|
1952
|
+
slug: w.slug,
|
|
1953
|
+
kind: 'shared',
|
|
1954
|
+
joinable: true,
|
|
1955
|
+
shared: { slug: w.slug, ownerEmail: w.owner_email, role: w.role },
|
|
1956
|
+
lastOpenedAt: 0,
|
|
1957
|
+
}));
|
|
1958
|
+
return [...local, ...joinable];
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
app.get('/api/lobby/workspaces', async (c) => {
|
|
1962
|
+
const forbidden = require(c, 'fileTree');
|
|
1963
|
+
if (forbidden) return forbidden;
|
|
1964
|
+
return c.json({ workspaces: await lobbyListMerged(), defaultId: defaultWorkspace.id });
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
app.post('/api/lobby/workspaces', async (c) => {
|
|
1968
|
+
const forbidden = require(c, 'fileTree');
|
|
1969
|
+
if (forbidden) return forbidden;
|
|
1970
|
+
if (!isHostRequest(c)) {
|
|
1971
|
+
return c.json(
|
|
1972
|
+
{
|
|
1973
|
+
error: 'host_only',
|
|
1974
|
+
message:
|
|
1975
|
+
'Create a workspace on your computer — its folder lives there. From here you can open the ones that already exist.',
|
|
1976
|
+
},
|
|
1977
|
+
403,
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1981
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
1982
|
+
const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
|
|
1983
|
+
if (!name && !dir) return c.json({ error: 'name_or_dir_required' }, 400);
|
|
1984
|
+
try {
|
|
1985
|
+
const w = registryCreateWorkspace(
|
|
1986
|
+
{ name: name || undefined, dir: dir || undefined },
|
|
1987
|
+
registryEnv,
|
|
1988
|
+
);
|
|
1989
|
+
auditAction(c, 'lobby.create', `id=${w.id} dir=${w.dir}`);
|
|
1990
|
+
return c.json({
|
|
1991
|
+
ok: true,
|
|
1992
|
+
workspace: { id: w.id, name: w.name, dir: w.dir, kind: w.kind },
|
|
1993
|
+
});
|
|
1994
|
+
} catch (e) {
|
|
1995
|
+
return c.json({ error: 'create_failed', message: String(e.message || e) }, 400);
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
app.post('/api/lobby/workspaces/:id/open', (c) => {
|
|
2000
|
+
const forbidden = require(c, 'fileTree');
|
|
2001
|
+
if (forbidden) return forbidden;
|
|
2002
|
+
const id = c.req.param('id');
|
|
2003
|
+
if (id !== defaultWorkspace.id) touchWorkspace(id, registryEnv);
|
|
2004
|
+
return c.json({ ok: true });
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
app.delete('/api/lobby/workspaces/:id', (c) => {
|
|
2008
|
+
const forbidden = require(c, 'fileTree');
|
|
2009
|
+
if (forbidden) return forbidden;
|
|
2010
|
+
if (!isHostRequest(c)) return c.json({ error: 'host_only' }, 403);
|
|
2011
|
+
const id = c.req.param('id');
|
|
2012
|
+
if (id === defaultWorkspace.id) {
|
|
2013
|
+
return c.json({ error: 'cannot_remove_default' }, 400);
|
|
2014
|
+
}
|
|
2015
|
+
const removed = removeWorkspace(id, registryEnv);
|
|
2016
|
+
auditAction(c, 'lobby.remove', `id=${id} removed=${removed}`);
|
|
2017
|
+
return c.json({ ok: true, removed });
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
// --- sharing slice (design §4): promote a local workspace to shared + invite ---
|
|
2021
|
+
// "Share" mints a first-class shared workspace on the bmo-sync rails (its own
|
|
2022
|
+
// slug + member set) and flips THIS install's registry entry to kind:'shared'.
|
|
2023
|
+
// The folder never moves (decision 7). HOST-only (you share the folder where it
|
|
2024
|
+
// lives) + owner-level (fileTree) + must be logged in (the rails identity is the
|
|
2025
|
+
// account's). The Decision-11 "this carries the files AND the agent's memory"
|
|
2026
|
+
// heads-up lives on the client; the server just mints + records.
|
|
2027
|
+
|
|
2028
|
+
// Resolve a lobby workspace id to its registry entry, seeding the computed
|
|
2029
|
+
// boot/default workspace on first touch so it can be shared like any other.
|
|
2030
|
+
function lobbyEntryFor(id) {
|
|
2031
|
+
let entry = getWorkspace(id, registryEnv);
|
|
2032
|
+
if (!entry && id === defaultWorkspace.id) {
|
|
2033
|
+
entry = seedDefault(
|
|
2034
|
+
{ dir: defaultWorkspace.dir, name: defaultWorkspace.name },
|
|
2035
|
+
registryEnv,
|
|
2036
|
+
);
|
|
2037
|
+
}
|
|
2038
|
+
return entry;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// Slice 3 "going live": start file-sync for a shared workspace by minting a
|
|
2042
|
+
// membership-gated pass on the rails (provisions the file-sync project on first
|
|
2043
|
+
// call) and pairing the LOCAL daemon to the folder, so it backs up to VW's
|
|
2044
|
+
// rails. Best-effort by design — a down daemon or a rails hiccup must NOT fail
|
|
2045
|
+
// Share (the membership identity is already minted); it returns a status the UI
|
|
2046
|
+
// can surface ("backing up…" / "we'll start syncing when the daemon is up").
|
|
2047
|
+
// Idempotent: a workspace already linked + already syncing is a no-op success.
|
|
2048
|
+
async function startWorkspaceSync(entry, slug) {
|
|
2049
|
+
try {
|
|
2050
|
+
const linked = entry.shared?.projectCode || null;
|
|
2051
|
+
if (linked) {
|
|
2052
|
+
// Already provisioned — if the daemon is already syncing it, nothing to do.
|
|
2053
|
+
const ws = await syncControl.listWorkspaces();
|
|
2054
|
+
if (ws.some((w) => w.workspaceId === linked || w.projectId === linked)) {
|
|
2055
|
+
return { synced: true, projectCode: linked };
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
const cred = await workspaceRails.syncCredential(slug);
|
|
2059
|
+
if (!cred.ok || !cred.data?.invite_code) {
|
|
2060
|
+
return { synced: false, reason: cred.code || 'rails_unreachable', message: cred.message };
|
|
2061
|
+
}
|
|
2062
|
+
const projectCode = cred.data.project_code;
|
|
2063
|
+
try {
|
|
2064
|
+
await syncControl.pair(cred.data.invite_code, entry.dir);
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
// The daemon is down / unreachable — file-sync will start once it's up and
|
|
2067
|
+
// the owner re-shares (or a future watcher re-pairs). Membership stands.
|
|
2068
|
+
return {
|
|
2069
|
+
synced: false,
|
|
2070
|
+
reason: 'daemon_unreachable',
|
|
2071
|
+
message: String(e.message || e),
|
|
2072
|
+
projectCode,
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
setSyncProject(entry.id, projectCode, registryEnv);
|
|
2076
|
+
return { synced: true, projectCode };
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
// Belt-and-braces: file-sync is best-effort — ANY failure here leaves the
|
|
2079
|
+
// workspace shared (membership minted) and just reports sync didn't start.
|
|
2080
|
+
return { synced: false, reason: 'sync_error', message: String(e?.message || e) };
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
app.post('/api/lobby/workspaces/:id/share', async (c) => {
|
|
2085
|
+
const forbidden = require(c, 'fileTree');
|
|
2086
|
+
if (forbidden) return forbidden;
|
|
2087
|
+
if (!isHostRequest(c)) {
|
|
2088
|
+
return c.json(
|
|
2089
|
+
{
|
|
2090
|
+
error: 'host_only',
|
|
2091
|
+
message: 'Share a workspace from the computer where its folder lives.',
|
|
2092
|
+
},
|
|
2093
|
+
403,
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2097
|
+
return c.json(
|
|
2098
|
+
{
|
|
2099
|
+
error: 'login_required',
|
|
2100
|
+
message: 'Sign in to VentureWild first — sharing gives the workspace an identity on the rails.',
|
|
2101
|
+
},
|
|
2102
|
+
400,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
const id = c.req.param('id');
|
|
2106
|
+
const entry = lobbyEntryFor(id);
|
|
2107
|
+
if (!entry) return c.json({ error: 'no_such_workspace' }, 404);
|
|
2108
|
+
const wasShared = entry.kind === 'shared' && Boolean(entry.shared?.slug);
|
|
2109
|
+
// Mint the membership identity (idempotent: an already-shared workspace keeps
|
|
2110
|
+
// its slug). Then "go live" — start backing the folder up. The file-sync step
|
|
2111
|
+
// runs on BOTH a fresh share AND a re-share of an already-shared-but-not-yet-
|
|
2112
|
+
// synced workspace, so a Share that happened before the daemon was up heals.
|
|
2113
|
+
let shared = entry;
|
|
2114
|
+
if (!wasShared) {
|
|
2115
|
+
const r = await workspaceRails.create(entry.name);
|
|
2116
|
+
if (!r.ok) {
|
|
2117
|
+
// Surface the rails' code/message (slug_taken / unreachable / …) at a sane status.
|
|
2118
|
+
const status = r.status >= 400 && r.status < 600 ? r.status : 502;
|
|
2119
|
+
return c.json({ error: r.code || 'share_failed', message: r.message }, status);
|
|
2120
|
+
}
|
|
2121
|
+
shared = markShared(
|
|
2122
|
+
id,
|
|
2123
|
+
{ slug: r.data.slug, ownerEmail: config.account.email, role: 'owner' },
|
|
2124
|
+
registryEnv,
|
|
2125
|
+
);
|
|
2126
|
+
auditAction(c, 'lobby.share', `id=${id} slug=${r.data.slug}`);
|
|
2127
|
+
}
|
|
2128
|
+
if (!shared?.shared?.slug) return c.json({ error: 'share_failed' }, 500);
|
|
2129
|
+
const sync = await startWorkspaceSync(shared, shared.shared.slug);
|
|
2130
|
+
auditAction(c, 'lobby.share.sync', `id=${id} slug=${shared.shared.slug} synced=${sync.synced}`);
|
|
2131
|
+
const finalEntry = getWorkspace(id, registryEnv) || shared;
|
|
2132
|
+
return c.json({ ok: true, alreadyShared: wasShared, workspace: lobbyShape(finalEntry), sync });
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
app.post('/api/lobby/workspaces/:id/invite', async (c) => {
|
|
2136
|
+
const forbidden = require(c, 'fileTree');
|
|
2137
|
+
if (forbidden) return forbidden;
|
|
2138
|
+
if (!isHostRequest(c)) return c.json({ error: 'host_only' }, 403);
|
|
2139
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2140
|
+
return c.json({ error: 'login_required' }, 400);
|
|
2141
|
+
}
|
|
2142
|
+
const id = c.req.param('id');
|
|
2143
|
+
const entry = getWorkspace(id, registryEnv);
|
|
2144
|
+
if (!entry || entry.kind !== 'shared' || !entry.shared?.slug) {
|
|
2145
|
+
return c.json({ error: 'not_shared', message: 'Share this workspace before inviting people.' }, 400);
|
|
2146
|
+
}
|
|
2147
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2148
|
+
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
|
2149
|
+
if (!email || !email.includes('@')) {
|
|
2150
|
+
return c.json({ error: 'invalid_email', message: 'Enter a teammate’s email address.' }, 400);
|
|
2151
|
+
}
|
|
2152
|
+
const r = await workspaceRails.addMember(entry.shared.slug, email);
|
|
2153
|
+
if (!r.ok) {
|
|
2154
|
+
const status = r.status >= 400 && r.status < 600 ? r.status : 502;
|
|
2155
|
+
return c.json({ error: r.code || 'invite_failed', message: r.message }, status);
|
|
2156
|
+
}
|
|
2157
|
+
// No account yet → the rails recorded a PENDING invite; they'll claim it by
|
|
2158
|
+
// signing in with Google + Join (no account-first requirement). Already-an-
|
|
2159
|
+
// account → active immediately.
|
|
2160
|
+
const pending = Boolean(r.data?.pending);
|
|
2161
|
+
auditAction(c, 'lobby.invite', `slug=${entry.shared.slug} email=${email} pending=${pending}`);
|
|
2162
|
+
return c.json({ ok: true, email, pending, accountId: r.data?.account_id || null });
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
// Join a shared workspace someone invited you to (design §4 decision 9). The
|
|
2166
|
+
// invitee PICKS where it lives on their disk (a new folder under the Workspaces
|
|
2167
|
+
// home, or an existing one — same mechanic as create), then it's registered
|
|
2168
|
+
// locally + marked shared with this member's role. HOST-only (the folder lives
|
|
2169
|
+
// here) + login. Membership is verified against the rails list, so you can only
|
|
2170
|
+
// give a local home to a workspace you actually belong to. This binds the
|
|
2171
|
+
// IDENTITY (so the per-person canvas syncs) AND — Slice 3 "filling in" — starts
|
|
2172
|
+
// file-sync via the SAME `startWorkspaceSync` the owner uses: the joiner's daemon
|
|
2173
|
+
// pairs the chosen folder and pulls the current state, so it fills with the
|
|
2174
|
+
// owner's files + agent-brain instead of landing empty. Best-effort: a down
|
|
2175
|
+
// daemon still joins you (you land in the folder; it fills once sync starts).
|
|
2176
|
+
app.post('/api/lobby/workspaces/join', async (c) => {
|
|
2177
|
+
const forbidden = require(c, 'fileTree');
|
|
2178
|
+
if (forbidden) return forbidden;
|
|
2179
|
+
if (!isHostRequest(c)) {
|
|
2180
|
+
return c.json(
|
|
2181
|
+
{ error: 'host_only', message: 'Join a shared workspace from the computer where you want its folder.' },
|
|
2182
|
+
403,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
if (!config.account?.email || !workspaceRails.capable) {
|
|
2186
|
+
return c.json({ error: 'login_required' }, 400);
|
|
2187
|
+
}
|
|
2188
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2189
|
+
const slug = typeof body.slug === 'string' ? body.slug.trim().toLowerCase() : '';
|
|
2190
|
+
if (!slug) return c.json({ error: 'slug_required' }, 400);
|
|
2191
|
+
// Claim membership on the rails: this BOTH verifies the caller belongs (an
|
|
2192
|
+
// active member OR a pending invite addressed to their verified email) AND
|
|
2193
|
+
// activates a pending invite (invited→active) — the invite-a-teammate-who-
|
|
2194
|
+
// has-no-account-yet payoff. 403 not_a_member if neither.
|
|
2195
|
+
const claimed = await workspaceRails.claim(slug);
|
|
2196
|
+
if (!claimed.ok) {
|
|
2197
|
+
if (claimed.code === 'not_a_member' || claimed.status === 403) {
|
|
2198
|
+
return c.json({ error: 'not_a_member', message: 'You’re not a member of that workspace.' }, 403);
|
|
2199
|
+
}
|
|
2200
|
+
const status = claimed.status >= 400 && claimed.status < 600 ? claimed.status : 502;
|
|
2201
|
+
return c.json({ error: claimed.code || 'join_failed', message: claimed.message }, status);
|
|
2202
|
+
}
|
|
2203
|
+
const ws = {
|
|
2204
|
+
name: claimed.data?.name || slug,
|
|
2205
|
+
role: claimed.data?.role || 'member',
|
|
2206
|
+
owner_email: claimed.data?.owner_email || '',
|
|
2207
|
+
};
|
|
2208
|
+
// Already have a local home for it? Return that (idempotent re-join), and
|
|
2209
|
+
// (re)start file-sync so a join that happened before the daemon was up heals.
|
|
2210
|
+
const existing = listWorkspaces(registryEnv).find((w) => w.shared?.slug === slug);
|
|
2211
|
+
if (existing) {
|
|
2212
|
+
touchWorkspace(existing.id, registryEnv);
|
|
2213
|
+
const sync = await startWorkspaceSync(existing, slug);
|
|
2214
|
+
const finalEntry = getWorkspace(existing.id, registryEnv) || existing;
|
|
2215
|
+
return c.json({ ok: true, alreadyJoined: true, workspace: lobbyShape(finalEntry), sync });
|
|
2216
|
+
}
|
|
2217
|
+
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : ws.name;
|
|
2218
|
+
const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
|
|
2219
|
+
let entry;
|
|
2220
|
+
try {
|
|
2221
|
+
entry = registryCreateWorkspace({ name, dir: dir || undefined }, registryEnv);
|
|
2222
|
+
} catch (e) {
|
|
2223
|
+
return c.json({ error: 'join_failed', message: String(e.message || e) }, 400);
|
|
2224
|
+
}
|
|
2225
|
+
const updated = markShared(
|
|
2226
|
+
entry.id,
|
|
2227
|
+
{ slug, ownerEmail: ws.owner_email, role: ws.role || 'member' },
|
|
2228
|
+
registryEnv,
|
|
2229
|
+
);
|
|
2230
|
+
auditAction(c, 'lobby.join', `id=${entry.id} slug=${slug} claimed=${Boolean(claimed.data?.claimed)}`);
|
|
2231
|
+
// Filling in: pair the joiner's daemon → it pulls the owner's files + brain.
|
|
2232
|
+
const sync = await startWorkspaceSync(updated, slug);
|
|
2233
|
+
auditAction(c, 'lobby.join.sync', `id=${entry.id} slug=${slug} synced=${sync.synced}`);
|
|
2234
|
+
const finalEntry = getWorkspace(entry.id, registryEnv) || updated;
|
|
2235
|
+
return c.json({ ok: true, workspace: lobbyShape(finalEntry), sync });
|
|
2236
|
+
});
|
|
2237
|
+
|
|
1770
2238
|
app.get('/api/workspace/tree', async (c) => {
|
|
1771
2239
|
if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
|
|
1772
2240
|
return c.json({ error: 'forbidden' }, 403);
|
|
1773
2241
|
}
|
|
1774
2242
|
try {
|
|
1775
|
-
const
|
|
1776
|
-
|
|
2243
|
+
const dir = workspaceFor(c).dir;
|
|
2244
|
+
const tree = await fullTree(dir, 3);
|
|
2245
|
+
return c.json({ root: dir, entries: tree });
|
|
1777
2246
|
} catch (e) {
|
|
1778
2247
|
return c.json({ error: String(e.message || e) }, 500);
|
|
1779
2248
|
}
|
|
@@ -1785,7 +2254,7 @@ export async function createServer(overrides = {}) {
|
|
|
1785
2254
|
}
|
|
1786
2255
|
const p = c.req.query('path') || '';
|
|
1787
2256
|
try {
|
|
1788
|
-
const items = await listDir(
|
|
2257
|
+
const items = await listDir(workspaceFor(c).dir, p);
|
|
1789
2258
|
if (items == null) return c.json({ error: 'not-a-directory' }, 400);
|
|
1790
2259
|
return c.json({ path: p, items });
|
|
1791
2260
|
} catch (e) {
|
|
@@ -1800,7 +2269,7 @@ export async function createServer(overrides = {}) {
|
|
|
1800
2269
|
const p = c.req.query('path');
|
|
1801
2270
|
if (!p) return c.json({ error: 'path-required' }, 400);
|
|
1802
2271
|
try {
|
|
1803
|
-
const result = await readFile(
|
|
2272
|
+
const result = await readFile(workspaceFor(c).dir, p);
|
|
1804
2273
|
return c.json({ path: p, ...result });
|
|
1805
2274
|
} catch (e) {
|
|
1806
2275
|
return c.json({ error: String(e.message || e) }, 400);
|
|
@@ -2364,10 +2833,16 @@ export async function createServer(overrides = {}) {
|
|
|
2364
2833
|
socket.destroy();
|
|
2365
2834
|
return;
|
|
2366
2835
|
}
|
|
2836
|
+
// The chat WS carries its tab's active workspace as `?workspace=<id>` (lobby
|
|
2837
|
+
// M1) — the per-tab channel for the socket, mirroring the X-Workspace-Id header
|
|
2838
|
+
// on HTTP. Absent/unknown → the boot default.
|
|
2839
|
+
const wsWorkspace = resolveWorkspace(reqUrl.searchParams.get('workspace'));
|
|
2367
2840
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2368
2841
|
ws._wsRole = role;
|
|
2369
2842
|
ws._wsSub = sub;
|
|
2370
|
-
|
|
2843
|
+
ws._wsWorkspace = wsWorkspace;
|
|
2844
|
+
ws._wsWorkspaceId = wsWorkspace.id;
|
|
2845
|
+
log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub} ws=${wsWorkspace.id}`);
|
|
2371
2846
|
wss.emit('connection', ws, req, reqUrl.pathname);
|
|
2372
2847
|
});
|
|
2373
2848
|
});
|
|
@@ -2442,22 +2917,25 @@ export async function createServer(overrides = {}) {
|
|
|
2442
2917
|
return;
|
|
2443
2918
|
}
|
|
2444
2919
|
ws._sendTimes.push(now);
|
|
2445
|
-
// The turn-runner
|
|
2446
|
-
//
|
|
2920
|
+
// The turn-runner resumes THIS tab's workspace conversation and streams
|
|
2921
|
+
// back only to tabs on the same workspace (lobby M1).
|
|
2447
2922
|
runChatTurn({
|
|
2448
2923
|
prompt: msg.text,
|
|
2449
2924
|
mode: msg.mode,
|
|
2450
2925
|
messageId: msg.messageId,
|
|
2451
2926
|
userText: msg.text,
|
|
2927
|
+
workspace: ws._wsWorkspace,
|
|
2452
2928
|
});
|
|
2453
2929
|
} else if (msg.type === 'cancel') {
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2930
|
+
// Cancel only the turn that belongs to this tab's workspace.
|
|
2931
|
+
const live = currentTurns.get(ws._wsWorkspaceId);
|
|
2932
|
+
if (live) {
|
|
2933
|
+
live.session.close();
|
|
2934
|
+
currentTurns.delete(ws._wsWorkspaceId);
|
|
2457
2935
|
}
|
|
2458
2936
|
} else if (msg.type === 'reset') {
|
|
2459
2937
|
// "New chat" — drop the resumed session so the next turn starts fresh.
|
|
2460
|
-
if (cap.chatWrite) resetChat();
|
|
2938
|
+
if (cap.chatWrite) resetChat(ws._wsWorkspace);
|
|
2461
2939
|
}
|
|
2462
2940
|
});
|
|
2463
2941
|
ws.on('close', () => {
|
|
@@ -2486,7 +2964,10 @@ export async function createServer(overrides = {}) {
|
|
|
2486
2964
|
getActiveAgent: () => activeAgent,
|
|
2487
2965
|
async stop() {
|
|
2488
2966
|
try { clearTimeout(autoWakeTimer); } catch {}
|
|
2489
|
-
|
|
2967
|
+
for (const t of currentTurns.values()) {
|
|
2968
|
+
try { t.session.close(); } catch {}
|
|
2969
|
+
}
|
|
2970
|
+
currentTurns.clear();
|
|
2490
2971
|
try { sessionReporter.stop(); } catch {}
|
|
2491
2972
|
try { transcriptRecorder.stop(); } catch {}
|
|
2492
2973
|
try { inboxWatcher.stop(); } catch {}
|