@venturewild/workspace 0.3.8 → 0.4.0

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,14 @@ 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
+ workspaceIdForDir,
69
+ } from './workspace-registry.mjs';
62
70
  import { matchCandidates } from './bazaar/mock-tickup.mjs';
63
71
  import { servePreviewFile, confineBuildDir } from './bazaar/preview-server.mjs';
64
72
  import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
@@ -110,6 +118,9 @@ function loadChatSessionId(dataDir) {
110
118
  }
111
119
  function saveChatSessionId(dataDir, sessionId) {
112
120
  try {
121
+ // A freshly-created workspace has no .wild-workspace/ dir yet — make it so the
122
+ // per-workspace conversation persists from the very first turn (lobby M1).
123
+ mkdirSync(dataDir, { recursive: true });
113
124
  writeFileSync(
114
125
  chatSessionPath(dataDir),
115
126
  JSON.stringify({ sessionId: sessionId || null }, null, 2),
@@ -400,9 +411,29 @@ export async function createServer(overrides = {}) {
400
411
  // One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
401
412
  // Both user sends and auto-wake turns thread through one turn-runner so they
402
413
  // share the agent's memory and never run two claude processes at once.
403
- let chatSessionId = loadChatSessionId(config.dataDir);
404
414
  const chatClients = new Set(); // every connected /ws/chat socket
405
- let currentTurn = null; // { session, messageId } at most one at a time
415
+ // Per-workspace live turns (lobby M2): one in-flight agent turn PER workspace,
416
+ // keyed by workspace id, so a task started in workspace A keeps running when you
417
+ // hop to B (design §8.1 decision 6 — "switching keeps in-flight work alive"). A
418
+ // user send supersedes only its OWN workspace's turn. Capped at
419
+ // MAX_CONCURRENT_TURNS live workspaces (reject, don't queue — the user resends,
420
+ // auto-wake self-retries): a `claude -p` turn measured ~340 MB peak working set
421
+ // (full-context turn in this repo, 2026-06-13), so 3 concurrent ≈ 1 GB — safe
422
+ // headroom on any modern machine, while the cap stops an unbounded fan-out from
423
+ // OOMing a laptop. Raise/lower via WILD_WORKSPACE_MAX_CONCURRENT_TURNS.
424
+ const currentTurns = new Map(); // workspace.id → { session, messageId, workspaceId }
425
+ const MAX_CONCURRENT_TURNS =
426
+ overrides.maxConcurrentTurns ??
427
+ Math.max(1, Number(process.env.WILD_WORKSPACE_MAX_CONCURRENT_TURNS) || 3);
428
+ // Drop a workspace's turn entry ONLY if it still points at THIS session. A
429
+ // superseded turn's `end` fires a tick AFTER its proc is killed (the 'close'
430
+ // event), by which point the replacement turn may already own the slot — so an
431
+ // unconditional delete would evict the live replacement. The identity check
432
+ // prevents that race.
433
+ function clearTurnIf(workspaceId, session) {
434
+ const t = currentTurns.get(workspaceId);
435
+ if (t && t.session === session) currentTurns.delete(workspaceId);
436
+ }
406
437
 
407
438
  // Per-connection chat rate limit (SECURITY.md S6 / concern C4). Every send
408
439
  // spawns an agent subprocess that costs real API tokens, so cap the burst a
@@ -416,10 +447,16 @@ export async function createServer(overrides = {}) {
416
447
  (Number(process.env.WILD_WORKSPACE_CHAT_RATE_WINDOW_MS) || 60_000),
417
448
  };
418
449
 
419
- function broadcastChat(obj) {
450
+ // Broadcast a chat frame. When `workspaceId` is given, only sockets attached to
451
+ // that workspace receive it — so a tab viewing workspace B never renders a turn
452
+ // that belongs to A (lobby M1: per-workspace conversations over one WS pool).
453
+ // Frames with no workspaceId (e.g. the usage gauge, bazaar meter) go to all.
454
+ function broadcastChat(obj, workspaceId) {
420
455
  const data = JSON.stringify(obj);
421
456
  for (const ws of chatClients) {
422
- if (ws.readyState === ws.OPEN) ws.send(data);
457
+ if (ws.readyState !== ws.OPEN) continue;
458
+ if (workspaceId && ws._wsWorkspaceId !== workspaceId) continue;
459
+ ws.send(data);
423
460
  }
424
461
  }
425
462
 
@@ -432,19 +469,40 @@ export async function createServer(overrides = {}) {
432
469
  * and retries once if the run fails (PRD §13 A8).
433
470
  * Returns false if the turn could not start (an auto turn while busy).
434
471
  */
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;
472
+ function runChatTurn({ prompt, mode, messageId, userText, note, auto = false, workspace = defaultWorkspace }) {
473
+ const id = messageId || nanoid(8);
474
+ // Supersede only THIS workspace's in-flight turn a send in B must never kill
475
+ // a turn still running in A (lobby M2).
476
+ const live = currentTurns.get(workspace.id);
477
+ if (live) {
478
+ if (auto) return false; // auto-wake yields to a live turn in this workspace
479
+ live.session.close(); // a user send supersedes what's running here
480
+ currentTurns.delete(workspace.id);
481
+ }
482
+ // Concurrency cap (after the per-workspace supersede, before spawn): too many
483
+ // OTHER workspaces are already mid-turn. Reject — don't queue — and tell the
484
+ // tab so the user can resend (an auto-wake re-queues itself, so it stays quiet).
485
+ if (currentTurns.size >= MAX_CONCURRENT_TURNS) {
486
+ log('[chat]', `cap reached (${currentTurns.size}/${MAX_CONCURRENT_TURNS}) — rejecting ${auto ? 'auto' : 'user'} turn in ${workspace.id}`);
487
+ if (!auto) {
488
+ broadcastChat({
489
+ type: 'error',
490
+ messageId: id,
491
+ message: `${MAX_CONCURRENT_TURNS} workspaces are already working at once. Wait for one to finish, then resend.`,
492
+ }, workspace.id);
493
+ }
494
+ return false;
440
495
  }
441
496
  // Apply the autonomy dial (§8). Auto-wake is exempt — it's the system consent
442
497
  // boundary and stays in the plan mode it was given. For a USER turn we resolve
443
498
  // the stored level + the per-message toggle through resolveAutonomyMode, whose
444
499
  // guardrail guarantees an unbuilt/unknown level can NEVER become bypassPermissions.
445
500
  if (!auto) mode = resolveAutonomyMode(settings.getAutonomyLevel(), mode);
446
- const id = messageId || nanoid(8);
447
- broadcastChat({ type: 'turn-begin', messageId: id, userText, note });
501
+ // Per-workspace conversation (lobby M1): resume THIS workspace's claude session
502
+ // from its own dataDir, and tag every frame with the workspace so it streams
503
+ // only to tabs viewing it.
504
+ const resumeId = loadChatSessionId(workspace.dataDir);
505
+ broadcastChat({ type: 'turn-begin', messageId: id, userText, note }, workspace.id);
448
506
  activityBus.publish({
449
507
  type: 'chat-user',
450
508
  messageId: id,
@@ -456,7 +514,7 @@ export async function createServer(overrides = {}) {
456
514
  const startedAt = Date.now();
457
515
  log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
458
516
  const session = new AgentSession(activeAgent);
459
- currentTurn = { session, messageId: id };
517
+ currentTurns.set(workspace.id, { session, messageId: id, workspaceId: workspace.id });
460
518
  let sawError = false;
461
519
  session.on('chunk', (chunk) => {
462
520
  // The per-turn context signal (working-memory gauge, §8) feeds the usage
@@ -467,7 +525,7 @@ export async function createServer(overrides = {}) {
467
525
  return;
468
526
  }
469
527
  if (chunk.type === 'error') sawError = true;
470
- broadcastChat({ type: 'chunk', messageId: id, chunk });
528
+ broadcastChat({ type: 'chunk', messageId: id, chunk }, workspace.id);
471
529
  activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
472
530
  // Surface the turn's token/cost totals so the activity bar can show
473
531
  // running usage — the ActivityBus accumulates events typed 'usage'.
@@ -478,7 +536,7 @@ export async function createServer(overrides = {}) {
478
536
  session.on('stderr', (text) => {
479
537
  const trimmed = String(text || '').trim();
480
538
  if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
481
- broadcastChat({ type: 'stderr', messageId: id, text });
539
+ broadcastChat({ type: 'stderr', messageId: id, text }, workspace.id);
482
540
  });
483
541
  session.on('error', (err) => {
484
542
  sawError = true;
@@ -494,11 +552,11 @@ export async function createServer(overrides = {}) {
494
552
  type: 'error',
495
553
  messageId: id,
496
554
  message: msg,
497
- });
498
- currentTurn = null;
555
+ }, workspace.id);
556
+ clearTurnIf(workspace.id, session);
499
557
  });
500
558
  session.on('end', ({ code }) => {
501
- currentTurn = null;
559
+ clearTurnIf(workspace.id, session);
502
560
  const elapsed = Date.now() - startedAt;
503
561
  log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
504
562
  // Silent agent crash → telemetry. A non-zero exit (or signal-kill =
@@ -528,17 +586,16 @@ export async function createServer(overrides = {}) {
528
586
  return;
529
587
  }
530
588
  if (session.sessionId) {
531
- chatSessionId = session.sessionId;
532
- saveChatSessionId(config.dataDir, chatSessionId);
589
+ saveChatSessionId(workspace.dataDir, session.sessionId);
533
590
  }
534
591
  }
535
- broadcastChat({ type: 'end', messageId: id, code });
592
+ broadcastChat({ type: 'end', messageId: id, code }, workspace.id);
536
593
  activityBus.publish({ type: 'chat-end', messageId: id, code });
537
594
  });
538
595
  session.send(prompt, {
539
- cwd: config.workspaceDir,
596
+ cwd: workspace.dir,
540
597
  mode,
541
- resumeSessionId: chatSessionId,
598
+ resumeSessionId: resumeId,
542
599
  // Auto-wake (import integration, plan mode) stays bazaar-free; only the
543
600
  // user's own turns get the marketplace tools + disposition.
544
601
  ...(auto ? {} : turnCtx),
@@ -548,14 +605,16 @@ export async function createServer(overrides = {}) {
548
605
  return true;
549
606
  }
550
607
 
551
- function resetChat() {
552
- if (currentTurn) {
553
- currentTurn.session.close();
554
- currentTurn = null;
608
+ function resetChat(workspace = defaultWorkspace) {
609
+ // Only stop the live turn if it belongs to THIS workspace — a "new chat" in B
610
+ // must never kill a turn still running in A (lobby M1/M2).
611
+ const live = currentTurns.get(workspace.id);
612
+ if (live) {
613
+ live.session.close();
614
+ currentTurns.delete(workspace.id);
555
615
  }
556
- chatSessionId = null;
557
- saveChatSessionId(config.dataDir, null);
558
- broadcastChat({ type: 'reset' });
616
+ saveChatSessionId(workspace.dataDir, null);
617
+ broadcastChat({ type: 'reset' }, workspace.id);
559
618
  }
560
619
 
561
620
  // --- auto-wake on import (AR-23) ------------------------------------------
@@ -734,6 +793,36 @@ export async function createServer(overrides = {}) {
734
793
  return null;
735
794
  }
736
795
 
796
+ // --- multi-workspace re-root (lobby M1) ---
797
+ // One install can hold many workspaces; the ACTIVE one is per-REQUEST, carried
798
+ // by the `X-Workspace-Id` header that each browser tab sets (a cookie can't be
799
+ // per-tab — it's shared across tabs). `workspaceFor(c)` resolves it to
800
+ // {id, dir, dataDir, name}; absent/unknown falls back to the boot default. The
801
+ // boot/default workspace's id is COMPUTED from its dir (never persisted), so
802
+ // startup writes nothing and the registry holds only workspaces the user adds.
803
+ const registryEnv = overrides.registryEnv || process.env;
804
+ const defaultWorkspace = {
805
+ id: workspaceIdForDir(config.workspaceDir),
806
+ dir: config.workspaceDir,
807
+ dataDir: config.dataDir, // preserves any WILD_WORKSPACE_DATA_DIR override
808
+ name: config.workspaceId,
809
+ isDefault: true,
810
+ };
811
+ function resolveWorkspace(id) {
812
+ if (!id || id === defaultWorkspace.id) return defaultWorkspace;
813
+ const w = getWorkspace(id, registryEnv);
814
+ if (!w) return defaultWorkspace; // unknown id → safe fallback to the default
815
+ return {
816
+ id: w.id,
817
+ dir: w.dir,
818
+ dataDir: path.join(w.dir, '.wild-workspace'),
819
+ name: w.name,
820
+ };
821
+ }
822
+ function workspaceFor(c) {
823
+ return c.get('workspace') || resolveWorkspace(c.req.header('x-workspace-id'));
824
+ }
825
+
737
826
  // Persistent audit trail for privileged actions (SECURITY.md S8). The [http]
738
827
  // line is ephemeral (stdout, rotated); this records WHO did WHAT to a durable
739
828
  // log under ~/.wild-workspace (outside the synced repo) that doctor/logs and
@@ -803,6 +892,9 @@ export async function createServer(overrides = {}) {
803
892
  const session = await resolveRole(c);
804
893
  c.set('role', session.role);
805
894
  c.set('session', session);
895
+ // Resolve the active workspace for this request (lobby M1). Per-tab via the
896
+ // X-Workspace-Id header; absent/unknown → the boot default.
897
+ c.set('workspace', resolveWorkspace(c.req.header('x-workspace-id')));
806
898
  // Block the API for denied (non-localhost, unauthenticated) requests, but
807
899
  // let static assets + the public endpoints through so the SPA can still
808
900
  // load and prompt for sign-in. (Concern C1.)
@@ -831,13 +923,24 @@ export async function createServer(overrides = {}) {
831
923
  app.get('/api/session', (c) => {
832
924
  const session = c.get('session');
833
925
  const role = c.get('role');
834
- const identity = loadIdentity(config.dataDir);
926
+ const ws = workspaceFor(c);
927
+ // A different agent per workspace (design §8.1 decision 4): the identity +
928
+ // onboarded flag are read from the ACTIVE workspace's own dataDir, so each
929
+ // workspace has its own named agent and "meet your agent" is a per-workspace
930
+ // first-entry moment. A brand-new workspace has no identity yet → onboarded
931
+ // is false → the client runs the meet-your-agent beat for it.
932
+ const identity = loadIdentity(ws.dataDir);
835
933
  return c.json({
836
934
  version: APP_VERSION,
837
935
  role,
838
936
  capabilities: ROLE_CAPABILITIES[role],
839
- workspace: workspaceSummary(config.workspaceDir),
937
+ workspace: workspaceSummary(ws.dir),
840
938
  workspaceId: config.workspaceId,
939
+ // The active workspace for THIS request/tab (lobby M1). Distinct from the
940
+ // legacy `workspaceId` (the boot default's folder name, kept stable so the
941
+ // canvas localStorage cache key doesn't churn). The client echoes
942
+ // `currentWorkspace.id` back in X-Workspace-Id to stay on this workspace.
943
+ currentWorkspace: { id: ws.id, name: ws.name, isDefault: Boolean(ws.isDefault) },
841
944
  session,
842
945
  // The identity that scopes this person's canvas state (multi-host step 1).
843
946
  // The client folds it into its localStorage cache key so two people on one
@@ -1157,7 +1260,7 @@ export async function createServer(overrides = {}) {
1157
1260
  // Persisted to <dataDir>/agent-identity.json. Absence of this file is the
1158
1261
  // signal the UI uses to launch the 5-step onboarding flow.
1159
1262
  app.get('/api/agent/identity', (c) => {
1160
- const identity = loadIdentity(config.dataDir);
1263
+ const identity = loadIdentity(workspaceFor(c).dataDir);
1161
1264
  return c.json({ identity, tones: TONES });
1162
1265
  });
1163
1266
 
@@ -1458,7 +1561,7 @@ export async function createServer(overrides = {}) {
1458
1561
  if (forbidden) return forbidden;
1459
1562
  const body = await c.req.json().catch(() => ({}));
1460
1563
  try {
1461
- const saved = saveIdentity(config.dataDir, {
1564
+ const saved = saveIdentity(workspaceFor(c).dataDir, {
1462
1565
  name: body.name,
1463
1566
  tone: body.tone,
1464
1567
  color: body.color,
@@ -1476,7 +1579,7 @@ export async function createServer(overrides = {}) {
1476
1579
  const forbidden = require(c, 'chatWrite');
1477
1580
  if (forbidden) return forbidden;
1478
1581
  try {
1479
- const saved = markOnboarded(config.dataDir);
1582
+ const saved = markOnboarded(workspaceFor(c).dataDir);
1480
1583
  log('[onboarding]', `complete name=${saved.name}`);
1481
1584
  activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
1482
1585
  return c.json({ identity: saved });
@@ -1521,7 +1624,7 @@ export async function createServer(overrides = {}) {
1521
1624
  : `--- ${f.path}`;
1522
1625
  })
1523
1626
  .join('\n');
1524
- const identity = loadIdentity(config.dataDir);
1627
+ const identity = loadIdentity(workspaceFor(c).dataDir);
1525
1628
  const youAre = identity?.name
1526
1629
  ? `You are ${identity.name}, a ${identity.tone || 'concise'} AI assistant just meeting your human for the first time.`
1527
1630
  : `You are an AI assistant just meeting your human for the first time.`;
@@ -1545,6 +1648,7 @@ export async function createServer(overrides = {}) {
1545
1648
  messageId,
1546
1649
  note: `👀 ${identity?.name || 'Your agent'} is looking at ${folderName}…`,
1547
1650
  auto: true,
1651
+ workspace: workspaceFor(c),
1548
1652
  });
1549
1653
  return c.json({ ok: true, sampled: files.length, started: started !== false });
1550
1654
  });
@@ -1568,7 +1672,7 @@ export async function createServer(overrides = {}) {
1568
1672
  typeof body.peekFolderName === 'string'
1569
1673
  ? body.peekFolderName.slice(0, 80)
1570
1674
  : null;
1571
- const identity = loadIdentity(config.dataDir);
1675
+ const identity = loadIdentity(workspaceFor(c).dataDir);
1572
1676
  const tone = identity?.tone || 'concise';
1573
1677
  const name = identity?.name || 'your agent';
1574
1678
  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 +1713,7 @@ export async function createServer(overrides = {}) {
1609
1713
  messageId,
1610
1714
  note,
1611
1715
  auto: true,
1716
+ workspace: workspaceFor(c),
1612
1717
  });
1613
1718
  return c.json({ ok: true, started: started !== false });
1614
1719
  });
@@ -1767,13 +1872,111 @@ export async function createServer(overrides = {}) {
1767
1872
  });
1768
1873
 
1769
1874
  // --- workspace files ---
1875
+ // --- lobby: the per-install workspace list (lobby M1) ---
1876
+ // One install holds many local workspaces; the lobby lists them and the user
1877
+ // picks one to enter (the client then sends X-Workspace-Id to re-root the agent,
1878
+ // files, and chat). Creating is a HOST action — the folder lives on THIS machine
1879
+ // — so it's refused for remote/tunnel requests; entering an existing one works
1880
+ // from anywhere. Gated on `fileTree` (owner-level: never expose the host's other
1881
+ // projects to a shared-link viewer/client). Distinct origin from the bmo-sync
1882
+ // rails `/api/workspaces/*` (those live on the sync server).
1883
+ const lobbyBootTime = Date.now();
1884
+ function isHostRequest(c) {
1885
+ const src = c.get('session')?.source;
1886
+ return src === 'localhost' || src === 'loopback';
1887
+ }
1888
+ function lobbyList() {
1889
+ const reg = listWorkspaces(registryEnv);
1890
+ const items = reg.map((w) => ({
1891
+ id: w.id,
1892
+ name: w.name,
1893
+ dir: w.dir,
1894
+ kind: w.kind,
1895
+ lastOpenedAt: w.lastOpenedAt,
1896
+ isDefault: w.id === defaultWorkspace.id,
1897
+ }));
1898
+ // The boot/default workspace is computed (never persisted) — inject it if it
1899
+ // isn't already an explicit registry entry, so the lobby is never blank.
1900
+ if (!reg.some((w) => w.id === defaultWorkspace.id)) {
1901
+ items.push({
1902
+ id: defaultWorkspace.id,
1903
+ name: defaultWorkspace.name,
1904
+ dir: defaultWorkspace.dir,
1905
+ kind: 'local',
1906
+ lastOpenedAt: lobbyBootTime, // "the one you booted into" → sorts to the top
1907
+ isDefault: true,
1908
+ });
1909
+ }
1910
+ return items.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
1911
+ }
1912
+
1913
+ app.get('/api/lobby/workspaces', (c) => {
1914
+ const forbidden = require(c, 'fileTree');
1915
+ if (forbidden) return forbidden;
1916
+ return c.json({ workspaces: lobbyList(), defaultId: defaultWorkspace.id });
1917
+ });
1918
+
1919
+ app.post('/api/lobby/workspaces', async (c) => {
1920
+ const forbidden = require(c, 'fileTree');
1921
+ if (forbidden) return forbidden;
1922
+ if (!isHostRequest(c)) {
1923
+ return c.json(
1924
+ {
1925
+ error: 'host_only',
1926
+ message:
1927
+ 'Create a workspace on your computer — its folder lives there. From here you can open the ones that already exist.',
1928
+ },
1929
+ 403,
1930
+ );
1931
+ }
1932
+ const body = await c.req.json().catch(() => ({}));
1933
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1934
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : '';
1935
+ if (!name && !dir) return c.json({ error: 'name_or_dir_required' }, 400);
1936
+ try {
1937
+ const w = registryCreateWorkspace(
1938
+ { name: name || undefined, dir: dir || undefined },
1939
+ registryEnv,
1940
+ );
1941
+ auditAction(c, 'lobby.create', `id=${w.id} dir=${w.dir}`);
1942
+ return c.json({
1943
+ ok: true,
1944
+ workspace: { id: w.id, name: w.name, dir: w.dir, kind: w.kind },
1945
+ });
1946
+ } catch (e) {
1947
+ return c.json({ error: 'create_failed', message: String(e.message || e) }, 400);
1948
+ }
1949
+ });
1950
+
1951
+ app.post('/api/lobby/workspaces/:id/open', (c) => {
1952
+ const forbidden = require(c, 'fileTree');
1953
+ if (forbidden) return forbidden;
1954
+ const id = c.req.param('id');
1955
+ if (id !== defaultWorkspace.id) touchWorkspace(id, registryEnv);
1956
+ return c.json({ ok: true });
1957
+ });
1958
+
1959
+ app.delete('/api/lobby/workspaces/:id', (c) => {
1960
+ const forbidden = require(c, 'fileTree');
1961
+ if (forbidden) return forbidden;
1962
+ if (!isHostRequest(c)) return c.json({ error: 'host_only' }, 403);
1963
+ const id = c.req.param('id');
1964
+ if (id === defaultWorkspace.id) {
1965
+ return c.json({ error: 'cannot_remove_default' }, 400);
1966
+ }
1967
+ const removed = removeWorkspace(id, registryEnv);
1968
+ auditAction(c, 'lobby.remove', `id=${id} removed=${removed}`);
1969
+ return c.json({ ok: true, removed });
1970
+ });
1971
+
1770
1972
  app.get('/api/workspace/tree', async (c) => {
1771
1973
  if (!ROLE_CAPABILITIES[c.get('role')].fileTree) {
1772
1974
  return c.json({ error: 'forbidden' }, 403);
1773
1975
  }
1774
1976
  try {
1775
- const tree = await fullTree(config.workspaceDir, 3);
1776
- return c.json({ root: config.workspaceDir, entries: tree });
1977
+ const dir = workspaceFor(c).dir;
1978
+ const tree = await fullTree(dir, 3);
1979
+ return c.json({ root: dir, entries: tree });
1777
1980
  } catch (e) {
1778
1981
  return c.json({ error: String(e.message || e) }, 500);
1779
1982
  }
@@ -1785,7 +1988,7 @@ export async function createServer(overrides = {}) {
1785
1988
  }
1786
1989
  const p = c.req.query('path') || '';
1787
1990
  try {
1788
- const items = await listDir(config.workspaceDir, p);
1991
+ const items = await listDir(workspaceFor(c).dir, p);
1789
1992
  if (items == null) return c.json({ error: 'not-a-directory' }, 400);
1790
1993
  return c.json({ path: p, items });
1791
1994
  } catch (e) {
@@ -1800,7 +2003,7 @@ export async function createServer(overrides = {}) {
1800
2003
  const p = c.req.query('path');
1801
2004
  if (!p) return c.json({ error: 'path-required' }, 400);
1802
2005
  try {
1803
- const result = await readFile(config.workspaceDir, p);
2006
+ const result = await readFile(workspaceFor(c).dir, p);
1804
2007
  return c.json({ path: p, ...result });
1805
2008
  } catch (e) {
1806
2009
  return c.json({ error: String(e.message || e) }, 400);
@@ -2364,10 +2567,16 @@ export async function createServer(overrides = {}) {
2364
2567
  socket.destroy();
2365
2568
  return;
2366
2569
  }
2570
+ // The chat WS carries its tab's active workspace as `?workspace=<id>` (lobby
2571
+ // M1) — the per-tab channel for the socket, mirroring the X-Workspace-Id header
2572
+ // on HTTP. Absent/unknown → the boot default.
2573
+ const wsWorkspace = resolveWorkspace(reqUrl.searchParams.get('workspace'));
2367
2574
  wss.handleUpgrade(req, socket, head, (ws) => {
2368
2575
  ws._wsRole = role;
2369
2576
  ws._wsSub = sub;
2370
- log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub}`);
2577
+ ws._wsWorkspace = wsWorkspace;
2578
+ ws._wsWorkspaceId = wsWorkspace.id;
2579
+ log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub} ws=${wsWorkspace.id}`);
2371
2580
  wss.emit('connection', ws, req, reqUrl.pathname);
2372
2581
  });
2373
2582
  });
@@ -2442,22 +2651,25 @@ export async function createServer(overrides = {}) {
2442
2651
  return;
2443
2652
  }
2444
2653
  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.
2654
+ // The turn-runner resumes THIS tab's workspace conversation and streams
2655
+ // back only to tabs on the same workspace (lobby M1).
2447
2656
  runChatTurn({
2448
2657
  prompt: msg.text,
2449
2658
  mode: msg.mode,
2450
2659
  messageId: msg.messageId,
2451
2660
  userText: msg.text,
2661
+ workspace: ws._wsWorkspace,
2452
2662
  });
2453
2663
  } else if (msg.type === 'cancel') {
2454
- if (currentTurn) {
2455
- currentTurn.session.close();
2456
- currentTurn = null;
2664
+ // Cancel only the turn that belongs to this tab's workspace.
2665
+ const live = currentTurns.get(ws._wsWorkspaceId);
2666
+ if (live) {
2667
+ live.session.close();
2668
+ currentTurns.delete(ws._wsWorkspaceId);
2457
2669
  }
2458
2670
  } else if (msg.type === 'reset') {
2459
2671
  // "New chat" — drop the resumed session so the next turn starts fresh.
2460
- if (cap.chatWrite) resetChat();
2672
+ if (cap.chatWrite) resetChat(ws._wsWorkspace);
2461
2673
  }
2462
2674
  });
2463
2675
  ws.on('close', () => {
@@ -2486,7 +2698,10 @@ export async function createServer(overrides = {}) {
2486
2698
  getActiveAgent: () => activeAgent,
2487
2699
  async stop() {
2488
2700
  try { clearTimeout(autoWakeTimer); } catch {}
2489
- try { currentTurn?.session.close(); } catch {}
2701
+ for (const t of currentTurns.values()) {
2702
+ try { t.session.close(); } catch {}
2703
+ }
2704
+ currentTurns.clear();
2490
2705
  try { sessionReporter.stop(); } catch {}
2491
2706
  try { transcriptRecorder.stop(); } catch {}
2492
2707
  try { inboxWatcher.stop(); } catch {}