@electric-agent/studio 1.14.0 → 1.14.2

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 (78) 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 +244 -0
  6. package/dist/api-schemas.d.ts.map +1 -0
  7. package/dist/api-schemas.js +103 -0
  8. package/dist/api-schemas.js.map +1 -0
  9. package/dist/bridge/claude-code-base.d.ts +2 -0
  10. package/dist/bridge/claude-code-base.d.ts.map +1 -1
  11. package/dist/bridge/claude-code-base.js +2 -0
  12. package/dist/bridge/claude-code-base.js.map +1 -1
  13. package/dist/bridge/claude-md-generator.d.ts +12 -2
  14. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  15. package/dist/bridge/claude-md-generator.js +72 -94
  16. package/dist/bridge/claude-md-generator.js.map +1 -1
  17. package/dist/bridge/message-parser.d.ts +3 -3
  18. package/dist/bridge/message-parser.d.ts.map +1 -1
  19. package/dist/bridge/message-parser.js +3 -3
  20. package/dist/bridge/message-parser.js.map +1 -1
  21. package/dist/bridge/role-skills.d.ts.map +1 -1
  22. package/dist/bridge/role-skills.js +8 -0
  23. package/dist/bridge/role-skills.js.map +1 -1
  24. package/dist/client/assets/index-BSGS-yya.css +1 -0
  25. package/dist/client/assets/index-qUqEqKXn.js +235 -0
  26. package/dist/client/index.html +2 -2
  27. package/dist/github-app.d.ts +14 -0
  28. package/dist/github-app.d.ts.map +1 -0
  29. package/dist/github-app.js +62 -0
  30. package/dist/github-app.js.map +1 -0
  31. package/dist/room-router.d.ts +13 -0
  32. package/dist/room-router.d.ts.map +1 -1
  33. package/dist/room-router.js +48 -5
  34. package/dist/room-router.js.map +1 -1
  35. package/dist/sandbox/docker.d.ts +10 -0
  36. package/dist/sandbox/docker.d.ts.map +1 -1
  37. package/dist/sandbox/docker.js +115 -1
  38. package/dist/sandbox/docker.js.map +1 -1
  39. package/dist/sandbox/sprites.d.ts +1 -0
  40. package/dist/sandbox/sprites.d.ts.map +1 -1
  41. package/dist/sandbox/sprites.js +51 -0
  42. package/dist/sandbox/sprites.js.map +1 -1
  43. package/dist/sandbox/types.d.ts +5 -0
  44. package/dist/sandbox/types.d.ts.map +1 -1
  45. package/dist/server.d.ts +10 -0
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +1088 -186
  48. package/dist/server.js.map +1 -1
  49. package/dist/session-auth.d.ts +3 -0
  50. package/dist/session-auth.d.ts.map +1 -1
  51. package/dist/session-auth.js +10 -0
  52. package/dist/session-auth.js.map +1 -1
  53. package/dist/sessions.d.ts +2 -0
  54. package/dist/sessions.d.ts.map +1 -1
  55. package/dist/sessions.js.map +1 -1
  56. package/dist/validate.d.ts +10 -0
  57. package/dist/validate.d.ts.map +1 -0
  58. package/dist/validate.js +24 -0
  59. package/dist/validate.js.map +1 -0
  60. package/package.json +7 -2
  61. package/dist/client/assets/index-BfvQSMwH.css +0 -1
  62. package/dist/client/assets/index-BtX82X61.js +0 -234
  63. package/dist/sandbox/daytona-push.d.ts +0 -3
  64. package/dist/sandbox/daytona-push.d.ts.map +0 -1
  65. package/dist/sandbox/daytona-push.js +0 -56
  66. package/dist/sandbox/daytona-push.js.map +0 -1
  67. package/dist/sandbox/daytona-registry.d.ts +0 -41
  68. package/dist/sandbox/daytona-registry.d.ts.map +0 -1
  69. package/dist/sandbox/daytona-registry.js +0 -127
  70. package/dist/sandbox/daytona-registry.js.map +0 -1
  71. package/dist/sandbox/daytona.d.ts +0 -41
  72. package/dist/sandbox/daytona.d.ts.map +0 -1
  73. package/dist/sandbox/daytona.js +0 -282
  74. package/dist/sandbox/daytona.js.map +0 -1
  75. package/dist/shared-sessions.d.ts +0 -16
  76. package/dist/shared-sessions.d.ts.map +0 -1
  77. package/dist/shared-sessions.js +0 -52
  78. package/dist/shared-sessions.js.map +0 -1
package/dist/server.js CHANGED
@@ -7,8 +7,9 @@ import { ts } from "@electric-agent/protocol";
7
7
  import { serve } from "@hono/node-server";
8
8
  import { serveStatic } from "@hono/node-server/serve-static";
9
9
  import { Hono } from "hono";
10
- import { cors } from "hono/cors";
11
10
  import { ActiveSessions } from "./active-sessions.js";
11
+ import { addAgentSchema, addSessionToRoomSchema, createAppRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
12
+ import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
12
13
  import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
13
14
  import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
14
15
  import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
@@ -16,11 +17,27 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
16
17
  import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
17
18
  import { createGate, rejectAllGates, resolveGate } from "./gate.js";
18
19
  import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
20
+ import { createOrgRepo, getInstallationToken } from "./github-app.js";
19
21
  import { generateInviteCode } from "./invite-code.js";
20
22
  import { resolveProjectDir } from "./project-utils.js";
23
+ import { Registry } from "./registry.js";
21
24
  import { RoomRouter } from "./room-router.js";
22
- import { deriveGlobalHookSecret, deriveHookToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateSessionToken, } from "./session-auth.js";
25
+ import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
23
26
  import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
27
+ import { isResponse, validateBody } from "./validate.js";
28
+ /** Read OAuth token from macOS Keychain (Claude Code credentials). */
29
+ function readKeychainOAuthToken() {
30
+ if (process.platform !== "darwin")
31
+ return null;
32
+ try {
33
+ const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
34
+ const parsed = JSON.parse(raw);
35
+ return parsed.claudeAiOauth?.accessToken ?? null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
24
41
  /** Active session bridges — one per running session */
25
42
  const bridges = new Map();
26
43
  /** Active room routers — one per room with agent-to-agent messaging */
@@ -67,9 +84,62 @@ function resolveStudioUrl(port) {
67
84
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
68
85
  return `http://localhost:${port}`;
69
86
  }
87
+ // ---------------------------------------------------------------------------
88
+ // Rate limiting — in-memory sliding window per IP
89
+ // ---------------------------------------------------------------------------
90
+ const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
91
+ const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
92
+ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
93
+ const sessionCreationsByIp = new Map();
94
+ // GitHub App config (prod mode — repo creation in electric-apps org)
95
+ const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
96
+ const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
97
+ const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
98
+ const GITHUB_ORG = "electric-apps";
99
+ // Rate limiting for GitHub token endpoint
100
+ const githubTokenRequestsBySession = new Map();
101
+ const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
102
+ function extractClientIp(c) {
103
+ return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
104
+ c.req.header("cf-connecting-ip") ||
105
+ "unknown");
106
+ }
107
+ function checkSessionRateLimit(ip) {
108
+ const now = Date.now();
109
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
110
+ let timestamps = sessionCreationsByIp.get(ip) ?? [];
111
+ // Prune stale entries
112
+ timestamps = timestamps.filter((t) => t > cutoff);
113
+ if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
114
+ sessionCreationsByIp.set(ip, timestamps);
115
+ return false;
116
+ }
117
+ timestamps.push(now);
118
+ sessionCreationsByIp.set(ip, timestamps);
119
+ return true;
120
+ }
121
+ function checkGlobalSessionCap(sessions) {
122
+ return sessions.size() >= MAX_TOTAL_SESSIONS;
123
+ }
124
+ function checkGithubTokenRateLimit(sessionId) {
125
+ const now = Date.now();
126
+ const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
127
+ const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
128
+ if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
129
+ return false;
130
+ }
131
+ recent.push(now);
132
+ githubTokenRequestsBySession.set(sessionId, recent);
133
+ return true;
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // Per-session cost budget
137
+ // ---------------------------------------------------------------------------
138
+ const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
70
139
  /**
71
140
  * Accumulate cost and turn metrics from a session_end event into the session's totals.
72
141
  * Called each time a Claude Code run finishes (initial + iterate runs).
142
+ * In production mode, enforces a per-session cost budget.
73
143
  */
74
144
  function accumulateSessionCost(config, sessionId, event) {
75
145
  if (event.type !== "session_end")
@@ -90,12 +160,39 @@ function accumulateSessionCost(config, sessionId, event) {
90
160
  }
91
161
  config.sessions.update(sessionId, updates);
92
162
  console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
163
+ // Enforce budget in production mode
164
+ if (!config.devMode &&
165
+ updates.totalCostUsd != null &&
166
+ updates.totalCostUsd > MAX_SESSION_COST_USD) {
167
+ console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
168
+ const bridge = bridges.get(sessionId);
169
+ if (bridge) {
170
+ bridge
171
+ .emit({
172
+ type: "budget_exceeded",
173
+ budget_usd: MAX_SESSION_COST_USD,
174
+ spent_usd: updates.totalCostUsd,
175
+ ts: ts(),
176
+ })
177
+ .catch(() => { });
178
+ }
179
+ config.sessions.update(sessionId, { status: "error" });
180
+ closeBridge(sessionId);
181
+ }
93
182
  }
94
183
  /**
95
184
  * Create a Claude Code bridge for a session.
96
185
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
186
+ * In production mode, enforces tool restrictions and hardcodes the model.
97
187
  */
98
188
  function createClaudeCodeBridge(config, sessionId, claudeConfig) {
189
+ // Production mode: restrict tools and hardcode model
190
+ if (!config.devMode) {
191
+ if (!claudeConfig.allowedTools) {
192
+ claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
193
+ }
194
+ claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
195
+ }
99
196
  const conn = sessionStream(config, sessionId);
100
197
  let bridge;
101
198
  if (config.sandbox.runtime === "sprites") {
@@ -127,31 +224,6 @@ function closeBridge(sessionId) {
127
224
  bridges.delete(sessionId);
128
225
  }
129
226
  }
130
- /**
131
- * Detect git operations from natural language prompts.
132
- * Returns structured gitOp fields if matched, null otherwise.
133
- */
134
- function detectGitOp(request) {
135
- const lower = request.toLowerCase().trim();
136
- // Commit: "commit", "commit the code", "commit changes", "commit with message ..."
137
- if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
138
- // Extract commit message after "commit" keyword, or after "message:" / "msg:"
139
- const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
140
- const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
141
- return { gitOp: "commit", gitMessage: message || undefined };
142
- }
143
- // Push: "push", "push to github", "push to remote", "git push"
144
- if (/^(git\s+)?push\b/.test(lower)) {
145
- return { gitOp: "push" };
146
- }
147
- // Create PR: "create pr", "open pr", "make pr", "create pull request"
148
- if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
149
- // Try to extract title after the PR keyword
150
- const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
151
- return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
152
- }
153
- return null;
154
- }
155
227
  /**
156
228
  * Map a Claude Code hook event JSON payload to an EngineEvent.
157
229
  *
@@ -265,8 +337,6 @@ function mapHookToEngineEvent(body) {
265
337
  }
266
338
  export function createApp(config) {
267
339
  const app = new Hono();
268
- // CORS for local development
269
- app.use("*", cors({ origin: "*" }));
270
340
  // --- API Routes ---
271
341
  // Health check
272
342
  app.get("/api/health", (c) => {
@@ -284,6 +354,13 @@ export function createApp(config) {
284
354
  checks.sandbox = config.sandbox.runtime;
285
355
  return c.json({ healthy, checks }, healthy ? 200 : 503);
286
356
  });
357
+ // Public config — exposes non-sensitive flags to the client
358
+ app.get("/api/config", (c) => {
359
+ return c.json({
360
+ devMode: config.devMode,
361
+ maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
362
+ });
363
+ });
287
364
  // Provision Electric Cloud resources via the Claim API
288
365
  app.post("/api/provision-electric", async (c) => {
289
366
  try {
@@ -481,6 +558,7 @@ export function createApp(config) {
481
558
  if (hookEvent.type === "ask_user_question") {
482
559
  const toolUseId = hookEvent.tool_use_id;
483
560
  console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
561
+ config.sessions.update(sessionId, { needsInput: true });
484
562
  try {
485
563
  const gateTimeout = 5 * 60 * 1000; // 5 minutes
486
564
  const result = await Promise.race([
@@ -488,6 +566,7 @@ export function createApp(config) {
488
566
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
489
567
  ]);
490
568
  console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
569
+ config.sessions.update(sessionId, { needsInput: false });
491
570
  return c.json({
492
571
  hookSpecificOutput: {
493
572
  hookEventName: "PreToolUse",
@@ -501,6 +580,7 @@ export function createApp(config) {
501
580
  }
502
581
  catch (err) {
503
582
  console.error(`[hook-event] ask_user_question gate error:`, err);
583
+ config.sessions.update(sessionId, { needsInput: false });
504
584
  return c.json({ ok: true }); // Don't block Claude Code on timeout
505
585
  }
506
586
  }
@@ -622,6 +702,7 @@ export function createApp(config) {
622
702
  if (hookEvent.type === "ask_user_question") {
623
703
  const toolUseId = hookEvent.tool_use_id;
624
704
  console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
705
+ config.sessions.update(sessionId, { needsInput: true });
625
706
  try {
626
707
  const gateTimeout = 5 * 60 * 1000;
627
708
  const result = await Promise.race([
@@ -629,6 +710,7 @@ export function createApp(config) {
629
710
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
630
711
  ]);
631
712
  console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
713
+ config.sessions.update(sessionId, { needsInput: false });
632
714
  return c.json({
633
715
  sessionId,
634
716
  hookSpecificOutput: {
@@ -643,6 +725,7 @@ export function createApp(config) {
643
725
  }
644
726
  catch (err) {
645
727
  console.error(`[hook] ask_user_question gate error:`, err);
728
+ config.sessions.update(sessionId, { needsInput: false });
646
729
  return c.json({ ok: true, sessionId });
647
730
  }
648
731
  }
@@ -738,17 +821,42 @@ echo "Start claude in this project — the session will appear in the studio UI.
738
821
  });
739
822
  // Start new project
740
823
  app.post("/api/sessions", async (c) => {
741
- const body = (await c.req.json());
742
- if (!body.description) {
743
- return c.json({ error: "description is required" }, 400);
824
+ const body = await validateBody(c, createSessionSchema);
825
+ if (isResponse(body))
826
+ return body;
827
+ // Resolve Claude credentials — try OAuth from keychain first, then API key
828
+ const apiKey = config.devMode
829
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
830
+ : process.env.ANTHROPIC_API_KEY;
831
+ const oauthToken = config.devMode
832
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
833
+ : (readKeychainOAuthToken() ?? undefined);
834
+ const ghToken = config.devMode ? body.ghToken : undefined;
835
+ const authType = oauthToken ? "oauth-keychain" : apiKey ? "api-key" : "none";
836
+ console.log(`[auth] Using ${authType} for Claude credentials`);
837
+ // Block freeform sessions in production mode
838
+ if (body.freeform && !config.devMode) {
839
+ return c.json({ error: "Freeform sessions are not available" }, 403);
840
+ }
841
+ // Rate-limit session creation in production mode
842
+ if (!config.devMode) {
843
+ const ip = extractClientIp(c);
844
+ if (!checkSessionRateLimit(ip)) {
845
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
846
+ }
847
+ if (checkGlobalSessionCap(config.sessions)) {
848
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
849
+ }
744
850
  }
745
851
  const sessionId = crypto.randomUUID();
746
- const inferredName = body.name ||
747
- body.description
748
- .slice(0, 40)
749
- .replace(/[^a-z0-9]+/gi, "-")
750
- .replace(/^-|-$/g, "")
751
- .toLowerCase();
852
+ const inferredName = config.devMode
853
+ ? body.name ||
854
+ body.description
855
+ .slice(0, 40)
856
+ .replace(/[^a-z0-9]+/gi, "-")
857
+ .replace(/^-|-$/g, "")
858
+ .toLowerCase()
859
+ : `electric-${sessionId.slice(0, 8)}`;
752
860
  const baseDir = body.baseDir || process.cwd();
753
861
  const { projectName } = resolveProjectDir(baseDir, inferredName);
754
862
  console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
@@ -785,11 +893,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
785
893
  // Freeform sessions skip the infra config gate — no Electric/DB setup needed
786
894
  let ghAccounts = [];
787
895
  if (!body.freeform) {
788
- // Gather GitHub accounts for the merged setup gate
789
- // Only check if the client provided a token — never fall back to server-side GH_TOKEN
790
- if (body.ghToken && isGhAuthenticated(body.ghToken)) {
896
+ // Gather GitHub accounts for the merged setup gate (dev mode only)
897
+ if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
791
898
  try {
792
- ghAccounts = ghListAccounts(body.ghToken);
899
+ ghAccounts = ghListAccounts(ghToken);
793
900
  }
794
901
  catch {
795
902
  // gh not available — no repo setup
@@ -872,9 +979,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
872
979
  const handle = await config.sandbox.create(sessionId, {
873
980
  projectName,
874
981
  infra,
875
- apiKey: body.apiKey,
876
- oauthToken: body.oauthToken,
877
- ghToken: body.ghToken,
982
+ apiKey,
983
+ oauthToken,
984
+ ghToken,
985
+ ...((!config.devMode || GITHUB_APP_ID) && {
986
+ prodMode: {
987
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
988
+ studioUrl: resolveStudioUrl(config.port),
989
+ },
990
+ }),
878
991
  });
879
992
  console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
880
993
  await bridge.emit({
@@ -940,6 +1053,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
940
1053
  ts: ts(),
941
1054
  });
942
1055
  }
1056
+ // Create GitHub repo via GitHub App when credentials are available
1057
+ let prodGitConfig;
1058
+ if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
1059
+ try {
1060
+ // Repo name matches the project name (already has random slug)
1061
+ const repoSlug = projectName;
1062
+ await bridge.emit({
1063
+ type: "log",
1064
+ level: "build",
1065
+ message: "Creating GitHub repository...",
1066
+ ts: ts(),
1067
+ });
1068
+ const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1069
+ const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
1070
+ if (repo) {
1071
+ const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
1072
+ // Initialize git and set remote in the sandbox
1073
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
1074
+ prodGitConfig = {
1075
+ mode: "pre-created",
1076
+ repoName: actualRepoName,
1077
+ repoUrl: repo.htmlUrl,
1078
+ };
1079
+ config.sessions.update(sessionId, {
1080
+ git: {
1081
+ branch: "main",
1082
+ remoteUrl: repo.htmlUrl,
1083
+ repoName: actualRepoName,
1084
+ lastCommitHash: null,
1085
+ lastCommitMessage: null,
1086
+ lastCheckpointAt: null,
1087
+ },
1088
+ });
1089
+ await bridge.emit({
1090
+ type: "log",
1091
+ level: "done",
1092
+ message: `GitHub repo created: ${repo.htmlUrl}`,
1093
+ ts: ts(),
1094
+ });
1095
+ }
1096
+ else {
1097
+ console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
1098
+ }
1099
+ }
1100
+ catch (err) {
1101
+ console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
1102
+ }
1103
+ }
943
1104
  // Write CLAUDE.md to the sandbox workspace.
944
1105
  // Our generator includes hardcoded playbook paths and reading order
945
1106
  // so we don't depend on @tanstack/intent generating a skill block.
@@ -948,15 +1109,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
948
1109
  projectName,
949
1110
  projectDir: handle.projectDir,
950
1111
  runtime: config.sandbox.runtime,
951
- ...(repoConfig
952
- ? {
953
- git: {
954
- mode: "create",
955
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
956
- visibility: repoConfig.visibility,
957
- },
958
- }
959
- : {}),
1112
+ production: !config.devMode,
1113
+ ...(prodGitConfig
1114
+ ? { git: prodGitConfig }
1115
+ : repoConfig
1116
+ ? {
1117
+ git: {
1118
+ mode: "create",
1119
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1120
+ visibility: repoConfig.visibility,
1121
+ },
1122
+ }
1123
+ : {}),
960
1124
  });
961
1125
  try {
962
1126
  await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
@@ -1118,79 +1282,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
1118
1282
  const session = config.sessions.get(sessionId);
1119
1283
  if (!session)
1120
1284
  return c.json({ error: "Session not found" }, 404);
1121
- const body = (await c.req.json());
1122
- if (!body.request) {
1123
- return c.json({ error: "request is required" }, 400);
1124
- }
1125
- // Intercept operational commands (start/stop/restart the app/server)
1126
- const normalised = body.request
1127
- .toLowerCase()
1128
- .replace(/[^a-z ]/g, "")
1129
- .trim();
1130
- const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
1131
- const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
1132
- const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
1133
- const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
1134
- if (isStartCmd || isStopCmd || isRestartCmd) {
1135
- const bridge = getOrCreateBridge(config, sessionId);
1136
- await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1137
- try {
1138
- const handle = config.sandbox.get(sessionId);
1139
- if (isStopCmd) {
1140
- if (handle && config.sandbox.isAlive(handle))
1141
- await config.sandbox.stopApp(handle);
1142
- await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
1285
+ const body = await validateBody(c, iterateSessionSchema);
1286
+ if (isResponse(body))
1287
+ return body;
1288
+ const handle = config.sandbox.get(sessionId);
1289
+ if (!handle || !config.sandbox.isAlive(handle)) {
1290
+ return c.json({ error: "Container is not running" }, 400);
1291
+ }
1292
+ // Ensure we have a CC bridge (not just a stream writer).
1293
+ // After server restart, bridges are lost — getOrCreateBridge would create
1294
+ // a HostedStreamBridge that can only write to the stream but can't spawn
1295
+ // Claude Code processes. We need a ClaudeCodeDockerBridge to restart the agent.
1296
+ let bridge = bridges.get(sessionId);
1297
+ if (!bridge) {
1298
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1299
+ const claudeConfig = config.sandbox.runtime === "sprites"
1300
+ ? {
1301
+ prompt: body.request,
1302
+ cwd: session.sandboxProjectDir || handle.projectDir,
1303
+ studioUrl: resolveStudioUrl(config.port),
1304
+ hookToken,
1143
1305
  }
1144
- else {
1145
- if (!handle || !config.sandbox.isAlive(handle)) {
1146
- return c.json({ error: "Container is not running" }, 400);
1306
+ : {
1307
+ prompt: body.request,
1308
+ cwd: session.sandboxProjectDir || handle.projectDir,
1309
+ studioPort: config.port,
1310
+ hookToken,
1311
+ };
1312
+ bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1313
+ // Re-register basic event tracking callbacks
1314
+ bridge.onAgentEvent((event) => {
1315
+ if (event.type === "session_start") {
1316
+ const ccSessionId = event.session_id;
1317
+ if (ccSessionId) {
1318
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1147
1319
  }
1148
- if (isRestartCmd)
1149
- await config.sandbox.stopApp(handle);
1150
- await config.sandbox.startApp(handle);
1151
- await bridge.emit({
1152
- type: "log",
1153
- level: "done",
1154
- message: "App started",
1155
- ts: ts(),
1156
- });
1157
- await bridge.emit({
1158
- type: "app_status",
1159
- status: "running",
1160
- port: session.appPort,
1161
- previewUrl: session.previewUrl,
1162
- ts: ts(),
1163
- });
1164
1320
  }
1165
- }
1166
- catch (err) {
1167
- const msg = err instanceof Error ? err.message : "Operation failed";
1168
- await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
1169
- }
1170
- return c.json({ ok: true });
1171
- }
1172
- // Intercept git commands (commit, push, create PR)
1173
- const gitOp = detectGitOp(body.request);
1174
- if (gitOp) {
1175
- const bridge = getOrCreateBridge(config, sessionId);
1176
- await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1177
- const handle = config.sandbox.get(sessionId);
1178
- if (!handle || !config.sandbox.isAlive(handle)) {
1179
- return c.json({ error: "Container is not running" }, 400);
1180
- }
1181
- // Send git requests as user messages via Claude Code bridge
1182
- await bridge.sendCommand({
1183
- command: "iterate",
1184
- request: body.request,
1321
+ if (event.type === "session_end") {
1322
+ accumulateSessionCost(config, sessionId, event);
1323
+ }
1185
1324
  });
1186
- return c.json({ ok: true });
1187
- }
1188
- const handle = config.sandbox.get(sessionId);
1189
- if (!handle || !config.sandbox.isAlive(handle)) {
1190
- return c.json({ error: "Container is not running" }, 400);
1325
+ bridge.onComplete(async (success) => {
1326
+ config.sessions.update(sessionId, {
1327
+ status: success ? "complete" : "error",
1328
+ });
1329
+ });
1330
+ console.log(`[iterate] Recreated CC bridge for session ${sessionId} after restart`);
1191
1331
  }
1192
1332
  // Write user prompt to the stream
1193
- const bridge = getOrCreateBridge(config, sessionId);
1194
1333
  await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1195
1334
  config.sessions.update(sessionId, { status: "running" });
1196
1335
  await bridge.sendCommand({
@@ -1201,6 +1340,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
1201
1340
  });
1202
1341
  return c.json({ ok: true });
1203
1342
  });
1343
+ // Generate a GitHub installation token for the sandbox (prod mode only)
1344
+ app.post("/api/sessions/:id/github-token", async (c) => {
1345
+ const sessionId = c.req.param("id");
1346
+ if (config.devMode) {
1347
+ return c.json({ error: "Not available in dev mode" }, 403);
1348
+ }
1349
+ if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
1350
+ return c.json({ error: "GitHub App not configured" }, 500);
1351
+ }
1352
+ if (!checkGithubTokenRateLimit(sessionId)) {
1353
+ return c.json({ error: "Too many token requests" }, 429);
1354
+ }
1355
+ try {
1356
+ const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1357
+ return c.json(result);
1358
+ }
1359
+ catch (err) {
1360
+ const message = err instanceof Error ? err.message : "Unknown error";
1361
+ console.error(`GitHub token error for session ${sessionId}:`, message);
1362
+ return c.json({ error: "Failed to generate GitHub token" }, 500);
1363
+ }
1364
+ });
1204
1365
  // Respond to a gate (approval, clarification, continue, revision)
1205
1366
  app.post("/api/sessions/:id/respond", async (c) => {
1206
1367
  const sessionId = c.req.param("id");
@@ -1491,7 +1652,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1491
1652
  });
1492
1653
  // Create a standalone sandbox (not tied to session creation flow)
1493
1654
  app.post("/api/sandboxes", async (c) => {
1494
- const body = (await c.req.json());
1655
+ const body = await validateBody(c, createSandboxSchema);
1656
+ if (isResponse(body))
1657
+ return body;
1495
1658
  const sessionId = body.sessionId ?? crypto.randomUUID();
1496
1659
  try {
1497
1660
  const handle = await config.sandbox.create(sessionId, {
@@ -1529,30 +1692,695 @@ echo "Start claude in this project — the session will appear in the studio UI.
1529
1692
  return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
1530
1693
  }
1531
1694
  // Protect room-scoped routes via X-Room-Token header
1695
+ // "create-app" is a creation endpoint — no room token exists yet
1696
+ const roomAuthExemptIds = new Set(["create-app"]);
1532
1697
  app.use("/api/rooms/:id/*", async (c, next) => {
1533
1698
  const id = c.req.param("id");
1699
+ if (roomAuthExemptIds.has(id))
1700
+ return next();
1534
1701
  const token = extractRoomToken(c);
1535
- if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1702
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1536
1703
  return c.json({ error: "Invalid or missing room token" }, 401);
1537
1704
  }
1538
1705
  return next();
1539
1706
  });
1540
1707
  app.use("/api/rooms/:id", async (c, next) => {
1708
+ const id = c.req.param("id");
1709
+ if (roomAuthExemptIds.has(id))
1710
+ return next();
1541
1711
  if (c.req.method !== "GET" && c.req.method !== "DELETE")
1542
1712
  return next();
1543
- const id = c.req.param("id");
1544
1713
  const token = extractRoomToken(c);
1545
- if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1714
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1546
1715
  return c.json({ error: "Invalid or missing room token" }, 401);
1547
1716
  }
1548
1717
  return next();
1549
1718
  });
1719
+ // Create a room with 3 agents for multi-agent app creation
1720
+ app.post("/api/rooms/create-app", async (c) => {
1721
+ const body = await validateBody(c, createAppRoomSchema);
1722
+ if (isResponse(body))
1723
+ return body;
1724
+ // Resolve Claude credentials — try OAuth from keychain first, then API key
1725
+ const apiKey = config.devMode
1726
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
1727
+ : process.env.ANTHROPIC_API_KEY;
1728
+ const oauthToken = config.devMode
1729
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
1730
+ : (readKeychainOAuthToken() ?? undefined);
1731
+ const ghToken = config.devMode ? body.ghToken : undefined;
1732
+ const authType = oauthToken ? "oauth-keychain" : apiKey ? "api-key" : "none";
1733
+ console.log(`[auth] Using ${authType} for Claude credentials`);
1734
+ // Rate-limit session creation in production mode
1735
+ if (!config.devMode) {
1736
+ const ip = extractClientIp(c);
1737
+ if (!checkSessionRateLimit(ip)) {
1738
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
1739
+ }
1740
+ if (checkGlobalSessionCap(config.sessions)) {
1741
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
1742
+ }
1743
+ }
1744
+ const roomId = crypto.randomUUID();
1745
+ const roomName = body.name || `app-${roomId.slice(0, 8)}`;
1746
+ // Create the room's durable stream
1747
+ const roomConn = roomStream(config, roomId);
1748
+ try {
1749
+ await DurableStream.create({
1750
+ url: roomConn.url,
1751
+ headers: roomConn.headers,
1752
+ contentType: "application/json",
1753
+ });
1754
+ }
1755
+ catch (err) {
1756
+ console.error(`[room:create-app] Failed to create room stream:`, err);
1757
+ return c.json({ error: "Failed to create room stream" }, 500);
1758
+ }
1759
+ // Create and start the router
1760
+ const router = new RoomRouter(roomId, roomName, config.streamConfig);
1761
+ await router.start();
1762
+ roomRouters.set(roomId, router);
1763
+ // Save to room registry
1764
+ const code = generateInviteCode();
1765
+ await config.rooms.addRoom({
1766
+ id: roomId,
1767
+ code,
1768
+ name: roomName,
1769
+ createdAt: new Date().toISOString(),
1770
+ revoked: false,
1771
+ });
1772
+ // Define the 3 agents with randomized display names
1773
+ const agentSuffixes = [
1774
+ "fox",
1775
+ "owl",
1776
+ "lynx",
1777
+ "wolf",
1778
+ "bear",
1779
+ "hawk",
1780
+ "pine",
1781
+ "oak",
1782
+ "elm",
1783
+ "ivy",
1784
+ "ray",
1785
+ "arc",
1786
+ "reef",
1787
+ "dusk",
1788
+ "ash",
1789
+ "sage",
1790
+ ];
1791
+ const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
1792
+ const usedSuffixes = new Set();
1793
+ const uniquePick = () => {
1794
+ let s = pick();
1795
+ while (usedSuffixes.has(s))
1796
+ s = pick();
1797
+ usedSuffixes.add(s);
1798
+ return s;
1799
+ };
1800
+ const agentDefs = [
1801
+ { name: `coder-${uniquePick()}`, role: "coder" },
1802
+ { name: `reviewer-${uniquePick()}`, role: "reviewer" },
1803
+ ];
1804
+ // Create session IDs and streams upfront for all 3 agents
1805
+ const sessions = [];
1806
+ for (const agentDef of agentDefs) {
1807
+ const sessionId = crypto.randomUUID();
1808
+ const conn = sessionStream(config, sessionId);
1809
+ try {
1810
+ await DurableStream.create({
1811
+ url: conn.url,
1812
+ headers: conn.headers,
1813
+ contentType: "application/json",
1814
+ });
1815
+ }
1816
+ catch (err) {
1817
+ console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
1818
+ return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
1819
+ }
1820
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1821
+ sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
1822
+ }
1823
+ const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1824
+ console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
1825
+ // Return immediately so the client can show the room + sessions
1826
+ // The async flow handles sandbox creation, skill injection, and agent startup
1827
+ // Sessions are created in agentDefs order: [coder, reviewer]
1828
+ const coderSession = sessions[0];
1829
+ const reviewerSession = sessions[1];
1830
+ const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
1831
+ // Record all sessions
1832
+ for (const s of sessions) {
1833
+ const projectName = s.role === "coder" && config.devMode
1834
+ ? body.name ||
1835
+ body.description
1836
+ .slice(0, 40)
1837
+ .replace(/[^a-z0-9]+/gi, "-")
1838
+ .replace(/^-|-$/g, "")
1839
+ .toLowerCase()
1840
+ : `room-${s.name}-${s.sessionId.slice(0, 8)}`;
1841
+ const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
1842
+ const session = {
1843
+ id: s.sessionId,
1844
+ projectName,
1845
+ sandboxProjectDir,
1846
+ description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
1847
+ createdAt: new Date().toISOString(),
1848
+ lastActiveAt: new Date().toISOString(),
1849
+ status: "running",
1850
+ };
1851
+ config.sessions.add(session);
1852
+ }
1853
+ // Write user prompt to coder's stream
1854
+ await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
1855
+ // Gather GitHub accounts for the infra config gate (dev mode only)
1856
+ let ghAccounts = [];
1857
+ if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
1858
+ try {
1859
+ ghAccounts = ghListAccounts(ghToken);
1860
+ }
1861
+ catch {
1862
+ // gh not available
1863
+ }
1864
+ }
1865
+ // Emit infra config gate on coder's stream
1866
+ const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
1867
+ await coderBridge.emit({
1868
+ type: "infra_config_prompt",
1869
+ projectName: coderProjectName,
1870
+ ghAccounts,
1871
+ runtime: config.sandbox.runtime,
1872
+ ts: ts(),
1873
+ });
1874
+ // Async flow: wait for gate, create sandboxes, start agents
1875
+ const asyncFlow = async () => {
1876
+ // 1. Wait for infra config gate on coder's session
1877
+ await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
1878
+ console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
1879
+ let infra;
1880
+ let repoConfig = null;
1881
+ let claimId;
1882
+ try {
1883
+ const gateValue = await createGate(coderSession.sessionId, "infra_config");
1884
+ console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
1885
+ if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
1886
+ infra = {
1887
+ mode: "cloud",
1888
+ databaseUrl: gateValue.databaseUrl,
1889
+ electricUrl: gateValue.electricUrl,
1890
+ sourceId: gateValue.sourceId,
1891
+ secret: gateValue.secret,
1892
+ };
1893
+ if (gateValue.mode === "claim") {
1894
+ claimId = gateValue.claimId;
1895
+ }
1896
+ }
1897
+ else {
1898
+ infra = { mode: "local" };
1899
+ }
1900
+ // Extract repo config if provided
1901
+ if (gateValue.repoAccount && gateValue.repoName?.trim()) {
1902
+ repoConfig = {
1903
+ account: gateValue.repoAccount,
1904
+ repoName: gateValue.repoName,
1905
+ visibility: gateValue.repoVisibility ?? "private",
1906
+ };
1907
+ config.sessions.update(coderSession.sessionId, {
1908
+ git: {
1909
+ branch: "main",
1910
+ remoteUrl: null,
1911
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1912
+ repoVisibility: repoConfig.visibility,
1913
+ lastCommitHash: null,
1914
+ lastCommitMessage: null,
1915
+ lastCheckpointAt: null,
1916
+ },
1917
+ });
1918
+ }
1919
+ }
1920
+ catch (err) {
1921
+ console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
1922
+ infra = { mode: "local" };
1923
+ }
1924
+ // 2. Create sandboxes in parallel
1925
+ // Coder gets full scaffold, reviewer/ui-designer get minimal
1926
+ await router.sendMessage("system", "Creating sandboxes");
1927
+ await coderBridge.emit({
1928
+ type: "log",
1929
+ level: "build",
1930
+ message: "Creating sandboxes for all agents...",
1931
+ ts: ts(),
1932
+ });
1933
+ const sandboxOpts = (sid) => ({
1934
+ ...((!config.devMode || GITHUB_APP_ID) && {
1935
+ prodMode: {
1936
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
1937
+ studioUrl: resolveStudioUrl(config.port),
1938
+ },
1939
+ }),
1940
+ });
1941
+ const coderInfo = config.sessions.get(coderSession.sessionId);
1942
+ if (!coderInfo)
1943
+ throw new Error("Coder session not found in registry");
1944
+ const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
1945
+ if (!reviewerInfo)
1946
+ throw new Error("Reviewer session not found in registry");
1947
+ const [coderHandle, reviewerHandle] = await Promise.all([
1948
+ config.sandbox.create(coderSession.sessionId, {
1949
+ projectName: coderInfo.projectName,
1950
+ infra,
1951
+ apiKey,
1952
+ oauthToken,
1953
+ ghToken,
1954
+ ...sandboxOpts(coderSession.sessionId),
1955
+ }),
1956
+ config.sandbox.create(reviewerSession.sessionId, {
1957
+ projectName: reviewerInfo.projectName,
1958
+ infra: { mode: "none" },
1959
+ apiKey,
1960
+ oauthToken,
1961
+ ghToken,
1962
+ ...sandboxOpts(reviewerSession.sessionId),
1963
+ }),
1964
+ ]);
1965
+ const handles = [
1966
+ { session: coderSession, handle: coderHandle },
1967
+ { session: reviewerSession, handle: reviewerHandle },
1968
+ ];
1969
+ // Update session info with sandbox details
1970
+ for (const { session: s, handle } of handles) {
1971
+ config.sessions.update(s.sessionId, {
1972
+ appPort: handle.port,
1973
+ sandboxProjectDir: handle.projectDir,
1974
+ previewUrl: handle.previewUrl,
1975
+ ...(s.role === "coder" && claimId ? { claimId } : {}),
1976
+ });
1977
+ }
1978
+ await coderBridge.emit({
1979
+ type: "log",
1980
+ level: "done",
1981
+ message: "All sandboxes ready",
1982
+ ts: ts(),
1983
+ });
1984
+ // 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
1985
+ {
1986
+ const handle = coderHandle;
1987
+ // Copy scaffold
1988
+ await coderBridge.emit({
1989
+ type: "log",
1990
+ level: "build",
1991
+ message: "Setting up project...",
1992
+ ts: ts(),
1993
+ });
1994
+ try {
1995
+ if (config.sandbox.runtime === "docker") {
1996
+ await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
1997
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
1998
+ }
1999
+ else {
2000
+ 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`);
2001
+ }
2002
+ await coderBridge.emit({
2003
+ type: "log",
2004
+ level: "done",
2005
+ message: "Project ready",
2006
+ ts: ts(),
2007
+ });
2008
+ }
2009
+ catch (err) {
2010
+ console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
2011
+ await coderBridge.emit({
2012
+ type: "log",
2013
+ level: "error",
2014
+ message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
2015
+ ts: ts(),
2016
+ });
2017
+ }
2018
+ // GitHub repo creation (uses GitHub App when credentials are available)
2019
+ let repoUrl = null;
2020
+ let prodGitConfig;
2021
+ if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
2022
+ try {
2023
+ const repoSlug = coderInfo.projectName;
2024
+ await coderBridge.emit({
2025
+ type: "log",
2026
+ level: "build",
2027
+ message: "Creating GitHub repository...",
2028
+ ts: ts(),
2029
+ });
2030
+ const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
2031
+ const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
2032
+ if (repo) {
2033
+ const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
2034
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
2035
+ prodGitConfig = {
2036
+ mode: "pre-created",
2037
+ repoName: actualRepoName,
2038
+ repoUrl: repo.htmlUrl,
2039
+ };
2040
+ repoUrl = repo.htmlUrl;
2041
+ config.sessions.update(coderSession.sessionId, {
2042
+ git: {
2043
+ branch: "main",
2044
+ remoteUrl: repo.htmlUrl,
2045
+ repoName: actualRepoName,
2046
+ lastCommitHash: null,
2047
+ lastCommitMessage: null,
2048
+ lastCheckpointAt: null,
2049
+ },
2050
+ });
2051
+ await coderBridge.emit({
2052
+ type: "log",
2053
+ level: "done",
2054
+ message: `GitHub repo created: ${repo.htmlUrl}`,
2055
+ ts: ts(),
2056
+ });
2057
+ }
2058
+ }
2059
+ catch (err) {
2060
+ console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
2061
+ }
2062
+ }
2063
+ else if (repoConfig) {
2064
+ repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
2065
+ }
2066
+ // Write CLAUDE.md to coder sandbox
2067
+ const claudeMd = generateClaudeMd({
2068
+ description: body.description,
2069
+ projectName: coderInfo.projectName,
2070
+ projectDir: handle.projectDir,
2071
+ runtime: config.sandbox.runtime,
2072
+ production: !config.devMode,
2073
+ ...(prodGitConfig
2074
+ ? { git: prodGitConfig }
2075
+ : repoConfig
2076
+ ? {
2077
+ git: {
2078
+ mode: "create",
2079
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
2080
+ visibility: repoConfig.visibility,
2081
+ },
2082
+ }
2083
+ : {}),
2084
+ });
2085
+ try {
2086
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
2087
+ }
2088
+ catch (err) {
2089
+ console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
2090
+ }
2091
+ // Write create-app skill to coder sandbox
2092
+ if (createAppSkillContent) {
2093
+ try {
2094
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
2095
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
2096
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2097
+ }
2098
+ catch (err) {
2099
+ console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
2100
+ }
2101
+ }
2102
+ // Write room-messaging skill to coder sandbox
2103
+ if (roomMessagingSkillContent) {
2104
+ try {
2105
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
2106
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2107
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2108
+ }
2109
+ catch (err) {
2110
+ console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
2111
+ }
2112
+ }
2113
+ // 4. Create Claude Code bridge for coder
2114
+ const coderPrompt = `/create-app ${body.description}`;
2115
+ const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
2116
+ const coderClaudeConfig = config.sandbox.runtime === "sprites"
2117
+ ? {
2118
+ prompt: coderPrompt,
2119
+ cwd: handle.projectDir,
2120
+ studioUrl: resolveStudioUrl(config.port),
2121
+ hookToken: coderHookToken,
2122
+ agentName: coderSession.name,
2123
+ }
2124
+ : {
2125
+ prompt: coderPrompt,
2126
+ cwd: handle.projectDir,
2127
+ studioPort: config.port,
2128
+ hookToken: coderHookToken,
2129
+ agentName: coderSession.name,
2130
+ };
2131
+ const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
2132
+ // Track coder events
2133
+ coderCcBridge.onAgentEvent((event) => {
2134
+ if (event.type === "session_start") {
2135
+ const ccSessionId = event.session_id;
2136
+ if (ccSessionId) {
2137
+ config.sessions.update(coderSession.sessionId, {
2138
+ lastCoderSessionId: ccSessionId,
2139
+ });
2140
+ }
2141
+ }
2142
+ if (event.type === "session_end") {
2143
+ accumulateSessionCost(config, coderSession.sessionId, event);
2144
+ }
2145
+ // Route assistant_message output to the room router
2146
+ if (event.type === "assistant_message" && "text" in event) {
2147
+ const text = event.text;
2148
+ router.handleAgentOutput(coderSession.sessionId, text).catch((err) => {
2149
+ console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
2150
+ });
2151
+ }
2152
+ // Notify room when coder is waiting for user input
2153
+ if (event.type === "ask_user_question") {
2154
+ config.sessions.update(coderSession.sessionId, { needsInput: true });
2155
+ router
2156
+ .sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
2157
+ .catch((err) => {
2158
+ console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
2159
+ });
2160
+ }
2161
+ if (event.type === "gate_resolved") {
2162
+ config.sessions.update(coderSession.sessionId, { needsInput: false });
2163
+ router
2164
+ .sendMessage("system", `${coderSession.name} received input — resuming.`)
2165
+ .catch(() => { });
2166
+ }
2167
+ });
2168
+ // Coder completion handler: notify room on success or failure
2169
+ coderCcBridge.onComplete(async (success) => {
2170
+ const updates = {
2171
+ status: success ? "complete" : "error",
2172
+ };
2173
+ try {
2174
+ const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
2175
+ if (gs.initialized) {
2176
+ const existing = config.sessions.get(coderSession.sessionId);
2177
+ updates.git = {
2178
+ branch: gs.branch ?? "main",
2179
+ remoteUrl: existing?.git?.remoteUrl ?? null,
2180
+ repoName: existing?.git?.repoName ?? null,
2181
+ repoVisibility: existing?.git?.repoVisibility,
2182
+ lastCommitHash: gs.lastCommitHash ?? null,
2183
+ lastCommitMessage: gs.lastCommitMessage ?? null,
2184
+ lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
2185
+ };
2186
+ }
2187
+ }
2188
+ catch {
2189
+ // Sandbox may be stopped
2190
+ }
2191
+ config.sessions.update(coderSession.sessionId, updates);
2192
+ const status = success ? "completed" : "ended with errors";
2193
+ console.log(`[room:create-app:${roomId}] Coder session ${status}`);
2194
+ });
2195
+ await coderBridge.emit({
2196
+ type: "log",
2197
+ level: "build",
2198
+ message: `Running: claude "/create-app ${body.description}"`,
2199
+ ts: ts(),
2200
+ });
2201
+ await coderCcBridge.start();
2202
+ // Add coder as room participant
2203
+ const coderParticipant = {
2204
+ sessionId: coderSession.sessionId,
2205
+ name: coderSession.name,
2206
+ role: "coder",
2207
+ bridge: coderCcBridge,
2208
+ };
2209
+ await router.addParticipant(coderParticipant, false);
2210
+ // Send the initial command to the coder
2211
+ await coderCcBridge.sendCommand({
2212
+ command: "new",
2213
+ description: body.description,
2214
+ projectName: coderInfo.projectName,
2215
+ baseDir: "/home/agent/workspace",
2216
+ });
2217
+ // Store the repoUrl for reviewer/ui-designer prompts
2218
+ // (we continue setting up those agents now)
2219
+ const finalRepoUrl = repoUrl;
2220
+ // Share repo info with all agents via the room router's discovery prompt
2221
+ router.setRepoInfo({
2222
+ url: finalRepoUrl,
2223
+ branch: "main",
2224
+ });
2225
+ // 5. Set up reviewer and ui-designer sandboxes
2226
+ const supportAgents = [{ session: reviewerSession, handle: reviewerHandle }];
2227
+ for (const { session: agentSession, handle: agentHandle } of supportAgents) {
2228
+ const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
2229
+ // Write a minimal CLAUDE.md
2230
+ const minimalClaudeMd = "Room agent workspace";
2231
+ try {
2232
+ await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
2233
+ }
2234
+ catch (err) {
2235
+ console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
2236
+ }
2237
+ // Write room-messaging skill
2238
+ if (roomMessagingSkillContent) {
2239
+ try {
2240
+ const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
2241
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2242
+ await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2243
+ }
2244
+ catch (err) {
2245
+ console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
2246
+ }
2247
+ }
2248
+ // Resolve and inject role skill
2249
+ const roleSkill = resolveRoleSkill(agentSession.role);
2250
+ if (roleSkill) {
2251
+ try {
2252
+ const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
2253
+ const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
2254
+ await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2255
+ }
2256
+ catch (err) {
2257
+ console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
2258
+ }
2259
+ }
2260
+ // Build prompt (repo info is now passed via the room router's discovery prompt)
2261
+ const agentPrompt = agentSession.role === "reviewer"
2262
+ ? `You are "reviewer", a read-only code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines. CRITICAL: You must NEVER modify code — only read and review. Wait for the coder to send a @room REVIEW_REQUEST: message before starting any work.`
2263
+ : `You are "${agentSession.role}", an agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.`;
2264
+ // Create Claude Code bridge
2265
+ const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
2266
+ const agentClaudeConfig = config.sandbox.runtime === "sprites"
2267
+ ? {
2268
+ prompt: agentPrompt,
2269
+ cwd: agentHandle.projectDir,
2270
+ studioUrl: resolveStudioUrl(config.port),
2271
+ hookToken: agentHookToken,
2272
+ agentName: agentSession.name,
2273
+ ...(roleSkill?.allowedTools && {
2274
+ allowedTools: roleSkill.allowedTools,
2275
+ }),
2276
+ }
2277
+ : {
2278
+ prompt: agentPrompt,
2279
+ cwd: agentHandle.projectDir,
2280
+ studioPort: config.port,
2281
+ hookToken: agentHookToken,
2282
+ agentName: agentSession.name,
2283
+ ...(roleSkill?.allowedTools && {
2284
+ allowedTools: roleSkill.allowedTools,
2285
+ }),
2286
+ };
2287
+ const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
2288
+ // Track events
2289
+ ccBridge.onAgentEvent((event) => {
2290
+ 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)}` : ""}`);
2291
+ if (event.type === "session_start") {
2292
+ const ccSessionId = event.session_id;
2293
+ if (ccSessionId) {
2294
+ config.sessions.update(agentSession.sessionId, {
2295
+ lastCoderSessionId: ccSessionId,
2296
+ });
2297
+ }
2298
+ }
2299
+ if (event.type === "session_end") {
2300
+ accumulateSessionCost(config, agentSession.sessionId, event);
2301
+ }
2302
+ if (event.type === "assistant_message" && "text" in event) {
2303
+ const text = event.text;
2304
+ console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
2305
+ router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
2306
+ console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
2307
+ });
2308
+ }
2309
+ if (event.type === "ask_user_question") {
2310
+ config.sessions.update(agentSession.sessionId, { needsInput: true });
2311
+ router
2312
+ .sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
2313
+ .catch((err) => {
2314
+ console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
2315
+ });
2316
+ }
2317
+ if (event.type === "gate_resolved") {
2318
+ config.sessions.update(agentSession.sessionId, { needsInput: false });
2319
+ router
2320
+ .sendMessage("system", `${agentSession.name} received input — resuming.`)
2321
+ .catch(() => { });
2322
+ }
2323
+ });
2324
+ ccBridge.onComplete(async (success) => {
2325
+ config.sessions.update(agentSession.sessionId, {
2326
+ status: success ? "complete" : "error",
2327
+ });
2328
+ });
2329
+ await agentBridge.emit({
2330
+ type: "log",
2331
+ level: "done",
2332
+ message: `Sandbox ready for "${agentSession.name}"`,
2333
+ ts: ts(),
2334
+ });
2335
+ await ccBridge.start();
2336
+ // Add as room participant (not gated — messages flow freely)
2337
+ const participant = {
2338
+ sessionId: agentSession.sessionId,
2339
+ name: agentSession.name,
2340
+ role: agentSession.role,
2341
+ bridge: ccBridge,
2342
+ };
2343
+ await router.addParticipant(participant, false);
2344
+ }
2345
+ console.log(`[room:create-app:${roomId}] All agents started and added to room`);
2346
+ await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} waiting for review request. UI designer can be added later via "Add Agent".`);
2347
+ }
2348
+ };
2349
+ asyncFlow().catch(async (err) => {
2350
+ console.error(`[room:create-app:${roomId}] Flow failed:`, err);
2351
+ for (const s of sessions) {
2352
+ config.sessions.update(s.sessionId, { status: "error" });
2353
+ }
2354
+ try {
2355
+ await coderBridge.emit({
2356
+ type: "log",
2357
+ level: "error",
2358
+ message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
2359
+ ts: ts(),
2360
+ });
2361
+ }
2362
+ catch {
2363
+ // Bridge may not be usable
2364
+ }
2365
+ });
2366
+ return c.json({
2367
+ roomId,
2368
+ code,
2369
+ name: roomName,
2370
+ roomToken,
2371
+ sessions: sessions.map((s) => ({
2372
+ sessionId: s.sessionId,
2373
+ name: s.name,
2374
+ role: s.role,
2375
+ sessionToken: s.sessionToken,
2376
+ })),
2377
+ }, 201);
2378
+ });
1550
2379
  // Create a room
1551
2380
  app.post("/api/rooms", async (c) => {
1552
- const body = (await c.req.json());
1553
- if (!body.name) {
1554
- return c.json({ error: "name is required" }, 400);
1555
- }
2381
+ const body = await validateBody(c, createRoomSchema);
2382
+ if (isResponse(body))
2383
+ return body;
1556
2384
  const roomId = crypto.randomUUID();
1557
2385
  // Create the room's durable stream
1558
2386
  const conn = roomStream(config, roomId);
@@ -1582,12 +2410,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1582
2410
  createdAt: new Date().toISOString(),
1583
2411
  revoked: false,
1584
2412
  });
1585
- const roomToken = deriveSessionToken(config.streamConfig.secret, roomId);
2413
+ const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1586
2414
  console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
1587
2415
  return c.json({ roomId, code, roomToken }, 201);
1588
2416
  });
1589
- // Join an agent room by id + invite code
1590
- app.get("/api/rooms/join/:id/:code", (c) => {
2417
+ // Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
2418
+ app.get("/api/join-room/:id/:code", (c) => {
1591
2419
  const id = c.req.param("id");
1592
2420
  const code = c.req.param("code");
1593
2421
  const room = config.rooms.getRoom(id);
@@ -1595,25 +2423,51 @@ echo "Start claude in this project — the session will appear in the studio UI.
1595
2423
  return c.json({ error: "Room not found" }, 404);
1596
2424
  if (room.revoked)
1597
2425
  return c.json({ error: "Room has been revoked" }, 410);
1598
- const roomToken = deriveSessionToken(config.streamConfig.secret, room.id);
2426
+ const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
1599
2427
  return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
1600
2428
  });
1601
2429
  // Get room state
1602
2430
  app.get("/api/rooms/:id", (c) => {
1603
2431
  const roomId = c.req.param("id");
1604
2432
  const router = roomRouters.get(roomId);
1605
- if (!router)
2433
+ if (router) {
2434
+ // Find preview URL / port from any participant's session (prefer coder role)
2435
+ const coderParticipant = router.participants.find((p) => p.role === "coder");
2436
+ const previewParticipant = coderParticipant ?? router.participants[0];
2437
+ let previewUrl;
2438
+ let appPort;
2439
+ if (previewParticipant) {
2440
+ const handle = config.sandbox.get(previewParticipant.sessionId);
2441
+ const session = config.sessions.get(previewParticipant.sessionId);
2442
+ previewUrl = handle?.previewUrl ?? session?.previewUrl;
2443
+ appPort = handle?.port ?? session?.appPort;
2444
+ }
2445
+ return c.json({
2446
+ roomId,
2447
+ state: router.state,
2448
+ roundCount: router.roundCount,
2449
+ previewUrl,
2450
+ appPort,
2451
+ participants: router.participants.map((p) => ({
2452
+ sessionId: p.sessionId,
2453
+ name: p.name,
2454
+ role: p.role,
2455
+ running: p.bridge.isRunning(),
2456
+ needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
2457
+ })),
2458
+ });
2459
+ }
2460
+ // No active router — check if room exists in the registry (e.g. after server restart)
2461
+ const roomEntry = config.rooms.getRoom(roomId);
2462
+ if (!roomEntry)
1606
2463
  return c.json({ error: "Room not found" }, 404);
2464
+ // Return basic room state without live participants
2465
+ // Sessions are still readable via their individual SSE streams
1607
2466
  return c.json({
1608
2467
  roomId,
1609
- state: router.state,
1610
- roundCount: router.roundCount,
1611
- participants: router.participants.map((p) => ({
1612
- sessionId: p.sessionId,
1613
- name: p.name,
1614
- role: p.role,
1615
- running: p.bridge.isRunning(),
1616
- })),
2468
+ state: "closed",
2469
+ roundCount: 0,
2470
+ participants: [],
1617
2471
  });
1618
2472
  });
1619
2473
  // Add an agent to a room
@@ -1622,7 +2476,26 @@ echo "Start claude in this project — the session will appear in the studio UI.
1622
2476
  const router = roomRouters.get(roomId);
1623
2477
  if (!router)
1624
2478
  return c.json({ error: "Room not found" }, 404);
1625
- const body = (await c.req.json());
2479
+ const body = await validateBody(c, addAgentSchema);
2480
+ if (isResponse(body))
2481
+ return body;
2482
+ // Rate-limit and gate credentials in production mode
2483
+ if (!config.devMode) {
2484
+ const ip = extractClientIp(c);
2485
+ if (!checkSessionRateLimit(ip)) {
2486
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
2487
+ }
2488
+ if (checkGlobalSessionCap(config.sessions)) {
2489
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
2490
+ }
2491
+ }
2492
+ const apiKey = config.devMode
2493
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
2494
+ : process.env.ANTHROPIC_API_KEY;
2495
+ const oauthToken = config.devMode
2496
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
2497
+ : undefined;
2498
+ const ghToken = config.devMode ? body.ghToken : undefined;
1626
2499
  const sessionId = crypto.randomUUID();
1627
2500
  const randomSuffix = sessionId.slice(0, 6);
1628
2501
  const agentName = body.name?.trim() || `agent-${randomSuffix}`;
@@ -1671,9 +2544,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
1671
2544
  const handle = await config.sandbox.create(sessionId, {
1672
2545
  projectName,
1673
2546
  infra: { mode: "local" },
1674
- apiKey: body.apiKey,
1675
- oauthToken: body.oauthToken,
1676
- ghToken: body.ghToken,
2547
+ apiKey,
2548
+ oauthToken,
2549
+ ghToken,
2550
+ ...((!config.devMode || GITHUB_APP_ID) && {
2551
+ prodMode: {
2552
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
2553
+ studioUrl: resolveStudioUrl(config.port),
2554
+ },
2555
+ }),
1677
2556
  });
1678
2557
  config.sessions.update(sessionId, {
1679
2558
  appPort: handle.port,
@@ -1787,10 +2666,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1787
2666
  const router = roomRouters.get(roomId);
1788
2667
  if (!router)
1789
2668
  return c.json({ error: "Room not found" }, 404);
1790
- const body = (await c.req.json());
1791
- if (!body.sessionId || !body.name) {
1792
- return c.json({ error: "sessionId and name are required" }, 400);
1793
- }
2669
+ const body = await validateBody(c, addSessionToRoomSchema);
2670
+ if (isResponse(body))
2671
+ return body;
1794
2672
  const { sessionId } = body;
1795
2673
  // Require a valid session token — caller must already own this session.
1796
2674
  // Room auth is handled by middleware via X-Room-Token; Authorization
@@ -1870,10 +2748,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1870
2748
  const participant = router.participants.find((p) => p.sessionId === sessionId);
1871
2749
  if (!participant)
1872
2750
  return c.json({ error: "Session not found in this room" }, 404);
1873
- const body = (await c.req.json());
1874
- if (!body.request) {
1875
- return c.json({ error: "request is required" }, 400);
1876
- }
2751
+ const body = await validateBody(c, iterateRoomSessionSchema);
2752
+ if (isResponse(body))
2753
+ return body;
1877
2754
  await participant.bridge.sendCommand({
1878
2755
  command: "iterate",
1879
2756
  request: body.request,
@@ -1886,19 +2763,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
1886
2763
  const router = roomRouters.get(roomId);
1887
2764
  if (!router)
1888
2765
  return c.json({ error: "Room not found" }, 404);
1889
- const body = (await c.req.json());
1890
- if (!body.from || !body.body) {
1891
- return c.json({ error: "from and body are required" }, 400);
1892
- }
2766
+ const body = await validateBody(c, sendRoomMessageSchema);
2767
+ if (isResponse(body))
2768
+ return body;
1893
2769
  await router.sendMessage(body.from, body.body, body.to);
1894
2770
  return c.json({ ok: true });
1895
2771
  });
1896
- // SSE proxy for room events
2772
+ // SSE proxy for room events (works even after server restart — reads from durable stream)
1897
2773
  app.get("/api/rooms/:id/events", async (c) => {
1898
2774
  const roomId = c.req.param("id");
1899
- const router = roomRouters.get(roomId);
1900
- if (!router)
2775
+ // Verify room exists in registry or has active router
2776
+ if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
1901
2777
  return c.json({ error: "Room not found" }, 404);
2778
+ }
1902
2779
  const connection = roomStream(config, roomId);
1903
2780
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1904
2781
  const reader = new DurableStream({
@@ -2115,7 +2992,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
2115
2992
  if (!handle || !sandboxDir) {
2116
2993
  return c.json({ error: "Container not available" }, 404);
2117
2994
  }
2118
- if (!filePath.startsWith(sandboxDir)) {
2995
+ const resolvedPath = path.resolve(filePath);
2996
+ const resolvedDir = path.resolve(sandboxDir) + path.sep;
2997
+ if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
2119
2998
  return c.json({ error: "Path outside project directory" }, 403);
2120
2999
  }
2121
3000
  const content = await config.sandbox.readFile(handle, filePath);
@@ -2126,6 +3005,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
2126
3005
  });
2127
3006
  // List GitHub accounts (personal + orgs) — requires client-provided token
2128
3007
  app.get("/api/github/accounts", (c) => {
3008
+ if (!config.devMode)
3009
+ return c.json({ error: "Not available" }, 403);
2129
3010
  const token = c.req.header("X-GH-Token");
2130
3011
  if (!token)
2131
3012
  return c.json({ accounts: [] });
@@ -2137,8 +3018,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2137
3018
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
2138
3019
  }
2139
3020
  });
2140
- // List GitHub repos for the authenticated user — requires client-provided token
3021
+ // List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
2141
3022
  app.get("/api/github/repos", (c) => {
3023
+ if (!config.devMode)
3024
+ return c.json({ error: "Not available" }, 403);
2142
3025
  const token = c.req.header("X-GH-Token");
2143
3026
  if (!token)
2144
3027
  return c.json({ repos: [] });
@@ -2151,6 +3034,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
2151
3034
  }
2152
3035
  });
2153
3036
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
3037
+ if (!config.devMode)
3038
+ return c.json({ error: "Not available" }, 403);
2154
3039
  const owner = c.req.param("owner");
2155
3040
  const repo = c.req.param("repo");
2156
3041
  const token = c.req.header("X-GH-Token");
@@ -2164,33 +3049,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
2164
3049
  return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
2165
3050
  }
2166
3051
  });
2167
- // Read Claude credentials from macOS Keychain (dev convenience)
3052
+ // Read Claude credentials from macOS Keychain.
2168
3053
  app.get("/api/credentials/keychain", (c) => {
2169
- if (process.platform !== "darwin") {
2170
- return c.json({ apiKey: null });
2171
- }
2172
- try {
2173
- const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
2174
- const parsed = JSON.parse(raw);
2175
- const token = parsed.claudeAiOauth?.accessToken ?? null;
2176
- if (token) {
2177
- console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
2178
- }
2179
- else {
2180
- console.log("[dev] No OAuth token found in keychain");
2181
- }
2182
- return c.json({ oauthToken: token });
2183
- }
2184
- catch {
2185
- return c.json({ oauthToken: null });
3054
+ const token = readKeychainOAuthToken();
3055
+ if (token) {
3056
+ console.log(`[keychain] Loaded OAuth token (length: ${token.length})`);
2186
3057
  }
3058
+ return c.json({ oauthToken: token });
2187
3059
  });
2188
- // Resume a project from a GitHub repo
3060
+ // Resume a project from a GitHub repo (dev mode only)
2189
3061
  app.post("/api/sessions/resume", async (c) => {
2190
- const body = (await c.req.json());
2191
- if (!body.repoUrl) {
2192
- return c.json({ error: "repoUrl is required" }, 400);
3062
+ if (!config.devMode) {
3063
+ return c.json({ error: "Resume from repo not available" }, 403);
2193
3064
  }
3065
+ const body = await validateBody(c, resumeSessionSchema);
3066
+ if (isResponse(body))
3067
+ return body;
2194
3068
  const sessionId = crypto.randomUUID();
2195
3069
  const repoName = body.repoUrl
2196
3070
  .split("/")
@@ -2270,6 +3144,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
2270
3144
  projectName: repoName,
2271
3145
  projectDir: handle.projectDir,
2272
3146
  runtime: config.sandbox.runtime,
3147
+ production: !config.devMode,
2273
3148
  git: {
2274
3149
  mode: "existing",
2275
3150
  repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
@@ -2434,15 +3309,42 @@ echo "Start claude in this project — the session will appear in the studio UI.
2434
3309
  return app;
2435
3310
  }
2436
3311
  export async function startWebServer(opts) {
3312
+ const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
3313
+ if (devMode) {
3314
+ console.log("[studio] Dev mode enabled");
3315
+ }
3316
+ // Hydrate session registry from durable stream (survives restarts)
3317
+ const registry = await Registry.create(opts.streamConfig);
2437
3318
  const config = {
2438
3319
  port: opts.port ?? 4400,
2439
3320
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
2440
- sessions: new ActiveSessions(),
3321
+ sessions: ActiveSessions.fromRegistry(registry),
2441
3322
  rooms: opts.rooms,
2442
3323
  sandbox: opts.sandbox,
2443
3324
  streamConfig: opts.streamConfig,
2444
3325
  bridgeMode: opts.bridgeMode ?? "claude-code",
3326
+ devMode,
2445
3327
  };
3328
+ // Reconnect to surviving sandbox containers (Docker only)
3329
+ if (config.sandbox.runtime === "docker") {
3330
+ const dockerProvider = config.sandbox;
3331
+ const allSessions = registry.listSessions();
3332
+ dockerProvider.reconnect(allSessions);
3333
+ // Mark sessions with live containers as "complete" (not stale),
3334
+ // and sessions without containers as "error"
3335
+ for (const session of allSessions) {
3336
+ if (session.status === "running") {
3337
+ const handle = dockerProvider.get(session.id);
3338
+ config.sessions.update(session.id, {
3339
+ status: handle ? "complete" : "error",
3340
+ });
3341
+ }
3342
+ }
3343
+ }
3344
+ else {
3345
+ // Non-Docker: mark all running sessions as stale
3346
+ registry.cleanupStaleSessions(0);
3347
+ }
2446
3348
  fs.mkdirSync(config.dataDir, { recursive: true });
2447
3349
  const app = createApp(config);
2448
3350
  const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";