@venturewild/workspace 0.4.2 → 0.5.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.
@@ -46,7 +46,7 @@ import {
46
46
  MAX_TIER,
47
47
  MAX_GRANT_MINUTES,
48
48
  } from './support-consent.mjs';
49
- import { ClaudeLoginSession } from './agent-login.mjs';
49
+ import { ClaudeLoginSession, defaultPtyLoader } from './agent-login.mjs';
50
50
  import { ErrorReporter } from './error-reporter.mjs';
51
51
  import { DaemonBridge } from './daemon.mjs';
52
52
  import { DaemonSupervisor } from './daemon-supervisor.mjs';
@@ -77,7 +77,7 @@ import { TURN_SYSTEM_PROMPT, writeTurnMcpConfig } from './turn-mcp.mjs';
77
77
  import { loadAccount } from './account.mjs';
78
78
  import { getOperatorToken } from './operator.mjs';
79
79
  import { runDoctor } from './doctor.mjs';
80
- import { appendLine, tailFile, logFile, TAILABLE, globalDir } from './logpaths.mjs';
80
+ import { appendLine, tailFile, logFile, listLogs, TAILABLE, globalDir } from './logpaths.mjs';
81
81
  import { SessionReporter } from './session-reporter.mjs';
82
82
  import { TranscriptRecorder } from './transcript.mjs';
83
83
  import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
@@ -109,24 +109,34 @@ function log(tag, ...args) {
109
109
  // The conversation's claude session id, stored in the workspace's gitignored
110
110
  // .wild-workspace/ dir. Persisting it means a browser reload — or a server
111
111
  // restart — doesn't wipe the agent's memory of the conversation.
112
- function chatSessionPath(dataDir) {
113
- return path.join(dataDir, 'chat-session.json');
112
+ // A chat THREAD id (C3 — independent chats on the canvas) becomes a filename
113
+ // segment, so harden it against traversal / illegal chars (`:` is illegal on
114
+ // Windows — never use it in the path). Null → the PRIMARY thread, which keeps the
115
+ // legacy un-suffixed files for upgrade-safe continuity.
116
+ function sanitizeThreadId(threadId) {
117
+ if (!threadId) return null;
118
+ const s = String(threadId).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
119
+ return s && s !== '.' && s !== '..' ? s : null;
114
120
  }
115
- function loadChatSessionId(dataDir) {
121
+ function chatSessionPath(dataDir, threadId) {
122
+ const safe = sanitizeThreadId(threadId);
123
+ return path.join(dataDir, safe ? `chat-session-${safe}.json` : 'chat-session.json');
124
+ }
125
+ function loadChatSessionId(dataDir, threadId) {
116
126
  try {
117
- const parsed = JSON.parse(readFileSync(chatSessionPath(dataDir), 'utf8'));
127
+ const parsed = JSON.parse(readFileSync(chatSessionPath(dataDir, threadId), 'utf8'));
118
128
  return typeof parsed.sessionId === 'string' ? parsed.sessionId : null;
119
129
  } catch {
120
130
  return null;
121
131
  }
122
132
  }
123
- function saveChatSessionId(dataDir, sessionId) {
133
+ function saveChatSessionId(dataDir, threadId, sessionId) {
124
134
  try {
125
135
  // A freshly-created workspace has no .wild-workspace/ dir yet — make it so the
126
136
  // per-workspace conversation persists from the very first turn (lobby M1).
127
137
  mkdirSync(dataDir, { recursive: true });
128
138
  writeFileSync(
129
- chatSessionPath(dataDir),
139
+ chatSessionPath(dataDir, threadId),
130
140
  JSON.stringify({ sessionId: sessionId || null }, null, 2),
131
141
  );
132
142
  } catch {
@@ -431,10 +441,18 @@ export async function createServer(overrides = {}) {
431
441
  // (full-context turn in this repo, 2026-06-13), so 3 concurrent ≈ 1 GB — safe
432
442
  // headroom on any modern machine, while the cap stops an unbounded fan-out from
433
443
  // OOMing a laptop. Raise/lower via WILD_WORKSPACE_MAX_CONCURRENT_TURNS.
434
- const currentTurns = new Map(); // workspace.id { session, messageId, workspaceId }
444
+ // Keyed by a TURN KEY = `${workspace.id}:${threadId}` (C3 independent chats on
445
+ // the canvas). A null/primary thread keys by bare workspace.id, so the primary
446
+ // chat behaves byte-for-byte as before. Each chat block on the canvas is its own
447
+ // thread with its own in-flight turn; the MAX_CONCURRENT_TURNS cap stays GLOBAL.
448
+ const currentTurns = new Map(); // turnKey → { session, messageId, workspaceId, threadId }
435
449
  const MAX_CONCURRENT_TURNS =
436
450
  overrides.maxConcurrentTurns ??
437
451
  Math.max(1, Number(process.env.WILD_WORKSPACE_MAX_CONCURRENT_TURNS) || 3);
452
+ function turnKey(workspaceId, threadId) {
453
+ const safe = sanitizeThreadId(threadId);
454
+ return safe ? `${workspaceId}:${safe}` : workspaceId;
455
+ }
438
456
  // Drop a workspace's turn entry ONLY if it still points at THIS session. A
439
457
  // superseded turn's `end` fires a tick AFTER its proc is killed (the 'close'
440
458
  // event), by which point the replacement turn may already own the slot — so an
@@ -461,11 +479,20 @@ export async function createServer(overrides = {}) {
461
479
  // that workspace receive it — so a tab viewing workspace B never renders a turn
462
480
  // that belongs to A (lobby M1: per-workspace conversations over one WS pool).
463
481
  // Frames with no workspaceId (e.g. the usage gauge, bazaar meter) go to all.
464
- function broadcastChat(obj, workspaceId) {
465
- const data = JSON.stringify(obj);
482
+ function broadcastChat(obj, workspaceId, threadId) {
483
+ const safe = sanitizeThreadId(threadId);
484
+ // Tag chat-turn frames with their thread so the client can double-filter
485
+ // (defense in depth on top of the per-socket scoping below).
486
+ const data = JSON.stringify(safe ? { ...obj, threadId: safe } : obj);
466
487
  for (const ws of chatClients) {
467
488
  if (ws.readyState !== ws.OPEN) continue;
468
- if (workspaceId && ws._wsWorkspaceId !== workspaceId) continue;
489
+ if (workspaceId) {
490
+ // A workspace-scoped frame goes only to sockets on that workspace AND bound
491
+ // to the same thread (C3): a frame for thread T must not render in another
492
+ // chat block, and the primary (null) thread must not leak into a secondary.
493
+ if (ws._wsWorkspaceId !== workspaceId) continue;
494
+ if ((ws._wsThreadId || null) !== (safe || null)) continue;
495
+ }
469
496
  ws.send(data);
470
497
  }
471
498
  }
@@ -479,27 +506,28 @@ export async function createServer(overrides = {}) {
479
506
  * and retries once if the run fails (PRD §13 A8).
480
507
  * Returns false if the turn could not start (an auto turn while busy).
481
508
  */
482
- function runChatTurn({ prompt, mode, messageId, userText, note, auto = false, workspace = defaultWorkspace }) {
509
+ function runChatTurn({ prompt, mode, messageId, userText, note, auto = false, workspace = defaultWorkspace, threadId = null }) {
483
510
  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);
511
+ const key = turnKey(workspace.id, threadId);
512
+ // Supersede only THIS thread's in-flight turn a send in another chat (or
513
+ // another workspace) must never kill a turn still running here (lobby M2 / C3).
514
+ const live = currentTurns.get(key);
487
515
  if (live) {
488
- if (auto) return false; // auto-wake yields to a live turn in this workspace
516
+ if (auto) return false; // auto-wake yields to a live turn in this thread
489
517
  live.session.close(); // a user send supersedes what's running here
490
- currentTurns.delete(workspace.id);
518
+ currentTurns.delete(key);
491
519
  }
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
520
+ // Concurrency cap (after the per-thread supersede, before spawn): too many
521
+ // OTHER turns are already mid-flight. Reject — don't queue — and tell the
494
522
  // tab so the user can resend (an auto-wake re-queues itself, so it stays quiet).
495
523
  if (currentTurns.size >= MAX_CONCURRENT_TURNS) {
496
- log('[chat]', `cap reached (${currentTurns.size}/${MAX_CONCURRENT_TURNS}) — rejecting ${auto ? 'auto' : 'user'} turn in ${workspace.id}`);
524
+ log('[chat]', `cap reached (${currentTurns.size}/${MAX_CONCURRENT_TURNS}) — rejecting ${auto ? 'auto' : 'user'} turn in ${key}`);
497
525
  if (!auto) {
498
526
  broadcastChat({
499
527
  type: 'error',
500
528
  messageId: id,
501
- message: `${MAX_CONCURRENT_TURNS} workspaces are already working at once. Wait for one to finish, then resend.`,
502
- }, workspace.id);
529
+ message: `${MAX_CONCURRENT_TURNS} chats are already working at once. Wait for one to finish, then resend.`,
530
+ }, workspace.id, threadId);
503
531
  }
504
532
  return false;
505
533
  }
@@ -508,11 +536,11 @@ export async function createServer(overrides = {}) {
508
536
  // the stored level + the per-message toggle through resolveAutonomyMode, whose
509
537
  // guardrail guarantees an unbuilt/unknown level can NEVER become bypassPermissions.
510
538
  if (!auto) mode = resolveAutonomyMode(settings.getAutonomyLevel(), mode);
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);
539
+ // Per-thread conversation (C3 on top of lobby M1): resume THIS thread's claude
540
+ // session from the workspace dataDir, and tag every frame with the workspace +
541
+ // thread so it streams only to the matching chat block.
542
+ const resumeId = loadChatSessionId(workspace.dataDir, threadId);
543
+ broadcastChat({ type: 'turn-begin', messageId: id, userText, note }, workspace.id, threadId);
516
544
  activityBus.publish({
517
545
  type: 'chat-user',
518
546
  messageId: id,
@@ -524,7 +552,7 @@ export async function createServer(overrides = {}) {
524
552
  const startedAt = Date.now();
525
553
  log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
526
554
  const session = new AgentSession(activeAgent);
527
- currentTurns.set(workspace.id, { session, messageId: id, workspaceId: workspace.id });
555
+ currentTurns.set(key, { session, messageId: id, workspaceId: workspace.id, threadId });
528
556
  let sawError = false;
529
557
  session.on('chunk', (chunk) => {
530
558
  // The per-turn context signal (working-memory gauge, §8) feeds the usage
@@ -535,7 +563,7 @@ export async function createServer(overrides = {}) {
535
563
  return;
536
564
  }
537
565
  if (chunk.type === 'error') sawError = true;
538
- broadcastChat({ type: 'chunk', messageId: id, chunk }, workspace.id);
566
+ broadcastChat({ type: 'chunk', messageId: id, chunk }, workspace.id, threadId);
539
567
  activityBus.publish({ type: 'chat-stream', messageId: id, chunk });
540
568
  // Surface the turn's token/cost totals so the activity bar can show
541
569
  // running usage — the ActivityBus accumulates events typed 'usage'.
@@ -546,7 +574,7 @@ export async function createServer(overrides = {}) {
546
574
  session.on('stderr', (text) => {
547
575
  const trimmed = String(text || '').trim();
548
576
  if (trimmed) log('[chat]', `stderr id=${id}: ${trimmed.slice(0, 240)}`);
549
- broadcastChat({ type: 'stderr', messageId: id, text }, workspace.id);
577
+ broadcastChat({ type: 'stderr', messageId: id, text }, workspace.id, threadId);
550
578
  });
551
579
  session.on('error', (err) => {
552
580
  sawError = true;
@@ -562,11 +590,11 @@ export async function createServer(overrides = {}) {
562
590
  type: 'error',
563
591
  messageId: id,
564
592
  message: msg,
565
- }, workspace.id);
566
- clearTurnIf(workspace.id, session);
593
+ }, workspace.id, threadId);
594
+ clearTurnIf(key, session);
567
595
  });
568
596
  session.on('end', ({ code }) => {
569
- clearTurnIf(workspace.id, session);
597
+ clearTurnIf(key, session);
570
598
  const elapsed = Date.now() - startedAt;
571
599
  log('[chat]', `turn-end id=${id} code=${code} ms=${elapsed} closed=${session.closed} sawError=${sawError}`);
572
600
  // Silent agent crash → telemetry. A non-zero exit (or signal-kill =
@@ -596,10 +624,10 @@ export async function createServer(overrides = {}) {
596
624
  return;
597
625
  }
598
626
  if (session.sessionId) {
599
- saveChatSessionId(workspace.dataDir, session.sessionId);
627
+ saveChatSessionId(workspace.dataDir, threadId, session.sessionId);
600
628
  }
601
629
  }
602
- broadcastChat({ type: 'end', messageId: id, code }, workspace.id);
630
+ broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
603
631
  activityBus.publish({ type: 'chat-end', messageId: id, code });
604
632
  });
605
633
  session.send(prompt, {
@@ -615,16 +643,17 @@ export async function createServer(overrides = {}) {
615
643
  return true;
616
644
  }
617
645
 
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);
646
+ function resetChat(workspace = defaultWorkspace, threadId = null) {
647
+ // Only stop the live turn if it belongs to THIS thread — a "new chat" in
648
+ // another chat block (or workspace) must never kill a turn running elsewhere.
649
+ const key = turnKey(workspace.id, threadId);
650
+ const live = currentTurns.get(key);
622
651
  if (live) {
623
652
  live.session.close();
624
- currentTurns.delete(workspace.id);
653
+ currentTurns.delete(key);
625
654
  }
626
- saveChatSessionId(workspace.dataDir, null);
627
- broadcastChat({ type: 'reset' }, workspace.id);
655
+ saveChatSessionId(workspace.dataDir, threadId, null);
656
+ broadcastChat({ type: 'reset' }, workspace.id, threadId);
628
657
  }
629
658
 
630
659
  // --- auto-wake on import (AR-23) ------------------------------------------
@@ -2276,6 +2305,25 @@ export async function createServer(overrides = {}) {
2276
2305
  }
2277
2306
  });
2278
2307
 
2308
+ // Logs block (C1): tail the user's own machine-global logs (server / daemon / cli /
2309
+ // operator / audit) BY NAME — never an arbitrary path (the TAILABLE allowlist, the
2310
+ // same one the operator channel uses). Owner-only (fileTree): a share-link viewer
2311
+ // must not read the host's system + audit logs. No `name` → the catalogue (which logs
2312
+ // exist + sizes). Absolute on-disk paths are NOT exposed (they'd leak the home dir).
2313
+ app.get('/api/workspace/logs', (c) => {
2314
+ const forbidden = require(c, 'fileTree');
2315
+ if (forbidden) return forbidden;
2316
+ const name = c.req.query('name');
2317
+ if (!name) {
2318
+ return c.json({ logs: listLogs().map(({ file, ...rest }) => rest) });
2319
+ }
2320
+ if (!TAILABLE.includes(name)) {
2321
+ return c.json({ error: 'unknown-log', name, allowed: TAILABLE }, 400);
2322
+ }
2323
+ const lines = Math.min(Number(c.req.query('lines')) || 200, 2000);
2324
+ return c.json({ name, lines, body: tailFile(logFile(name), lines) });
2325
+ });
2326
+
2279
2327
  // --- component inbox ---
2280
2328
  app.get('/api/inbox', async (c) => {
2281
2329
  // Enforce the `inbox` capability (partner-only). It existed in the matrix
@@ -2399,6 +2447,26 @@ export async function createServer(overrides = {}) {
2399
2447
  if (!res.ok) return c.json({ error: res.error }, 404);
2400
2448
  return c.json(res);
2401
2449
  });
2450
+ // Publish the user's CURRENT look as a theme on their Themes shelf — the human
2451
+ // authoring loop ("Publish my look" in the Theme picker). chatWrite-gated (a
2452
+ // read-only viewer can't publish on the owner's behalf). The bundle is hex-only,
2453
+ // re-validated by normalizeTheme inside publishTheme (data, never CSS). Optional
2454
+ // `builtFrom` records a remix's lineage (the shelf theme it was tweaked from).
2455
+ app.post('/api/bazaar/themes/publish', async (c) => {
2456
+ const forbidden = require(c, 'chatWrite');
2457
+ if (forbidden) return forbidden;
2458
+ const body = await c.req.json().catch(() => ({}));
2459
+ const title = String(body.title || '').trim();
2460
+ if (!title) return c.json({ error: 'title-required' }, 400);
2461
+ const res = bazaar.publishTheme({
2462
+ title,
2463
+ pitch: body.pitch ? String(body.pitch).slice(0, 200) : '',
2464
+ tags: Array.isArray(body.tags) ? body.tags.slice(0, 8).map((t) => String(t).slice(0, 24)) : [],
2465
+ theme: body.theme && typeof body.theme === 'object' ? body.theme : {},
2466
+ builtFrom: body.builtFrom && body.builtFrom.id ? { id: body.builtFrom.id, title: body.builtFrom.title } : null,
2467
+ });
2468
+ return c.json(res);
2469
+ });
2402
2470
 
2403
2471
  // --- canvas (agent-made custom blocks — §3.3) -----------------------------
2404
2472
  // The blocks the agent has built for the user. The UI hydrates these on load (so
@@ -2795,10 +2863,13 @@ export async function createServer(overrides = {}) {
2795
2863
  }
2796
2864
 
2797
2865
  // --- websocket bridge ---
2866
+ // The power-user terminal (T1) loads node-pty lazily + optionally — same loader as
2867
+ // in-app sign-in. Overridable so tests can inject a deterministic fake PTY.
2868
+ const ptyLoader = overrides.ptyLoader || defaultPtyLoader;
2798
2869
  const wss = new WebSocketServer({ noServer: true });
2799
2870
  httpServer.on('upgrade', async (req, socket, head) => {
2800
2871
  const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
2801
- const supported = ['/ws/chat', '/ws/activity'];
2872
+ const supported = ['/ws/chat', '/ws/activity', '/ws/pty'];
2802
2873
  if (!supported.includes(reqUrl.pathname)) {
2803
2874
  socket.destroy();
2804
2875
  return;
@@ -2833,16 +2904,35 @@ export async function createServer(overrides = {}) {
2833
2904
  socket.destroy();
2834
2905
  return;
2835
2906
  }
2907
+ // T1 — the power-user terminal is a real shell with the owner's full privileges,
2908
+ // so it is the MOST sensitive socket. Gate it hard, BEFORE the handshake:
2909
+ // - PARTNER only (a viewer/client share token can never open a shell), AND
2910
+ // - in public mode, ONLY a genuine-LOCAL request (host-machine owner over
2911
+ // loopback) — never the reverse tunnel. loopbackHeaders() returns false the
2912
+ // moment any x-forwarded-*/x-real-ip header is present, so a relayed visitor
2913
+ // can't reach it even with a stolen partner token.
2914
+ if (reqUrl.pathname === '/ws/pty') {
2915
+ const genuineLocal = isLocalBind && loopbackHeaders((n) => req.headers[n]);
2916
+ if (role !== ROLES.PARTNER || (config.publicMode && !genuineLocal)) {
2917
+ log('[ws]', `denied /ws/pty (role=${role} publicMode=${config.publicMode} local=${genuineLocal})`);
2918
+ socket.destroy();
2919
+ return;
2920
+ }
2921
+ }
2836
2922
  // The chat WS carries its tab's active workspace as `?workspace=<id>` (lobby
2837
2923
  // M1) — the per-tab channel for the socket, mirroring the X-Workspace-Id header
2838
2924
  // on HTTP. Absent/unknown → the boot default.
2839
2925
  const wsWorkspace = resolveWorkspace(reqUrl.searchParams.get('workspace'));
2926
+ // C3: a chat socket also carries its THREAD (`?thread=<id>`) — one chat block on
2927
+ // the canvas = one thread. Absent → the primary thread (legacy continuity).
2928
+ const wsThreadId = sanitizeThreadId(reqUrl.searchParams.get('thread'));
2840
2929
  wss.handleUpgrade(req, socket, head, (ws) => {
2841
2930
  ws._wsRole = role;
2842
2931
  ws._wsSub = sub;
2843
2932
  ws._wsWorkspace = wsWorkspace;
2844
2933
  ws._wsWorkspaceId = wsWorkspace.id;
2845
- log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub} ws=${wsWorkspace.id}`);
2934
+ ws._wsThreadId = wsThreadId;
2935
+ log('[ws]', `open ${reqUrl.pathname} role=${role} sub=${sub} ws=${wsWorkspace.id}${wsThreadId ? ` thread=${wsThreadId}` : ''}`);
2846
2936
  wss.emit('connection', ws, req, reqUrl.pathname);
2847
2937
  });
2848
2938
  });
@@ -2850,7 +2940,64 @@ export async function createServer(overrides = {}) {
2850
2940
  wss.on('connection', (ws, req, route) => {
2851
2941
  if (route === '/ws/activity') return wireActivityWs(ws);
2852
2942
  if (route === '/ws/chat') return wireChatWs(ws);
2853
- });
2943
+ if (route === '/ws/pty') return wirePtyWs(ws);
2944
+ });
2945
+
2946
+ // T1 — a real interactive shell over the socket (already auth-gated to a local
2947
+ // partner in the upgrade handler). Spawns the platform shell in the workspace
2948
+ // root via node-pty; pipes pty→ws as {type:'data'} and ws→pty for data/resize;
2949
+ // kills the child on close. If node-pty isn't available on this platform, sends a
2950
+ // single {type:'error'} and closes (the UI shows "terminal not available").
2951
+ async function wirePtyWs(ws) {
2952
+ const pty = await ptyLoader();
2953
+ if (!pty || typeof pty.spawn !== 'function') {
2954
+ try { ws.send(JSON.stringify({ type: 'error', message: 'Terminal is not available on this platform.' })); } catch {}
2955
+ try { ws.close(); } catch {}
2956
+ return;
2957
+ }
2958
+ const shell = process.platform === 'win32'
2959
+ ? (process.env.ComSpec || 'cmd.exe')
2960
+ : (process.env.SHELL || '/bin/bash');
2961
+ let proc;
2962
+ try {
2963
+ proc = pty.spawn(shell, [], {
2964
+ name: 'xterm-color',
2965
+ cols: 80,
2966
+ rows: 24,
2967
+ cwd: config.workspaceDir,
2968
+ env: { ...process.env },
2969
+ });
2970
+ } catch (e) {
2971
+ try { ws.send(JSON.stringify({ type: 'error', message: `couldn't start a shell: ${e?.message || e}` })); } catch {}
2972
+ try { ws.close(); } catch {}
2973
+ return;
2974
+ }
2975
+ log('[ws]', `pty open pid=${proc.pid} shell=${shell} cwd=${config.workspaceDir}`);
2976
+ ws.send(JSON.stringify({ type: 'ready', shell }));
2977
+ proc.onData((d) => {
2978
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'data', data: String(d) }));
2979
+ });
2980
+ proc.onExit((ev) => {
2981
+ const code = ev && typeof ev === 'object' ? ev.exitCode : ev;
2982
+ try { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'exit', code })); } catch {}
2983
+ try { ws.close(); } catch {}
2984
+ });
2985
+ ws.on('message', (raw) => {
2986
+ let msg;
2987
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
2988
+ if (msg.type === 'data' && typeof msg.data === 'string') {
2989
+ try { proc.write(msg.data); } catch {}
2990
+ } else if (msg.type === 'resize') {
2991
+ const cols = Math.max(1, Math.min(1000, Number(msg.cols) || 80));
2992
+ const rows = Math.max(1, Math.min(1000, Number(msg.rows) || 24));
2993
+ try { proc.resize(cols, rows); } catch {}
2994
+ }
2995
+ });
2996
+ ws.on('close', () => {
2997
+ try { proc.kill(); } catch {}
2998
+ log('[ws]', `pty close pid=${proc.pid}`);
2999
+ });
3000
+ }
2854
3001
 
2855
3002
  function wireActivityWs(ws) {
2856
3003
  const presence = activityBus.joinPresence({
@@ -2917,25 +3064,27 @@ export async function createServer(overrides = {}) {
2917
3064
  return;
2918
3065
  }
2919
3066
  ws._sendTimes.push(now);
2920
- // The turn-runner resumes THIS tab's workspace conversation and streams
2921
- // back only to tabs on the same workspace (lobby M1).
3067
+ // The turn-runner resumes THIS socket's workspace + thread conversation and
3068
+ // streams back only to the matching chat block (lobby M1 + C3).
2922
3069
  runChatTurn({
2923
3070
  prompt: msg.text,
2924
3071
  mode: msg.mode,
2925
3072
  messageId: msg.messageId,
2926
3073
  userText: msg.text,
2927
3074
  workspace: ws._wsWorkspace,
3075
+ threadId: ws._wsThreadId,
2928
3076
  });
2929
3077
  } else if (msg.type === 'cancel') {
2930
- // Cancel only the turn that belongs to this tab's workspace.
2931
- const live = currentTurns.get(ws._wsWorkspaceId);
3078
+ // Cancel only the turn that belongs to this socket's thread.
3079
+ const key = turnKey(ws._wsWorkspaceId, ws._wsThreadId);
3080
+ const live = currentTurns.get(key);
2932
3081
  if (live) {
2933
3082
  live.session.close();
2934
- currentTurns.delete(ws._wsWorkspaceId);
3083
+ currentTurns.delete(key);
2935
3084
  }
2936
3085
  } else if (msg.type === 'reset') {
2937
3086
  // "New chat" — drop the resumed session so the next turn starts fresh.
2938
- if (cap.chatWrite) resetChat(ws._wsWorkspace);
3087
+ if (cap.chatWrite) resetChat(ws._wsWorkspace, ws._wsThreadId);
2939
3088
  }
2940
3089
  });
2941
3090
  ws.on('close', () => {