@electric-agent/studio 1.12.1 → 1.13.1
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/active-sessions.d.ts +11 -4
- package/dist/active-sessions.d.ts.map +1 -1
- package/dist/active-sessions.js +35 -5
- package/dist/active-sessions.js.map +1 -1
- package/dist/api-schemas.d.ts +29 -10
- package/dist/api-schemas.d.ts.map +1 -1
- package/dist/api-schemas.js +8 -0
- package/dist/api-schemas.js.map +1 -1
- package/dist/bridge/role-skills.d.ts.map +1 -1
- package/dist/bridge/role-skills.js +8 -0
- package/dist/bridge/role-skills.js.map +1 -1
- package/dist/client/assets/index-BXdgNRgB.js +235 -0
- package/dist/client/assets/index-IvCtVUfs.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/room-router.d.ts.map +1 -1
- package/dist/room-router.js +20 -5
- package/dist/room-router.js.map +1 -1
- package/dist/sandbox/docker.d.ts +9 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +64 -1
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +800 -24
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-BfvQSMwH.css +0 -1
- package/dist/client/assets/index-CtOOaA2Q.js +0 -235
package/dist/server.js
CHANGED
|
@@ -8,7 +8,7 @@ import { serve } from "@hono/node-server";
|
|
|
8
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
import { ActiveSessions } from "./active-sessions.js";
|
|
11
|
-
import { addAgentSchema, addSessionToRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
|
|
11
|
+
import { addAgentSchema, addSessionToRoomSchema, createAppRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
|
|
12
12
|
import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
|
|
13
13
|
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
14
14
|
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
@@ -20,6 +20,7 @@ import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "
|
|
|
20
20
|
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
21
21
|
import { generateInviteCode } from "./invite-code.js";
|
|
22
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
23
|
+
import { Registry } from "./registry.js";
|
|
23
24
|
import { RoomRouter } from "./room-router.js";
|
|
24
25
|
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
25
26
|
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
@@ -544,6 +545,7 @@ export function createApp(config) {
|
|
|
544
545
|
if (hookEvent.type === "ask_user_question") {
|
|
545
546
|
const toolUseId = hookEvent.tool_use_id;
|
|
546
547
|
console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
548
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
547
549
|
try {
|
|
548
550
|
const gateTimeout = 5 * 60 * 1000; // 5 minutes
|
|
549
551
|
const result = await Promise.race([
|
|
@@ -551,6 +553,7 @@ export function createApp(config) {
|
|
|
551
553
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
552
554
|
]);
|
|
553
555
|
console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
|
|
556
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
554
557
|
return c.json({
|
|
555
558
|
hookSpecificOutput: {
|
|
556
559
|
hookEventName: "PreToolUse",
|
|
@@ -564,6 +567,7 @@ export function createApp(config) {
|
|
|
564
567
|
}
|
|
565
568
|
catch (err) {
|
|
566
569
|
console.error(`[hook-event] ask_user_question gate error:`, err);
|
|
570
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
567
571
|
return c.json({ ok: true }); // Don't block Claude Code on timeout
|
|
568
572
|
}
|
|
569
573
|
}
|
|
@@ -685,6 +689,7 @@ export function createApp(config) {
|
|
|
685
689
|
if (hookEvent.type === "ask_user_question") {
|
|
686
690
|
const toolUseId = hookEvent.tool_use_id;
|
|
687
691
|
console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
692
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
688
693
|
try {
|
|
689
694
|
const gateTimeout = 5 * 60 * 1000;
|
|
690
695
|
const result = await Promise.race([
|
|
@@ -692,6 +697,7 @@ export function createApp(config) {
|
|
|
692
697
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
693
698
|
]);
|
|
694
699
|
console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
|
|
700
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
695
701
|
return c.json({
|
|
696
702
|
sessionId,
|
|
697
703
|
hookSpecificOutput: {
|
|
@@ -706,6 +712,7 @@ export function createApp(config) {
|
|
|
706
712
|
}
|
|
707
713
|
catch (err) {
|
|
708
714
|
console.error(`[hook] ask_user_question gate error:`, err);
|
|
715
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
709
716
|
return c.json({ ok: true, sessionId });
|
|
710
717
|
}
|
|
711
718
|
}
|
|
@@ -805,8 +812,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
805
812
|
if (isResponse(body))
|
|
806
813
|
return body;
|
|
807
814
|
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
808
|
-
const apiKey = config.devMode
|
|
809
|
-
|
|
815
|
+
const apiKey = config.devMode
|
|
816
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
817
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
818
|
+
const oauthToken = config.devMode
|
|
819
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
820
|
+
: undefined;
|
|
810
821
|
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
811
822
|
// Block freeform sessions in production mode
|
|
812
823
|
if (body.freeform && !config.devMode) {
|
|
@@ -956,7 +967,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
956
967
|
apiKey,
|
|
957
968
|
oauthToken,
|
|
958
969
|
ghToken,
|
|
959
|
-
...(!config.devMode && {
|
|
970
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
960
971
|
prodMode: {
|
|
961
972
|
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
962
973
|
studioUrl: resolveStudioUrl(config.port),
|
|
@@ -1027,9 +1038,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1027
1038
|
ts: ts(),
|
|
1028
1039
|
});
|
|
1029
1040
|
}
|
|
1030
|
-
//
|
|
1041
|
+
// Create GitHub repo via GitHub App when credentials are available
|
|
1031
1042
|
let prodGitConfig;
|
|
1032
|
-
if (
|
|
1043
|
+
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
1033
1044
|
try {
|
|
1034
1045
|
// Repo name matches the project name (already has random slug)
|
|
1035
1046
|
const repoSlug = projectName;
|
|
@@ -1263,8 +1274,47 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1263
1274
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1264
1275
|
return c.json({ error: "Container is not running" }, 400);
|
|
1265
1276
|
}
|
|
1277
|
+
// Ensure we have a CC bridge (not just a stream writer).
|
|
1278
|
+
// After server restart, bridges are lost — getOrCreateBridge would create
|
|
1279
|
+
// a HostedStreamBridge that can only write to the stream but can't spawn
|
|
1280
|
+
// Claude Code processes. We need a ClaudeCodeDockerBridge to restart the agent.
|
|
1281
|
+
let bridge = bridges.get(sessionId);
|
|
1282
|
+
if (!bridge) {
|
|
1283
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1284
|
+
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1285
|
+
? {
|
|
1286
|
+
prompt: body.request,
|
|
1287
|
+
cwd: session.sandboxProjectDir || handle.projectDir,
|
|
1288
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1289
|
+
hookToken,
|
|
1290
|
+
}
|
|
1291
|
+
: {
|
|
1292
|
+
prompt: body.request,
|
|
1293
|
+
cwd: session.sandboxProjectDir || handle.projectDir,
|
|
1294
|
+
studioPort: config.port,
|
|
1295
|
+
hookToken,
|
|
1296
|
+
};
|
|
1297
|
+
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1298
|
+
// Re-register basic event tracking callbacks
|
|
1299
|
+
bridge.onAgentEvent((event) => {
|
|
1300
|
+
if (event.type === "session_start") {
|
|
1301
|
+
const ccSessionId = event.session_id;
|
|
1302
|
+
if (ccSessionId) {
|
|
1303
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (event.type === "session_end") {
|
|
1307
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
bridge.onComplete(async (success) => {
|
|
1311
|
+
config.sessions.update(sessionId, {
|
|
1312
|
+
status: success ? "complete" : "error",
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
console.log(`[iterate] Recreated CC bridge for session ${sessionId} after restart`);
|
|
1316
|
+
}
|
|
1266
1317
|
// Write user prompt to the stream
|
|
1267
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1268
1318
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1269
1319
|
config.sessions.update(sessionId, { status: "running" });
|
|
1270
1320
|
await bridge.sendCommand({
|
|
@@ -1627,8 +1677,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1627
1677
|
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1628
1678
|
}
|
|
1629
1679
|
// Protect room-scoped routes via X-Room-Token header
|
|
1680
|
+
// "create-app" is a creation endpoint — no room token exists yet
|
|
1681
|
+
const roomAuthExemptIds = new Set(["create-app"]);
|
|
1630
1682
|
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1631
1683
|
const id = c.req.param("id");
|
|
1684
|
+
if (roomAuthExemptIds.has(id))
|
|
1685
|
+
return next();
|
|
1632
1686
|
const token = extractRoomToken(c);
|
|
1633
1687
|
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1634
1688
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
@@ -1636,15 +1690,697 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1636
1690
|
return next();
|
|
1637
1691
|
});
|
|
1638
1692
|
app.use("/api/rooms/:id", async (c, next) => {
|
|
1693
|
+
const id = c.req.param("id");
|
|
1694
|
+
if (roomAuthExemptIds.has(id))
|
|
1695
|
+
return next();
|
|
1639
1696
|
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1640
1697
|
return next();
|
|
1641
|
-
const id = c.req.param("id");
|
|
1642
1698
|
const token = extractRoomToken(c);
|
|
1643
1699
|
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1644
1700
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1645
1701
|
}
|
|
1646
1702
|
return next();
|
|
1647
1703
|
});
|
|
1704
|
+
// Create a room with 3 agents for multi-agent app creation
|
|
1705
|
+
app.post("/api/rooms/create-app", async (c) => {
|
|
1706
|
+
const body = await validateBody(c, createAppRoomSchema);
|
|
1707
|
+
if (isResponse(body))
|
|
1708
|
+
return body;
|
|
1709
|
+
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
1710
|
+
const apiKey = config.devMode
|
|
1711
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
1712
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
1713
|
+
const oauthToken = config.devMode
|
|
1714
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
1715
|
+
: undefined;
|
|
1716
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1717
|
+
// Rate-limit session creation in production mode
|
|
1718
|
+
if (!config.devMode) {
|
|
1719
|
+
const ip = extractClientIp(c);
|
|
1720
|
+
if (!checkSessionRateLimit(ip)) {
|
|
1721
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
1722
|
+
}
|
|
1723
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
1724
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const roomId = crypto.randomUUID();
|
|
1728
|
+
const roomName = body.name || `app-${roomId.slice(0, 8)}`;
|
|
1729
|
+
// Create the room's durable stream
|
|
1730
|
+
const roomConn = roomStream(config, roomId);
|
|
1731
|
+
try {
|
|
1732
|
+
await DurableStream.create({
|
|
1733
|
+
url: roomConn.url,
|
|
1734
|
+
headers: roomConn.headers,
|
|
1735
|
+
contentType: "application/json",
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
catch (err) {
|
|
1739
|
+
console.error(`[room:create-app] Failed to create room stream:`, err);
|
|
1740
|
+
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1741
|
+
}
|
|
1742
|
+
// Create and start the router
|
|
1743
|
+
const router = new RoomRouter(roomId, roomName, config.streamConfig);
|
|
1744
|
+
await router.start();
|
|
1745
|
+
roomRouters.set(roomId, router);
|
|
1746
|
+
// Save to room registry
|
|
1747
|
+
const code = generateInviteCode();
|
|
1748
|
+
await config.rooms.addRoom({
|
|
1749
|
+
id: roomId,
|
|
1750
|
+
code,
|
|
1751
|
+
name: roomName,
|
|
1752
|
+
createdAt: new Date().toISOString(),
|
|
1753
|
+
revoked: false,
|
|
1754
|
+
});
|
|
1755
|
+
// Define the 3 agents with randomized display names
|
|
1756
|
+
const agentSuffixes = [
|
|
1757
|
+
"fox",
|
|
1758
|
+
"owl",
|
|
1759
|
+
"lynx",
|
|
1760
|
+
"wolf",
|
|
1761
|
+
"bear",
|
|
1762
|
+
"hawk",
|
|
1763
|
+
"pine",
|
|
1764
|
+
"oak",
|
|
1765
|
+
"elm",
|
|
1766
|
+
"ivy",
|
|
1767
|
+
"ray",
|
|
1768
|
+
"arc",
|
|
1769
|
+
"reef",
|
|
1770
|
+
"dusk",
|
|
1771
|
+
"ash",
|
|
1772
|
+
"sage",
|
|
1773
|
+
];
|
|
1774
|
+
const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
|
|
1775
|
+
const usedSuffixes = new Set();
|
|
1776
|
+
const uniquePick = () => {
|
|
1777
|
+
let s = pick();
|
|
1778
|
+
while (usedSuffixes.has(s))
|
|
1779
|
+
s = pick();
|
|
1780
|
+
usedSuffixes.add(s);
|
|
1781
|
+
return s;
|
|
1782
|
+
};
|
|
1783
|
+
const agentDefs = [
|
|
1784
|
+
{ name: `coder-${uniquePick()}`, role: "coder" },
|
|
1785
|
+
{ name: `reviewer-${uniquePick()}`, role: "reviewer" },
|
|
1786
|
+
{ name: `designer-${uniquePick()}`, role: "ui-designer" },
|
|
1787
|
+
];
|
|
1788
|
+
// Create session IDs and streams upfront for all 3 agents
|
|
1789
|
+
const sessions = [];
|
|
1790
|
+
for (const agentDef of agentDefs) {
|
|
1791
|
+
const sessionId = crypto.randomUUID();
|
|
1792
|
+
const conn = sessionStream(config, sessionId);
|
|
1793
|
+
try {
|
|
1794
|
+
await DurableStream.create({
|
|
1795
|
+
url: conn.url,
|
|
1796
|
+
headers: conn.headers,
|
|
1797
|
+
contentType: "application/json",
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
|
|
1802
|
+
return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
|
|
1803
|
+
}
|
|
1804
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1805
|
+
sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
|
|
1806
|
+
}
|
|
1807
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1808
|
+
console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
|
|
1809
|
+
// Return immediately so the client can show the room + sessions
|
|
1810
|
+
// The async flow handles sandbox creation, skill injection, and agent startup
|
|
1811
|
+
// Sessions are created in agentDefs order: [coder, reviewer, ui-designer]
|
|
1812
|
+
const coderSession = sessions[0];
|
|
1813
|
+
const reviewerSession = sessions[1];
|
|
1814
|
+
const uiDesignerSession = sessions[2];
|
|
1815
|
+
const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
|
|
1816
|
+
// Record all sessions
|
|
1817
|
+
for (const s of sessions) {
|
|
1818
|
+
const projectName = s.role === "coder" && config.devMode
|
|
1819
|
+
? body.name ||
|
|
1820
|
+
body.description
|
|
1821
|
+
.slice(0, 40)
|
|
1822
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
1823
|
+
.replace(/^-|-$/g, "")
|
|
1824
|
+
.toLowerCase()
|
|
1825
|
+
: `room-${s.name}-${s.sessionId.slice(0, 8)}`;
|
|
1826
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1827
|
+
const session = {
|
|
1828
|
+
id: s.sessionId,
|
|
1829
|
+
projectName,
|
|
1830
|
+
sandboxProjectDir,
|
|
1831
|
+
description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
|
|
1832
|
+
createdAt: new Date().toISOString(),
|
|
1833
|
+
lastActiveAt: new Date().toISOString(),
|
|
1834
|
+
status: "running",
|
|
1835
|
+
};
|
|
1836
|
+
config.sessions.add(session);
|
|
1837
|
+
}
|
|
1838
|
+
// Write user prompt to coder's stream
|
|
1839
|
+
await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
1840
|
+
// Gather GitHub accounts for the infra config gate (dev mode only)
|
|
1841
|
+
let ghAccounts = [];
|
|
1842
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
1843
|
+
try {
|
|
1844
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
1845
|
+
}
|
|
1846
|
+
catch {
|
|
1847
|
+
// gh not available
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// Emit infra config gate on coder's stream
|
|
1851
|
+
const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
|
|
1852
|
+
await coderBridge.emit({
|
|
1853
|
+
type: "infra_config_prompt",
|
|
1854
|
+
projectName: coderProjectName,
|
|
1855
|
+
ghAccounts,
|
|
1856
|
+
runtime: config.sandbox.runtime,
|
|
1857
|
+
ts: ts(),
|
|
1858
|
+
});
|
|
1859
|
+
// Async flow: wait for gate, create sandboxes, start agents
|
|
1860
|
+
const asyncFlow = async () => {
|
|
1861
|
+
// 1. Wait for infra config gate on coder's session
|
|
1862
|
+
await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
|
|
1863
|
+
console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
|
|
1864
|
+
let infra;
|
|
1865
|
+
let repoConfig = null;
|
|
1866
|
+
let claimId;
|
|
1867
|
+
try {
|
|
1868
|
+
const gateValue = await createGate(coderSession.sessionId, "infra_config");
|
|
1869
|
+
console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
1870
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
1871
|
+
infra = {
|
|
1872
|
+
mode: "cloud",
|
|
1873
|
+
databaseUrl: gateValue.databaseUrl,
|
|
1874
|
+
electricUrl: gateValue.electricUrl,
|
|
1875
|
+
sourceId: gateValue.sourceId,
|
|
1876
|
+
secret: gateValue.secret,
|
|
1877
|
+
};
|
|
1878
|
+
if (gateValue.mode === "claim") {
|
|
1879
|
+
claimId = gateValue.claimId;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
else {
|
|
1883
|
+
infra = { mode: "local" };
|
|
1884
|
+
}
|
|
1885
|
+
// Extract repo config if provided
|
|
1886
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
1887
|
+
repoConfig = {
|
|
1888
|
+
account: gateValue.repoAccount,
|
|
1889
|
+
repoName: gateValue.repoName,
|
|
1890
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
1891
|
+
};
|
|
1892
|
+
config.sessions.update(coderSession.sessionId, {
|
|
1893
|
+
git: {
|
|
1894
|
+
branch: "main",
|
|
1895
|
+
remoteUrl: null,
|
|
1896
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1897
|
+
repoVisibility: repoConfig.visibility,
|
|
1898
|
+
lastCommitHash: null,
|
|
1899
|
+
lastCommitMessage: null,
|
|
1900
|
+
lastCheckpointAt: null,
|
|
1901
|
+
},
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
catch (err) {
|
|
1906
|
+
console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
|
|
1907
|
+
infra = { mode: "local" };
|
|
1908
|
+
}
|
|
1909
|
+
// 2. Create sandboxes in parallel
|
|
1910
|
+
// Coder gets full scaffold, reviewer/ui-designer get minimal
|
|
1911
|
+
await router.sendMessage("system", "Creating sandboxes");
|
|
1912
|
+
await coderBridge.emit({
|
|
1913
|
+
type: "log",
|
|
1914
|
+
level: "build",
|
|
1915
|
+
message: "Creating sandboxes for all agents...",
|
|
1916
|
+
ts: ts(),
|
|
1917
|
+
});
|
|
1918
|
+
const sandboxOpts = (sid) => ({
|
|
1919
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
1920
|
+
prodMode: {
|
|
1921
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
|
|
1922
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1923
|
+
},
|
|
1924
|
+
}),
|
|
1925
|
+
});
|
|
1926
|
+
const coderInfo = config.sessions.get(coderSession.sessionId);
|
|
1927
|
+
if (!coderInfo)
|
|
1928
|
+
throw new Error("Coder session not found in registry");
|
|
1929
|
+
const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
|
|
1930
|
+
if (!reviewerInfo)
|
|
1931
|
+
throw new Error("Reviewer session not found in registry");
|
|
1932
|
+
const uiDesignerInfo = config.sessions.get(uiDesignerSession.sessionId);
|
|
1933
|
+
if (!uiDesignerInfo)
|
|
1934
|
+
throw new Error("UI designer session not found in registry");
|
|
1935
|
+
const [coderHandle, reviewerHandle, uiDesignerHandle] = await Promise.all([
|
|
1936
|
+
config.sandbox.create(coderSession.sessionId, {
|
|
1937
|
+
projectName: coderInfo.projectName,
|
|
1938
|
+
infra,
|
|
1939
|
+
apiKey,
|
|
1940
|
+
oauthToken,
|
|
1941
|
+
ghToken,
|
|
1942
|
+
...sandboxOpts(coderSession.sessionId),
|
|
1943
|
+
}),
|
|
1944
|
+
config.sandbox.create(reviewerSession.sessionId, {
|
|
1945
|
+
projectName: reviewerInfo.projectName,
|
|
1946
|
+
infra: { mode: "none" },
|
|
1947
|
+
apiKey,
|
|
1948
|
+
oauthToken,
|
|
1949
|
+
ghToken,
|
|
1950
|
+
...sandboxOpts(reviewerSession.sessionId),
|
|
1951
|
+
}),
|
|
1952
|
+
config.sandbox.create(uiDesignerSession.sessionId, {
|
|
1953
|
+
projectName: uiDesignerInfo.projectName,
|
|
1954
|
+
infra: { mode: "none" },
|
|
1955
|
+
apiKey,
|
|
1956
|
+
oauthToken,
|
|
1957
|
+
ghToken,
|
|
1958
|
+
...sandboxOpts(uiDesignerSession.sessionId),
|
|
1959
|
+
}),
|
|
1960
|
+
]);
|
|
1961
|
+
const handles = [
|
|
1962
|
+
{ session: coderSession, handle: coderHandle },
|
|
1963
|
+
{ session: reviewerSession, handle: reviewerHandle },
|
|
1964
|
+
{ session: uiDesignerSession, handle: uiDesignerHandle },
|
|
1965
|
+
];
|
|
1966
|
+
// Update session info with sandbox details
|
|
1967
|
+
for (const { session: s, handle } of handles) {
|
|
1968
|
+
config.sessions.update(s.sessionId, {
|
|
1969
|
+
appPort: handle.port,
|
|
1970
|
+
sandboxProjectDir: handle.projectDir,
|
|
1971
|
+
previewUrl: handle.previewUrl,
|
|
1972
|
+
...(s.role === "coder" && claimId ? { claimId } : {}),
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
await coderBridge.emit({
|
|
1976
|
+
type: "log",
|
|
1977
|
+
level: "done",
|
|
1978
|
+
message: "All sandboxes ready",
|
|
1979
|
+
ts: ts(),
|
|
1980
|
+
});
|
|
1981
|
+
// 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
|
|
1982
|
+
{
|
|
1983
|
+
const handle = coderHandle;
|
|
1984
|
+
// Copy scaffold
|
|
1985
|
+
await coderBridge.emit({
|
|
1986
|
+
type: "log",
|
|
1987
|
+
level: "build",
|
|
1988
|
+
message: "Setting up project...",
|
|
1989
|
+
ts: ts(),
|
|
1990
|
+
});
|
|
1991
|
+
try {
|
|
1992
|
+
if (config.sandbox.runtime === "docker") {
|
|
1993
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
1994
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
1995
|
+
}
|
|
1996
|
+
else {
|
|
1997
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${coderInfo.projectName}' --skip-git`);
|
|
1998
|
+
}
|
|
1999
|
+
await coderBridge.emit({
|
|
2000
|
+
type: "log",
|
|
2001
|
+
level: "done",
|
|
2002
|
+
message: "Project ready",
|
|
2003
|
+
ts: ts(),
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
|
|
2008
|
+
await coderBridge.emit({
|
|
2009
|
+
type: "log",
|
|
2010
|
+
level: "error",
|
|
2011
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
2012
|
+
ts: ts(),
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
// GitHub repo creation (uses GitHub App when credentials are available)
|
|
2016
|
+
let repoUrl = null;
|
|
2017
|
+
let prodGitConfig;
|
|
2018
|
+
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
2019
|
+
try {
|
|
2020
|
+
const repoSlug = coderInfo.projectName;
|
|
2021
|
+
await coderBridge.emit({
|
|
2022
|
+
type: "log",
|
|
2023
|
+
level: "build",
|
|
2024
|
+
message: "Creating GitHub repository...",
|
|
2025
|
+
ts: ts(),
|
|
2026
|
+
});
|
|
2027
|
+
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
2028
|
+
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
2029
|
+
if (repo) {
|
|
2030
|
+
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
2031
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
2032
|
+
prodGitConfig = {
|
|
2033
|
+
mode: "pre-created",
|
|
2034
|
+
repoName: actualRepoName,
|
|
2035
|
+
repoUrl: repo.htmlUrl,
|
|
2036
|
+
};
|
|
2037
|
+
repoUrl = repo.htmlUrl;
|
|
2038
|
+
config.sessions.update(coderSession.sessionId, {
|
|
2039
|
+
git: {
|
|
2040
|
+
branch: "main",
|
|
2041
|
+
remoteUrl: repo.htmlUrl,
|
|
2042
|
+
repoName: actualRepoName,
|
|
2043
|
+
lastCommitHash: null,
|
|
2044
|
+
lastCommitMessage: null,
|
|
2045
|
+
lastCheckpointAt: null,
|
|
2046
|
+
},
|
|
2047
|
+
});
|
|
2048
|
+
await coderBridge.emit({
|
|
2049
|
+
type: "log",
|
|
2050
|
+
level: "done",
|
|
2051
|
+
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
2052
|
+
ts: ts(),
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
catch (err) {
|
|
2057
|
+
console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
else if (repoConfig) {
|
|
2061
|
+
repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
|
|
2062
|
+
}
|
|
2063
|
+
// Write CLAUDE.md to coder sandbox
|
|
2064
|
+
const claudeMd = generateClaudeMd({
|
|
2065
|
+
description: body.description,
|
|
2066
|
+
projectName: coderInfo.projectName,
|
|
2067
|
+
projectDir: handle.projectDir,
|
|
2068
|
+
runtime: config.sandbox.runtime,
|
|
2069
|
+
production: !config.devMode,
|
|
2070
|
+
...(prodGitConfig
|
|
2071
|
+
? { git: prodGitConfig }
|
|
2072
|
+
: repoConfig
|
|
2073
|
+
? {
|
|
2074
|
+
git: {
|
|
2075
|
+
mode: "create",
|
|
2076
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
2077
|
+
visibility: repoConfig.visibility,
|
|
2078
|
+
},
|
|
2079
|
+
}
|
|
2080
|
+
: {}),
|
|
2081
|
+
});
|
|
2082
|
+
try {
|
|
2083
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
2084
|
+
}
|
|
2085
|
+
catch (err) {
|
|
2086
|
+
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
|
|
2087
|
+
}
|
|
2088
|
+
// Write create-app skill to coder sandbox
|
|
2089
|
+
if (createAppSkillContent) {
|
|
2090
|
+
try {
|
|
2091
|
+
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
2092
|
+
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
2093
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2094
|
+
}
|
|
2095
|
+
catch (err) {
|
|
2096
|
+
console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
// Write room-messaging skill to coder sandbox
|
|
2100
|
+
if (roomMessagingSkillContent) {
|
|
2101
|
+
try {
|
|
2102
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2103
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2104
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2105
|
+
}
|
|
2106
|
+
catch (err) {
|
|
2107
|
+
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
// 4. Create Claude Code bridge for coder
|
|
2111
|
+
const coderPrompt = `/create-app ${body.description}`;
|
|
2112
|
+
const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
|
|
2113
|
+
const coderClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2114
|
+
? {
|
|
2115
|
+
prompt: coderPrompt,
|
|
2116
|
+
cwd: handle.projectDir,
|
|
2117
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2118
|
+
hookToken: coderHookToken,
|
|
2119
|
+
agentName: coderSession.name,
|
|
2120
|
+
}
|
|
2121
|
+
: {
|
|
2122
|
+
prompt: coderPrompt,
|
|
2123
|
+
cwd: handle.projectDir,
|
|
2124
|
+
studioPort: config.port,
|
|
2125
|
+
hookToken: coderHookToken,
|
|
2126
|
+
agentName: coderSession.name,
|
|
2127
|
+
};
|
|
2128
|
+
const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
|
|
2129
|
+
// Track coder events
|
|
2130
|
+
coderCcBridge.onAgentEvent((event) => {
|
|
2131
|
+
if (event.type === "session_start") {
|
|
2132
|
+
const ccSessionId = event.session_id;
|
|
2133
|
+
if (ccSessionId) {
|
|
2134
|
+
config.sessions.update(coderSession.sessionId, {
|
|
2135
|
+
lastCoderSessionId: ccSessionId,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
if (event.type === "session_end") {
|
|
2140
|
+
accumulateSessionCost(config, coderSession.sessionId, event);
|
|
2141
|
+
}
|
|
2142
|
+
// Route assistant_message output to the room router
|
|
2143
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
2144
|
+
router
|
|
2145
|
+
.handleAgentOutput(coderSession.sessionId, event.text)
|
|
2146
|
+
.catch((err) => {
|
|
2147
|
+
console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
// Notify room when coder is waiting for user input
|
|
2151
|
+
if (event.type === "ask_user_question") {
|
|
2152
|
+
config.sessions.update(coderSession.sessionId, { needsInput: true });
|
|
2153
|
+
router
|
|
2154
|
+
.sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
|
|
2155
|
+
.catch((err) => {
|
|
2156
|
+
console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
if (event.type === "gate_resolved") {
|
|
2160
|
+
config.sessions.update(coderSession.sessionId, { needsInput: false });
|
|
2161
|
+
router
|
|
2162
|
+
.sendMessage("system", `${coderSession.name} received input — resuming.`)
|
|
2163
|
+
.catch(() => { });
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
// Coder completion handler: notify room on success or failure
|
|
2167
|
+
coderCcBridge.onComplete(async (success) => {
|
|
2168
|
+
const updates = {
|
|
2169
|
+
status: success ? "complete" : "error",
|
|
2170
|
+
};
|
|
2171
|
+
let repoInfo = "";
|
|
2172
|
+
try {
|
|
2173
|
+
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
2174
|
+
if (gs.initialized) {
|
|
2175
|
+
const existing = config.sessions.get(coderSession.sessionId);
|
|
2176
|
+
updates.git = {
|
|
2177
|
+
branch: gs.branch ?? "main",
|
|
2178
|
+
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
2179
|
+
repoName: existing?.git?.repoName ?? null,
|
|
2180
|
+
repoVisibility: existing?.git?.repoVisibility,
|
|
2181
|
+
lastCommitHash: gs.lastCommitHash ?? null,
|
|
2182
|
+
lastCommitMessage: gs.lastCommitMessage ?? null,
|
|
2183
|
+
lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
|
|
2184
|
+
};
|
|
2185
|
+
if (existing?.git?.repoName) {
|
|
2186
|
+
repoInfo = ` Repo: https://github.com/${existing.git.repoName}`;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
catch {
|
|
2191
|
+
// Sandbox may be stopped
|
|
2192
|
+
}
|
|
2193
|
+
config.sessions.update(coderSession.sessionId, updates);
|
|
2194
|
+
const msg = success
|
|
2195
|
+
? `@room DONE: App is ready.${repoInfo}`
|
|
2196
|
+
: "@room Coder session ended unexpectedly.";
|
|
2197
|
+
router.handleAgentOutput(coderSession.sessionId, msg).catch((err) => {
|
|
2198
|
+
console.error(`[room:create-app:${roomId}] Failed to send coder completion message:`, err);
|
|
2199
|
+
});
|
|
2200
|
+
});
|
|
2201
|
+
await coderBridge.emit({
|
|
2202
|
+
type: "log",
|
|
2203
|
+
level: "build",
|
|
2204
|
+
message: `Running: claude "/create-app ${body.description}"`,
|
|
2205
|
+
ts: ts(),
|
|
2206
|
+
});
|
|
2207
|
+
await coderCcBridge.start();
|
|
2208
|
+
// Add coder as room participant
|
|
2209
|
+
const coderParticipant = {
|
|
2210
|
+
sessionId: coderSession.sessionId,
|
|
2211
|
+
name: coderSession.name,
|
|
2212
|
+
role: "coder",
|
|
2213
|
+
bridge: coderCcBridge,
|
|
2214
|
+
};
|
|
2215
|
+
await router.addParticipant(coderParticipant, false);
|
|
2216
|
+
// Send the initial command to the coder
|
|
2217
|
+
await coderCcBridge.sendCommand({
|
|
2218
|
+
command: "new",
|
|
2219
|
+
description: body.description,
|
|
2220
|
+
projectName: coderInfo.projectName,
|
|
2221
|
+
baseDir: "/home/agent/workspace",
|
|
2222
|
+
});
|
|
2223
|
+
// Store the repoUrl for reviewer/ui-designer prompts
|
|
2224
|
+
// (we continue setting up those agents now)
|
|
2225
|
+
const finalRepoUrl = repoUrl;
|
|
2226
|
+
// 5. Set up reviewer and ui-designer sandboxes
|
|
2227
|
+
const supportAgents = [
|
|
2228
|
+
{ session: reviewerSession, handle: reviewerHandle },
|
|
2229
|
+
{ session: uiDesignerSession, handle: uiDesignerHandle },
|
|
2230
|
+
];
|
|
2231
|
+
for (const { session: agentSession, handle: agentHandle } of supportAgents) {
|
|
2232
|
+
const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
|
|
2233
|
+
// Write a minimal CLAUDE.md
|
|
2234
|
+
const minimalClaudeMd = "Room agent workspace";
|
|
2235
|
+
try {
|
|
2236
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
|
|
2237
|
+
}
|
|
2238
|
+
catch (err) {
|
|
2239
|
+
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
|
|
2240
|
+
}
|
|
2241
|
+
// Write room-messaging skill
|
|
2242
|
+
if (roomMessagingSkillContent) {
|
|
2243
|
+
try {
|
|
2244
|
+
const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
|
|
2245
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2246
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2247
|
+
}
|
|
2248
|
+
catch (err) {
|
|
2249
|
+
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// Resolve and inject role skill
|
|
2253
|
+
const roleSkill = resolveRoleSkill(agentSession.role);
|
|
2254
|
+
if (roleSkill) {
|
|
2255
|
+
try {
|
|
2256
|
+
const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
|
|
2257
|
+
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
2258
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2259
|
+
}
|
|
2260
|
+
catch (err) {
|
|
2261
|
+
console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
// Build prompt
|
|
2265
|
+
const repoRef = finalRepoUrl ? ` The GitHub repo is: ${finalRepoUrl}.` : "";
|
|
2266
|
+
const agentPrompt = agentSession.role === "reviewer"
|
|
2267
|
+
? `You are "reviewer", a code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`
|
|
2268
|
+
: `You are "ui-designer", a UI design agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`;
|
|
2269
|
+
// Create Claude Code bridge
|
|
2270
|
+
const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
|
|
2271
|
+
const agentClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2272
|
+
? {
|
|
2273
|
+
prompt: agentPrompt,
|
|
2274
|
+
cwd: agentHandle.projectDir,
|
|
2275
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2276
|
+
hookToken: agentHookToken,
|
|
2277
|
+
agentName: agentSession.name,
|
|
2278
|
+
...(roleSkill?.allowedTools && {
|
|
2279
|
+
allowedTools: roleSkill.allowedTools,
|
|
2280
|
+
}),
|
|
2281
|
+
}
|
|
2282
|
+
: {
|
|
2283
|
+
prompt: agentPrompt,
|
|
2284
|
+
cwd: agentHandle.projectDir,
|
|
2285
|
+
studioPort: config.port,
|
|
2286
|
+
hookToken: agentHookToken,
|
|
2287
|
+
agentName: agentSession.name,
|
|
2288
|
+
...(roleSkill?.allowedTools && {
|
|
2289
|
+
allowedTools: roleSkill.allowedTools,
|
|
2290
|
+
}),
|
|
2291
|
+
};
|
|
2292
|
+
const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
|
|
2293
|
+
// Track events
|
|
2294
|
+
ccBridge.onAgentEvent((event) => {
|
|
2295
|
+
console.log(`[room:create-app:${roomId}] ${agentSession.name} event: type=${event.type}${event.type === "assistant_message" && "text" in event ? ` text=${event.text.slice(0, 120)}` : ""}`);
|
|
2296
|
+
if (event.type === "session_start") {
|
|
2297
|
+
const ccSessionId = event.session_id;
|
|
2298
|
+
if (ccSessionId) {
|
|
2299
|
+
config.sessions.update(agentSession.sessionId, {
|
|
2300
|
+
lastCoderSessionId: ccSessionId,
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
if (event.type === "session_end") {
|
|
2305
|
+
accumulateSessionCost(config, agentSession.sessionId, event);
|
|
2306
|
+
}
|
|
2307
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
2308
|
+
const text = event.text;
|
|
2309
|
+
console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
|
|
2310
|
+
router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
|
|
2311
|
+
console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
if (event.type === "ask_user_question") {
|
|
2315
|
+
config.sessions.update(agentSession.sessionId, { needsInput: true });
|
|
2316
|
+
router
|
|
2317
|
+
.sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
|
|
2318
|
+
.catch((err) => {
|
|
2319
|
+
console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
if (event.type === "gate_resolved") {
|
|
2323
|
+
config.sessions.update(agentSession.sessionId, { needsInput: false });
|
|
2324
|
+
router
|
|
2325
|
+
.sendMessage("system", `${agentSession.name} received input — resuming.`)
|
|
2326
|
+
.catch(() => { });
|
|
2327
|
+
}
|
|
2328
|
+
});
|
|
2329
|
+
ccBridge.onComplete(async (success) => {
|
|
2330
|
+
config.sessions.update(agentSession.sessionId, {
|
|
2331
|
+
status: success ? "complete" : "error",
|
|
2332
|
+
});
|
|
2333
|
+
});
|
|
2334
|
+
await agentBridge.emit({
|
|
2335
|
+
type: "log",
|
|
2336
|
+
level: "done",
|
|
2337
|
+
message: `Sandbox ready for "${agentSession.name}"`,
|
|
2338
|
+
ts: ts(),
|
|
2339
|
+
});
|
|
2340
|
+
await ccBridge.start();
|
|
2341
|
+
// Add as room participant (not gated — messages flow freely)
|
|
2342
|
+
const participant = {
|
|
2343
|
+
sessionId: agentSession.sessionId,
|
|
2344
|
+
name: agentSession.name,
|
|
2345
|
+
role: agentSession.role,
|
|
2346
|
+
bridge: ccBridge,
|
|
2347
|
+
};
|
|
2348
|
+
await router.addParticipant(participant, false);
|
|
2349
|
+
}
|
|
2350
|
+
console.log(`[room:create-app:${roomId}] All 3 agents started and added to room`);
|
|
2351
|
+
await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} and ${uiDesignerSession.name} waiting.`);
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
asyncFlow().catch(async (err) => {
|
|
2355
|
+
console.error(`[room:create-app:${roomId}] Flow failed:`, err);
|
|
2356
|
+
for (const s of sessions) {
|
|
2357
|
+
config.sessions.update(s.sessionId, { status: "error" });
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
await coderBridge.emit({
|
|
2361
|
+
type: "log",
|
|
2362
|
+
level: "error",
|
|
2363
|
+
message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2364
|
+
ts: ts(),
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
catch {
|
|
2368
|
+
// Bridge may not be usable
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
return c.json({
|
|
2372
|
+
roomId,
|
|
2373
|
+
code,
|
|
2374
|
+
name: roomName,
|
|
2375
|
+
roomToken,
|
|
2376
|
+
sessions: sessions.map((s) => ({
|
|
2377
|
+
sessionId: s.sessionId,
|
|
2378
|
+
name: s.name,
|
|
2379
|
+
role: s.role,
|
|
2380
|
+
sessionToken: s.sessionToken,
|
|
2381
|
+
})),
|
|
2382
|
+
}, 201);
|
|
2383
|
+
});
|
|
1648
2384
|
// Create a room
|
|
1649
2385
|
app.post("/api/rooms", async (c) => {
|
|
1650
2386
|
const body = await validateBody(c, createRoomSchema);
|
|
@@ -1699,18 +2435,31 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1699
2435
|
app.get("/api/rooms/:id", (c) => {
|
|
1700
2436
|
const roomId = c.req.param("id");
|
|
1701
2437
|
const router = roomRouters.get(roomId);
|
|
1702
|
-
if (
|
|
2438
|
+
if (router) {
|
|
2439
|
+
return c.json({
|
|
2440
|
+
roomId,
|
|
2441
|
+
state: router.state,
|
|
2442
|
+
roundCount: router.roundCount,
|
|
2443
|
+
participants: router.participants.map((p) => ({
|
|
2444
|
+
sessionId: p.sessionId,
|
|
2445
|
+
name: p.name,
|
|
2446
|
+
role: p.role,
|
|
2447
|
+
running: p.bridge.isRunning(),
|
|
2448
|
+
needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
|
|
2449
|
+
})),
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
// No active router — check if room exists in the registry (e.g. after server restart)
|
|
2453
|
+
const roomEntry = config.rooms.getRoom(roomId);
|
|
2454
|
+
if (!roomEntry)
|
|
1703
2455
|
return c.json({ error: "Room not found" }, 404);
|
|
2456
|
+
// Return basic room state without live participants
|
|
2457
|
+
// Sessions are still readable via their individual SSE streams
|
|
1704
2458
|
return c.json({
|
|
1705
2459
|
roomId,
|
|
1706
|
-
state:
|
|
1707
|
-
roundCount:
|
|
1708
|
-
participants:
|
|
1709
|
-
sessionId: p.sessionId,
|
|
1710
|
-
name: p.name,
|
|
1711
|
-
role: p.role,
|
|
1712
|
-
running: p.bridge.isRunning(),
|
|
1713
|
-
})),
|
|
2460
|
+
state: "closed",
|
|
2461
|
+
roundCount: 0,
|
|
2462
|
+
participants: [],
|
|
1714
2463
|
});
|
|
1715
2464
|
});
|
|
1716
2465
|
// Add an agent to a room
|
|
@@ -1732,8 +2481,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1732
2481
|
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
1733
2482
|
}
|
|
1734
2483
|
}
|
|
1735
|
-
const apiKey = config.devMode
|
|
1736
|
-
|
|
2484
|
+
const apiKey = config.devMode
|
|
2485
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
2486
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
2487
|
+
const oauthToken = config.devMode
|
|
2488
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
2489
|
+
: undefined;
|
|
1737
2490
|
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1738
2491
|
const sessionId = crypto.randomUUID();
|
|
1739
2492
|
const randomSuffix = sessionId.slice(0, 6);
|
|
@@ -1786,7 +2539,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1786
2539
|
apiKey,
|
|
1787
2540
|
oauthToken,
|
|
1788
2541
|
ghToken,
|
|
1789
|
-
...(!config.devMode && {
|
|
2542
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
1790
2543
|
prodMode: {
|
|
1791
2544
|
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
1792
2545
|
studioUrl: resolveStudioUrl(config.port),
|
|
@@ -2008,12 +2761,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2008
2761
|
await router.sendMessage(body.from, body.body, body.to);
|
|
2009
2762
|
return c.json({ ok: true });
|
|
2010
2763
|
});
|
|
2011
|
-
// SSE proxy for room events
|
|
2764
|
+
// SSE proxy for room events (works even after server restart — reads from durable stream)
|
|
2012
2765
|
app.get("/api/rooms/:id/events", async (c) => {
|
|
2013
2766
|
const roomId = c.req.param("id");
|
|
2014
|
-
|
|
2015
|
-
if (!
|
|
2767
|
+
// Verify room exists in registry or has active router
|
|
2768
|
+
if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
|
|
2016
2769
|
return c.json({ error: "Room not found" }, 404);
|
|
2770
|
+
}
|
|
2017
2771
|
const connection = roomStream(config, roomId);
|
|
2018
2772
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
2019
2773
|
const reader = new DurableStream({
|
|
@@ -2567,16 +3321,38 @@ export async function startWebServer(opts) {
|
|
|
2567
3321
|
if (devMode) {
|
|
2568
3322
|
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
2569
3323
|
}
|
|
3324
|
+
// Hydrate session registry from durable stream (survives restarts)
|
|
3325
|
+
const registry = await Registry.create(opts.streamConfig);
|
|
2570
3326
|
const config = {
|
|
2571
3327
|
port: opts.port ?? 4400,
|
|
2572
3328
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
2573
|
-
sessions:
|
|
3329
|
+
sessions: ActiveSessions.fromRegistry(registry),
|
|
2574
3330
|
rooms: opts.rooms,
|
|
2575
3331
|
sandbox: opts.sandbox,
|
|
2576
3332
|
streamConfig: opts.streamConfig,
|
|
2577
3333
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
2578
3334
|
devMode,
|
|
2579
3335
|
};
|
|
3336
|
+
// Reconnect to surviving sandbox containers (Docker only)
|
|
3337
|
+
if (config.sandbox.runtime === "docker") {
|
|
3338
|
+
const dockerProvider = config.sandbox;
|
|
3339
|
+
const allSessions = registry.listSessions();
|
|
3340
|
+
dockerProvider.reconnect(allSessions);
|
|
3341
|
+
// Mark sessions with live containers as "complete" (not stale),
|
|
3342
|
+
// and sessions without containers as "error"
|
|
3343
|
+
for (const session of allSessions) {
|
|
3344
|
+
if (session.status === "running") {
|
|
3345
|
+
const handle = dockerProvider.get(session.id);
|
|
3346
|
+
config.sessions.update(session.id, {
|
|
3347
|
+
status: handle ? "complete" : "error",
|
|
3348
|
+
});
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
else {
|
|
3353
|
+
// Non-Docker: mark all running sessions as stale
|
|
3354
|
+
registry.cleanupStaleSessions(0);
|
|
3355
|
+
}
|
|
2580
3356
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2581
3357
|
const app = createApp(config);
|
|
2582
3358
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|