@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.
- package/package.json +3 -1
- package/server/src/agent-login.mjs +1 -1
- package/server/src/bazaar/core.mjs +159 -8
- package/server/src/bazaar/index.mjs +15 -2
- package/server/src/bazaar/mcp-server.mjs +89 -0
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +8 -0
- package/server/src/index.mjs +201 -52
- package/web/dist/assets/index-DWNJ55qg.css +32 -0
- package/web/dist/assets/index-YlSTL4Wv.js +131 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DahRXN26.js +0 -91
- package/web/dist/assets/index-NXZN2LU2.css +0 -1
package/server/src/index.mjs
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
485
|
-
//
|
|
486
|
-
|
|
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
|
|
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(
|
|
518
|
+
currentTurns.delete(key);
|
|
491
519
|
}
|
|
492
|
-
// Concurrency cap (after the per-
|
|
493
|
-
// OTHER
|
|
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 ${
|
|
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}
|
|
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-
|
|
512
|
-
// from
|
|
513
|
-
// only to
|
|
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(
|
|
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(
|
|
593
|
+
}, workspace.id, threadId);
|
|
594
|
+
clearTurnIf(key, session);
|
|
567
595
|
});
|
|
568
596
|
session.on('end', ({ code }) => {
|
|
569
|
-
clearTurnIf(
|
|
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
|
|
620
|
-
// must never kill a turn
|
|
621
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
2921
|
-
// back only to
|
|
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
|
|
2931
|
-
const
|
|
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(
|
|
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', () => {
|