@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.
@@ -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
- let currentTurn = null; // { session, messageId } at most one at a time
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
- function broadcastChat(obj) {
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 === ws.OPEN) ws.send(data);
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
- if (currentTurn) {
437
- if (auto) return false; // auto-wake yields to a live turn
438
- currentTurn.session.close(); // a user send supersedes what's running
439
- currentTurn = null;
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
- const id = messageId || nanoid(8);
447
- broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
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
- currentTurn = { session, messageId: id };
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
- currentTurn = null;
565
+ }, workspace.id);
566
+ clearTurnIf(workspace.id, session);
499
567
  });
500
568
  session.on('end', ({ code }) => {
501
- currentTurn = null;
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
- chatSessionId = session.sessionId;
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: config.workspaceDir,
606
+ cwd: workspace.dir,
540
607
  mode,
541
- resumeSessionId: chatSessionId,
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 (currentTurn) {
553
- currentTurn.session.close();
554
- currentTurn = null;
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
- chatSessionId = null;
557
- saveChatSessionId(config.dataDir, null);
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 identity = loadIdentity(config.dataDir);
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(config.workspaceDir),
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(config.dataDir);
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(config.dataDir, {
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(config.dataDir);
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(config.dataDir);
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(config.dataDir);
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 tree = await fullTree(config.workspaceDir, 3);
1776
- return c.json({ root: config.workspaceDir, entries: tree });
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(config.workspaceDir, p);
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(config.workspaceDir, p);
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
- log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub}`);
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 is server-level: it streams to every chat client and
2446
- // resumes the persisted claude session, so the agent keeps its memory.
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
- if (currentTurn) {
2455
- currentTurn.session.close();
2456
- currentTurn = null;
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
- try { currentTurn?.session.close(); } catch {}
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 {}