@electric-agent/studio 1.12.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/active-sessions.d.ts +2 -0
  2. package/dist/active-sessions.d.ts.map +1 -1
  3. package/dist/active-sessions.js +4 -0
  4. package/dist/active-sessions.js.map +1 -1
  5. package/dist/bridge/claude-md-generator.d.ts +5 -2
  6. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  7. package/dist/bridge/claude-md-generator.js +33 -1
  8. package/dist/bridge/claude-md-generator.js.map +1 -1
  9. package/dist/client/assets/index-CtOOaA2Q.js +235 -0
  10. package/dist/client/index.html +1 -1
  11. package/dist/github-app.d.ts +14 -0
  12. package/dist/github-app.d.ts.map +1 -0
  13. package/dist/github-app.js +62 -0
  14. package/dist/github-app.js.map +1 -0
  15. package/dist/github-app.test.d.ts +2 -0
  16. package/dist/github-app.test.d.ts.map +1 -0
  17. package/dist/github-app.test.js +62 -0
  18. package/dist/github-app.test.js.map +1 -0
  19. package/dist/room-router.d.ts.map +1 -1
  20. package/dist/room-router.js +1 -1
  21. package/dist/room-router.js.map +1 -1
  22. package/dist/sandbox/docker.d.ts +1 -0
  23. package/dist/sandbox/docker.d.ts.map +1 -1
  24. package/dist/sandbox/docker.js +51 -0
  25. package/dist/sandbox/docker.js.map +1 -1
  26. package/dist/sandbox/sprites.d.ts +1 -0
  27. package/dist/sandbox/sprites.d.ts.map +1 -1
  28. package/dist/sandbox/sprites.js +51 -0
  29. package/dist/sandbox/sprites.js.map +1 -1
  30. package/dist/sandbox/types.d.ts +5 -0
  31. package/dist/sandbox/types.d.ts.map +1 -1
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +171 -125
  34. package/dist/server.js.map +1 -1
  35. package/package.json +1 -1
  36. package/dist/client/assets/index-CiwD5LkP.js +0 -235
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ 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";
22
23
  import { RoomRouter } from "./room-router.js";
@@ -73,8 +74,17 @@ function resolveStudioUrl(port) {
73
74
  // Rate limiting — in-memory sliding window per IP
74
75
  // ---------------------------------------------------------------------------
75
76
  const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
77
+ const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
76
78
  const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
77
79
  const sessionCreationsByIp = new Map();
80
+ // GitHub App config (prod mode — repo creation in electric-apps org)
81
+ const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
82
+ const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
83
+ const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
84
+ const GITHUB_ORG = "electric-apps";
85
+ // Rate limiting for GitHub token endpoint
86
+ const githubTokenRequestsBySession = new Map();
87
+ const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
78
88
  function extractClientIp(c) {
79
89
  return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
80
90
  c.req.header("cf-connecting-ip") ||
@@ -94,6 +104,20 @@ function checkSessionRateLimit(ip) {
94
104
  sessionCreationsByIp.set(ip, timestamps);
95
105
  return true;
96
106
  }
107
+ function checkGlobalSessionCap(sessions) {
108
+ return sessions.size() >= MAX_TOTAL_SESSIONS;
109
+ }
110
+ function checkGithubTokenRateLimit(sessionId) {
111
+ const now = Date.now();
112
+ const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
113
+ const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
114
+ if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
115
+ return false;
116
+ }
117
+ recent.push(now);
118
+ githubTokenRequestsBySession.set(sessionId, recent);
119
+ return true;
120
+ }
97
121
  // ---------------------------------------------------------------------------
98
122
  // Per-session cost budget
99
123
  // ---------------------------------------------------------------------------
@@ -186,31 +210,6 @@ function closeBridge(sessionId) {
186
210
  bridges.delete(sessionId);
187
211
  }
188
212
  }
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
213
  /**
215
214
  * Map a Claude Code hook event JSON payload to an EngineEvent.
216
215
  *
@@ -343,7 +342,10 @@ export function createApp(config) {
343
342
  });
344
343
  // Public config — exposes non-sensitive flags to the client
345
344
  app.get("/api/config", (c) => {
346
- return c.json({ devMode: config.devMode });
345
+ return c.json({
346
+ devMode: config.devMode,
347
+ maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
348
+ });
347
349
  });
348
350
  // Provision Electric Cloud resources via the Claim API
349
351
  app.post("/api/provision-electric", async (c) => {
@@ -802,6 +804,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
802
804
  const body = await validateBody(c, createSessionSchema);
803
805
  if (isResponse(body))
804
806
  return body;
807
+ // In prod mode, use server-side API key; ignore user-provided credentials
808
+ const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
809
+ const oauthToken = config.devMode ? body.oauthToken : undefined;
810
+ const ghToken = config.devMode ? body.ghToken : undefined;
805
811
  // Block freeform sessions in production mode
806
812
  if (body.freeform && !config.devMode) {
807
813
  return c.json({ error: "Freeform sessions are not available" }, 403);
@@ -812,14 +818,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
812
818
  if (!checkSessionRateLimit(ip)) {
813
819
  return c.json({ error: "Too many sessions. Please try again later." }, 429);
814
820
  }
821
+ if (checkGlobalSessionCap(config.sessions)) {
822
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
823
+ }
815
824
  }
816
825
  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();
826
+ const inferredName = config.devMode
827
+ ? body.name ||
828
+ body.description
829
+ .slice(0, 40)
830
+ .replace(/[^a-z0-9]+/gi, "-")
831
+ .replace(/^-|-$/g, "")
832
+ .toLowerCase()
833
+ : `electric-${sessionId.slice(0, 8)}`;
823
834
  const baseDir = body.baseDir || process.cwd();
824
835
  const { projectName } = resolveProjectDir(baseDir, inferredName);
825
836
  console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
@@ -856,11 +867,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
856
867
  // Freeform sessions skip the infra config gate — no Electric/DB setup needed
857
868
  let ghAccounts = [];
858
869
  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)) {
870
+ // Gather GitHub accounts for the merged setup gate (dev mode only)
871
+ if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
862
872
  try {
863
- ghAccounts = ghListAccounts(body.ghToken);
873
+ ghAccounts = ghListAccounts(ghToken);
864
874
  }
865
875
  catch {
866
876
  // gh not available — no repo setup
@@ -943,9 +953,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
943
953
  const handle = await config.sandbox.create(sessionId, {
944
954
  projectName,
945
955
  infra,
946
- apiKey: body.apiKey,
947
- oauthToken: body.oauthToken,
948
- ghToken: body.ghToken,
956
+ apiKey,
957
+ oauthToken,
958
+ ghToken,
959
+ ...(!config.devMode && {
960
+ prodMode: {
961
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
962
+ studioUrl: resolveStudioUrl(config.port),
963
+ },
964
+ }),
949
965
  });
950
966
  console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
951
967
  await bridge.emit({
@@ -1011,6 +1027,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
1011
1027
  ts: ts(),
1012
1028
  });
1013
1029
  }
1030
+ // In prod mode, create GitHub repo and initialize git in the sandbox
1031
+ let prodGitConfig;
1032
+ if (!config.devMode && GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
1033
+ try {
1034
+ // Repo name matches the project name (already has random slug)
1035
+ const repoSlug = projectName;
1036
+ await bridge.emit({
1037
+ type: "log",
1038
+ level: "build",
1039
+ message: "Creating GitHub repository...",
1040
+ ts: ts(),
1041
+ });
1042
+ const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1043
+ const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
1044
+ if (repo) {
1045
+ const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
1046
+ // Initialize git and set remote in the sandbox
1047
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
1048
+ prodGitConfig = {
1049
+ mode: "pre-created",
1050
+ repoName: actualRepoName,
1051
+ repoUrl: repo.htmlUrl,
1052
+ };
1053
+ config.sessions.update(sessionId, {
1054
+ git: {
1055
+ branch: "main",
1056
+ remoteUrl: repo.htmlUrl,
1057
+ repoName: actualRepoName,
1058
+ lastCommitHash: null,
1059
+ lastCommitMessage: null,
1060
+ lastCheckpointAt: null,
1061
+ },
1062
+ });
1063
+ await bridge.emit({
1064
+ type: "log",
1065
+ level: "done",
1066
+ message: `GitHub repo created: ${repo.htmlUrl}`,
1067
+ ts: ts(),
1068
+ });
1069
+ }
1070
+ else {
1071
+ console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
1072
+ }
1073
+ }
1074
+ catch (err) {
1075
+ console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
1076
+ }
1077
+ }
1014
1078
  // Write CLAUDE.md to the sandbox workspace.
1015
1079
  // Our generator includes hardcoded playbook paths and reading order
1016
1080
  // so we don't depend on @tanstack/intent generating a skill block.
@@ -1020,15 +1084,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
1020
1084
  projectDir: handle.projectDir,
1021
1085
  runtime: config.sandbox.runtime,
1022
1086
  production: !config.devMode,
1023
- ...(repoConfig
1024
- ? {
1025
- git: {
1026
- mode: "create",
1027
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1028
- visibility: repoConfig.visibility,
1029
- },
1030
- }
1031
- : {}),
1087
+ ...(prodGitConfig
1088
+ ? { git: prodGitConfig }
1089
+ : repoConfig
1090
+ ? {
1091
+ git: {
1092
+ mode: "create",
1093
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1094
+ visibility: repoConfig.visibility,
1095
+ },
1096
+ }
1097
+ : {}),
1032
1098
  });
1033
1099
  try {
1034
1100
  await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
@@ -1193,69 +1259,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
1193
1259
  const body = await validateBody(c, iterateSessionSchema);
1194
1260
  if (isResponse(body))
1195
1261
  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() });
1214
- }
1215
- else {
1216
- if (!handle || !config.sandbox.isAlive(handle)) {
1217
- return c.json({ error: "Container is not running" }, 400);
1218
- }
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
- }
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,
1256
- });
1257
- return c.json({ ok: true });
1258
- }
1259
1262
  const handle = config.sandbox.get(sessionId);
1260
1263
  if (!handle || !config.sandbox.isAlive(handle)) {
1261
1264
  return c.json({ error: "Container is not running" }, 400);
@@ -1272,6 +1275,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
1272
1275
  });
1273
1276
  return c.json({ ok: true });
1274
1277
  });
1278
+ // Generate a GitHub installation token for the sandbox (prod mode only)
1279
+ app.post("/api/sessions/:id/github-token", async (c) => {
1280
+ const sessionId = c.req.param("id");
1281
+ if (config.devMode) {
1282
+ return c.json({ error: "Not available in dev mode" }, 403);
1283
+ }
1284
+ if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
1285
+ return c.json({ error: "GitHub App not configured" }, 500);
1286
+ }
1287
+ if (!checkGithubTokenRateLimit(sessionId)) {
1288
+ return c.json({ error: "Too many token requests" }, 429);
1289
+ }
1290
+ try {
1291
+ const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1292
+ return c.json(result);
1293
+ }
1294
+ catch (err) {
1295
+ const message = err instanceof Error ? err.message : "Unknown error";
1296
+ console.error(`GitHub token error for session ${sessionId}:`, message);
1297
+ return c.json({ error: "Failed to generate GitHub token" }, 500);
1298
+ }
1299
+ });
1275
1300
  // Respond to a gate (approval, clarification, continue, revision)
1276
1301
  app.post("/api/sessions/:id/respond", async (c) => {
1277
1302
  const sessionId = c.req.param("id");
@@ -1658,8 +1683,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1658
1683
  console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
1659
1684
  return c.json({ roomId, code, roomToken }, 201);
1660
1685
  });
1661
- // Join an agent room by id + invite code
1662
- app.get("/api/rooms/join/:id/:code", (c) => {
1686
+ // Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
1687
+ app.get("/api/join-room/:id/:code", (c) => {
1663
1688
  const id = c.req.param("id");
1664
1689
  const code = c.req.param("code");
1665
1690
  const room = config.rooms.getRoom(id);
@@ -1697,6 +1722,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
1697
1722
  const body = await validateBody(c, addAgentSchema);
1698
1723
  if (isResponse(body))
1699
1724
  return body;
1725
+ // Rate-limit and gate credentials in production mode
1726
+ if (!config.devMode) {
1727
+ const ip = extractClientIp(c);
1728
+ if (!checkSessionRateLimit(ip)) {
1729
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
1730
+ }
1731
+ if (checkGlobalSessionCap(config.sessions)) {
1732
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
1733
+ }
1734
+ }
1735
+ const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
1736
+ const oauthToken = config.devMode ? body.oauthToken : undefined;
1737
+ const ghToken = config.devMode ? body.ghToken : undefined;
1700
1738
  const sessionId = crypto.randomUUID();
1701
1739
  const randomSuffix = sessionId.slice(0, 6);
1702
1740
  const agentName = body.name?.trim() || `agent-${randomSuffix}`;
@@ -1745,9 +1783,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
1745
1783
  const handle = await config.sandbox.create(sessionId, {
1746
1784
  projectName,
1747
1785
  infra: { mode: "local" },
1748
- apiKey: body.apiKey,
1749
- oauthToken: body.oauthToken,
1750
- ghToken: body.ghToken,
1786
+ apiKey,
1787
+ oauthToken,
1788
+ ghToken,
1789
+ ...(!config.devMode && {
1790
+ prodMode: {
1791
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
1792
+ studioUrl: resolveStudioUrl(config.port),
1793
+ },
1794
+ }),
1751
1795
  });
1752
1796
  config.sessions.update(sessionId, {
1753
1797
  appPort: handle.port,
@@ -2199,6 +2243,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
2199
2243
  });
2200
2244
  // List GitHub accounts (personal + orgs) — requires client-provided token
2201
2245
  app.get("/api/github/accounts", (c) => {
2246
+ if (!config.devMode)
2247
+ return c.json({ error: "Not available" }, 403);
2202
2248
  const token = c.req.header("X-GH-Token");
2203
2249
  if (!token)
2204
2250
  return c.json({ accounts: [] });
@@ -2210,8 +2256,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2210
2256
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
2211
2257
  }
2212
2258
  });
2213
- // List GitHub repos for the authenticated user — requires client-provided token
2259
+ // List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
2214
2260
  app.get("/api/github/repos", (c) => {
2261
+ if (!config.devMode)
2262
+ return c.json({ error: "Not available" }, 403);
2215
2263
  const token = c.req.header("X-GH-Token");
2216
2264
  if (!token)
2217
2265
  return c.json({ repos: [] });
@@ -2224,6 +2272,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
2224
2272
  }
2225
2273
  });
2226
2274
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
2275
+ if (!config.devMode)
2276
+ return c.json({ error: "Not available" }, 403);
2227
2277
  const owner = c.req.param("owner");
2228
2278
  const repo = c.req.param("repo");
2229
2279
  const token = c.req.header("X-GH-Token");
@@ -2261,18 +2311,14 @@ echo "Start claude in this project — the session will appear in the studio UI.
2261
2311
  }
2262
2312
  });
2263
2313
  }
2264
- // Resume a project from a GitHub repo
2314
+ // Resume a project from a GitHub repo (dev mode only)
2265
2315
  app.post("/api/sessions/resume", async (c) => {
2316
+ if (!config.devMode) {
2317
+ return c.json({ error: "Resume from repo not available" }, 403);
2318
+ }
2266
2319
  const body = await validateBody(c, resumeSessionSchema);
2267
2320
  if (isResponse(body))
2268
2321
  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
2322
  const sessionId = crypto.randomUUID();
2277
2323
  const repoName = body.repoUrl
2278
2324
  .split("/")