@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.
Files changed (48) hide show
  1. package/dist/active-sessions.d.ts +13 -4
  2. package/dist/active-sessions.d.ts.map +1 -1
  3. package/dist/active-sessions.js +39 -5
  4. package/dist/active-sessions.js.map +1 -1
  5. package/dist/api-schemas.d.ts +29 -10
  6. package/dist/api-schemas.d.ts.map +1 -1
  7. package/dist/api-schemas.js +8 -0
  8. package/dist/api-schemas.js.map +1 -1
  9. package/dist/bridge/claude-md-generator.d.ts +5 -2
  10. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  11. package/dist/bridge/claude-md-generator.js +33 -1
  12. package/dist/bridge/claude-md-generator.js.map +1 -1
  13. package/dist/bridge/role-skills.d.ts.map +1 -1
  14. package/dist/bridge/role-skills.js +8 -0
  15. package/dist/bridge/role-skills.js.map +1 -1
  16. package/dist/client/assets/index-BXdgNRgB.js +235 -0
  17. package/dist/client/assets/index-IvCtVUfs.css +1 -0
  18. package/dist/client/index.html +2 -2
  19. package/dist/github-app.d.ts +14 -0
  20. package/dist/github-app.d.ts.map +1 -0
  21. package/dist/github-app.js +62 -0
  22. package/dist/github-app.js.map +1 -0
  23. package/dist/github-app.test.d.ts +2 -0
  24. package/dist/github-app.test.d.ts.map +1 -0
  25. package/dist/github-app.test.js +62 -0
  26. package/dist/github-app.test.js.map +1 -0
  27. package/dist/room-router.d.ts.map +1 -1
  28. package/dist/room-router.js +20 -5
  29. package/dist/room-router.js.map +1 -1
  30. package/dist/sandbox/docker.d.ts +10 -0
  31. package/dist/sandbox/docker.d.ts.map +1 -1
  32. package/dist/sandbox/docker.js +115 -1
  33. package/dist/sandbox/docker.js.map +1 -1
  34. package/dist/sandbox/sprites.d.ts +1 -0
  35. package/dist/sandbox/sprites.d.ts.map +1 -1
  36. package/dist/sandbox/sprites.js +51 -0
  37. package/dist/sandbox/sprites.js.map +1 -1
  38. package/dist/sandbox/types.d.ts +5 -0
  39. package/dist/sandbox/types.d.ts.map +1 -1
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +962 -140
  42. package/dist/server.js.map +1 -1
  43. package/dist/sessions.d.ts +2 -0
  44. package/dist/sessions.d.ts.map +1 -1
  45. package/dist/sessions.js.map +1 -1
  46. package/package.json +1 -1
  47. package/dist/client/assets/index-BfvQSMwH.css +0 -1
  48. 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({ devMode: config.devMode });
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 = body.name ||
818
- body.description
819
- .slice(0, 40)
820
- .replace(/[^a-z0-9]+/gi, "-")
821
- .replace(/^-|-$/g, "")
822
- .toLowerCase();
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
- // Only check if the client provided a token — never fall back to server-side GH_TOKEN
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(body.ghToken);
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: body.apiKey,
947
- oauthToken: body.oauthToken,
948
- ghToken: body.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
- ...(repoConfig
1024
- ? {
1025
- git: {
1026
- mode: "create",
1027
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1028
- visibility: repoConfig.visibility,
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
- // Intercept operational commands (start/stop/restart the app/server)
1197
- const normalised = body.request
1198
- .toLowerCase()
1199
- .replace(/[^a-z ]/g, "")
1200
- .trim();
1201
- const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
1202
- const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
1203
- const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
1204
- const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
1205
- if (isStartCmd || isStopCmd || isRestartCmd) {
1206
- const bridge = getOrCreateBridge(config, sessionId);
1207
- await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1208
- try {
1209
- const handle = config.sandbox.get(sessionId);
1210
- if (isStopCmd) {
1211
- if (handle && config.sandbox.isAlive(handle))
1212
- await config.sandbox.stopApp(handle);
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
- else {
1216
- if (!handle || !config.sandbox.isAlive(handle)) {
1217
- return c.json({ error: "Container is not running" }, 400);
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
- catch (err) {
1238
- const msg = err instanceof Error ? err.message : "Operation failed";
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
- return c.json({ ok: true });
1258
- }
1259
- const handle = config.sandbox.get(sessionId);
1260
- if (!handle || !config.sandbox.isAlive(handle)) {
1261
- return c.json({ error: "Container is not running" }, 400);
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/rooms/join/:id/:code", (c) => {
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 (!router)
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: router.state,
1682
- roundCount: router.roundCount,
1683
- participants: router.participants.map((p) => ({
1684
- sessionId: p.sessionId,
1685
- name: p.name,
1686
- role: p.role,
1687
- running: p.bridge.isRunning(),
1688
- })),
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: body.apiKey,
1749
- oauthToken: body.oauthToken,
1750
- ghToken: body.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
- const router = roomRouters.get(roomId);
1971
- if (!router)
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: new ActiveSessions(),
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";