@electric-agent/studio 1.7.0 → 1.12.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/dist/api-schemas.d.ts +225 -0
- package/dist/api-schemas.d.ts.map +1 -0
- package/dist/api-schemas.js +95 -0
- package/dist/api-schemas.js.map +1 -0
- package/dist/bridge/claude-code-base.d.ts +121 -0
- package/dist/bridge/claude-code-base.d.ts.map +1 -0
- package/dist/bridge/claude-code-base.js +263 -0
- package/dist/bridge/claude-code-base.js.map +1 -0
- package/dist/bridge/claude-code-docker.d.ts +13 -73
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +91 -302
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts +12 -59
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +88 -281
- package/dist/bridge/claude-code-sprites.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +10 -1
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +47 -98
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/codex-docker.d.ts +56 -51
- package/dist/bridge/codex-docker.js +222 -230
- package/dist/bridge/codex-json-parser.d.ts +11 -11
- package/dist/bridge/codex-json-parser.js +231 -238
- package/dist/bridge/codex-md-generator.d.ts +3 -3
- package/dist/bridge/codex-md-generator.js +42 -32
- package/dist/bridge/codex-sprites.d.ts +50 -45
- package/dist/bridge/codex-sprites.js +212 -222
- package/dist/bridge/daytona.d.ts +25 -25
- package/dist/bridge/daytona.js +131 -136
- package/dist/bridge/docker-stdio.d.ts +21 -21
- package/dist/bridge/docker-stdio.js +126 -132
- package/dist/bridge/hosted.d.ts +3 -2
- package/dist/bridge/hosted.d.ts.map +1 -1
- package/dist/bridge/hosted.js +4 -0
- package/dist/bridge/hosted.js.map +1 -1
- package/dist/bridge/message-parser.d.ts +24 -0
- package/dist/bridge/message-parser.d.ts.map +1 -0
- package/dist/bridge/message-parser.js +39 -0
- package/dist/bridge/message-parser.js.map +1 -0
- package/dist/bridge/role-skills.d.ts +25 -0
- package/dist/bridge/role-skills.d.ts.map +1 -0
- package/dist/bridge/role-skills.js +120 -0
- package/dist/bridge/role-skills.js.map +1 -0
- package/dist/bridge/room-messaging-skill.d.ts +11 -0
- package/dist/bridge/room-messaging-skill.d.ts.map +1 -0
- package/dist/bridge/room-messaging-skill.js +41 -0
- package/dist/bridge/room-messaging-skill.js.map +1 -0
- package/dist/bridge/sprites.d.ts +22 -22
- package/dist/bridge/sprites.js +123 -128
- package/dist/bridge/types.d.ts +4 -10
- package/dist/bridge/types.d.ts.map +1 -1
- package/dist/client/assets/index-BfvQSMwH.css +1 -0
- package/dist/client/assets/index-CiwD5LkP.js +235 -0
- package/dist/client/index.html +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/invite-code.d.ts +5 -0
- package/dist/invite-code.d.ts.map +1 -0
- package/dist/invite-code.js +14 -0
- package/dist/invite-code.js.map +1 -0
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/registry.d.ts +11 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/room-router.d.ts +73 -0
- package/dist/room-router.d.ts.map +1 -0
- package/dist/room-router.js +345 -0
- package/dist/room-router.js.map +1 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +5 -6
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/index.d.ts +0 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +0 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +40 -10
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +4 -2
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +761 -346
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +9 -0
- package/dist/session-auth.d.ts.map +1 -1
- package/dist/session-auth.js +30 -0
- package/dist/session-auth.js.map +1 -1
- package/dist/sessions.d.ts +1 -1
- package/dist/streams.d.ts +2 -6
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +6 -17
- package/dist/streams.js.map +1 -1
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +6 -9
- package/dist/client/assets/index-D5-jqAV-.js +0 -234
- package/dist/client/assets/index-YyyiO26y.css +0 -1
package/dist/server.js
CHANGED
|
@@ -7,23 +7,26 @@ import { ts } from "@electric-agent/protocol";
|
|
|
7
7
|
import { serve } from "@hono/node-server";
|
|
8
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
|
-
import { cors } from "hono/cors";
|
|
11
10
|
import { ActiveSessions } from "./active-sessions.js";
|
|
11
|
+
import { addAgentSchema, addSessionToRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
|
|
12
|
+
import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
|
|
12
13
|
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
13
14
|
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
14
|
-
import { createAppSkillContent, generateClaudeMd } from "./bridge/claude-md-generator.js";
|
|
15
|
+
import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
|
|
15
16
|
import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
16
17
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
17
18
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
18
19
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
+
import { generateInviteCode } from "./invite-code.js";
|
|
19
21
|
import { resolveProjectDir } from "./project-utils.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
22
|
+
import { RoomRouter } from "./room-router.js";
|
|
23
|
+
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
24
|
+
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
25
|
+
import { isResponse, validateBody } from "./validate.js";
|
|
23
26
|
/** Active session bridges — one per running session */
|
|
24
27
|
const bridges = new Map();
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
28
|
+
/** Active room routers — one per room with agent-to-agent messaging */
|
|
29
|
+
const roomRouters = new Map();
|
|
27
30
|
/** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
|
|
28
31
|
const inflightHookCreations = new Map();
|
|
29
32
|
function parseRepoNameFromUrl(url) {
|
|
@@ -36,9 +39,9 @@ function parseRepoNameFromUrl(url) {
|
|
|
36
39
|
function sessionStream(config, sessionId) {
|
|
37
40
|
return getStreamConnectionInfo(sessionId, config.streamConfig);
|
|
38
41
|
}
|
|
39
|
-
/** Get stream connection info for a
|
|
40
|
-
function
|
|
41
|
-
return
|
|
42
|
+
/** Get stream connection info for a room */
|
|
43
|
+
function roomStream(config, roomId) {
|
|
44
|
+
return getRoomStreamConnectionInfo(roomId, config.streamConfig);
|
|
42
45
|
}
|
|
43
46
|
/** Create or retrieve the SessionBridge for a session */
|
|
44
47
|
function getOrCreateBridge(config, sessionId) {
|
|
@@ -66,9 +69,39 @@ function resolveStudioUrl(port) {
|
|
|
66
69
|
// Fallback — won't work from sprites VMs, but at least logs a useful URL
|
|
67
70
|
return `http://localhost:${port}`;
|
|
68
71
|
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Rate limiting — in-memory sliding window per IP
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
76
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
77
|
+
const sessionCreationsByIp = new Map();
|
|
78
|
+
function extractClientIp(c) {
|
|
79
|
+
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
80
|
+
c.req.header("cf-connecting-ip") ||
|
|
81
|
+
"unknown");
|
|
82
|
+
}
|
|
83
|
+
function checkSessionRateLimit(ip) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
86
|
+
let timestamps = sessionCreationsByIp.get(ip) ?? [];
|
|
87
|
+
// Prune stale entries
|
|
88
|
+
timestamps = timestamps.filter((t) => t > cutoff);
|
|
89
|
+
if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
|
90
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
timestamps.push(now);
|
|
94
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Per-session cost budget
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
|
|
69
101
|
/**
|
|
70
102
|
* Accumulate cost and turn metrics from a session_end event into the session's totals.
|
|
71
103
|
* Called each time a Claude Code run finishes (initial + iterate runs).
|
|
104
|
+
* In production mode, enforces a per-session cost budget.
|
|
72
105
|
*/
|
|
73
106
|
function accumulateSessionCost(config, sessionId, event) {
|
|
74
107
|
if (event.type !== "session_end")
|
|
@@ -89,12 +122,39 @@ function accumulateSessionCost(config, sessionId, event) {
|
|
|
89
122
|
}
|
|
90
123
|
config.sessions.update(sessionId, updates);
|
|
91
124
|
console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
|
|
125
|
+
// Enforce budget in production mode
|
|
126
|
+
if (!config.devMode &&
|
|
127
|
+
updates.totalCostUsd != null &&
|
|
128
|
+
updates.totalCostUsd > MAX_SESSION_COST_USD) {
|
|
129
|
+
console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
|
|
130
|
+
const bridge = bridges.get(sessionId);
|
|
131
|
+
if (bridge) {
|
|
132
|
+
bridge
|
|
133
|
+
.emit({
|
|
134
|
+
type: "budget_exceeded",
|
|
135
|
+
budget_usd: MAX_SESSION_COST_USD,
|
|
136
|
+
spent_usd: updates.totalCostUsd,
|
|
137
|
+
ts: ts(),
|
|
138
|
+
})
|
|
139
|
+
.catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
142
|
+
closeBridge(sessionId);
|
|
143
|
+
}
|
|
92
144
|
}
|
|
93
145
|
/**
|
|
94
146
|
* Create a Claude Code bridge for a session.
|
|
95
147
|
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
148
|
+
* In production mode, enforces tool restrictions and hardcodes the model.
|
|
96
149
|
*/
|
|
97
150
|
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
151
|
+
// Production mode: restrict tools and hardcode model
|
|
152
|
+
if (!config.devMode) {
|
|
153
|
+
if (!claudeConfig.allowedTools) {
|
|
154
|
+
claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
|
|
155
|
+
}
|
|
156
|
+
claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
|
|
157
|
+
}
|
|
98
158
|
const conn = sessionStream(config, sessionId);
|
|
99
159
|
let bridge;
|
|
100
160
|
if (config.sandbox.runtime === "sprites") {
|
|
@@ -264,8 +324,6 @@ function mapHookToEngineEvent(body) {
|
|
|
264
324
|
}
|
|
265
325
|
export function createApp(config) {
|
|
266
326
|
const app = new Hono();
|
|
267
|
-
// CORS for local development
|
|
268
|
-
app.use("*", cors({ origin: "*" }));
|
|
269
327
|
// --- API Routes ---
|
|
270
328
|
// Health check
|
|
271
329
|
app.get("/api/health", (c) => {
|
|
@@ -283,6 +341,10 @@ export function createApp(config) {
|
|
|
283
341
|
checks.sandbox = config.sandbox.runtime;
|
|
284
342
|
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
285
343
|
});
|
|
344
|
+
// Public config — exposes non-sensitive flags to the client
|
|
345
|
+
app.get("/api/config", (c) => {
|
|
346
|
+
return c.json({ devMode: config.devMode });
|
|
347
|
+
});
|
|
286
348
|
// Provision Electric Cloud resources via the Claim API
|
|
287
349
|
app.post("/api/provision-electric", async (c) => {
|
|
288
350
|
try {
|
|
@@ -307,7 +369,7 @@ export function createApp(config) {
|
|
|
307
369
|
// Hono's wildcard middleware matches creation routes like /api/sessions/local as
|
|
308
370
|
// :id="local", so we must explicitly skip those.
|
|
309
371
|
const authExemptIds = new Set(["local", "auto", "resume"]);
|
|
310
|
-
|
|
372
|
+
// Hook-event auth is handled in the endpoint handler via validateHookToken
|
|
311
373
|
/** Extract session token from Authorization header or query param. */
|
|
312
374
|
function extractToken(c) {
|
|
313
375
|
const authHeader = c.req.header("Authorization");
|
|
@@ -321,7 +383,8 @@ export function createApp(config) {
|
|
|
321
383
|
if (authExemptIds.has(id))
|
|
322
384
|
return next();
|
|
323
385
|
const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
|
|
324
|
-
|
|
386
|
+
// Hook-event uses a purpose-scoped hook token (validated in the handler)
|
|
387
|
+
if (subPath === "/hook-event")
|
|
325
388
|
return next();
|
|
326
389
|
const token = extractToken(c);
|
|
327
390
|
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
@@ -382,8 +445,9 @@ export function createApp(config) {
|
|
|
382
445
|
// Pre-create a bridge so hook-event can emit to it immediately
|
|
383
446
|
getOrCreateBridge(config, sessionId);
|
|
384
447
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
448
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
385
449
|
console.log(`[local-session] Created session: ${sessionId}`);
|
|
386
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
450
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
387
451
|
});
|
|
388
452
|
// Auto-register a local session on first hook event (SessionStart).
|
|
389
453
|
// Eliminates the manual `curl POST /api/sessions/local` step.
|
|
@@ -429,14 +493,20 @@ export function createApp(config) {
|
|
|
429
493
|
await bridge.emit(hookEvent);
|
|
430
494
|
}
|
|
431
495
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
496
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
432
497
|
console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
|
|
433
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
498
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
434
499
|
});
|
|
435
500
|
// Receive a hook event from Claude Code (via forward.sh) and write it
|
|
436
501
|
// to the session's durable stream as an EngineEvent.
|
|
437
502
|
// For AskUserQuestion, this blocks until the user answers in the web UI.
|
|
438
503
|
app.post("/api/sessions/:id/hook-event", async (c) => {
|
|
439
504
|
const sessionId = c.req.param("id");
|
|
505
|
+
// Validate hook token (scoped per-session, separate from session token)
|
|
506
|
+
const token = extractToken(c);
|
|
507
|
+
if (!token || !validateHookToken(config.streamConfig.secret, sessionId, token)) {
|
|
508
|
+
return c.json({ error: "Invalid or missing hook token" }, 401);
|
|
509
|
+
}
|
|
440
510
|
const body = (await c.req.json());
|
|
441
511
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
442
512
|
// Map Claude Code hook JSON → EngineEvent
|
|
@@ -498,6 +568,16 @@ export function createApp(config) {
|
|
|
498
568
|
return c.json({ ok: true });
|
|
499
569
|
});
|
|
500
570
|
// --- Unified Hook Endpoint (transcript_path correlation) ---
|
|
571
|
+
// Protect the unified hook endpoint with a global hook secret derived from
|
|
572
|
+
// the DS secret. The hook setup script embeds this secret in the forwarder
|
|
573
|
+
// so that only local Claude Code instances can post events.
|
|
574
|
+
app.use("/api/hook", async (c, next) => {
|
|
575
|
+
const token = extractToken(c);
|
|
576
|
+
if (!token || !validateGlobalHookSecret(config.streamConfig.secret, token)) {
|
|
577
|
+
return c.json({ error: "Invalid or missing hook secret" }, 401);
|
|
578
|
+
}
|
|
579
|
+
return next();
|
|
580
|
+
});
|
|
501
581
|
// Single endpoint for all Claude Code hook events. Uses transcript_path
|
|
502
582
|
// from the hook JSON as the correlation key — stable across resume/compact,
|
|
503
583
|
// changes on /clear. Replaces the need for client-side session tracking.
|
|
@@ -635,6 +715,7 @@ export function createApp(config) {
|
|
|
635
715
|
// Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
|
|
636
716
|
app.get("/api/hooks/setup", (c) => {
|
|
637
717
|
const port = config.port;
|
|
718
|
+
const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
|
|
638
719
|
const script = `#!/bin/bash
|
|
639
720
|
# Electric Agent — Claude Code hook installer (project-scoped)
|
|
640
721
|
# Installs the hook forwarder into the current project's .claude/ directory.
|
|
@@ -655,10 +736,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
|
|
|
655
736
|
# Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
|
|
656
737
|
|
|
657
738
|
EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
|
|
739
|
+
EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
|
|
658
740
|
BODY="$(cat)"
|
|
659
741
|
|
|
660
742
|
RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
|
|
661
743
|
-H "Content-Type: application/json" \\
|
|
744
|
+
-H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
|
|
662
745
|
-d "\${BODY}" \\
|
|
663
746
|
--max-time 360 \\
|
|
664
747
|
--connect-timeout 2 \\
|
|
@@ -672,8 +755,9 @@ fi
|
|
|
672
755
|
exit 0
|
|
673
756
|
HOOKEOF
|
|
674
757
|
|
|
675
|
-
# Replace
|
|
758
|
+
# Replace placeholders with actual values
|
|
676
759
|
sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
760
|
+
sed -i.bak "s/EA_HOOK_SECRET_PLACEHOLDER/${hookSecret}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
677
761
|
chmod +x "\${FORWARD_SH}"
|
|
678
762
|
|
|
679
763
|
# Merge hook config into project-level settings.local.json
|
|
@@ -715,9 +799,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
715
799
|
});
|
|
716
800
|
// Start new project
|
|
717
801
|
app.post("/api/sessions", async (c) => {
|
|
718
|
-
const body =
|
|
719
|
-
if (
|
|
720
|
-
return
|
|
802
|
+
const body = await validateBody(c, createSessionSchema);
|
|
803
|
+
if (isResponse(body))
|
|
804
|
+
return body;
|
|
805
|
+
// Block freeform sessions in production mode
|
|
806
|
+
if (body.freeform && !config.devMode) {
|
|
807
|
+
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
808
|
+
}
|
|
809
|
+
// Rate-limit session creation in production mode
|
|
810
|
+
if (!config.devMode) {
|
|
811
|
+
const ip = extractClientIp(c);
|
|
812
|
+
if (!checkSessionRateLimit(ip)) {
|
|
813
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
814
|
+
}
|
|
721
815
|
}
|
|
722
816
|
const sessionId = crypto.randomUUID();
|
|
723
817
|
const inferredName = body.name ||
|
|
@@ -759,74 +853,84 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
759
853
|
config.sessions.add(session);
|
|
760
854
|
// Write user prompt to the stream so it shows in the UI
|
|
761
855
|
await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
762
|
-
//
|
|
763
|
-
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
856
|
+
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
764
857
|
let ghAccounts = [];
|
|
765
|
-
if (body.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
858
|
+
if (!body.freeform) {
|
|
859
|
+
// Gather GitHub accounts for the merged setup gate
|
|
860
|
+
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
861
|
+
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
862
|
+
try {
|
|
863
|
+
ghAccounts = ghListAccounts(body.ghToken);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
// gh not available — no repo setup
|
|
867
|
+
}
|
|
771
868
|
}
|
|
869
|
+
// Emit combined infra + repo setup gate
|
|
870
|
+
await bridge.emit({
|
|
871
|
+
type: "infra_config_prompt",
|
|
872
|
+
projectName,
|
|
873
|
+
ghAccounts,
|
|
874
|
+
runtime: config.sandbox.runtime,
|
|
875
|
+
ts: ts(),
|
|
876
|
+
});
|
|
772
877
|
}
|
|
773
|
-
// Emit combined infra + repo setup gate
|
|
774
|
-
await bridge.emit({
|
|
775
|
-
type: "infra_config_prompt",
|
|
776
|
-
projectName,
|
|
777
|
-
ghAccounts,
|
|
778
|
-
runtime: config.sandbox.runtime,
|
|
779
|
-
ts: ts(),
|
|
780
|
-
});
|
|
781
878
|
// Launch async flow: wait for setup gate → create sandbox → start agent
|
|
782
879
|
const asyncFlow = async () => {
|
|
783
|
-
// 1. Wait for combined infra + repo config
|
|
880
|
+
// 1. Wait for combined infra + repo config (skip for freeform)
|
|
784
881
|
let infra;
|
|
785
882
|
let repoConfig = null;
|
|
786
|
-
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
787
883
|
let claimId;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
884
|
+
if (body.freeform) {
|
|
885
|
+
// Freeform sessions don't need Electric infrastructure
|
|
886
|
+
infra = { mode: "none" };
|
|
887
|
+
console.log(`[session:${sessionId}] Freeform session — skipping infra gate`);
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
891
|
+
try {
|
|
892
|
+
const gateValue = await createGate(sessionId, "infra_config");
|
|
893
|
+
console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
894
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
895
|
+
// Normalize claim → cloud for the sandbox layer (same env vars)
|
|
896
|
+
infra = {
|
|
897
|
+
mode: "cloud",
|
|
898
|
+
databaseUrl: gateValue.databaseUrl,
|
|
899
|
+
electricUrl: gateValue.electricUrl,
|
|
900
|
+
sourceId: gateValue.sourceId,
|
|
901
|
+
secret: gateValue.secret,
|
|
902
|
+
};
|
|
903
|
+
if (gateValue.mode === "claim") {
|
|
904
|
+
claimId = gateValue.claimId;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
infra = { mode: "local" };
|
|
909
|
+
}
|
|
910
|
+
// Extract repo config if provided
|
|
911
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
912
|
+
repoConfig = {
|
|
913
|
+
account: gateValue.repoAccount,
|
|
914
|
+
repoName: gateValue.repoName,
|
|
915
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
916
|
+
};
|
|
917
|
+
config.sessions.update(sessionId, {
|
|
918
|
+
git: {
|
|
919
|
+
branch: "main",
|
|
920
|
+
remoteUrl: null,
|
|
921
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
922
|
+
repoVisibility: repoConfig.visibility,
|
|
923
|
+
lastCommitHash: null,
|
|
924
|
+
lastCommitMessage: null,
|
|
925
|
+
lastCheckpointAt: null,
|
|
926
|
+
},
|
|
927
|
+
});
|
|
802
928
|
}
|
|
803
929
|
}
|
|
804
|
-
|
|
930
|
+
catch (err) {
|
|
931
|
+
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
805
932
|
infra = { mode: "local" };
|
|
806
933
|
}
|
|
807
|
-
// Extract repo config if provided
|
|
808
|
-
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
809
|
-
repoConfig = {
|
|
810
|
-
account: gateValue.repoAccount,
|
|
811
|
-
repoName: gateValue.repoName,
|
|
812
|
-
visibility: gateValue.repoVisibility ?? "private",
|
|
813
|
-
};
|
|
814
|
-
config.sessions.update(sessionId, {
|
|
815
|
-
git: {
|
|
816
|
-
branch: "main",
|
|
817
|
-
remoteUrl: null,
|
|
818
|
-
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
819
|
-
repoVisibility: repoConfig.visibility,
|
|
820
|
-
lastCommitHash: null,
|
|
821
|
-
lastCommitMessage: null,
|
|
822
|
-
lastCheckpointAt: null,
|
|
823
|
-
},
|
|
824
|
-
});
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
catch (err) {
|
|
828
|
-
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
829
|
-
infra = { mode: "local" };
|
|
830
934
|
}
|
|
831
935
|
// 2. Create sandbox — emit progress events so the UI shows feedback
|
|
832
936
|
await bridge.emit({
|
|
@@ -859,85 +963,119 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
859
963
|
// 3. Write CLAUDE.md and create a ClaudeCode bridge.
|
|
860
964
|
{
|
|
861
965
|
console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
type: "log",
|
|
865
|
-
level: "build",
|
|
866
|
-
message: "Setting up project...",
|
|
867
|
-
ts: ts(),
|
|
868
|
-
});
|
|
869
|
-
try {
|
|
870
|
-
if (config.sandbox.runtime === "docker") {
|
|
871
|
-
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
872
|
-
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
873
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
|
|
874
|
-
}
|
|
875
|
-
else {
|
|
876
|
-
// Sprites/Daytona: run scaffold from globally installed electric-agent
|
|
877
|
-
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
878
|
-
}
|
|
879
|
-
console.log(`[session:${sessionId}] Project setup complete`);
|
|
966
|
+
if (!body.freeform) {
|
|
967
|
+
// Copy pre-scaffolded project from the image and customize per-session
|
|
880
968
|
await bridge.emit({
|
|
881
969
|
type: "log",
|
|
882
|
-
level: "
|
|
883
|
-
message: "
|
|
970
|
+
level: "build",
|
|
971
|
+
message: "Setting up project...",
|
|
884
972
|
ts: ts(),
|
|
885
973
|
});
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
974
|
+
try {
|
|
975
|
+
if (config.sandbox.runtime === "docker") {
|
|
976
|
+
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
977
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
978
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
// Sprites: run scaffold from globally installed electric-agent
|
|
982
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
983
|
+
}
|
|
984
|
+
console.log(`[session:${sessionId}] Project setup complete`);
|
|
985
|
+
await bridge.emit({
|
|
986
|
+
type: "log",
|
|
987
|
+
level: "done",
|
|
988
|
+
message: "Project ready",
|
|
989
|
+
ts: ts(),
|
|
990
|
+
});
|
|
991
|
+
// Log the agent package version installed in the sandbox
|
|
992
|
+
try {
|
|
993
|
+
const agentVersion = (await config.sandbox.exec(handle, "electric-agent --version 2>/dev/null | tail -1")).trim();
|
|
994
|
+
await bridge.emit({
|
|
995
|
+
type: "log",
|
|
996
|
+
level: "verbose",
|
|
997
|
+
message: `electric-agent@${agentVersion}`,
|
|
998
|
+
ts: ts(),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
// Non-critical — don't block session creation
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
console.error(`[session:${sessionId}] Project setup failed:`, err);
|
|
1007
|
+
await bridge.emit({
|
|
1008
|
+
type: "log",
|
|
1009
|
+
level: "error",
|
|
1010
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
1011
|
+
ts: ts(),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
// Write CLAUDE.md to the sandbox workspace.
|
|
1015
|
+
// Our generator includes hardcoded playbook paths and reading order
|
|
1016
|
+
// so we don't depend on @tanstack/intent generating a skill block.
|
|
1017
|
+
const claudeMd = generateClaudeMd({
|
|
1018
|
+
description: body.description,
|
|
1019
|
+
projectName,
|
|
1020
|
+
projectDir: handle.projectDir,
|
|
1021
|
+
runtime: config.sandbox.runtime,
|
|
1022
|
+
production: !config.devMode,
|
|
1023
|
+
...(repoConfig
|
|
1024
|
+
? {
|
|
1025
|
+
git: {
|
|
1026
|
+
mode: "create",
|
|
1027
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1028
|
+
visibility: repoConfig.visibility,
|
|
1029
|
+
},
|
|
1030
|
+
}
|
|
1031
|
+
: {}),
|
|
894
1032
|
});
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
1033
|
+
try {
|
|
1034
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
1038
|
+
}
|
|
1039
|
+
// Ensure the create-app skill is present in the project.
|
|
1040
|
+
// The npm-installed electric-agent may be an older version that
|
|
1041
|
+
// doesn't include .claude/skills/ in its template directory.
|
|
1042
|
+
if (createAppSkillContent) {
|
|
1043
|
+
try {
|
|
1044
|
+
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
1045
|
+
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
1046
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
909
1047
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
catch (err) {
|
|
916
|
-
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
917
1052
|
}
|
|
918
|
-
// Ensure the
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
if (createAppSkillContent) {
|
|
1053
|
+
// Ensure the room-messaging skill is present so agents have
|
|
1054
|
+
// persistent access to the multi-agent protocol reference.
|
|
1055
|
+
if (roomMessagingSkillContent) {
|
|
922
1056
|
try {
|
|
923
|
-
const skillDir = `${handle.projectDir}/.claude/skills/
|
|
924
|
-
const skillB64 = Buffer.from(
|
|
1057
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1058
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
925
1059
|
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
926
1060
|
}
|
|
927
1061
|
catch (err) {
|
|
928
|
-
console.error(`[session:${sessionId}] Failed to write
|
|
1062
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
929
1063
|
}
|
|
930
1064
|
}
|
|
1065
|
+
const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
|
|
1066
|
+
const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
931
1067
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
932
1068
|
? {
|
|
933
|
-
prompt:
|
|
1069
|
+
prompt: sessionPrompt,
|
|
934
1070
|
cwd: handle.projectDir,
|
|
935
1071
|
studioUrl: resolveStudioUrl(config.port),
|
|
1072
|
+
hookToken: sessionHookToken,
|
|
936
1073
|
}
|
|
937
1074
|
: {
|
|
938
|
-
prompt:
|
|
1075
|
+
prompt: sessionPrompt,
|
|
939
1076
|
cwd: handle.projectDir,
|
|
940
1077
|
studioPort: config.port,
|
|
1078
|
+
hookToken: sessionHookToken,
|
|
941
1079
|
};
|
|
942
1080
|
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
943
1081
|
}
|
|
@@ -1011,7 +1149,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1011
1149
|
await bridge.emit({
|
|
1012
1150
|
type: "log",
|
|
1013
1151
|
level: "build",
|
|
1014
|
-
message:
|
|
1152
|
+
message: body.freeform
|
|
1153
|
+
? `Running: claude "${body.description}"`
|
|
1154
|
+
: `Running: claude "/create-app ${body.description}"`,
|
|
1015
1155
|
ts: ts(),
|
|
1016
1156
|
});
|
|
1017
1157
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
@@ -1029,6 +1169,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1029
1169
|
asyncFlow().catch(async (err) => {
|
|
1030
1170
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
1031
1171
|
config.sessions.update(sessionId, { status: "error" });
|
|
1172
|
+
try {
|
|
1173
|
+
await bridge.emit({
|
|
1174
|
+
type: "log",
|
|
1175
|
+
level: "error",
|
|
1176
|
+
message: `Session failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1177
|
+
ts: ts(),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
// Bridge may not be usable if the failure happened early
|
|
1182
|
+
}
|
|
1032
1183
|
});
|
|
1033
1184
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1034
1185
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -1039,10 +1190,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1039
1190
|
const session = config.sessions.get(sessionId);
|
|
1040
1191
|
if (!session)
|
|
1041
1192
|
return c.json({ error: "Session not found" }, 404);
|
|
1042
|
-
const body =
|
|
1043
|
-
if (
|
|
1044
|
-
return
|
|
1045
|
-
}
|
|
1193
|
+
const body = await validateBody(c, iterateSessionSchema);
|
|
1194
|
+
if (isResponse(body))
|
|
1195
|
+
return body;
|
|
1046
1196
|
// Intercept operational commands (start/stop/restart the app/server)
|
|
1047
1197
|
const normalised = body.request
|
|
1048
1198
|
.toLowerCase()
|
|
@@ -1171,6 +1321,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1171
1321
|
}
|
|
1172
1322
|
// No pending gate — fall through to bridge.sendGateResponse()
|
|
1173
1323
|
}
|
|
1324
|
+
// Outbound message gates (room agent → room stream): resolved in-process
|
|
1325
|
+
if (gate === "outbound_message_gate") {
|
|
1326
|
+
const gateId = body.gateId;
|
|
1327
|
+
const action = body.action;
|
|
1328
|
+
if (!gateId || !action) {
|
|
1329
|
+
return c.json({ error: "gateId and action are required for outbound_message_gate" }, 400);
|
|
1330
|
+
}
|
|
1331
|
+
const resolved = resolveGate(sessionId, gateId, {
|
|
1332
|
+
action,
|
|
1333
|
+
editedBody: body.editedBody,
|
|
1334
|
+
});
|
|
1335
|
+
if (resolved) {
|
|
1336
|
+
return c.json({ ok: true });
|
|
1337
|
+
}
|
|
1338
|
+
return c.json({ error: "No pending gate found" }, 404);
|
|
1339
|
+
}
|
|
1174
1340
|
// Server-side gates are resolved in-process (they run on the server, not inside the container)
|
|
1175
1341
|
const serverGates = new Set(["infra_config"]);
|
|
1176
1342
|
// Forward agent gate responses via the bridge
|
|
@@ -1396,7 +1562,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1396
1562
|
});
|
|
1397
1563
|
// Create a standalone sandbox (not tied to session creation flow)
|
|
1398
1564
|
app.post("/api/sandboxes", async (c) => {
|
|
1399
|
-
const body =
|
|
1565
|
+
const body = await validateBody(c, createSandboxSchema);
|
|
1566
|
+
if (isResponse(body))
|
|
1567
|
+
return body;
|
|
1400
1568
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1401
1569
|
try {
|
|
1402
1570
|
const handle = await config.sandbox.create(sessionId, {
|
|
@@ -1426,30 +1594,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1426
1594
|
await config.sandbox.destroy(handle);
|
|
1427
1595
|
return c.json({ ok: true });
|
|
1428
1596
|
});
|
|
1429
|
-
// ---
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
|
|
1433
|
-
|
|
1597
|
+
// --- Room Routes (agent-to-agent messaging) ---
|
|
1598
|
+
// Extract room token from X-Room-Token header or ?token= query param.
|
|
1599
|
+
// This is separate from extractToken() (which reads Authorization) so that
|
|
1600
|
+
// Authorization remains available for session tokens on endpoints that need both.
|
|
1601
|
+
function extractRoomToken(c) {
|
|
1602
|
+
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1603
|
+
}
|
|
1604
|
+
// Protect room-scoped routes via X-Room-Token header
|
|
1605
|
+
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1434
1606
|
const id = c.req.param("id");
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
const token = extractToken(c);
|
|
1438
|
-
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1607
|
+
const token = extractRoomToken(c);
|
|
1608
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1439
1609
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1440
1610
|
}
|
|
1441
1611
|
return next();
|
|
1442
1612
|
});
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1613
|
+
app.use("/api/rooms/:id", async (c, next) => {
|
|
1614
|
+
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1615
|
+
return next();
|
|
1616
|
+
const id = c.req.param("id");
|
|
1617
|
+
const token = extractRoomToken(c);
|
|
1618
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1619
|
+
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1448
1620
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1621
|
+
return next();
|
|
1622
|
+
});
|
|
1623
|
+
// Create a room
|
|
1624
|
+
app.post("/api/rooms", async (c) => {
|
|
1625
|
+
const body = await validateBody(c, createRoomSchema);
|
|
1626
|
+
if (isResponse(body))
|
|
1627
|
+
return body;
|
|
1628
|
+
const roomId = crypto.randomUUID();
|
|
1629
|
+
// Create the room's durable stream
|
|
1630
|
+
const conn = roomStream(config, roomId);
|
|
1453
1631
|
try {
|
|
1454
1632
|
await DurableStream.create({
|
|
1455
1633
|
url: conn.url,
|
|
@@ -1458,191 +1636,341 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1458
1636
|
});
|
|
1459
1637
|
}
|
|
1460
1638
|
catch (err) {
|
|
1461
|
-
console.error(`[
|
|
1462
|
-
return c.json({ error: "Failed to create
|
|
1639
|
+
console.error(`[room] Failed to create durable stream:`, err);
|
|
1640
|
+
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1463
1641
|
}
|
|
1464
|
-
//
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
headers: conn.headers,
|
|
1468
|
-
contentType: "application/json",
|
|
1642
|
+
// Create and start the router
|
|
1643
|
+
const router = new RoomRouter(roomId, body.name, config.streamConfig, {
|
|
1644
|
+
maxRounds: body.maxRounds,
|
|
1469
1645
|
});
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
createdBy: body.participant,
|
|
1475
|
-
ts: ts(),
|
|
1476
|
-
};
|
|
1477
|
-
await stream.append(JSON.stringify(createdEvent));
|
|
1478
|
-
// Write participant_joined for the creator
|
|
1479
|
-
const joinedEvent = {
|
|
1480
|
-
type: "participant_joined",
|
|
1481
|
-
participant: body.participant,
|
|
1482
|
-
ts: ts(),
|
|
1483
|
-
};
|
|
1484
|
-
await stream.append(JSON.stringify(joinedEvent));
|
|
1485
|
-
// Save to room registry
|
|
1646
|
+
await router.start();
|
|
1647
|
+
roomRouters.set(roomId, router);
|
|
1648
|
+
// Save to room registry for persistence
|
|
1649
|
+
const code = generateInviteCode();
|
|
1486
1650
|
await config.rooms.addRoom({
|
|
1487
|
-
id,
|
|
1651
|
+
id: roomId,
|
|
1488
1652
|
code,
|
|
1489
1653
|
name: body.name,
|
|
1490
1654
|
createdAt: new Date().toISOString(),
|
|
1491
1655
|
revoked: false,
|
|
1492
1656
|
});
|
|
1493
|
-
const roomToken =
|
|
1494
|
-
console.log(`[
|
|
1495
|
-
return c.json({
|
|
1657
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1658
|
+
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1659
|
+
return c.json({ roomId, code, roomToken }, 201);
|
|
1496
1660
|
});
|
|
1497
|
-
//
|
|
1498
|
-
app.get("/api/
|
|
1661
|
+
// Join an agent room by id + invite code
|
|
1662
|
+
app.get("/api/rooms/join/:id/:code", (c) => {
|
|
1663
|
+
const id = c.req.param("id");
|
|
1499
1664
|
const code = c.req.param("code");
|
|
1500
|
-
const
|
|
1501
|
-
if (!
|
|
1502
|
-
return c.json({ error: "
|
|
1503
|
-
|
|
1504
|
-
|
|
1665
|
+
const room = config.rooms.getRoom(id);
|
|
1666
|
+
if (!room || room.code !== code)
|
|
1667
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1668
|
+
if (room.revoked)
|
|
1669
|
+
return c.json({ error: "Room has been revoked" }, 410);
|
|
1670
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
|
|
1671
|
+
return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
|
|
1505
1672
|
});
|
|
1506
|
-
//
|
|
1507
|
-
app.
|
|
1508
|
-
const
|
|
1509
|
-
const
|
|
1510
|
-
if (!
|
|
1511
|
-
return c.json({ error: "
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
contentType: "application/json",
|
|
1673
|
+
// Get room state
|
|
1674
|
+
app.get("/api/rooms/:id", (c) => {
|
|
1675
|
+
const roomId = c.req.param("id");
|
|
1676
|
+
const router = roomRouters.get(roomId);
|
|
1677
|
+
if (!router)
|
|
1678
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1679
|
+
return c.json({
|
|
1680
|
+
roomId,
|
|
1681
|
+
state: router.state,
|
|
1682
|
+
roundCount: router.roundCount,
|
|
1683
|
+
participants: router.participants.map((p) => ({
|
|
1684
|
+
sessionId: p.sessionId,
|
|
1685
|
+
name: p.name,
|
|
1686
|
+
role: p.role,
|
|
1687
|
+
running: p.bridge.isRunning(),
|
|
1688
|
+
})),
|
|
1523
1689
|
});
|
|
1524
|
-
const event = {
|
|
1525
|
-
type: "participant_joined",
|
|
1526
|
-
participant: body.participant,
|
|
1527
|
-
ts: ts(),
|
|
1528
|
-
};
|
|
1529
|
-
await stream.append(JSON.stringify(event));
|
|
1530
|
-
return c.json({ ok: true });
|
|
1531
1690
|
});
|
|
1532
|
-
//
|
|
1533
|
-
app.post("/api/
|
|
1534
|
-
const
|
|
1535
|
-
const
|
|
1536
|
-
if (!
|
|
1537
|
-
return c.json({ error: "
|
|
1691
|
+
// Add an agent to a room
|
|
1692
|
+
app.post("/api/rooms/:id/agents", async (c) => {
|
|
1693
|
+
const roomId = c.req.param("id");
|
|
1694
|
+
const router = roomRouters.get(roomId);
|
|
1695
|
+
if (!router)
|
|
1696
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1697
|
+
const body = await validateBody(c, addAgentSchema);
|
|
1698
|
+
if (isResponse(body))
|
|
1699
|
+
return body;
|
|
1700
|
+
const sessionId = crypto.randomUUID();
|
|
1701
|
+
const randomSuffix = sessionId.slice(0, 6);
|
|
1702
|
+
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
1703
|
+
const projectName = `room-${agentName}-${sessionId.slice(0, 8)}`;
|
|
1704
|
+
console.log(`[room:${roomId}] Adding agent: name=${agentName} session=${sessionId}`);
|
|
1705
|
+
// Create the session's durable stream
|
|
1706
|
+
const conn = sessionStream(config, sessionId);
|
|
1707
|
+
try {
|
|
1708
|
+
await DurableStream.create({
|
|
1709
|
+
url: conn.url,
|
|
1710
|
+
headers: conn.headers,
|
|
1711
|
+
contentType: "application/json",
|
|
1712
|
+
});
|
|
1538
1713
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
console.error(`[room:${roomId}] Failed to create session stream:`, err);
|
|
1716
|
+
return c.json({ error: "Failed to create session stream" }, 500);
|
|
1717
|
+
}
|
|
1718
|
+
// Create bridge
|
|
1719
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1720
|
+
// Record session
|
|
1721
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1722
|
+
const session = {
|
|
1723
|
+
id: sessionId,
|
|
1724
|
+
projectName,
|
|
1725
|
+
sandboxProjectDir,
|
|
1726
|
+
description: `Room agent: ${agentName} (${body.role ?? "participant"})`,
|
|
1727
|
+
createdAt: new Date().toISOString(),
|
|
1728
|
+
lastActiveAt: new Date().toISOString(),
|
|
1729
|
+
status: "running",
|
|
1549
1730
|
};
|
|
1550
|
-
|
|
1551
|
-
|
|
1731
|
+
config.sessions.add(session);
|
|
1732
|
+
// Return early so the client can store the session token and show the
|
|
1733
|
+
// session in the sidebar immediately. The sandbox setup continues in
|
|
1734
|
+
// the background — events stream to the session's durable stream so
|
|
1735
|
+
// the UI stays up to date.
|
|
1736
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1737
|
+
(async () => {
|
|
1738
|
+
await bridge.emit({
|
|
1739
|
+
type: "log",
|
|
1740
|
+
level: "build",
|
|
1741
|
+
message: `Creating sandbox for room agent "${agentName}"...`,
|
|
1742
|
+
ts: ts(),
|
|
1743
|
+
});
|
|
1744
|
+
try {
|
|
1745
|
+
const handle = await config.sandbox.create(sessionId, {
|
|
1746
|
+
projectName,
|
|
1747
|
+
infra: { mode: "local" },
|
|
1748
|
+
apiKey: body.apiKey,
|
|
1749
|
+
oauthToken: body.oauthToken,
|
|
1750
|
+
ghToken: body.ghToken,
|
|
1751
|
+
});
|
|
1752
|
+
config.sessions.update(sessionId, {
|
|
1753
|
+
appPort: handle.port,
|
|
1754
|
+
sandboxProjectDir: handle.projectDir,
|
|
1755
|
+
previewUrl: handle.previewUrl,
|
|
1756
|
+
});
|
|
1757
|
+
// Inject room-messaging skill so agents know the @room protocol
|
|
1758
|
+
if (roomMessagingSkillContent) {
|
|
1759
|
+
try {
|
|
1760
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1761
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1762
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1763
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1764
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1765
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1766
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1767
|
+
}
|
|
1768
|
+
catch (err) {
|
|
1769
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
// Resolve role skill (behavioral guidelines + tool permissions)
|
|
1773
|
+
const roleSkill = resolveRoleSkill(body.role);
|
|
1774
|
+
// Inject role skill file into sandbox
|
|
1775
|
+
if (roleSkill) {
|
|
1776
|
+
try {
|
|
1777
|
+
const skillDir = `${handle.projectDir}/.claude/skills/role`;
|
|
1778
|
+
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
1779
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1780
|
+
}
|
|
1781
|
+
catch (err) {
|
|
1782
|
+
console.error(`[session:${sessionId}] Failed to write role skill:`, err);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
// Build prompt — reference the role skill if available
|
|
1786
|
+
const rolePromptSuffix = roleSkill
|
|
1787
|
+
? `\nRead .claude/skills/role/SKILL.md for your role guidelines before proceeding.`
|
|
1788
|
+
: "";
|
|
1789
|
+
const agentPrompt = `You are "${agentName}"${body.role ? `, role: ${body.role}` : ""}. You are joining a multi-agent room.${rolePromptSuffix}`;
|
|
1790
|
+
// Create Claude Code bridge (with role-specific tool permissions)
|
|
1791
|
+
const agentHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1792
|
+
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1793
|
+
? {
|
|
1794
|
+
prompt: agentPrompt,
|
|
1795
|
+
cwd: handle.projectDir,
|
|
1796
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1797
|
+
hookToken: agentHookToken,
|
|
1798
|
+
agentName: agentName,
|
|
1799
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1800
|
+
}
|
|
1801
|
+
: {
|
|
1802
|
+
prompt: agentPrompt,
|
|
1803
|
+
cwd: handle.projectDir,
|
|
1804
|
+
studioPort: config.port,
|
|
1805
|
+
hookToken: agentHookToken,
|
|
1806
|
+
agentName: agentName,
|
|
1807
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1808
|
+
};
|
|
1809
|
+
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1810
|
+
// Track Claude Code session ID and cost
|
|
1811
|
+
ccBridge.onAgentEvent((event) => {
|
|
1812
|
+
if (event.type === "session_start") {
|
|
1813
|
+
const ccSessionId = event.session_id;
|
|
1814
|
+
if (ccSessionId) {
|
|
1815
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (event.type === "session_end") {
|
|
1819
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1820
|
+
}
|
|
1821
|
+
// Route assistant_message output to the room router
|
|
1822
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1823
|
+
router
|
|
1824
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1825
|
+
.catch((err) => {
|
|
1826
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
await bridge.emit({
|
|
1831
|
+
type: "log",
|
|
1832
|
+
level: "done",
|
|
1833
|
+
message: `Sandbox ready for "${agentName}"`,
|
|
1834
|
+
ts: ts(),
|
|
1835
|
+
});
|
|
1836
|
+
await ccBridge.start();
|
|
1837
|
+
// Add participant to room router
|
|
1838
|
+
const participant = {
|
|
1839
|
+
sessionId,
|
|
1840
|
+
name: agentName,
|
|
1841
|
+
role: body.role,
|
|
1842
|
+
bridge: ccBridge,
|
|
1843
|
+
};
|
|
1844
|
+
await router.addParticipant(participant, body.gated ?? false);
|
|
1845
|
+
// If there's an initial prompt, send it directly to this agent only (not broadcast)
|
|
1846
|
+
if (body.initialPrompt) {
|
|
1847
|
+
await ccBridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
const msg = err instanceof Error ? err.message : "Failed to create agent sandbox";
|
|
1852
|
+
console.error(`[room:${roomId}] Agent creation failed:`, err);
|
|
1853
|
+
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1854
|
+
}
|
|
1855
|
+
})();
|
|
1856
|
+
return c.json({ sessionId, participantName: agentName, sessionToken }, 201);
|
|
1552
1857
|
});
|
|
1553
|
-
//
|
|
1554
|
-
app.post("/api/
|
|
1555
|
-
const
|
|
1556
|
-
const
|
|
1557
|
-
if (!
|
|
1558
|
-
return c.json({ error: "
|
|
1858
|
+
// Add an existing running session to a room
|
|
1859
|
+
app.post("/api/rooms/:id/sessions", async (c) => {
|
|
1860
|
+
const roomId = c.req.param("id");
|
|
1861
|
+
const router = roomRouters.get(roomId);
|
|
1862
|
+
if (!router)
|
|
1863
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1864
|
+
const body = await validateBody(c, addSessionToRoomSchema);
|
|
1865
|
+
if (isResponse(body))
|
|
1866
|
+
return body;
|
|
1867
|
+
const { sessionId } = body;
|
|
1868
|
+
// Require a valid session token — caller must already own this session.
|
|
1869
|
+
// Room auth is handled by middleware via X-Room-Token; Authorization
|
|
1870
|
+
// carries the session ownership proof here.
|
|
1871
|
+
const authHeader = c.req.header("Authorization");
|
|
1872
|
+
const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
|
1873
|
+
if (!sessionToken ||
|
|
1874
|
+
!validateSessionToken(config.streamConfig.secret, sessionId, sessionToken)) {
|
|
1875
|
+
return c.json({ error: "Invalid or missing session token" }, 401);
|
|
1559
1876
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1877
|
+
// Verify the session exists
|
|
1878
|
+
const sessionInfo = config.sessions.get(sessionId);
|
|
1879
|
+
if (!sessionInfo) {
|
|
1880
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1564
1881
|
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1882
|
+
// Get the sandbox handle — must be running
|
|
1883
|
+
const handle = config.sandbox.get(sessionId);
|
|
1884
|
+
if (!handle) {
|
|
1885
|
+
return c.json({ error: "Session sandbox not found or not running" }, 400);
|
|
1886
|
+
}
|
|
1887
|
+
// Get or create bridge (it should already exist for a running session)
|
|
1888
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1889
|
+
console.log(`[room:${roomId}] Adding existing session: name=${body.name} session=${sessionId}`);
|
|
1890
|
+
try {
|
|
1891
|
+
// Inject room-messaging skill
|
|
1892
|
+
if (roomMessagingSkillContent) {
|
|
1893
|
+
try {
|
|
1894
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1895
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1896
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1897
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1898
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1899
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1900
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1582
1901
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1585
1904
|
}
|
|
1586
1905
|
}
|
|
1906
|
+
// The existing bridge is already a Claude Code bridge — wire up room output handling
|
|
1907
|
+
bridge.onAgentEvent((event) => {
|
|
1908
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1909
|
+
router
|
|
1910
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1911
|
+
.catch((err) => {
|
|
1912
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
// Add participant to room router
|
|
1917
|
+
const participant = {
|
|
1918
|
+
sessionId,
|
|
1919
|
+
name: body.name,
|
|
1920
|
+
bridge,
|
|
1921
|
+
};
|
|
1922
|
+
await router.addParticipant(participant, false);
|
|
1923
|
+
// If there's an initial prompt, send it directly to this agent
|
|
1924
|
+
if (body.initialPrompt) {
|
|
1925
|
+
await bridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1926
|
+
}
|
|
1587
1927
|
}
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
app.post("/api/shared-sessions/:id/sessions", async (c) => {
|
|
1593
|
-
const id = c.req.param("id");
|
|
1594
|
-
const body = (await c.req.json());
|
|
1595
|
-
if (!body.sessionId || !body.linkedBy) {
|
|
1596
|
-
return c.json({ error: "sessionId and linkedBy are required" }, 400);
|
|
1928
|
+
catch (err) {
|
|
1929
|
+
const msg = err instanceof Error ? err.message : "Failed to add session to room";
|
|
1930
|
+
console.error(`[room:${roomId}] Add session failed:`, err);
|
|
1931
|
+
return c.json({ error: msg }, 500);
|
|
1597
1932
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
url: conn.url,
|
|
1601
|
-
headers: conn.headers,
|
|
1602
|
-
contentType: "application/json",
|
|
1603
|
-
});
|
|
1604
|
-
const event = {
|
|
1605
|
-
type: "session_linked",
|
|
1606
|
-
sessionId: body.sessionId,
|
|
1607
|
-
sessionName: body.sessionName || "",
|
|
1608
|
-
sessionDescription: body.sessionDescription || "",
|
|
1609
|
-
linkedBy: body.linkedBy,
|
|
1610
|
-
ts: ts(),
|
|
1611
|
-
};
|
|
1612
|
-
await stream.append(JSON.stringify(event));
|
|
1613
|
-
return c.json({ ok: true });
|
|
1933
|
+
// No need to return sessionToken — caller already proved they have it
|
|
1934
|
+
return c.json({ sessionId, participantName: body.name }, 201);
|
|
1614
1935
|
});
|
|
1615
|
-
//
|
|
1616
|
-
app.
|
|
1617
|
-
const
|
|
1618
|
-
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1619
|
-
return c.json({ sessionToken });
|
|
1620
|
-
});
|
|
1621
|
-
// Unlink a session from a shared session
|
|
1622
|
-
app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
|
|
1623
|
-
const id = c.req.param("id");
|
|
1936
|
+
// Send a message directly to a specific session in a room (bypasses room stream)
|
|
1937
|
+
app.post("/api/rooms/:id/sessions/:sessionId/iterate", async (c) => {
|
|
1938
|
+
const roomId = c.req.param("id");
|
|
1624
1939
|
const sessionId = c.req.param("sessionId");
|
|
1625
|
-
const
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1940
|
+
const router = roomRouters.get(roomId);
|
|
1941
|
+
if (!router)
|
|
1942
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1943
|
+
const participant = router.participants.find((p) => p.sessionId === sessionId);
|
|
1944
|
+
if (!participant)
|
|
1945
|
+
return c.json({ error: "Session not found in this room" }, 404);
|
|
1946
|
+
const body = await validateBody(c, iterateRoomSessionSchema);
|
|
1947
|
+
if (isResponse(body))
|
|
1948
|
+
return body;
|
|
1949
|
+
await participant.bridge.sendCommand({
|
|
1950
|
+
command: "iterate",
|
|
1951
|
+
request: body.request,
|
|
1630
1952
|
});
|
|
1631
|
-
const event = {
|
|
1632
|
-
type: "session_unlinked",
|
|
1633
|
-
sessionId,
|
|
1634
|
-
ts: ts(),
|
|
1635
|
-
};
|
|
1636
|
-
await stream.append(JSON.stringify(event));
|
|
1637
1953
|
return c.json({ ok: true });
|
|
1638
1954
|
});
|
|
1639
|
-
//
|
|
1640
|
-
app.
|
|
1641
|
-
const
|
|
1642
|
-
const
|
|
1643
|
-
if (!
|
|
1644
|
-
return c.json({ error: "
|
|
1645
|
-
const
|
|
1955
|
+
// Send a message to a room (from human or API)
|
|
1956
|
+
app.post("/api/rooms/:id/messages", async (c) => {
|
|
1957
|
+
const roomId = c.req.param("id");
|
|
1958
|
+
const router = roomRouters.get(roomId);
|
|
1959
|
+
if (!router)
|
|
1960
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1961
|
+
const body = await validateBody(c, sendRoomMessageSchema);
|
|
1962
|
+
if (isResponse(body))
|
|
1963
|
+
return body;
|
|
1964
|
+
await router.sendMessage(body.from, body.body, body.to);
|
|
1965
|
+
return c.json({ ok: true });
|
|
1966
|
+
});
|
|
1967
|
+
// SSE proxy for room events
|
|
1968
|
+
app.get("/api/rooms/:id/events", async (c) => {
|
|
1969
|
+
const roomId = c.req.param("id");
|
|
1970
|
+
const router = roomRouters.get(roomId);
|
|
1971
|
+
if (!router)
|
|
1972
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1973
|
+
const connection = roomStream(config, roomId);
|
|
1646
1974
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1647
1975
|
const reader = new DurableStream({
|
|
1648
1976
|
url: connection.url,
|
|
@@ -1681,23 +2009,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1681
2009
|
},
|
|
1682
2010
|
});
|
|
1683
2011
|
});
|
|
1684
|
-
//
|
|
1685
|
-
app.post("/api/
|
|
1686
|
-
const
|
|
1687
|
-
const
|
|
1688
|
-
if (!
|
|
1689
|
-
return c.json({ error: "
|
|
1690
|
-
|
|
2012
|
+
// Close a room
|
|
2013
|
+
app.post("/api/rooms/:id/close", async (c) => {
|
|
2014
|
+
const roomId = c.req.param("id");
|
|
2015
|
+
const router = roomRouters.get(roomId);
|
|
2016
|
+
if (!router)
|
|
2017
|
+
return c.json({ error: "Room not found" }, 404);
|
|
2018
|
+
// Emit room_closed event
|
|
2019
|
+
const conn = roomStream(config, roomId);
|
|
1691
2020
|
const stream = new DurableStream({
|
|
1692
2021
|
url: conn.url,
|
|
1693
2022
|
headers: conn.headers,
|
|
1694
2023
|
contentType: "application/json",
|
|
1695
2024
|
});
|
|
1696
2025
|
const event = {
|
|
1697
|
-
type: "
|
|
2026
|
+
type: "room_closed",
|
|
2027
|
+
closedBy: "human",
|
|
2028
|
+
summary: "Room closed by user",
|
|
1698
2029
|
ts: ts(),
|
|
1699
2030
|
};
|
|
1700
2031
|
await stream.append(JSON.stringify(event));
|
|
2032
|
+
router.close();
|
|
1701
2033
|
return c.json({ ok: true });
|
|
1702
2034
|
});
|
|
1703
2035
|
// --- SSE Proxy ---
|
|
@@ -1767,6 +2099,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1767
2099
|
},
|
|
1768
2100
|
});
|
|
1769
2101
|
});
|
|
2102
|
+
// --- Stream Append Proxy ---
|
|
2103
|
+
// Proxy endpoint for writing events to a session's durable stream.
|
|
2104
|
+
// Authenticates via session token so the caller never needs DS_SECRET.
|
|
2105
|
+
// Used by sandbox agents to write events back to the session stream.
|
|
2106
|
+
app.post("/api/sessions/:id/stream/append", async (c) => {
|
|
2107
|
+
const sessionId = c.req.param("id");
|
|
2108
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
2109
|
+
if (!contentType.includes("application/json")) {
|
|
2110
|
+
return c.json({ error: "Content-Type must be application/json" }, 415);
|
|
2111
|
+
}
|
|
2112
|
+
const body = await c.req.text();
|
|
2113
|
+
if (!body) {
|
|
2114
|
+
return c.json({ error: "Request body is required" }, 400);
|
|
2115
|
+
}
|
|
2116
|
+
// Guard against oversized payloads (64 KB limit)
|
|
2117
|
+
if (body.length > 65_536) {
|
|
2118
|
+
return c.json({ error: "Payload too large" }, 413);
|
|
2119
|
+
}
|
|
2120
|
+
// Validate JSON before forwarding to the stream
|
|
2121
|
+
try {
|
|
2122
|
+
JSON.parse(body);
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
2126
|
+
}
|
|
2127
|
+
const connection = sessionStream(config, sessionId);
|
|
2128
|
+
try {
|
|
2129
|
+
const writer = new DurableStream({
|
|
2130
|
+
url: connection.url,
|
|
2131
|
+
headers: connection.headers,
|
|
2132
|
+
contentType: "application/json",
|
|
2133
|
+
});
|
|
2134
|
+
await writer.append(body);
|
|
2135
|
+
return c.json({ ok: true });
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
console.error(`[stream-proxy] Append failed: session=${sessionId}`, err);
|
|
2139
|
+
return c.json({ error: "Failed to append to stream" }, 500);
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
1770
2142
|
// --- Git/GitHub Routes ---
|
|
1771
2143
|
// Get git status for a session
|
|
1772
2144
|
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
@@ -1814,7 +2186,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1814
2186
|
if (!handle || !sandboxDir) {
|
|
1815
2187
|
return c.json({ error: "Container not available" }, 404);
|
|
1816
2188
|
}
|
|
1817
|
-
|
|
2189
|
+
const resolvedPath = path.resolve(filePath);
|
|
2190
|
+
const resolvedDir = path.resolve(sandboxDir) + path.sep;
|
|
2191
|
+
if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
|
|
1818
2192
|
return c.json({ error: "Path outside project directory" }, 403);
|
|
1819
2193
|
}
|
|
1820
2194
|
const content = await config.sandbox.readFile(handle, filePath);
|
|
@@ -1863,32 +2237,41 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1863
2237
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
1864
2238
|
}
|
|
1865
2239
|
});
|
|
1866
|
-
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1873
|
-
const parsed = JSON.parse(raw);
|
|
1874
|
-
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
1875
|
-
if (token) {
|
|
1876
|
-
console.log(`[dev] Loaded OAuth token from keychain: ${token.slice(0, 20)}...${token.slice(-10)}`);
|
|
2240
|
+
// Read Claude credentials from macOS Keychain (dev convenience).
|
|
2241
|
+
// Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
|
|
2242
|
+
if (config.devMode) {
|
|
2243
|
+
app.get("/api/credentials/keychain", (c) => {
|
|
2244
|
+
if (process.platform !== "darwin") {
|
|
2245
|
+
return c.json({ apiKey: null });
|
|
1877
2246
|
}
|
|
1878
|
-
|
|
1879
|
-
|
|
2247
|
+
try {
|
|
2248
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2249
|
+
const parsed = JSON.parse(raw);
|
|
2250
|
+
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
2251
|
+
if (token) {
|
|
2252
|
+
console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
|
|
2253
|
+
}
|
|
2254
|
+
else {
|
|
2255
|
+
console.log("[dev] No OAuth token found in keychain");
|
|
2256
|
+
}
|
|
2257
|
+
return c.json({ oauthToken: token });
|
|
1880
2258
|
}
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
});
|
|
2259
|
+
catch {
|
|
2260
|
+
return c.json({ oauthToken: null });
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
1887
2264
|
// Resume a project from a GitHub repo
|
|
1888
2265
|
app.post("/api/sessions/resume", async (c) => {
|
|
1889
|
-
const body =
|
|
1890
|
-
if (
|
|
1891
|
-
return
|
|
2266
|
+
const body = await validateBody(c, resumeSessionSchema);
|
|
2267
|
+
if (isResponse(body))
|
|
2268
|
+
return body;
|
|
2269
|
+
// Rate-limit session creation in production mode
|
|
2270
|
+
if (!config.devMode) {
|
|
2271
|
+
const ip = extractClientIp(c);
|
|
2272
|
+
if (!checkSessionRateLimit(ip)) {
|
|
2273
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2274
|
+
}
|
|
1892
2275
|
}
|
|
1893
2276
|
const sessionId = crypto.randomUUID();
|
|
1894
2277
|
const repoName = body.repoUrl
|
|
@@ -1969,6 +2352,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1969
2352
|
projectName: repoName,
|
|
1970
2353
|
projectDir: handle.projectDir,
|
|
1971
2354
|
runtime: config.sandbox.runtime,
|
|
2355
|
+
production: !config.devMode,
|
|
1972
2356
|
git: {
|
|
1973
2357
|
mode: "existing",
|
|
1974
2358
|
repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
|
|
@@ -1992,18 +2376,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1992
2376
|
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1993
2377
|
}
|
|
1994
2378
|
}
|
|
2379
|
+
// Ensure the room-messaging skill is present so agents have
|
|
2380
|
+
// persistent access to the multi-agent protocol reference.
|
|
2381
|
+
if (roomMessagingSkillContent) {
|
|
2382
|
+
try {
|
|
2383
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2384
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2385
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2386
|
+
}
|
|
2387
|
+
catch (err) {
|
|
2388
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
1995
2391
|
// 3. Create Claude Code bridge with a resume prompt
|
|
1996
2392
|
const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
|
|
2393
|
+
const resumeHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1997
2394
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1998
2395
|
? {
|
|
1999
2396
|
prompt: resumePrompt,
|
|
2000
2397
|
cwd: handle.projectDir,
|
|
2001
2398
|
studioUrl: resolveStudioUrl(config.port),
|
|
2399
|
+
hookToken: resumeHookToken,
|
|
2002
2400
|
}
|
|
2003
2401
|
: {
|
|
2004
2402
|
prompt: resumePrompt,
|
|
2005
2403
|
cwd: handle.projectDir,
|
|
2006
2404
|
studioPort: config.port,
|
|
2405
|
+
hookToken: resumeHookToken,
|
|
2007
2406
|
};
|
|
2008
2407
|
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
2009
2408
|
// 4. Register event listeners (reuse pattern from normal flow)
|
|
@@ -2083,6 +2482,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2083
2482
|
asyncFlow().catch(async (err) => {
|
|
2084
2483
|
console.error(`[session:${sessionId}] Resume flow failed:`, err);
|
|
2085
2484
|
config.sessions.update(sessionId, { status: "error" });
|
|
2485
|
+
try {
|
|
2486
|
+
await bridge.emit({
|
|
2487
|
+
type: "log",
|
|
2488
|
+
level: "error",
|
|
2489
|
+
message: `Resume failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2490
|
+
ts: ts(),
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
// Bridge may not be usable if the failure happened early
|
|
2495
|
+
}
|
|
2086
2496
|
});
|
|
2087
2497
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
2088
2498
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -2107,6 +2517,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2107
2517
|
return app;
|
|
2108
2518
|
}
|
|
2109
2519
|
export async function startWebServer(opts) {
|
|
2520
|
+
const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
|
|
2521
|
+
if (devMode) {
|
|
2522
|
+
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
2523
|
+
}
|
|
2110
2524
|
const config = {
|
|
2111
2525
|
port: opts.port ?? 4400,
|
|
2112
2526
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
@@ -2115,6 +2529,7 @@ export async function startWebServer(opts) {
|
|
|
2115
2529
|
sandbox: opts.sandbox,
|
|
2116
2530
|
streamConfig: opts.streamConfig,
|
|
2117
2531
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
2532
|
+
devMode,
|
|
2118
2533
|
};
|
|
2119
2534
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2120
2535
|
const app = createApp(config);
|