@electric-agent/studio 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/active-sessions.d.ts +4 -13
  2. package/dist/active-sessions.d.ts.map +1 -1
  3. package/dist/active-sessions.js +5 -39
  4. package/dist/active-sessions.js.map +1 -1
  5. package/dist/bridge/claude-code-base.d.ts +0 -2
  6. package/dist/bridge/claude-code-base.d.ts.map +1 -1
  7. package/dist/bridge/claude-code-base.js +0 -2
  8. package/dist/bridge/claude-code-base.js.map +1 -1
  9. package/dist/bridge/claude-md-generator.d.ts +2 -12
  10. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  11. package/dist/bridge/claude-md-generator.js +94 -72
  12. package/dist/bridge/claude-md-generator.js.map +1 -1
  13. package/dist/bridge/role-skills.d.ts.map +1 -1
  14. package/dist/bridge/role-skills.js +0 -8
  15. package/dist/bridge/role-skills.js.map +1 -1
  16. package/dist/client/assets/index-BfvQSMwH.css +1 -0
  17. package/dist/client/assets/index-BtX82X61.js +234 -0
  18. package/dist/client/index.html +2 -2
  19. package/dist/room-router.d.ts.map +1 -1
  20. package/dist/room-router.js +5 -20
  21. package/dist/room-router.js.map +1 -1
  22. package/dist/sandbox/docker.d.ts +0 -10
  23. package/dist/sandbox/docker.d.ts.map +1 -1
  24. package/dist/sandbox/docker.js +1 -115
  25. package/dist/sandbox/docker.js.map +1 -1
  26. package/dist/sandbox/sprites.d.ts +0 -1
  27. package/dist/sandbox/sprites.d.ts.map +1 -1
  28. package/dist/sandbox/sprites.js +0 -51
  29. package/dist/sandbox/sprites.js.map +1 -1
  30. package/dist/sandbox/types.d.ts +0 -5
  31. package/dist/sandbox/types.d.ts.map +1 -1
  32. package/dist/server.d.ts +0 -12
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +187 -1097
  35. package/dist/server.js.map +1 -1
  36. package/dist/session-auth.d.ts +0 -3
  37. package/dist/session-auth.d.ts.map +1 -1
  38. package/dist/session-auth.js +0 -10
  39. package/dist/session-auth.js.map +1 -1
  40. package/dist/sessions.d.ts +0 -2
  41. package/dist/sessions.d.ts.map +1 -1
  42. package/dist/sessions.js.map +1 -1
  43. package/package.json +2 -2
  44. package/dist/api-schemas.d.ts +0 -244
  45. package/dist/api-schemas.d.ts.map +0 -1
  46. package/dist/api-schemas.js +0 -103
  47. package/dist/api-schemas.js.map +0 -1
  48. package/dist/bridge/codex-docker.d.ts +0 -70
  49. package/dist/bridge/codex-docker.d.ts.map +0 -1
  50. package/dist/bridge/codex-docker.js +0 -234
  51. package/dist/bridge/codex-docker.js.map +0 -1
  52. package/dist/bridge/codex-json-parser.d.ts +0 -31
  53. package/dist/bridge/codex-json-parser.d.ts.map +0 -1
  54. package/dist/bridge/codex-json-parser.js +0 -267
  55. package/dist/bridge/codex-json-parser.js.map +0 -1
  56. package/dist/bridge/codex-md-generator.d.ts +0 -14
  57. package/dist/bridge/codex-md-generator.d.ts.map +0 -1
  58. package/dist/bridge/codex-md-generator.js +0 -55
  59. package/dist/bridge/codex-md-generator.js.map +0 -1
  60. package/dist/bridge/codex-sprites.d.ts +0 -64
  61. package/dist/bridge/codex-sprites.d.ts.map +0 -1
  62. package/dist/bridge/codex-sprites.js +0 -227
  63. package/dist/bridge/codex-sprites.js.map +0 -1
  64. package/dist/bridge/daytona.d.ts +0 -35
  65. package/dist/bridge/daytona.d.ts.map +0 -1
  66. package/dist/bridge/daytona.js +0 -141
  67. package/dist/bridge/daytona.js.map +0 -1
  68. package/dist/bridge/docker-stdio.d.ts +0 -30
  69. package/dist/bridge/docker-stdio.d.ts.map +0 -1
  70. package/dist/bridge/docker-stdio.js +0 -135
  71. package/dist/bridge/docker-stdio.js.map +0 -1
  72. package/dist/bridge/sprites.d.ts +0 -32
  73. package/dist/bridge/sprites.d.ts.map +0 -1
  74. package/dist/bridge/sprites.js +0 -133
  75. package/dist/bridge/sprites.js.map +0 -1
  76. package/dist/client/assets/index-BXdgNRgB.js +0 -235
  77. package/dist/client/assets/index-IvCtVUfs.css +0 -1
  78. package/dist/github-app.d.ts +0 -14
  79. package/dist/github-app.d.ts.map +0 -1
  80. package/dist/github-app.js +0 -62
  81. package/dist/github-app.js.map +0 -1
  82. package/dist/github-app.test.d.ts +0 -2
  83. package/dist/github-app.test.d.ts.map +0 -1
  84. package/dist/github-app.test.js +0 -62
  85. package/dist/github-app.test.js.map +0 -1
  86. package/dist/validate.d.ts +0 -10
  87. package/dist/validate.d.ts.map +0 -1
  88. package/dist/validate.js +0 -24
  89. package/dist/validate.js.map +0 -1
package/dist/server.js CHANGED
@@ -7,9 +7,8 @@ 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";
10
11
  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";
13
12
  import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
14
13
  import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
15
14
  import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
@@ -17,14 +16,11 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
17
16
  import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
18
17
  import { createGate, rejectAllGates, resolveGate } from "./gate.js";
19
18
  import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
20
- import { createOrgRepo, getInstallationToken } from "./github-app.js";
21
19
  import { generateInviteCode } from "./invite-code.js";
22
20
  import { resolveProjectDir } from "./project-utils.js";
23
- import { Registry } from "./registry.js";
24
21
  import { RoomRouter } from "./room-router.js";
25
- import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
22
+ import { deriveGlobalHookSecret, deriveHookToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateSessionToken, } from "./session-auth.js";
26
23
  import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
27
- import { isResponse, validateBody } from "./validate.js";
28
24
  /** Active session bridges — one per running session */
29
25
  const bridges = new Map();
30
26
  /** Active room routers — one per room with agent-to-agent messaging */
@@ -71,62 +67,9 @@ function resolveStudioUrl(port) {
71
67
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
72
68
  return `http://localhost:${port}`;
73
69
  }
74
- // ---------------------------------------------------------------------------
75
- // Rate limiting — in-memory sliding window per IP
76
- // ---------------------------------------------------------------------------
77
- const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
78
- const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
79
- const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
80
- const sessionCreationsByIp = new Map();
81
- // GitHub App config (prod mode — repo creation in electric-apps org)
82
- const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
83
- const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
84
- const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
85
- const GITHUB_ORG = "electric-apps";
86
- // Rate limiting for GitHub token endpoint
87
- const githubTokenRequestsBySession = new Map();
88
- const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
89
- function extractClientIp(c) {
90
- return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
91
- c.req.header("cf-connecting-ip") ||
92
- "unknown");
93
- }
94
- function checkSessionRateLimit(ip) {
95
- const now = Date.now();
96
- const cutoff = now - RATE_LIMIT_WINDOW_MS;
97
- let timestamps = sessionCreationsByIp.get(ip) ?? [];
98
- // Prune stale entries
99
- timestamps = timestamps.filter((t) => t > cutoff);
100
- if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
101
- sessionCreationsByIp.set(ip, timestamps);
102
- return false;
103
- }
104
- timestamps.push(now);
105
- sessionCreationsByIp.set(ip, timestamps);
106
- return true;
107
- }
108
- function checkGlobalSessionCap(sessions) {
109
- return sessions.size() >= MAX_TOTAL_SESSIONS;
110
- }
111
- function checkGithubTokenRateLimit(sessionId) {
112
- const now = Date.now();
113
- const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
114
- const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
115
- if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
116
- return false;
117
- }
118
- recent.push(now);
119
- githubTokenRequestsBySession.set(sessionId, recent);
120
- return true;
121
- }
122
- // ---------------------------------------------------------------------------
123
- // Per-session cost budget
124
- // ---------------------------------------------------------------------------
125
- const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
126
70
  /**
127
71
  * Accumulate cost and turn metrics from a session_end event into the session's totals.
128
72
  * Called each time a Claude Code run finishes (initial + iterate runs).
129
- * In production mode, enforces a per-session cost budget.
130
73
  */
131
74
  function accumulateSessionCost(config, sessionId, event) {
132
75
  if (event.type !== "session_end")
@@ -147,39 +90,12 @@ function accumulateSessionCost(config, sessionId, event) {
147
90
  }
148
91
  config.sessions.update(sessionId, updates);
149
92
  console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
150
- // Enforce budget in production mode
151
- if (!config.devMode &&
152
- updates.totalCostUsd != null &&
153
- updates.totalCostUsd > MAX_SESSION_COST_USD) {
154
- console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
155
- const bridge = bridges.get(sessionId);
156
- if (bridge) {
157
- bridge
158
- .emit({
159
- type: "budget_exceeded",
160
- budget_usd: MAX_SESSION_COST_USD,
161
- spent_usd: updates.totalCostUsd,
162
- ts: ts(),
163
- })
164
- .catch(() => { });
165
- }
166
- config.sessions.update(sessionId, { status: "error" });
167
- closeBridge(sessionId);
168
- }
169
93
  }
170
94
  /**
171
95
  * Create a Claude Code bridge for a session.
172
96
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
173
- * In production mode, enforces tool restrictions and hardcodes the model.
174
97
  */
175
98
  function createClaudeCodeBridge(config, sessionId, claudeConfig) {
176
- // Production mode: restrict tools and hardcode model
177
- if (!config.devMode) {
178
- if (!claudeConfig.allowedTools) {
179
- claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
180
- }
181
- claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
182
- }
183
99
  const conn = sessionStream(config, sessionId);
184
100
  let bridge;
185
101
  if (config.sandbox.runtime === "sprites") {
@@ -211,6 +127,31 @@ function closeBridge(sessionId) {
211
127
  bridges.delete(sessionId);
212
128
  }
213
129
  }
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
+ }
214
155
  /**
215
156
  * Map a Claude Code hook event JSON payload to an EngineEvent.
216
157
  *
@@ -324,6 +265,8 @@ function mapHookToEngineEvent(body) {
324
265
  }
325
266
  export function createApp(config) {
326
267
  const app = new Hono();
268
+ // CORS for local development
269
+ app.use("*", cors({ origin: "*" }));
327
270
  // --- API Routes ---
328
271
  // Health check
329
272
  app.get("/api/health", (c) => {
@@ -341,13 +284,6 @@ export function createApp(config) {
341
284
  checks.sandbox = config.sandbox.runtime;
342
285
  return c.json({ healthy, checks }, healthy ? 200 : 503);
343
286
  });
344
- // Public config — exposes non-sensitive flags to the client
345
- app.get("/api/config", (c) => {
346
- return c.json({
347
- devMode: config.devMode,
348
- maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
349
- });
350
- });
351
287
  // Provision Electric Cloud resources via the Claim API
352
288
  app.post("/api/provision-electric", async (c) => {
353
289
  try {
@@ -545,7 +481,6 @@ export function createApp(config) {
545
481
  if (hookEvent.type === "ask_user_question") {
546
482
  const toolUseId = hookEvent.tool_use_id;
547
483
  console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
548
- config.sessions.update(sessionId, { needsInput: true });
549
484
  try {
550
485
  const gateTimeout = 5 * 60 * 1000; // 5 minutes
551
486
  const result = await Promise.race([
@@ -553,7 +488,6 @@ export function createApp(config) {
553
488
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
554
489
  ]);
555
490
  console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
556
- config.sessions.update(sessionId, { needsInput: false });
557
491
  return c.json({
558
492
  hookSpecificOutput: {
559
493
  hookEventName: "PreToolUse",
@@ -567,7 +501,6 @@ export function createApp(config) {
567
501
  }
568
502
  catch (err) {
569
503
  console.error(`[hook-event] ask_user_question gate error:`, err);
570
- config.sessions.update(sessionId, { needsInput: false });
571
504
  return c.json({ ok: true }); // Don't block Claude Code on timeout
572
505
  }
573
506
  }
@@ -689,7 +622,6 @@ export function createApp(config) {
689
622
  if (hookEvent.type === "ask_user_question") {
690
623
  const toolUseId = hookEvent.tool_use_id;
691
624
  console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
692
- config.sessions.update(sessionId, { needsInput: true });
693
625
  try {
694
626
  const gateTimeout = 5 * 60 * 1000;
695
627
  const result = await Promise.race([
@@ -697,7 +629,6 @@ export function createApp(config) {
697
629
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
698
630
  ]);
699
631
  console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
700
- config.sessions.update(sessionId, { needsInput: false });
701
632
  return c.json({
702
633
  sessionId,
703
634
  hookSpecificOutput: {
@@ -712,7 +643,6 @@ export function createApp(config) {
712
643
  }
713
644
  catch (err) {
714
645
  console.error(`[hook] ask_user_question gate error:`, err);
715
- config.sessions.update(sessionId, { needsInput: false });
716
646
  return c.json({ ok: true, sessionId });
717
647
  }
718
648
  }
@@ -808,40 +738,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
808
738
  });
809
739
  // Start new project
810
740
  app.post("/api/sessions", async (c) => {
811
- const body = await validateBody(c, createSessionSchema);
812
- if (isResponse(body))
813
- return body;
814
- // In prod mode, use server-side API key; ignore user-provided credentials
815
- const apiKey = config.devMode
816
- ? body.apiKey || process.env.ANTHROPIC_API_KEY
817
- : process.env.ANTHROPIC_API_KEY;
818
- const oauthToken = config.devMode
819
- ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
820
- : undefined;
821
- const ghToken = config.devMode ? body.ghToken : undefined;
822
- // Block freeform sessions in production mode
823
- if (body.freeform && !config.devMode) {
824
- return c.json({ error: "Freeform sessions are not available" }, 403);
825
- }
826
- // Rate-limit session creation in production mode
827
- if (!config.devMode) {
828
- const ip = extractClientIp(c);
829
- if (!checkSessionRateLimit(ip)) {
830
- return c.json({ error: "Too many sessions. Please try again later." }, 429);
831
- }
832
- if (checkGlobalSessionCap(config.sessions)) {
833
- return c.json({ error: "Service at capacity, please try again later" }, 503);
834
- }
741
+ const body = (await c.req.json());
742
+ if (!body.description) {
743
+ return c.json({ error: "description is required" }, 400);
835
744
  }
836
745
  const sessionId = crypto.randomUUID();
837
- const inferredName = config.devMode
838
- ? body.name ||
839
- body.description
840
- .slice(0, 40)
841
- .replace(/[^a-z0-9]+/gi, "-")
842
- .replace(/^-|-$/g, "")
843
- .toLowerCase()
844
- : `electric-${sessionId.slice(0, 8)}`;
746
+ const inferredName = body.name ||
747
+ body.description
748
+ .slice(0, 40)
749
+ .replace(/[^a-z0-9]+/gi, "-")
750
+ .replace(/^-|-$/g, "")
751
+ .toLowerCase();
845
752
  const baseDir = body.baseDir || process.cwd();
846
753
  const { projectName } = resolveProjectDir(baseDir, inferredName);
847
754
  console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
@@ -878,10 +785,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
878
785
  // Freeform sessions skip the infra config gate — no Electric/DB setup needed
879
786
  let ghAccounts = [];
880
787
  if (!body.freeform) {
881
- // Gather GitHub accounts for the merged setup gate (dev mode only)
882
- if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
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)) {
883
791
  try {
884
- ghAccounts = ghListAccounts(ghToken);
792
+ ghAccounts = ghListAccounts(body.ghToken);
885
793
  }
886
794
  catch {
887
795
  // gh not available — no repo setup
@@ -964,15 +872,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
964
872
  const handle = await config.sandbox.create(sessionId, {
965
873
  projectName,
966
874
  infra,
967
- apiKey,
968
- oauthToken,
969
- ghToken,
970
- ...((!config.devMode || GITHUB_APP_ID) && {
971
- prodMode: {
972
- sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
973
- studioUrl: resolveStudioUrl(config.port),
974
- },
975
- }),
875
+ apiKey: body.apiKey,
876
+ oauthToken: body.oauthToken,
877
+ ghToken: body.ghToken,
976
878
  });
977
879
  console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
978
880
  await bridge.emit({
@@ -1038,54 +940,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
1038
940
  ts: ts(),
1039
941
  });
1040
942
  }
1041
- // Create GitHub repo via GitHub App when credentials are available
1042
- let prodGitConfig;
1043
- if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
1044
- try {
1045
- // Repo name matches the project name (already has random slug)
1046
- const repoSlug = projectName;
1047
- await bridge.emit({
1048
- type: "log",
1049
- level: "build",
1050
- message: "Creating GitHub repository...",
1051
- ts: ts(),
1052
- });
1053
- const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1054
- const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
1055
- if (repo) {
1056
- const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
1057
- // Initialize git and set remote in the sandbox
1058
- await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
1059
- prodGitConfig = {
1060
- mode: "pre-created",
1061
- repoName: actualRepoName,
1062
- repoUrl: repo.htmlUrl,
1063
- };
1064
- config.sessions.update(sessionId, {
1065
- git: {
1066
- branch: "main",
1067
- remoteUrl: repo.htmlUrl,
1068
- repoName: actualRepoName,
1069
- lastCommitHash: null,
1070
- lastCommitMessage: null,
1071
- lastCheckpointAt: null,
1072
- },
1073
- });
1074
- await bridge.emit({
1075
- type: "log",
1076
- level: "done",
1077
- message: `GitHub repo created: ${repo.htmlUrl}`,
1078
- ts: ts(),
1079
- });
1080
- }
1081
- else {
1082
- console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
1083
- }
1084
- }
1085
- catch (err) {
1086
- console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
1087
- }
1088
- }
1089
943
  // Write CLAUDE.md to the sandbox workspace.
1090
944
  // Our generator includes hardcoded playbook paths and reading order
1091
945
  // so we don't depend on @tanstack/intent generating a skill block.
@@ -1094,18 +948,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
1094
948
  projectName,
1095
949
  projectDir: handle.projectDir,
1096
950
  runtime: config.sandbox.runtime,
1097
- production: !config.devMode,
1098
- ...(prodGitConfig
1099
- ? { git: prodGitConfig }
1100
- : repoConfig
1101
- ? {
1102
- git: {
1103
- mode: "create",
1104
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1105
- visibility: repoConfig.visibility,
1106
- },
1107
- }
1108
- : {}),
951
+ ...(repoConfig
952
+ ? {
953
+ git: {
954
+ mode: "create",
955
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
956
+ visibility: repoConfig.visibility,
957
+ },
958
+ }
959
+ : {}),
1109
960
  });
1110
961
  try {
1111
962
  await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
@@ -1267,54 +1118,79 @@ echo "Start claude in this project — the session will appear in the studio UI.
1267
1118
  const session = config.sessions.get(sessionId);
1268
1119
  if (!session)
1269
1120
  return c.json({ error: "Session not found" }, 404);
1270
- const body = await validateBody(c, iterateSessionSchema);
1271
- if (isResponse(body))
1272
- return body;
1273
- const handle = config.sandbox.get(sessionId);
1274
- if (!handle || !config.sandbox.isAlive(handle)) {
1275
- return c.json({ error: "Container is not running" }, 400);
1276
- }
1277
- // Ensure we have a CC bridge (not just a stream writer).
1278
- // After server restart, bridges are lost — getOrCreateBridge would create
1279
- // a HostedStreamBridge that can only write to the stream but can't spawn
1280
- // Claude Code processes. We need a ClaudeCodeDockerBridge to restart the agent.
1281
- let bridge = bridges.get(sessionId);
1282
- if (!bridge) {
1283
- const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1284
- const claudeConfig = config.sandbox.runtime === "sprites"
1285
- ? {
1286
- prompt: body.request,
1287
- cwd: session.sandboxProjectDir || handle.projectDir,
1288
- studioUrl: resolveStudioUrl(config.port),
1289
- hookToken,
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() });
1290
1143
  }
1291
- : {
1292
- prompt: body.request,
1293
- cwd: session.sandboxProjectDir || handle.projectDir,
1294
- studioPort: config.port,
1295
- hookToken,
1296
- };
1297
- bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1298
- // Re-register basic event tracking callbacks
1299
- bridge.onAgentEvent((event) => {
1300
- if (event.type === "session_start") {
1301
- const ccSessionId = event.session_id;
1302
- if (ccSessionId) {
1303
- config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1144
+ else {
1145
+ if (!handle || !config.sandbox.isAlive(handle)) {
1146
+ return c.json({ error: "Container is not running" }, 400);
1304
1147
  }
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
+ });
1305
1164
  }
1306
- if (event.type === "session_end") {
1307
- accumulateSessionCost(config, sessionId, event);
1308
- }
1309
- });
1310
- bridge.onComplete(async (success) => {
1311
- config.sessions.update(sessionId, {
1312
- status: success ? "complete" : "error",
1313
- });
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,
1314
1185
  });
1315
- console.log(`[iterate] Recreated CC bridge for session ${sessionId} after restart`);
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);
1316
1191
  }
1317
1192
  // Write user prompt to the stream
1193
+ const bridge = getOrCreateBridge(config, sessionId);
1318
1194
  await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1319
1195
  config.sessions.update(sessionId, { status: "running" });
1320
1196
  await bridge.sendCommand({
@@ -1325,28 +1201,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
1325
1201
  });
1326
1202
  return c.json({ ok: true });
1327
1203
  });
1328
- // Generate a GitHub installation token for the sandbox (prod mode only)
1329
- app.post("/api/sessions/:id/github-token", async (c) => {
1330
- const sessionId = c.req.param("id");
1331
- if (config.devMode) {
1332
- return c.json({ error: "Not available in dev mode" }, 403);
1333
- }
1334
- if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
1335
- return c.json({ error: "GitHub App not configured" }, 500);
1336
- }
1337
- if (!checkGithubTokenRateLimit(sessionId)) {
1338
- return c.json({ error: "Too many token requests" }, 429);
1339
- }
1340
- try {
1341
- const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
1342
- return c.json(result);
1343
- }
1344
- catch (err) {
1345
- const message = err instanceof Error ? err.message : "Unknown error";
1346
- console.error(`GitHub token error for session ${sessionId}:`, message);
1347
- return c.json({ error: "Failed to generate GitHub token" }, 500);
1348
- }
1349
- });
1350
1204
  // Respond to a gate (approval, clarification, continue, revision)
1351
1205
  app.post("/api/sessions/:id/respond", async (c) => {
1352
1206
  const sessionId = c.req.param("id");
@@ -1637,9 +1491,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1637
1491
  });
1638
1492
  // Create a standalone sandbox (not tied to session creation flow)
1639
1493
  app.post("/api/sandboxes", async (c) => {
1640
- const body = await validateBody(c, createSandboxSchema);
1641
- if (isResponse(body))
1642
- return body;
1494
+ const body = (await c.req.json());
1643
1495
  const sessionId = body.sessionId ?? crypto.randomUUID();
1644
1496
  try {
1645
1497
  const handle = await config.sandbox.create(sessionId, {
@@ -1677,715 +1529,30 @@ echo "Start claude in this project — the session will appear in the studio UI.
1677
1529
  return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
1678
1530
  }
1679
1531
  // Protect room-scoped routes via X-Room-Token header
1680
- // "create-app" is a creation endpoint — no room token exists yet
1681
- const roomAuthExemptIds = new Set(["create-app"]);
1682
1532
  app.use("/api/rooms/:id/*", async (c, next) => {
1683
1533
  const id = c.req.param("id");
1684
- if (roomAuthExemptIds.has(id))
1685
- return next();
1686
1534
  const token = extractRoomToken(c);
1687
- if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1535
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1688
1536
  return c.json({ error: "Invalid or missing room token" }, 401);
1689
1537
  }
1690
1538
  return next();
1691
1539
  });
1692
1540
  app.use("/api/rooms/:id", async (c, next) => {
1693
- const id = c.req.param("id");
1694
- if (roomAuthExemptIds.has(id))
1695
- return next();
1696
1541
  if (c.req.method !== "GET" && c.req.method !== "DELETE")
1697
1542
  return next();
1543
+ const id = c.req.param("id");
1698
1544
  const token = extractRoomToken(c);
1699
- if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1545
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1700
1546
  return c.json({ error: "Invalid or missing room token" }, 401);
1701
1547
  }
1702
1548
  return next();
1703
1549
  });
1704
- // Create a room with 3 agents for multi-agent app creation
1705
- app.post("/api/rooms/create-app", async (c) => {
1706
- const body = await validateBody(c, createAppRoomSchema);
1707
- if (isResponse(body))
1708
- return body;
1709
- // In prod mode, use server-side API key; ignore user-provided credentials
1710
- const apiKey = config.devMode
1711
- ? body.apiKey || process.env.ANTHROPIC_API_KEY
1712
- : process.env.ANTHROPIC_API_KEY;
1713
- const oauthToken = config.devMode
1714
- ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
1715
- : undefined;
1716
- const ghToken = config.devMode ? body.ghToken : undefined;
1717
- // Rate-limit session creation in production mode
1718
- if (!config.devMode) {
1719
- const ip = extractClientIp(c);
1720
- if (!checkSessionRateLimit(ip)) {
1721
- return c.json({ error: "Too many sessions. Please try again later." }, 429);
1722
- }
1723
- if (checkGlobalSessionCap(config.sessions)) {
1724
- return c.json({ error: "Service at capacity, please try again later" }, 503);
1725
- }
1726
- }
1727
- const roomId = crypto.randomUUID();
1728
- const roomName = body.name || `app-${roomId.slice(0, 8)}`;
1729
- // Create the room's durable stream
1730
- const roomConn = roomStream(config, roomId);
1731
- try {
1732
- await DurableStream.create({
1733
- url: roomConn.url,
1734
- headers: roomConn.headers,
1735
- contentType: "application/json",
1736
- });
1737
- }
1738
- catch (err) {
1739
- console.error(`[room:create-app] Failed to create room stream:`, err);
1740
- return c.json({ error: "Failed to create room stream" }, 500);
1741
- }
1742
- // Create and start the router
1743
- const router = new RoomRouter(roomId, roomName, config.streamConfig);
1744
- await router.start();
1745
- roomRouters.set(roomId, router);
1746
- // Save to room registry
1747
- const code = generateInviteCode();
1748
- await config.rooms.addRoom({
1749
- id: roomId,
1750
- code,
1751
- name: roomName,
1752
- createdAt: new Date().toISOString(),
1753
- revoked: false,
1754
- });
1755
- // Define the 3 agents with randomized display names
1756
- const agentSuffixes = [
1757
- "fox",
1758
- "owl",
1759
- "lynx",
1760
- "wolf",
1761
- "bear",
1762
- "hawk",
1763
- "pine",
1764
- "oak",
1765
- "elm",
1766
- "ivy",
1767
- "ray",
1768
- "arc",
1769
- "reef",
1770
- "dusk",
1771
- "ash",
1772
- "sage",
1773
- ];
1774
- const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
1775
- const usedSuffixes = new Set();
1776
- const uniquePick = () => {
1777
- let s = pick();
1778
- while (usedSuffixes.has(s))
1779
- s = pick();
1780
- usedSuffixes.add(s);
1781
- return s;
1782
- };
1783
- const agentDefs = [
1784
- { name: `coder-${uniquePick()}`, role: "coder" },
1785
- { name: `reviewer-${uniquePick()}`, role: "reviewer" },
1786
- { name: `designer-${uniquePick()}`, role: "ui-designer" },
1787
- ];
1788
- // Create session IDs and streams upfront for all 3 agents
1789
- const sessions = [];
1790
- for (const agentDef of agentDefs) {
1791
- const sessionId = crypto.randomUUID();
1792
- const conn = sessionStream(config, sessionId);
1793
- try {
1794
- await DurableStream.create({
1795
- url: conn.url,
1796
- headers: conn.headers,
1797
- contentType: "application/json",
1798
- });
1799
- }
1800
- catch (err) {
1801
- console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
1802
- return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
1803
- }
1804
- const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1805
- sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
1806
- }
1807
- const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1808
- console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
1809
- // Return immediately so the client can show the room + sessions
1810
- // The async flow handles sandbox creation, skill injection, and agent startup
1811
- // Sessions are created in agentDefs order: [coder, reviewer, ui-designer]
1812
- const coderSession = sessions[0];
1813
- const reviewerSession = sessions[1];
1814
- const uiDesignerSession = sessions[2];
1815
- const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
1816
- // Record all sessions
1817
- for (const s of sessions) {
1818
- const projectName = s.role === "coder" && config.devMode
1819
- ? body.name ||
1820
- body.description
1821
- .slice(0, 40)
1822
- .replace(/[^a-z0-9]+/gi, "-")
1823
- .replace(/^-|-$/g, "")
1824
- .toLowerCase()
1825
- : `room-${s.name}-${s.sessionId.slice(0, 8)}`;
1826
- const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
1827
- const session = {
1828
- id: s.sessionId,
1829
- projectName,
1830
- sandboxProjectDir,
1831
- description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
1832
- createdAt: new Date().toISOString(),
1833
- lastActiveAt: new Date().toISOString(),
1834
- status: "running",
1835
- };
1836
- config.sessions.add(session);
1837
- }
1838
- // Write user prompt to coder's stream
1839
- await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
1840
- // Gather GitHub accounts for the infra config gate (dev mode only)
1841
- let ghAccounts = [];
1842
- if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
1843
- try {
1844
- ghAccounts = ghListAccounts(ghToken);
1845
- }
1846
- catch {
1847
- // gh not available
1848
- }
1849
- }
1850
- // Emit infra config gate on coder's stream
1851
- const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
1852
- await coderBridge.emit({
1853
- type: "infra_config_prompt",
1854
- projectName: coderProjectName,
1855
- ghAccounts,
1856
- runtime: config.sandbox.runtime,
1857
- ts: ts(),
1858
- });
1859
- // Async flow: wait for gate, create sandboxes, start agents
1860
- const asyncFlow = async () => {
1861
- // 1. Wait for infra config gate on coder's session
1862
- await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
1863
- console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
1864
- let infra;
1865
- let repoConfig = null;
1866
- let claimId;
1867
- try {
1868
- const gateValue = await createGate(coderSession.sessionId, "infra_config");
1869
- console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
1870
- if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
1871
- infra = {
1872
- mode: "cloud",
1873
- databaseUrl: gateValue.databaseUrl,
1874
- electricUrl: gateValue.electricUrl,
1875
- sourceId: gateValue.sourceId,
1876
- secret: gateValue.secret,
1877
- };
1878
- if (gateValue.mode === "claim") {
1879
- claimId = gateValue.claimId;
1880
- }
1881
- }
1882
- else {
1883
- infra = { mode: "local" };
1884
- }
1885
- // Extract repo config if provided
1886
- if (gateValue.repoAccount && gateValue.repoName?.trim()) {
1887
- repoConfig = {
1888
- account: gateValue.repoAccount,
1889
- repoName: gateValue.repoName,
1890
- visibility: gateValue.repoVisibility ?? "private",
1891
- };
1892
- config.sessions.update(coderSession.sessionId, {
1893
- git: {
1894
- branch: "main",
1895
- remoteUrl: null,
1896
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1897
- repoVisibility: repoConfig.visibility,
1898
- lastCommitHash: null,
1899
- lastCommitMessage: null,
1900
- lastCheckpointAt: null,
1901
- },
1902
- });
1903
- }
1904
- }
1905
- catch (err) {
1906
- console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
1907
- infra = { mode: "local" };
1908
- }
1909
- // 2. Create sandboxes in parallel
1910
- // Coder gets full scaffold, reviewer/ui-designer get minimal
1911
- await router.sendMessage("system", "Creating sandboxes");
1912
- await coderBridge.emit({
1913
- type: "log",
1914
- level: "build",
1915
- message: "Creating sandboxes for all agents...",
1916
- ts: ts(),
1917
- });
1918
- const sandboxOpts = (sid) => ({
1919
- ...((!config.devMode || GITHUB_APP_ID) && {
1920
- prodMode: {
1921
- sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
1922
- studioUrl: resolveStudioUrl(config.port),
1923
- },
1924
- }),
1925
- });
1926
- const coderInfo = config.sessions.get(coderSession.sessionId);
1927
- if (!coderInfo)
1928
- throw new Error("Coder session not found in registry");
1929
- const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
1930
- if (!reviewerInfo)
1931
- throw new Error("Reviewer session not found in registry");
1932
- const uiDesignerInfo = config.sessions.get(uiDesignerSession.sessionId);
1933
- if (!uiDesignerInfo)
1934
- throw new Error("UI designer session not found in registry");
1935
- const [coderHandle, reviewerHandle, uiDesignerHandle] = await Promise.all([
1936
- config.sandbox.create(coderSession.sessionId, {
1937
- projectName: coderInfo.projectName,
1938
- infra,
1939
- apiKey,
1940
- oauthToken,
1941
- ghToken,
1942
- ...sandboxOpts(coderSession.sessionId),
1943
- }),
1944
- config.sandbox.create(reviewerSession.sessionId, {
1945
- projectName: reviewerInfo.projectName,
1946
- infra: { mode: "none" },
1947
- apiKey,
1948
- oauthToken,
1949
- ghToken,
1950
- ...sandboxOpts(reviewerSession.sessionId),
1951
- }),
1952
- config.sandbox.create(uiDesignerSession.sessionId, {
1953
- projectName: uiDesignerInfo.projectName,
1954
- infra: { mode: "none" },
1955
- apiKey,
1956
- oauthToken,
1957
- ghToken,
1958
- ...sandboxOpts(uiDesignerSession.sessionId),
1959
- }),
1960
- ]);
1961
- const handles = [
1962
- { session: coderSession, handle: coderHandle },
1963
- { session: reviewerSession, handle: reviewerHandle },
1964
- { session: uiDesignerSession, handle: uiDesignerHandle },
1965
- ];
1966
- // Update session info with sandbox details
1967
- for (const { session: s, handle } of handles) {
1968
- config.sessions.update(s.sessionId, {
1969
- appPort: handle.port,
1970
- sandboxProjectDir: handle.projectDir,
1971
- previewUrl: handle.previewUrl,
1972
- ...(s.role === "coder" && claimId ? { claimId } : {}),
1973
- });
1974
- }
1975
- await coderBridge.emit({
1976
- type: "log",
1977
- level: "done",
1978
- message: "All sandboxes ready",
1979
- ts: ts(),
1980
- });
1981
- // 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
1982
- {
1983
- const handle = coderHandle;
1984
- // Copy scaffold
1985
- await coderBridge.emit({
1986
- type: "log",
1987
- level: "build",
1988
- message: "Setting up project...",
1989
- ts: ts(),
1990
- });
1991
- try {
1992
- if (config.sandbox.runtime === "docker") {
1993
- await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
1994
- await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
1995
- }
1996
- else {
1997
- await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${coderInfo.projectName}' --skip-git`);
1998
- }
1999
- await coderBridge.emit({
2000
- type: "log",
2001
- level: "done",
2002
- message: "Project ready",
2003
- ts: ts(),
2004
- });
2005
- }
2006
- catch (err) {
2007
- console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
2008
- await coderBridge.emit({
2009
- type: "log",
2010
- level: "error",
2011
- message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
2012
- ts: ts(),
2013
- });
2014
- }
2015
- // GitHub repo creation (uses GitHub App when credentials are available)
2016
- let repoUrl = null;
2017
- let prodGitConfig;
2018
- if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
2019
- try {
2020
- const repoSlug = coderInfo.projectName;
2021
- await coderBridge.emit({
2022
- type: "log",
2023
- level: "build",
2024
- message: "Creating GitHub repository...",
2025
- ts: ts(),
2026
- });
2027
- const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
2028
- const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
2029
- if (repo) {
2030
- const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
2031
- await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
2032
- prodGitConfig = {
2033
- mode: "pre-created",
2034
- repoName: actualRepoName,
2035
- repoUrl: repo.htmlUrl,
2036
- };
2037
- repoUrl = repo.htmlUrl;
2038
- config.sessions.update(coderSession.sessionId, {
2039
- git: {
2040
- branch: "main",
2041
- remoteUrl: repo.htmlUrl,
2042
- repoName: actualRepoName,
2043
- lastCommitHash: null,
2044
- lastCommitMessage: null,
2045
- lastCheckpointAt: null,
2046
- },
2047
- });
2048
- await coderBridge.emit({
2049
- type: "log",
2050
- level: "done",
2051
- message: `GitHub repo created: ${repo.htmlUrl}`,
2052
- ts: ts(),
2053
- });
2054
- }
2055
- }
2056
- catch (err) {
2057
- console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
2058
- }
2059
- }
2060
- else if (repoConfig) {
2061
- repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
2062
- }
2063
- // Write CLAUDE.md to coder sandbox
2064
- const claudeMd = generateClaudeMd({
2065
- description: body.description,
2066
- projectName: coderInfo.projectName,
2067
- projectDir: handle.projectDir,
2068
- runtime: config.sandbox.runtime,
2069
- production: !config.devMode,
2070
- ...(prodGitConfig
2071
- ? { git: prodGitConfig }
2072
- : repoConfig
2073
- ? {
2074
- git: {
2075
- mode: "create",
2076
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
2077
- visibility: repoConfig.visibility,
2078
- },
2079
- }
2080
- : {}),
2081
- });
2082
- try {
2083
- await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
2084
- }
2085
- catch (err) {
2086
- console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
2087
- }
2088
- // Write create-app skill to coder sandbox
2089
- if (createAppSkillContent) {
2090
- try {
2091
- const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
2092
- const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
2093
- await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2094
- }
2095
- catch (err) {
2096
- console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
2097
- }
2098
- }
2099
- // Write room-messaging skill to coder sandbox
2100
- if (roomMessagingSkillContent) {
2101
- try {
2102
- const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
2103
- const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2104
- await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2105
- }
2106
- catch (err) {
2107
- console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
2108
- }
2109
- }
2110
- // 4. Create Claude Code bridge for coder
2111
- const coderPrompt = `/create-app ${body.description}`;
2112
- const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
2113
- const coderClaudeConfig = config.sandbox.runtime === "sprites"
2114
- ? {
2115
- prompt: coderPrompt,
2116
- cwd: handle.projectDir,
2117
- studioUrl: resolveStudioUrl(config.port),
2118
- hookToken: coderHookToken,
2119
- agentName: coderSession.name,
2120
- }
2121
- : {
2122
- prompt: coderPrompt,
2123
- cwd: handle.projectDir,
2124
- studioPort: config.port,
2125
- hookToken: coderHookToken,
2126
- agentName: coderSession.name,
2127
- };
2128
- const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
2129
- // Track coder events
2130
- coderCcBridge.onAgentEvent((event) => {
2131
- if (event.type === "session_start") {
2132
- const ccSessionId = event.session_id;
2133
- if (ccSessionId) {
2134
- config.sessions.update(coderSession.sessionId, {
2135
- lastCoderSessionId: ccSessionId,
2136
- });
2137
- }
2138
- }
2139
- if (event.type === "session_end") {
2140
- accumulateSessionCost(config, coderSession.sessionId, event);
2141
- }
2142
- // Route assistant_message output to the room router
2143
- if (event.type === "assistant_message" && "text" in event) {
2144
- router
2145
- .handleAgentOutput(coderSession.sessionId, event.text)
2146
- .catch((err) => {
2147
- console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
2148
- });
2149
- }
2150
- // Notify room when coder is waiting for user input
2151
- if (event.type === "ask_user_question") {
2152
- config.sessions.update(coderSession.sessionId, { needsInput: true });
2153
- router
2154
- .sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
2155
- .catch((err) => {
2156
- console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
2157
- });
2158
- }
2159
- if (event.type === "gate_resolved") {
2160
- config.sessions.update(coderSession.sessionId, { needsInput: false });
2161
- router
2162
- .sendMessage("system", `${coderSession.name} received input — resuming.`)
2163
- .catch(() => { });
2164
- }
2165
- });
2166
- // Coder completion handler: notify room on success or failure
2167
- coderCcBridge.onComplete(async (success) => {
2168
- const updates = {
2169
- status: success ? "complete" : "error",
2170
- };
2171
- let repoInfo = "";
2172
- try {
2173
- const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
2174
- if (gs.initialized) {
2175
- const existing = config.sessions.get(coderSession.sessionId);
2176
- updates.git = {
2177
- branch: gs.branch ?? "main",
2178
- remoteUrl: existing?.git?.remoteUrl ?? null,
2179
- repoName: existing?.git?.repoName ?? null,
2180
- repoVisibility: existing?.git?.repoVisibility,
2181
- lastCommitHash: gs.lastCommitHash ?? null,
2182
- lastCommitMessage: gs.lastCommitMessage ?? null,
2183
- lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
2184
- };
2185
- if (existing?.git?.repoName) {
2186
- repoInfo = ` Repo: https://github.com/${existing.git.repoName}`;
2187
- }
2188
- }
2189
- }
2190
- catch {
2191
- // Sandbox may be stopped
2192
- }
2193
- config.sessions.update(coderSession.sessionId, updates);
2194
- const msg = success
2195
- ? `@room DONE: App is ready.${repoInfo}`
2196
- : "@room Coder session ended unexpectedly.";
2197
- router.handleAgentOutput(coderSession.sessionId, msg).catch((err) => {
2198
- console.error(`[room:create-app:${roomId}] Failed to send coder completion message:`, err);
2199
- });
2200
- });
2201
- await coderBridge.emit({
2202
- type: "log",
2203
- level: "build",
2204
- message: `Running: claude "/create-app ${body.description}"`,
2205
- ts: ts(),
2206
- });
2207
- await coderCcBridge.start();
2208
- // Add coder as room participant
2209
- const coderParticipant = {
2210
- sessionId: coderSession.sessionId,
2211
- name: coderSession.name,
2212
- role: "coder",
2213
- bridge: coderCcBridge,
2214
- };
2215
- await router.addParticipant(coderParticipant, false);
2216
- // Send the initial command to the coder
2217
- await coderCcBridge.sendCommand({
2218
- command: "new",
2219
- description: body.description,
2220
- projectName: coderInfo.projectName,
2221
- baseDir: "/home/agent/workspace",
2222
- });
2223
- // Store the repoUrl for reviewer/ui-designer prompts
2224
- // (we continue setting up those agents now)
2225
- const finalRepoUrl = repoUrl;
2226
- // 5. Set up reviewer and ui-designer sandboxes
2227
- const supportAgents = [
2228
- { session: reviewerSession, handle: reviewerHandle },
2229
- { session: uiDesignerSession, handle: uiDesignerHandle },
2230
- ];
2231
- for (const { session: agentSession, handle: agentHandle } of supportAgents) {
2232
- const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
2233
- // Write a minimal CLAUDE.md
2234
- const minimalClaudeMd = "Room agent workspace";
2235
- try {
2236
- await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
2237
- }
2238
- catch (err) {
2239
- console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
2240
- }
2241
- // Write room-messaging skill
2242
- if (roomMessagingSkillContent) {
2243
- try {
2244
- const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
2245
- const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2246
- await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2247
- }
2248
- catch (err) {
2249
- console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
2250
- }
2251
- }
2252
- // Resolve and inject role skill
2253
- const roleSkill = resolveRoleSkill(agentSession.role);
2254
- if (roleSkill) {
2255
- try {
2256
- const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
2257
- const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
2258
- await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2259
- }
2260
- catch (err) {
2261
- console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
2262
- }
2263
- }
2264
- // Build prompt
2265
- const repoRef = finalRepoUrl ? ` The GitHub repo is: ${finalRepoUrl}.` : "";
2266
- const agentPrompt = agentSession.role === "reviewer"
2267
- ? `You are "reviewer", a code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`
2268
- : `You are "ui-designer", a UI design agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`;
2269
- // Create Claude Code bridge
2270
- const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
2271
- const agentClaudeConfig = config.sandbox.runtime === "sprites"
2272
- ? {
2273
- prompt: agentPrompt,
2274
- cwd: agentHandle.projectDir,
2275
- studioUrl: resolveStudioUrl(config.port),
2276
- hookToken: agentHookToken,
2277
- agentName: agentSession.name,
2278
- ...(roleSkill?.allowedTools && {
2279
- allowedTools: roleSkill.allowedTools,
2280
- }),
2281
- }
2282
- : {
2283
- prompt: agentPrompt,
2284
- cwd: agentHandle.projectDir,
2285
- studioPort: config.port,
2286
- hookToken: agentHookToken,
2287
- agentName: agentSession.name,
2288
- ...(roleSkill?.allowedTools && {
2289
- allowedTools: roleSkill.allowedTools,
2290
- }),
2291
- };
2292
- const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
2293
- // Track events
2294
- ccBridge.onAgentEvent((event) => {
2295
- console.log(`[room:create-app:${roomId}] ${agentSession.name} event: type=${event.type}${event.type === "assistant_message" && "text" in event ? ` text=${event.text.slice(0, 120)}` : ""}`);
2296
- if (event.type === "session_start") {
2297
- const ccSessionId = event.session_id;
2298
- if (ccSessionId) {
2299
- config.sessions.update(agentSession.sessionId, {
2300
- lastCoderSessionId: ccSessionId,
2301
- });
2302
- }
2303
- }
2304
- if (event.type === "session_end") {
2305
- accumulateSessionCost(config, agentSession.sessionId, event);
2306
- }
2307
- if (event.type === "assistant_message" && "text" in event) {
2308
- const text = event.text;
2309
- console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
2310
- router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
2311
- console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
2312
- });
2313
- }
2314
- if (event.type === "ask_user_question") {
2315
- config.sessions.update(agentSession.sessionId, { needsInput: true });
2316
- router
2317
- .sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
2318
- .catch((err) => {
2319
- console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
2320
- });
2321
- }
2322
- if (event.type === "gate_resolved") {
2323
- config.sessions.update(agentSession.sessionId, { needsInput: false });
2324
- router
2325
- .sendMessage("system", `${agentSession.name} received input — resuming.`)
2326
- .catch(() => { });
2327
- }
2328
- });
2329
- ccBridge.onComplete(async (success) => {
2330
- config.sessions.update(agentSession.sessionId, {
2331
- status: success ? "complete" : "error",
2332
- });
2333
- });
2334
- await agentBridge.emit({
2335
- type: "log",
2336
- level: "done",
2337
- message: `Sandbox ready for "${agentSession.name}"`,
2338
- ts: ts(),
2339
- });
2340
- await ccBridge.start();
2341
- // Add as room participant (not gated — messages flow freely)
2342
- const participant = {
2343
- sessionId: agentSession.sessionId,
2344
- name: agentSession.name,
2345
- role: agentSession.role,
2346
- bridge: ccBridge,
2347
- };
2348
- await router.addParticipant(participant, false);
2349
- }
2350
- console.log(`[room:create-app:${roomId}] All 3 agents started and added to room`);
2351
- await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} and ${uiDesignerSession.name} waiting.`);
2352
- }
2353
- };
2354
- asyncFlow().catch(async (err) => {
2355
- console.error(`[room:create-app:${roomId}] Flow failed:`, err);
2356
- for (const s of sessions) {
2357
- config.sessions.update(s.sessionId, { status: "error" });
2358
- }
2359
- try {
2360
- await coderBridge.emit({
2361
- type: "log",
2362
- level: "error",
2363
- message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
2364
- ts: ts(),
2365
- });
2366
- }
2367
- catch {
2368
- // Bridge may not be usable
2369
- }
2370
- });
2371
- return c.json({
2372
- roomId,
2373
- code,
2374
- name: roomName,
2375
- roomToken,
2376
- sessions: sessions.map((s) => ({
2377
- sessionId: s.sessionId,
2378
- name: s.name,
2379
- role: s.role,
2380
- sessionToken: s.sessionToken,
2381
- })),
2382
- }, 201);
2383
- });
2384
1550
  // Create a room
2385
1551
  app.post("/api/rooms", async (c) => {
2386
- const body = await validateBody(c, createRoomSchema);
2387
- if (isResponse(body))
2388
- return body;
1552
+ const body = (await c.req.json());
1553
+ if (!body.name) {
1554
+ return c.json({ error: "name is required" }, 400);
1555
+ }
2389
1556
  const roomId = crypto.randomUUID();
2390
1557
  // Create the room's durable stream
2391
1558
  const conn = roomStream(config, roomId);
@@ -2415,12 +1582,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
2415
1582
  createdAt: new Date().toISOString(),
2416
1583
  revoked: false,
2417
1584
  });
2418
- const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1585
+ const roomToken = deriveSessionToken(config.streamConfig.secret, roomId);
2419
1586
  console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
2420
1587
  return c.json({ roomId, code, roomToken }, 201);
2421
1588
  });
2422
- // Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
2423
- app.get("/api/join-room/:id/:code", (c) => {
1589
+ // Join an agent room by id + invite code
1590
+ app.get("/api/rooms/join/:id/:code", (c) => {
2424
1591
  const id = c.req.param("id");
2425
1592
  const code = c.req.param("code");
2426
1593
  const room = config.rooms.getRoom(id);
@@ -2428,38 +1595,25 @@ echo "Start claude in this project — the session will appear in the studio UI.
2428
1595
  return c.json({ error: "Room not found" }, 404);
2429
1596
  if (room.revoked)
2430
1597
  return c.json({ error: "Room has been revoked" }, 410);
2431
- const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
1598
+ const roomToken = deriveSessionToken(config.streamConfig.secret, room.id);
2432
1599
  return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
2433
1600
  });
2434
1601
  // Get room state
2435
1602
  app.get("/api/rooms/:id", (c) => {
2436
1603
  const roomId = c.req.param("id");
2437
1604
  const router = roomRouters.get(roomId);
2438
- if (router) {
2439
- return c.json({
2440
- roomId,
2441
- state: router.state,
2442
- roundCount: router.roundCount,
2443
- participants: router.participants.map((p) => ({
2444
- sessionId: p.sessionId,
2445
- name: p.name,
2446
- role: p.role,
2447
- running: p.bridge.isRunning(),
2448
- needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
2449
- })),
2450
- });
2451
- }
2452
- // No active router — check if room exists in the registry (e.g. after server restart)
2453
- const roomEntry = config.rooms.getRoom(roomId);
2454
- if (!roomEntry)
1605
+ if (!router)
2455
1606
  return c.json({ error: "Room not found" }, 404);
2456
- // Return basic room state without live participants
2457
- // Sessions are still readable via their individual SSE streams
2458
1607
  return c.json({
2459
1608
  roomId,
2460
- state: "closed",
2461
- roundCount: 0,
2462
- participants: [],
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
+ })),
2463
1617
  });
2464
1618
  });
2465
1619
  // Add an agent to a room
@@ -2468,26 +1622,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
2468
1622
  const router = roomRouters.get(roomId);
2469
1623
  if (!router)
2470
1624
  return c.json({ error: "Room not found" }, 404);
2471
- const body = await validateBody(c, addAgentSchema);
2472
- if (isResponse(body))
2473
- return body;
2474
- // Rate-limit and gate credentials in production mode
2475
- if (!config.devMode) {
2476
- const ip = extractClientIp(c);
2477
- if (!checkSessionRateLimit(ip)) {
2478
- return c.json({ error: "Too many sessions. Please try again later." }, 429);
2479
- }
2480
- if (checkGlobalSessionCap(config.sessions)) {
2481
- return c.json({ error: "Service at capacity, please try again later" }, 503);
2482
- }
2483
- }
2484
- const apiKey = config.devMode
2485
- ? body.apiKey || process.env.ANTHROPIC_API_KEY
2486
- : process.env.ANTHROPIC_API_KEY;
2487
- const oauthToken = config.devMode
2488
- ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
2489
- : undefined;
2490
- const ghToken = config.devMode ? body.ghToken : undefined;
1625
+ const body = (await c.req.json());
2491
1626
  const sessionId = crypto.randomUUID();
2492
1627
  const randomSuffix = sessionId.slice(0, 6);
2493
1628
  const agentName = body.name?.trim() || `agent-${randomSuffix}`;
@@ -2536,15 +1671,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
2536
1671
  const handle = await config.sandbox.create(sessionId, {
2537
1672
  projectName,
2538
1673
  infra: { mode: "local" },
2539
- apiKey,
2540
- oauthToken,
2541
- ghToken,
2542
- ...((!config.devMode || GITHUB_APP_ID) && {
2543
- prodMode: {
2544
- sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
2545
- studioUrl: resolveStudioUrl(config.port),
2546
- },
2547
- }),
1674
+ apiKey: body.apiKey,
1675
+ oauthToken: body.oauthToken,
1676
+ ghToken: body.ghToken,
2548
1677
  });
2549
1678
  config.sessions.update(sessionId, {
2550
1679
  appPort: handle.port,
@@ -2658,9 +1787,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2658
1787
  const router = roomRouters.get(roomId);
2659
1788
  if (!router)
2660
1789
  return c.json({ error: "Room not found" }, 404);
2661
- const body = await validateBody(c, addSessionToRoomSchema);
2662
- if (isResponse(body))
2663
- return body;
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
+ }
2664
1794
  const { sessionId } = body;
2665
1795
  // Require a valid session token — caller must already own this session.
2666
1796
  // Room auth is handled by middleware via X-Room-Token; Authorization
@@ -2740,9 +1870,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2740
1870
  const participant = router.participants.find((p) => p.sessionId === sessionId);
2741
1871
  if (!participant)
2742
1872
  return c.json({ error: "Session not found in this room" }, 404);
2743
- const body = await validateBody(c, iterateRoomSessionSchema);
2744
- if (isResponse(body))
2745
- return body;
1873
+ const body = (await c.req.json());
1874
+ if (!body.request) {
1875
+ return c.json({ error: "request is required" }, 400);
1876
+ }
2746
1877
  await participant.bridge.sendCommand({
2747
1878
  command: "iterate",
2748
1879
  request: body.request,
@@ -2755,19 +1886,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
2755
1886
  const router = roomRouters.get(roomId);
2756
1887
  if (!router)
2757
1888
  return c.json({ error: "Room not found" }, 404);
2758
- const body = await validateBody(c, sendRoomMessageSchema);
2759
- if (isResponse(body))
2760
- return body;
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
+ }
2761
1893
  await router.sendMessage(body.from, body.body, body.to);
2762
1894
  return c.json({ ok: true });
2763
1895
  });
2764
- // SSE proxy for room events (works even after server restart — reads from durable stream)
1896
+ // SSE proxy for room events
2765
1897
  app.get("/api/rooms/:id/events", async (c) => {
2766
1898
  const roomId = c.req.param("id");
2767
- // Verify room exists in registry or has active router
2768
- if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
1899
+ const router = roomRouters.get(roomId);
1900
+ if (!router)
2769
1901
  return c.json({ error: "Room not found" }, 404);
2770
- }
2771
1902
  const connection = roomStream(config, roomId);
2772
1903
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
2773
1904
  const reader = new DurableStream({
@@ -2984,9 +2115,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
2984
2115
  if (!handle || !sandboxDir) {
2985
2116
  return c.json({ error: "Container not available" }, 404);
2986
2117
  }
2987
- const resolvedPath = path.resolve(filePath);
2988
- const resolvedDir = path.resolve(sandboxDir) + path.sep;
2989
- if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
2118
+ if (!filePath.startsWith(sandboxDir)) {
2990
2119
  return c.json({ error: "Path outside project directory" }, 403);
2991
2120
  }
2992
2121
  const content = await config.sandbox.readFile(handle, filePath);
@@ -2997,8 +2126,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
2997
2126
  });
2998
2127
  // List GitHub accounts (personal + orgs) — requires client-provided token
2999
2128
  app.get("/api/github/accounts", (c) => {
3000
- if (!config.devMode)
3001
- return c.json({ error: "Not available" }, 403);
3002
2129
  const token = c.req.header("X-GH-Token");
3003
2130
  if (!token)
3004
2131
  return c.json({ accounts: [] });
@@ -3010,10 +2137,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
3010
2137
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
3011
2138
  }
3012
2139
  });
3013
- // List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
2140
+ // List GitHub repos for the authenticated user — requires client-provided token
3014
2141
  app.get("/api/github/repos", (c) => {
3015
- if (!config.devMode)
3016
- return c.json({ error: "Not available" }, 403);
3017
2142
  const token = c.req.header("X-GH-Token");
3018
2143
  if (!token)
3019
2144
  return c.json({ repos: [] });
@@ -3026,8 +2151,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
3026
2151
  }
3027
2152
  });
3028
2153
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
3029
- if (!config.devMode)
3030
- return c.json({ error: "Not available" }, 403);
3031
2154
  const owner = c.req.param("owner");
3032
2155
  const repo = c.req.param("repo");
3033
2156
  const token = c.req.header("X-GH-Token");
@@ -3041,38 +2164,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
3041
2164
  return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
3042
2165
  }
3043
2166
  });
3044
- // Read Claude credentials from macOS Keychain (dev convenience).
3045
- // Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
3046
- if (config.devMode) {
3047
- app.get("/api/credentials/keychain", (c) => {
3048
- if (process.platform !== "darwin") {
3049
- return c.json({ apiKey: null });
3050
- }
3051
- try {
3052
- const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
3053
- const parsed = JSON.parse(raw);
3054
- const token = parsed.claudeAiOauth?.accessToken ?? null;
3055
- if (token) {
3056
- console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
3057
- }
3058
- else {
3059
- console.log("[dev] No OAuth token found in keychain");
3060
- }
3061
- return c.json({ oauthToken: token });
2167
+ // Read Claude credentials from macOS Keychain (dev convenience)
2168
+ 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})`);
3062
2178
  }
3063
- catch {
3064
- return c.json({ oauthToken: null });
2179
+ else {
2180
+ console.log("[dev] No OAuth token found in keychain");
3065
2181
  }
3066
- });
3067
- }
3068
- // Resume a project from a GitHub repo (dev mode only)
2182
+ return c.json({ oauthToken: token });
2183
+ }
2184
+ catch {
2185
+ return c.json({ oauthToken: null });
2186
+ }
2187
+ });
2188
+ // Resume a project from a GitHub repo
3069
2189
  app.post("/api/sessions/resume", async (c) => {
3070
- if (!config.devMode) {
3071
- return c.json({ error: "Resume from repo not available" }, 403);
2190
+ const body = (await c.req.json());
2191
+ if (!body.repoUrl) {
2192
+ return c.json({ error: "repoUrl is required" }, 400);
3072
2193
  }
3073
- const body = await validateBody(c, resumeSessionSchema);
3074
- if (isResponse(body))
3075
- return body;
3076
2194
  const sessionId = crypto.randomUUID();
3077
2195
  const repoName = body.repoUrl
3078
2196
  .split("/")
@@ -3152,7 +2270,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
3152
2270
  projectName: repoName,
3153
2271
  projectDir: handle.projectDir,
3154
2272
  runtime: config.sandbox.runtime,
3155
- production: !config.devMode,
3156
2273
  git: {
3157
2274
  mode: "existing",
3158
2275
  repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
@@ -3317,42 +2434,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
3317
2434
  return app;
3318
2435
  }
3319
2436
  export async function startWebServer(opts) {
3320
- const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
3321
- if (devMode) {
3322
- console.log("[studio] Dev mode enabled — keychain endpoint active");
3323
- }
3324
- // Hydrate session registry from durable stream (survives restarts)
3325
- const registry = await Registry.create(opts.streamConfig);
3326
2437
  const config = {
3327
2438
  port: opts.port ?? 4400,
3328
2439
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
3329
- sessions: ActiveSessions.fromRegistry(registry),
2440
+ sessions: new ActiveSessions(),
3330
2441
  rooms: opts.rooms,
3331
2442
  sandbox: opts.sandbox,
3332
2443
  streamConfig: opts.streamConfig,
3333
2444
  bridgeMode: opts.bridgeMode ?? "claude-code",
3334
- devMode,
3335
2445
  };
3336
- // Reconnect to surviving sandbox containers (Docker only)
3337
- if (config.sandbox.runtime === "docker") {
3338
- const dockerProvider = config.sandbox;
3339
- const allSessions = registry.listSessions();
3340
- dockerProvider.reconnect(allSessions);
3341
- // Mark sessions with live containers as "complete" (not stale),
3342
- // and sessions without containers as "error"
3343
- for (const session of allSessions) {
3344
- if (session.status === "running") {
3345
- const handle = dockerProvider.get(session.id);
3346
- config.sessions.update(session.id, {
3347
- status: handle ? "complete" : "error",
3348
- });
3349
- }
3350
- }
3351
- }
3352
- else {
3353
- // Non-Docker: mark all running sessions as stale
3354
- registry.cleanupStaleSessions(0);
3355
- }
3356
2446
  fs.mkdirSync(config.dataDir, { recursive: true });
3357
2447
  const app = createApp(config);
3358
2448
  const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";