@electric-agent/studio 1.12.0 → 1.13.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/active-sessions.d.ts +13 -4
- package/dist/active-sessions.d.ts.map +1 -1
- package/dist/active-sessions.js +39 -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/claude-md-generator.d.ts +5 -2
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +33 -1
- package/dist/bridge/claude-md-generator.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/github-app.d.ts +14 -0
- package/dist/github-app.d.ts.map +1 -0
- package/dist/github-app.js +62 -0
- package/dist/github-app.js.map +1 -0
- package/dist/github-app.test.d.ts +2 -0
- package/dist/github-app.test.d.ts.map +1 -0
- package/dist/github-app.test.js +62 -0
- package/dist/github-app.test.js.map +1 -0
- 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 +10 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +115 -1
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +1 -0
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +51 -0
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +962 -140
- 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-CiwD5LkP.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";
|
|
@@ -17,8 +17,10 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
|
17
17
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
18
18
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
19
19
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
+
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
20
21
|
import { generateInviteCode } from "./invite-code.js";
|
|
21
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
23
|
+
import { Registry } from "./registry.js";
|
|
22
24
|
import { RoomRouter } from "./room-router.js";
|
|
23
25
|
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
24
26
|
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
@@ -73,8 +75,17 @@ function resolveStudioUrl(port) {
|
|
|
73
75
|
// Rate limiting — in-memory sliding window per IP
|
|
74
76
|
// ---------------------------------------------------------------------------
|
|
75
77
|
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
78
|
+
const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
|
|
76
79
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
77
80
|
const sessionCreationsByIp = new Map();
|
|
81
|
+
// GitHub App config (prod mode — repo creation in electric-apps org)
|
|
82
|
+
const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
|
|
83
|
+
const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
|
|
84
|
+
const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
85
|
+
const GITHUB_ORG = "electric-apps";
|
|
86
|
+
// Rate limiting for GitHub token endpoint
|
|
87
|
+
const githubTokenRequestsBySession = new Map();
|
|
88
|
+
const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
|
|
78
89
|
function extractClientIp(c) {
|
|
79
90
|
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
80
91
|
c.req.header("cf-connecting-ip") ||
|
|
@@ -94,6 +105,20 @@ function checkSessionRateLimit(ip) {
|
|
|
94
105
|
sessionCreationsByIp.set(ip, timestamps);
|
|
95
106
|
return true;
|
|
96
107
|
}
|
|
108
|
+
function checkGlobalSessionCap(sessions) {
|
|
109
|
+
return sessions.size() >= MAX_TOTAL_SESSIONS;
|
|
110
|
+
}
|
|
111
|
+
function checkGithubTokenRateLimit(sessionId) {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
|
|
114
|
+
const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
115
|
+
if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
recent.push(now);
|
|
119
|
+
githubTokenRequestsBySession.set(sessionId, recent);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
97
122
|
// ---------------------------------------------------------------------------
|
|
98
123
|
// Per-session cost budget
|
|
99
124
|
// ---------------------------------------------------------------------------
|
|
@@ -186,31 +211,6 @@ function closeBridge(sessionId) {
|
|
|
186
211
|
bridges.delete(sessionId);
|
|
187
212
|
}
|
|
188
213
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Detect git operations from natural language prompts.
|
|
191
|
-
* Returns structured gitOp fields if matched, null otherwise.
|
|
192
|
-
*/
|
|
193
|
-
function detectGitOp(request) {
|
|
194
|
-
const lower = request.toLowerCase().trim();
|
|
195
|
-
// Commit: "commit", "commit the code", "commit changes", "commit with message ..."
|
|
196
|
-
if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
|
|
197
|
-
// Extract commit message after "commit" keyword, or after "message:" / "msg:"
|
|
198
|
-
const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
|
|
199
|
-
const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
|
|
200
|
-
return { gitOp: "commit", gitMessage: message || undefined };
|
|
201
|
-
}
|
|
202
|
-
// Push: "push", "push to github", "push to remote", "git push"
|
|
203
|
-
if (/^(git\s+)?push\b/.test(lower)) {
|
|
204
|
-
return { gitOp: "push" };
|
|
205
|
-
}
|
|
206
|
-
// Create PR: "create pr", "open pr", "make pr", "create pull request"
|
|
207
|
-
if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
|
|
208
|
-
// Try to extract title after the PR keyword
|
|
209
|
-
const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
|
|
210
|
-
return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
214
|
/**
|
|
215
215
|
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
216
216
|
*
|
|
@@ -343,7 +343,10 @@ export function createApp(config) {
|
|
|
343
343
|
});
|
|
344
344
|
// Public config — exposes non-sensitive flags to the client
|
|
345
345
|
app.get("/api/config", (c) => {
|
|
346
|
-
return c.json({
|
|
346
|
+
return c.json({
|
|
347
|
+
devMode: config.devMode,
|
|
348
|
+
maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
|
|
349
|
+
});
|
|
347
350
|
});
|
|
348
351
|
// Provision Electric Cloud resources via the Claim API
|
|
349
352
|
app.post("/api/provision-electric", async (c) => {
|
|
@@ -542,6 +545,7 @@ export function createApp(config) {
|
|
|
542
545
|
if (hookEvent.type === "ask_user_question") {
|
|
543
546
|
const toolUseId = hookEvent.tool_use_id;
|
|
544
547
|
console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
548
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
545
549
|
try {
|
|
546
550
|
const gateTimeout = 5 * 60 * 1000; // 5 minutes
|
|
547
551
|
const result = await Promise.race([
|
|
@@ -549,6 +553,7 @@ export function createApp(config) {
|
|
|
549
553
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
550
554
|
]);
|
|
551
555
|
console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
|
|
556
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
552
557
|
return c.json({
|
|
553
558
|
hookSpecificOutput: {
|
|
554
559
|
hookEventName: "PreToolUse",
|
|
@@ -562,6 +567,7 @@ export function createApp(config) {
|
|
|
562
567
|
}
|
|
563
568
|
catch (err) {
|
|
564
569
|
console.error(`[hook-event] ask_user_question gate error:`, err);
|
|
570
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
565
571
|
return c.json({ ok: true }); // Don't block Claude Code on timeout
|
|
566
572
|
}
|
|
567
573
|
}
|
|
@@ -683,6 +689,7 @@ export function createApp(config) {
|
|
|
683
689
|
if (hookEvent.type === "ask_user_question") {
|
|
684
690
|
const toolUseId = hookEvent.tool_use_id;
|
|
685
691
|
console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
692
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
686
693
|
try {
|
|
687
694
|
const gateTimeout = 5 * 60 * 1000;
|
|
688
695
|
const result = await Promise.race([
|
|
@@ -690,6 +697,7 @@ export function createApp(config) {
|
|
|
690
697
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
691
698
|
]);
|
|
692
699
|
console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
|
|
700
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
693
701
|
return c.json({
|
|
694
702
|
sessionId,
|
|
695
703
|
hookSpecificOutput: {
|
|
@@ -704,6 +712,7 @@ export function createApp(config) {
|
|
|
704
712
|
}
|
|
705
713
|
catch (err) {
|
|
706
714
|
console.error(`[hook] ask_user_question gate error:`, err);
|
|
715
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
707
716
|
return c.json({ ok: true, sessionId });
|
|
708
717
|
}
|
|
709
718
|
}
|
|
@@ -802,6 +811,14 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
802
811
|
const body = await validateBody(c, createSessionSchema);
|
|
803
812
|
if (isResponse(body))
|
|
804
813
|
return body;
|
|
814
|
+
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
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;
|
|
821
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
805
822
|
// Block freeform sessions in production mode
|
|
806
823
|
if (body.freeform && !config.devMode) {
|
|
807
824
|
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
@@ -812,14 +829,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
812
829
|
if (!checkSessionRateLimit(ip)) {
|
|
813
830
|
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
814
831
|
}
|
|
832
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
833
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
834
|
+
}
|
|
815
835
|
}
|
|
816
836
|
const sessionId = crypto.randomUUID();
|
|
817
|
-
const inferredName =
|
|
818
|
-
body.
|
|
819
|
-
.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
837
|
+
const inferredName = config.devMode
|
|
838
|
+
? body.name ||
|
|
839
|
+
body.description
|
|
840
|
+
.slice(0, 40)
|
|
841
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
842
|
+
.replace(/^-|-$/g, "")
|
|
843
|
+
.toLowerCase()
|
|
844
|
+
: `electric-${sessionId.slice(0, 8)}`;
|
|
823
845
|
const baseDir = body.baseDir || process.cwd();
|
|
824
846
|
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
825
847
|
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
@@ -856,11 +878,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
856
878
|
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
857
879
|
let ghAccounts = [];
|
|
858
880
|
if (!body.freeform) {
|
|
859
|
-
// Gather GitHub accounts for the merged setup gate
|
|
860
|
-
|
|
861
|
-
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
881
|
+
// Gather GitHub accounts for the merged setup gate (dev mode only)
|
|
882
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
862
883
|
try {
|
|
863
|
-
ghAccounts = ghListAccounts(
|
|
884
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
864
885
|
}
|
|
865
886
|
catch {
|
|
866
887
|
// gh not available — no repo setup
|
|
@@ -943,9 +964,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
943
964
|
const handle = await config.sandbox.create(sessionId, {
|
|
944
965
|
projectName,
|
|
945
966
|
infra,
|
|
946
|
-
apiKey
|
|
947
|
-
oauthToken
|
|
948
|
-
ghToken
|
|
967
|
+
apiKey,
|
|
968
|
+
oauthToken,
|
|
969
|
+
ghToken,
|
|
970
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
971
|
+
prodMode: {
|
|
972
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
973
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
974
|
+
},
|
|
975
|
+
}),
|
|
949
976
|
});
|
|
950
977
|
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
951
978
|
await bridge.emit({
|
|
@@ -1011,6 +1038,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1011
1038
|
ts: ts(),
|
|
1012
1039
|
});
|
|
1013
1040
|
}
|
|
1041
|
+
// Create GitHub repo via GitHub App when credentials are available
|
|
1042
|
+
let prodGitConfig;
|
|
1043
|
+
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
1044
|
+
try {
|
|
1045
|
+
// Repo name matches the project name (already has random slug)
|
|
1046
|
+
const repoSlug = projectName;
|
|
1047
|
+
await bridge.emit({
|
|
1048
|
+
type: "log",
|
|
1049
|
+
level: "build",
|
|
1050
|
+
message: "Creating GitHub repository...",
|
|
1051
|
+
ts: ts(),
|
|
1052
|
+
});
|
|
1053
|
+
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1054
|
+
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
1055
|
+
if (repo) {
|
|
1056
|
+
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
1057
|
+
// Initialize git and set remote in the sandbox
|
|
1058
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
1059
|
+
prodGitConfig = {
|
|
1060
|
+
mode: "pre-created",
|
|
1061
|
+
repoName: actualRepoName,
|
|
1062
|
+
repoUrl: repo.htmlUrl,
|
|
1063
|
+
};
|
|
1064
|
+
config.sessions.update(sessionId, {
|
|
1065
|
+
git: {
|
|
1066
|
+
branch: "main",
|
|
1067
|
+
remoteUrl: repo.htmlUrl,
|
|
1068
|
+
repoName: actualRepoName,
|
|
1069
|
+
lastCommitHash: null,
|
|
1070
|
+
lastCommitMessage: null,
|
|
1071
|
+
lastCheckpointAt: null,
|
|
1072
|
+
},
|
|
1073
|
+
});
|
|
1074
|
+
await bridge.emit({
|
|
1075
|
+
type: "log",
|
|
1076
|
+
level: "done",
|
|
1077
|
+
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
1078
|
+
ts: ts(),
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1014
1089
|
// Write CLAUDE.md to the sandbox workspace.
|
|
1015
1090
|
// Our generator includes hardcoded playbook paths and reading order
|
|
1016
1091
|
// so we don't depend on @tanstack/intent generating a skill block.
|
|
@@ -1020,15 +1095,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1020
1095
|
projectDir: handle.projectDir,
|
|
1021
1096
|
runtime: config.sandbox.runtime,
|
|
1022
1097
|
production: !config.devMode,
|
|
1023
|
-
...(
|
|
1024
|
-
? {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1098
|
+
...(prodGitConfig
|
|
1099
|
+
? { git: prodGitConfig }
|
|
1100
|
+
: repoConfig
|
|
1101
|
+
? {
|
|
1102
|
+
git: {
|
|
1103
|
+
mode: "create",
|
|
1104
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1105
|
+
visibility: repoConfig.visibility,
|
|
1106
|
+
},
|
|
1107
|
+
}
|
|
1108
|
+
: {}),
|
|
1032
1109
|
});
|
|
1033
1110
|
try {
|
|
1034
1111
|
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
@@ -1193,75 +1270,51 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1193
1270
|
const body = await validateBody(c, iterateSessionSchema);
|
|
1194
1271
|
if (isResponse(body))
|
|
1195
1272
|
return body;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
.
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
if (
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
1273
|
+
const handle = config.sandbox.get(sessionId);
|
|
1274
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1275
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
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,
|
|
1214
1290
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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 });
|
|
1218
1304
|
}
|
|
1219
|
-
if (isRestartCmd)
|
|
1220
|
-
await config.sandbox.stopApp(handle);
|
|
1221
|
-
await config.sandbox.startApp(handle);
|
|
1222
|
-
await bridge.emit({
|
|
1223
|
-
type: "log",
|
|
1224
|
-
level: "done",
|
|
1225
|
-
message: "App started",
|
|
1226
|
-
ts: ts(),
|
|
1227
|
-
});
|
|
1228
|
-
await bridge.emit({
|
|
1229
|
-
type: "app_status",
|
|
1230
|
-
status: "running",
|
|
1231
|
-
port: session.appPort,
|
|
1232
|
-
previewUrl: session.previewUrl,
|
|
1233
|
-
ts: ts(),
|
|
1234
|
-
});
|
|
1235
1305
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1240
|
-
}
|
|
1241
|
-
return c.json({ ok: true });
|
|
1242
|
-
}
|
|
1243
|
-
// Intercept git commands (commit, push, create PR)
|
|
1244
|
-
const gitOp = detectGitOp(body.request);
|
|
1245
|
-
if (gitOp) {
|
|
1246
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1247
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1248
|
-
const handle = config.sandbox.get(sessionId);
|
|
1249
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1250
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1251
|
-
}
|
|
1252
|
-
// Send git requests as user messages via Claude Code bridge
|
|
1253
|
-
await bridge.sendCommand({
|
|
1254
|
-
command: "iterate",
|
|
1255
|
-
request: body.request,
|
|
1306
|
+
if (event.type === "session_end") {
|
|
1307
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1308
|
+
}
|
|
1256
1309
|
});
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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`);
|
|
1262
1316
|
}
|
|
1263
1317
|
// Write user prompt to the stream
|
|
1264
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1265
1318
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1266
1319
|
config.sessions.update(sessionId, { status: "running" });
|
|
1267
1320
|
await bridge.sendCommand({
|
|
@@ -1272,6 +1325,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1272
1325
|
});
|
|
1273
1326
|
return c.json({ ok: true });
|
|
1274
1327
|
});
|
|
1328
|
+
// Generate a GitHub installation token for the sandbox (prod mode only)
|
|
1329
|
+
app.post("/api/sessions/:id/github-token", async (c) => {
|
|
1330
|
+
const sessionId = c.req.param("id");
|
|
1331
|
+
if (config.devMode) {
|
|
1332
|
+
return c.json({ error: "Not available in dev mode" }, 403);
|
|
1333
|
+
}
|
|
1334
|
+
if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
|
|
1335
|
+
return c.json({ error: "GitHub App not configured" }, 500);
|
|
1336
|
+
}
|
|
1337
|
+
if (!checkGithubTokenRateLimit(sessionId)) {
|
|
1338
|
+
return c.json({ error: "Too many token requests" }, 429);
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1342
|
+
return c.json(result);
|
|
1343
|
+
}
|
|
1344
|
+
catch (err) {
|
|
1345
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1346
|
+
console.error(`GitHub token error for session ${sessionId}:`, message);
|
|
1347
|
+
return c.json({ error: "Failed to generate GitHub token" }, 500);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1275
1350
|
// Respond to a gate (approval, clarification, continue, revision)
|
|
1276
1351
|
app.post("/api/sessions/:id/respond", async (c) => {
|
|
1277
1352
|
const sessionId = c.req.param("id");
|
|
@@ -1602,8 +1677,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1602
1677
|
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1603
1678
|
}
|
|
1604
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"]);
|
|
1605
1682
|
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1606
1683
|
const id = c.req.param("id");
|
|
1684
|
+
if (roomAuthExemptIds.has(id))
|
|
1685
|
+
return next();
|
|
1607
1686
|
const token = extractRoomToken(c);
|
|
1608
1687
|
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1609
1688
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
@@ -1611,15 +1690,697 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1611
1690
|
return next();
|
|
1612
1691
|
});
|
|
1613
1692
|
app.use("/api/rooms/:id", async (c, next) => {
|
|
1693
|
+
const id = c.req.param("id");
|
|
1694
|
+
if (roomAuthExemptIds.has(id))
|
|
1695
|
+
return next();
|
|
1614
1696
|
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1615
1697
|
return next();
|
|
1616
|
-
const id = c.req.param("id");
|
|
1617
1698
|
const token = extractRoomToken(c);
|
|
1618
1699
|
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1619
1700
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1620
1701
|
}
|
|
1621
1702
|
return next();
|
|
1622
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
|
+
});
|
|
1623
2384
|
// Create a room
|
|
1624
2385
|
app.post("/api/rooms", async (c) => {
|
|
1625
2386
|
const body = await validateBody(c, createRoomSchema);
|
|
@@ -1658,8 +2419,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1658
2419
|
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1659
2420
|
return c.json({ roomId, code, roomToken }, 201);
|
|
1660
2421
|
});
|
|
1661
|
-
// Join an agent room by id + invite code
|
|
1662
|
-
app.get("/api/
|
|
2422
|
+
// Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
|
|
2423
|
+
app.get("/api/join-room/:id/:code", (c) => {
|
|
1663
2424
|
const id = c.req.param("id");
|
|
1664
2425
|
const code = c.req.param("code");
|
|
1665
2426
|
const room = config.rooms.getRoom(id);
|
|
@@ -1674,18 +2435,31 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1674
2435
|
app.get("/api/rooms/:id", (c) => {
|
|
1675
2436
|
const roomId = c.req.param("id");
|
|
1676
2437
|
const router = roomRouters.get(roomId);
|
|
1677
|
-
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)
|
|
1678
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
|
|
1679
2458
|
return c.json({
|
|
1680
2459
|
roomId,
|
|
1681
|
-
state:
|
|
1682
|
-
roundCount:
|
|
1683
|
-
participants:
|
|
1684
|
-
sessionId: p.sessionId,
|
|
1685
|
-
name: p.name,
|
|
1686
|
-
role: p.role,
|
|
1687
|
-
running: p.bridge.isRunning(),
|
|
1688
|
-
})),
|
|
2460
|
+
state: "closed",
|
|
2461
|
+
roundCount: 0,
|
|
2462
|
+
participants: [],
|
|
1689
2463
|
});
|
|
1690
2464
|
});
|
|
1691
2465
|
// Add an agent to a room
|
|
@@ -1697,6 +2471,23 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1697
2471
|
const body = await validateBody(c, addAgentSchema);
|
|
1698
2472
|
if (isResponse(body))
|
|
1699
2473
|
return body;
|
|
2474
|
+
// Rate-limit and gate credentials in production mode
|
|
2475
|
+
if (!config.devMode) {
|
|
2476
|
+
const ip = extractClientIp(c);
|
|
2477
|
+
if (!checkSessionRateLimit(ip)) {
|
|
2478
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2479
|
+
}
|
|
2480
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
2481
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
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;
|
|
2490
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1700
2491
|
const sessionId = crypto.randomUUID();
|
|
1701
2492
|
const randomSuffix = sessionId.slice(0, 6);
|
|
1702
2493
|
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
@@ -1745,9 +2536,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1745
2536
|
const handle = await config.sandbox.create(sessionId, {
|
|
1746
2537
|
projectName,
|
|
1747
2538
|
infra: { mode: "local" },
|
|
1748
|
-
apiKey
|
|
1749
|
-
oauthToken
|
|
1750
|
-
ghToken
|
|
2539
|
+
apiKey,
|
|
2540
|
+
oauthToken,
|
|
2541
|
+
ghToken,
|
|
2542
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
2543
|
+
prodMode: {
|
|
2544
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
2545
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2546
|
+
},
|
|
2547
|
+
}),
|
|
1751
2548
|
});
|
|
1752
2549
|
config.sessions.update(sessionId, {
|
|
1753
2550
|
appPort: handle.port,
|
|
@@ -1964,12 +2761,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1964
2761
|
await router.sendMessage(body.from, body.body, body.to);
|
|
1965
2762
|
return c.json({ ok: true });
|
|
1966
2763
|
});
|
|
1967
|
-
// SSE proxy for room events
|
|
2764
|
+
// SSE proxy for room events (works even after server restart — reads from durable stream)
|
|
1968
2765
|
app.get("/api/rooms/:id/events", async (c) => {
|
|
1969
2766
|
const roomId = c.req.param("id");
|
|
1970
|
-
|
|
1971
|
-
if (!
|
|
2767
|
+
// Verify room exists in registry or has active router
|
|
2768
|
+
if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
|
|
1972
2769
|
return c.json({ error: "Room not found" }, 404);
|
|
2770
|
+
}
|
|
1973
2771
|
const connection = roomStream(config, roomId);
|
|
1974
2772
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1975
2773
|
const reader = new DurableStream({
|
|
@@ -2199,6 +2997,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2199
2997
|
});
|
|
2200
2998
|
// List GitHub accounts (personal + orgs) — requires client-provided token
|
|
2201
2999
|
app.get("/api/github/accounts", (c) => {
|
|
3000
|
+
if (!config.devMode)
|
|
3001
|
+
return c.json({ error: "Not available" }, 403);
|
|
2202
3002
|
const token = c.req.header("X-GH-Token");
|
|
2203
3003
|
if (!token)
|
|
2204
3004
|
return c.json({ accounts: [] });
|
|
@@ -2210,8 +3010,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2210
3010
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
2211
3011
|
}
|
|
2212
3012
|
});
|
|
2213
|
-
// List GitHub repos for the authenticated user — requires client-provided token
|
|
3013
|
+
// List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
|
|
2214
3014
|
app.get("/api/github/repos", (c) => {
|
|
3015
|
+
if (!config.devMode)
|
|
3016
|
+
return c.json({ error: "Not available" }, 403);
|
|
2215
3017
|
const token = c.req.header("X-GH-Token");
|
|
2216
3018
|
if (!token)
|
|
2217
3019
|
return c.json({ repos: [] });
|
|
@@ -2224,6 +3026,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2224
3026
|
}
|
|
2225
3027
|
});
|
|
2226
3028
|
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
3029
|
+
if (!config.devMode)
|
|
3030
|
+
return c.json({ error: "Not available" }, 403);
|
|
2227
3031
|
const owner = c.req.param("owner");
|
|
2228
3032
|
const repo = c.req.param("repo");
|
|
2229
3033
|
const token = c.req.header("X-GH-Token");
|
|
@@ -2261,18 +3065,14 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2261
3065
|
}
|
|
2262
3066
|
});
|
|
2263
3067
|
}
|
|
2264
|
-
// Resume a project from a GitHub repo
|
|
3068
|
+
// Resume a project from a GitHub repo (dev mode only)
|
|
2265
3069
|
app.post("/api/sessions/resume", async (c) => {
|
|
3070
|
+
if (!config.devMode) {
|
|
3071
|
+
return c.json({ error: "Resume from repo not available" }, 403);
|
|
3072
|
+
}
|
|
2266
3073
|
const body = await validateBody(c, resumeSessionSchema);
|
|
2267
3074
|
if (isResponse(body))
|
|
2268
3075
|
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
|
-
}
|
|
2275
|
-
}
|
|
2276
3076
|
const sessionId = crypto.randomUUID();
|
|
2277
3077
|
const repoName = body.repoUrl
|
|
2278
3078
|
.split("/")
|
|
@@ -2521,16 +3321,38 @@ export async function startWebServer(opts) {
|
|
|
2521
3321
|
if (devMode) {
|
|
2522
3322
|
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
2523
3323
|
}
|
|
3324
|
+
// Hydrate session registry from durable stream (survives restarts)
|
|
3325
|
+
const registry = await Registry.create(opts.streamConfig);
|
|
2524
3326
|
const config = {
|
|
2525
3327
|
port: opts.port ?? 4400,
|
|
2526
3328
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
2527
|
-
sessions:
|
|
3329
|
+
sessions: ActiveSessions.fromRegistry(registry),
|
|
2528
3330
|
rooms: opts.rooms,
|
|
2529
3331
|
sandbox: opts.sandbox,
|
|
2530
3332
|
streamConfig: opts.streamConfig,
|
|
2531
3333
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
2532
3334
|
devMode,
|
|
2533
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
|
+
}
|
|
2534
3356
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2535
3357
|
const app = createApp(config);
|
|
2536
3358
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|