@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.
- package/package.json +1 -1
- package/server/bin/wild-workspace.mjs +101 -0
- package/server/src/index.mjs +263 -48
- package/server/src/workspace-registry.mjs +225 -0
- package/server/src/workspaces.mjs +111 -0
- package/web/dist/assets/index-PAS8Inwp.js +91 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-B44y93r4.js +0 -91
package/server/src/index.mjs
CHANGED
|
@@ -59,6 +59,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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
+
}, workspace.id);
|
|
556
|
+
clearTurnIf(workspace.id, session);
|
|
499
557
|
});
|
|
500
558
|
session.on('end', ({ code }) => {
|
|
501
|
-
|
|
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
|
-
|
|
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:
|
|
596
|
+
cwd: workspace.dir,
|
|
540
597
|
mode,
|
|
541
|
-
resumeSessionId:
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1776
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
2446
|
-
//
|
|
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
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
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
|
-
|
|
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 {}
|