@electric-agent/studio 1.7.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 (119) 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/api-schemas.d.ts +225 -0
  6. package/dist/api-schemas.d.ts.map +1 -0
  7. package/dist/api-schemas.js +95 -0
  8. package/dist/api-schemas.js.map +1 -0
  9. package/dist/bridge/claude-code-base.d.ts +121 -0
  10. package/dist/bridge/claude-code-base.d.ts.map +1 -0
  11. package/dist/bridge/claude-code-base.js +263 -0
  12. package/dist/bridge/claude-code-base.js.map +1 -0
  13. package/dist/bridge/claude-code-docker.d.ts +13 -73
  14. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  15. package/dist/bridge/claude-code-docker.js +91 -302
  16. package/dist/bridge/claude-code-docker.js.map +1 -1
  17. package/dist/bridge/claude-code-sprites.d.ts +12 -59
  18. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  19. package/dist/bridge/claude-code-sprites.js +88 -281
  20. package/dist/bridge/claude-code-sprites.js.map +1 -1
  21. package/dist/bridge/claude-md-generator.d.ts +15 -3
  22. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  23. package/dist/bridge/claude-md-generator.js +79 -98
  24. package/dist/bridge/claude-md-generator.js.map +1 -1
  25. package/dist/bridge/codex-docker.d.ts +56 -51
  26. package/dist/bridge/codex-docker.js +222 -230
  27. package/dist/bridge/codex-json-parser.d.ts +11 -11
  28. package/dist/bridge/codex-json-parser.js +231 -238
  29. package/dist/bridge/codex-md-generator.d.ts +3 -3
  30. package/dist/bridge/codex-md-generator.js +42 -32
  31. package/dist/bridge/codex-sprites.d.ts +50 -45
  32. package/dist/bridge/codex-sprites.js +212 -222
  33. package/dist/bridge/daytona.d.ts +25 -25
  34. package/dist/bridge/daytona.js +131 -136
  35. package/dist/bridge/docker-stdio.d.ts +21 -21
  36. package/dist/bridge/docker-stdio.js +126 -132
  37. package/dist/bridge/hosted.d.ts +3 -2
  38. package/dist/bridge/hosted.d.ts.map +1 -1
  39. package/dist/bridge/hosted.js +4 -0
  40. package/dist/bridge/hosted.js.map +1 -1
  41. package/dist/bridge/message-parser.d.ts +24 -0
  42. package/dist/bridge/message-parser.d.ts.map +1 -0
  43. package/dist/bridge/message-parser.js +39 -0
  44. package/dist/bridge/message-parser.js.map +1 -0
  45. package/dist/bridge/role-skills.d.ts +25 -0
  46. package/dist/bridge/role-skills.d.ts.map +1 -0
  47. package/dist/bridge/role-skills.js +120 -0
  48. package/dist/bridge/role-skills.js.map +1 -0
  49. package/dist/bridge/room-messaging-skill.d.ts +11 -0
  50. package/dist/bridge/room-messaging-skill.d.ts.map +1 -0
  51. package/dist/bridge/room-messaging-skill.js +41 -0
  52. package/dist/bridge/room-messaging-skill.js.map +1 -0
  53. package/dist/bridge/sprites.d.ts +22 -22
  54. package/dist/bridge/sprites.js +123 -128
  55. package/dist/bridge/types.d.ts +4 -10
  56. package/dist/bridge/types.d.ts.map +1 -1
  57. package/dist/client/assets/index-BfvQSMwH.css +1 -0
  58. package/dist/client/assets/index-CtOOaA2Q.js +235 -0
  59. package/dist/client/index.html +2 -2
  60. package/dist/github-app.d.ts +14 -0
  61. package/dist/github-app.d.ts.map +1 -0
  62. package/dist/github-app.js +62 -0
  63. package/dist/github-app.js.map +1 -0
  64. package/dist/github-app.test.d.ts +2 -0
  65. package/dist/github-app.test.d.ts.map +1 -0
  66. package/dist/github-app.test.js +62 -0
  67. package/dist/github-app.test.js.map +1 -0
  68. package/dist/index.d.ts +4 -3
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +3 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/invite-code.d.ts +5 -0
  73. package/dist/invite-code.d.ts.map +1 -0
  74. package/dist/invite-code.js +14 -0
  75. package/dist/invite-code.js.map +1 -0
  76. package/dist/project-utils.d.ts.map +1 -1
  77. package/dist/project-utils.js.map +1 -1
  78. package/dist/registry.d.ts +11 -4
  79. package/dist/registry.d.ts.map +1 -1
  80. package/dist/registry.js +1 -1
  81. package/dist/registry.js.map +1 -1
  82. package/dist/room-router.d.ts +73 -0
  83. package/dist/room-router.d.ts.map +1 -0
  84. package/dist/room-router.js +345 -0
  85. package/dist/room-router.js.map +1 -0
  86. package/dist/sandbox/docker.d.ts +1 -0
  87. package/dist/sandbox/docker.d.ts.map +1 -1
  88. package/dist/sandbox/docker.js +56 -6
  89. package/dist/sandbox/docker.js.map +1 -1
  90. package/dist/sandbox/index.d.ts +0 -1
  91. package/dist/sandbox/index.d.ts.map +1 -1
  92. package/dist/sandbox/index.js +0 -1
  93. package/dist/sandbox/index.js.map +1 -1
  94. package/dist/sandbox/sprites.d.ts +1 -0
  95. package/dist/sandbox/sprites.d.ts.map +1 -1
  96. package/dist/sandbox/sprites.js +91 -10
  97. package/dist/sandbox/sprites.js.map +1 -1
  98. package/dist/sandbox/types.d.ts +9 -2
  99. package/dist/sandbox/types.d.ts.map +1 -1
  100. package/dist/server.d.ts +12 -0
  101. package/dist/server.d.ts.map +1 -1
  102. package/dist/server.js +906 -445
  103. package/dist/server.js.map +1 -1
  104. package/dist/session-auth.d.ts +9 -0
  105. package/dist/session-auth.d.ts.map +1 -1
  106. package/dist/session-auth.js +30 -0
  107. package/dist/session-auth.js.map +1 -1
  108. package/dist/sessions.d.ts +1 -1
  109. package/dist/streams.d.ts +2 -6
  110. package/dist/streams.d.ts.map +1 -1
  111. package/dist/streams.js +6 -17
  112. package/dist/streams.js.map +1 -1
  113. package/dist/validate.d.ts +10 -0
  114. package/dist/validate.d.ts.map +1 -0
  115. package/dist/validate.js +24 -0
  116. package/dist/validate.js.map +1 -0
  117. package/package.json +6 -9
  118. package/dist/client/assets/index-D5-jqAV-.js +0 -234
  119. package/dist/client/assets/index-YyyiO26y.css +0 -1
package/dist/server.js CHANGED
@@ -7,23 +7,27 @@ import { ts } from "@electric-agent/protocol";
7
7
  import { serve } from "@hono/node-server";
8
8
  import { serveStatic } from "@hono/node-server/serve-static";
9
9
  import { Hono } from "hono";
10
- import { cors } from "hono/cors";
11
10
  import { ActiveSessions } from "./active-sessions.js";
11
+ import { addAgentSchema, addSessionToRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
12
+ import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
12
13
  import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
13
14
  import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
14
- import { createAppSkillContent, generateClaudeMd } from "./bridge/claude-md-generator.js";
15
+ import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
15
16
  import { HostedStreamBridge } from "./bridge/hosted.js";
16
17
  import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
17
18
  import { createGate, rejectAllGates, resolveGate } from "./gate.js";
18
19
  import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
20
+ import { createOrgRepo, getInstallationToken } from "./github-app.js";
21
+ import { generateInviteCode } from "./invite-code.js";
19
22
  import { resolveProjectDir } from "./project-utils.js";
20
- import { deriveSessionToken, validateSessionToken } from "./session-auth.js";
21
- import { generateInviteCode } from "./shared-sessions.js";
22
- import { getSharedStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
23
+ import { RoomRouter } from "./room-router.js";
24
+ import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
25
+ import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
26
+ import { isResponse, validateBody } from "./validate.js";
23
27
  /** Active session bridges — one per running session */
24
28
  const bridges = new Map();
25
- /** In-memory room presence: roomId participantId { displayName, lastPing } */
26
- const roomPresence = new Map();
29
+ /** Active room routers one per room with agent-to-agent messaging */
30
+ const roomRouters = new Map();
27
31
  /** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
28
32
  const inflightHookCreations = new Map();
29
33
  function parseRepoNameFromUrl(url) {
@@ -36,9 +40,9 @@ function parseRepoNameFromUrl(url) {
36
40
  function sessionStream(config, sessionId) {
37
41
  return getStreamConnectionInfo(sessionId, config.streamConfig);
38
42
  }
39
- /** Get stream connection info for a shared session */
40
- function sharedSessionStream(config, sharedSessionId) {
41
- return getSharedStreamConnectionInfo(sharedSessionId, config.streamConfig);
43
+ /** Get stream connection info for a room */
44
+ function roomStream(config, roomId) {
45
+ return getRoomStreamConnectionInfo(roomId, config.streamConfig);
42
46
  }
43
47
  /** Create or retrieve the SessionBridge for a session */
44
48
  function getOrCreateBridge(config, sessionId) {
@@ -66,9 +70,62 @@ function resolveStudioUrl(port) {
66
70
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
67
71
  return `http://localhost:${port}`;
68
72
  }
73
+ // ---------------------------------------------------------------------------
74
+ // Rate limiting — in-memory sliding window per IP
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);
78
+ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
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;
88
+ function extractClientIp(c) {
89
+ return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
90
+ c.req.header("cf-connecting-ip") ||
91
+ "unknown");
92
+ }
93
+ function checkSessionRateLimit(ip) {
94
+ const now = Date.now();
95
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
96
+ let timestamps = sessionCreationsByIp.get(ip) ?? [];
97
+ // Prune stale entries
98
+ timestamps = timestamps.filter((t) => t > cutoff);
99
+ if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
100
+ sessionCreationsByIp.set(ip, timestamps);
101
+ return false;
102
+ }
103
+ timestamps.push(now);
104
+ sessionCreationsByIp.set(ip, timestamps);
105
+ return true;
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
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Per-session cost budget
123
+ // ---------------------------------------------------------------------------
124
+ const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
69
125
  /**
70
126
  * Accumulate cost and turn metrics from a session_end event into the session's totals.
71
127
  * Called each time a Claude Code run finishes (initial + iterate runs).
128
+ * In production mode, enforces a per-session cost budget.
72
129
  */
73
130
  function accumulateSessionCost(config, sessionId, event) {
74
131
  if (event.type !== "session_end")
@@ -89,12 +146,39 @@ function accumulateSessionCost(config, sessionId, event) {
89
146
  }
90
147
  config.sessions.update(sessionId, updates);
91
148
  console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
149
+ // Enforce budget in production mode
150
+ if (!config.devMode &&
151
+ updates.totalCostUsd != null &&
152
+ updates.totalCostUsd > MAX_SESSION_COST_USD) {
153
+ console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
154
+ const bridge = bridges.get(sessionId);
155
+ if (bridge) {
156
+ bridge
157
+ .emit({
158
+ type: "budget_exceeded",
159
+ budget_usd: MAX_SESSION_COST_USD,
160
+ spent_usd: updates.totalCostUsd,
161
+ ts: ts(),
162
+ })
163
+ .catch(() => { });
164
+ }
165
+ config.sessions.update(sessionId, { status: "error" });
166
+ closeBridge(sessionId);
167
+ }
92
168
  }
93
169
  /**
94
170
  * Create a Claude Code bridge for a session.
95
171
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
172
+ * In production mode, enforces tool restrictions and hardcodes the model.
96
173
  */
97
174
  function createClaudeCodeBridge(config, sessionId, claudeConfig) {
175
+ // Production mode: restrict tools and hardcode model
176
+ if (!config.devMode) {
177
+ if (!claudeConfig.allowedTools) {
178
+ claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
179
+ }
180
+ claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
181
+ }
98
182
  const conn = sessionStream(config, sessionId);
99
183
  let bridge;
100
184
  if (config.sandbox.runtime === "sprites") {
@@ -126,31 +210,6 @@ function closeBridge(sessionId) {
126
210
  bridges.delete(sessionId);
127
211
  }
128
212
  }
129
- /**
130
- * Detect git operations from natural language prompts.
131
- * Returns structured gitOp fields if matched, null otherwise.
132
- */
133
- function detectGitOp(request) {
134
- const lower = request.toLowerCase().trim();
135
- // Commit: "commit", "commit the code", "commit changes", "commit with message ..."
136
- if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
137
- // Extract commit message after "commit" keyword, or after "message:" / "msg:"
138
- const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
139
- const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
140
- return { gitOp: "commit", gitMessage: message || undefined };
141
- }
142
- // Push: "push", "push to github", "push to remote", "git push"
143
- if (/^(git\s+)?push\b/.test(lower)) {
144
- return { gitOp: "push" };
145
- }
146
- // Create PR: "create pr", "open pr", "make pr", "create pull request"
147
- if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
148
- // Try to extract title after the PR keyword
149
- const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
150
- return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
151
- }
152
- return null;
153
- }
154
213
  /**
155
214
  * Map a Claude Code hook event JSON payload to an EngineEvent.
156
215
  *
@@ -264,8 +323,6 @@ function mapHookToEngineEvent(body) {
264
323
  }
265
324
  export function createApp(config) {
266
325
  const app = new Hono();
267
- // CORS for local development
268
- app.use("*", cors({ origin: "*" }));
269
326
  // --- API Routes ---
270
327
  // Health check
271
328
  app.get("/api/health", (c) => {
@@ -283,6 +340,13 @@ export function createApp(config) {
283
340
  checks.sandbox = config.sandbox.runtime;
284
341
  return c.json({ healthy, checks }, healthy ? 200 : 503);
285
342
  });
343
+ // Public config — exposes non-sensitive flags to the client
344
+ app.get("/api/config", (c) => {
345
+ return c.json({
346
+ devMode: config.devMode,
347
+ maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
348
+ });
349
+ });
286
350
  // Provision Electric Cloud resources via the Claim API
287
351
  app.post("/api/provision-electric", async (c) => {
288
352
  try {
@@ -307,7 +371,7 @@ export function createApp(config) {
307
371
  // Hono's wildcard middleware matches creation routes like /api/sessions/local as
308
372
  // :id="local", so we must explicitly skip those.
309
373
  const authExemptIds = new Set(["local", "auto", "resume"]);
310
- const hookExemptSuffixes = new Set(["/hook-event"]);
374
+ // Hook-event auth is handled in the endpoint handler via validateHookToken
311
375
  /** Extract session token from Authorization header or query param. */
312
376
  function extractToken(c) {
313
377
  const authHeader = c.req.header("Authorization");
@@ -321,7 +385,8 @@ export function createApp(config) {
321
385
  if (authExemptIds.has(id))
322
386
  return next();
323
387
  const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
324
- if (hookExemptSuffixes.has(subPath))
388
+ // Hook-event uses a purpose-scoped hook token (validated in the handler)
389
+ if (subPath === "/hook-event")
325
390
  return next();
326
391
  const token = extractToken(c);
327
392
  if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
@@ -382,8 +447,9 @@ export function createApp(config) {
382
447
  // Pre-create a bridge so hook-event can emit to it immediately
383
448
  getOrCreateBridge(config, sessionId);
384
449
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
450
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
385
451
  console.log(`[local-session] Created session: ${sessionId}`);
386
- return c.json({ sessionId, sessionToken }, 201);
452
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
387
453
  });
388
454
  // Auto-register a local session on first hook event (SessionStart).
389
455
  // Eliminates the manual `curl POST /api/sessions/local` step.
@@ -429,14 +495,20 @@ export function createApp(config) {
429
495
  await bridge.emit(hookEvent);
430
496
  }
431
497
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
498
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
432
499
  console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
433
- return c.json({ sessionId, sessionToken }, 201);
500
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
434
501
  });
435
502
  // Receive a hook event from Claude Code (via forward.sh) and write it
436
503
  // to the session's durable stream as an EngineEvent.
437
504
  // For AskUserQuestion, this blocks until the user answers in the web UI.
438
505
  app.post("/api/sessions/:id/hook-event", async (c) => {
439
506
  const sessionId = c.req.param("id");
507
+ // Validate hook token (scoped per-session, separate from session token)
508
+ const token = extractToken(c);
509
+ if (!token || !validateHookToken(config.streamConfig.secret, sessionId, token)) {
510
+ return c.json({ error: "Invalid or missing hook token" }, 401);
511
+ }
440
512
  const body = (await c.req.json());
441
513
  const bridge = getOrCreateBridge(config, sessionId);
442
514
  // Map Claude Code hook JSON → EngineEvent
@@ -498,6 +570,16 @@ export function createApp(config) {
498
570
  return c.json({ ok: true });
499
571
  });
500
572
  // --- Unified Hook Endpoint (transcript_path correlation) ---
573
+ // Protect the unified hook endpoint with a global hook secret derived from
574
+ // the DS secret. The hook setup script embeds this secret in the forwarder
575
+ // so that only local Claude Code instances can post events.
576
+ app.use("/api/hook", async (c, next) => {
577
+ const token = extractToken(c);
578
+ if (!token || !validateGlobalHookSecret(config.streamConfig.secret, token)) {
579
+ return c.json({ error: "Invalid or missing hook secret" }, 401);
580
+ }
581
+ return next();
582
+ });
501
583
  // Single endpoint for all Claude Code hook events. Uses transcript_path
502
584
  // from the hook JSON as the correlation key — stable across resume/compact,
503
585
  // changes on /clear. Replaces the need for client-side session tracking.
@@ -635,6 +717,7 @@ export function createApp(config) {
635
717
  // Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
636
718
  app.get("/api/hooks/setup", (c) => {
637
719
  const port = config.port;
720
+ const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
638
721
  const script = `#!/bin/bash
639
722
  # Electric Agent — Claude Code hook installer (project-scoped)
640
723
  # Installs the hook forwarder into the current project's .claude/ directory.
@@ -655,10 +738,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
655
738
  # Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
656
739
 
657
740
  EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
741
+ EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
658
742
  BODY="$(cat)"
659
743
 
660
744
  RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
661
745
  -H "Content-Type: application/json" \\
746
+ -H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
662
747
  -d "\${BODY}" \\
663
748
  --max-time 360 \\
664
749
  --connect-timeout 2 \\
@@ -672,8 +757,9 @@ fi
672
757
  exit 0
673
758
  HOOKEOF
674
759
 
675
- # Replace placeholder with actual port
760
+ # Replace placeholders with actual values
676
761
  sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
762
+ sed -i.bak "s/EA_HOOK_SECRET_PLACEHOLDER/${hookSecret}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
677
763
  chmod +x "\${FORWARD_SH}"
678
764
 
679
765
  # Merge hook config into project-level settings.local.json
@@ -715,17 +801,36 @@ echo "Start claude in this project — the session will appear in the studio UI.
715
801
  });
716
802
  // Start new project
717
803
  app.post("/api/sessions", async (c) => {
718
- const body = (await c.req.json());
719
- if (!body.description) {
720
- return c.json({ error: "description is required" }, 400);
804
+ const body = await validateBody(c, createSessionSchema);
805
+ if (isResponse(body))
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;
811
+ // Block freeform sessions in production mode
812
+ if (body.freeform && !config.devMode) {
813
+ return c.json({ error: "Freeform sessions are not available" }, 403);
814
+ }
815
+ // Rate-limit session creation in production mode
816
+ if (!config.devMode) {
817
+ const ip = extractClientIp(c);
818
+ if (!checkSessionRateLimit(ip)) {
819
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
820
+ }
821
+ if (checkGlobalSessionCap(config.sessions)) {
822
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
823
+ }
721
824
  }
722
825
  const sessionId = crypto.randomUUID();
723
- const inferredName = body.name ||
724
- body.description
725
- .slice(0, 40)
726
- .replace(/[^a-z0-9]+/gi, "-")
727
- .replace(/^-|-$/g, "")
728
- .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)}`;
729
834
  const baseDir = body.baseDir || process.cwd();
730
835
  const { projectName } = resolveProjectDir(baseDir, inferredName);
731
836
  console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
@@ -759,74 +864,83 @@ echo "Start claude in this project — the session will appear in the studio UI.
759
864
  config.sessions.add(session);
760
865
  // Write user prompt to the stream so it shows in the UI
761
866
  await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
762
- // Gather GitHub accounts for the merged setup gate
763
- // Only check if the client provided a token — never fall back to server-side GH_TOKEN
867
+ // Freeform sessions skip the infra config gate — no Electric/DB setup needed
764
868
  let ghAccounts = [];
765
- if (body.ghToken && isGhAuthenticated(body.ghToken)) {
766
- try {
767
- ghAccounts = ghListAccounts(body.ghToken);
768
- }
769
- catch {
770
- // gh not available — no repo setup
869
+ if (!body.freeform) {
870
+ // Gather GitHub accounts for the merged setup gate (dev mode only)
871
+ if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
872
+ try {
873
+ ghAccounts = ghListAccounts(ghToken);
874
+ }
875
+ catch {
876
+ // gh not available — no repo setup
877
+ }
771
878
  }
879
+ // Emit combined infra + repo setup gate
880
+ await bridge.emit({
881
+ type: "infra_config_prompt",
882
+ projectName,
883
+ ghAccounts,
884
+ runtime: config.sandbox.runtime,
885
+ ts: ts(),
886
+ });
772
887
  }
773
- // Emit combined infra + repo setup gate
774
- await bridge.emit({
775
- type: "infra_config_prompt",
776
- projectName,
777
- ghAccounts,
778
- runtime: config.sandbox.runtime,
779
- ts: ts(),
780
- });
781
888
  // Launch async flow: wait for setup gate → create sandbox → start agent
782
889
  const asyncFlow = async () => {
783
- // 1. Wait for combined infra + repo config
890
+ // 1. Wait for combined infra + repo config (skip for freeform)
784
891
  let infra;
785
892
  let repoConfig = null;
786
- console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
787
893
  let claimId;
788
- try {
789
- const gateValue = await createGate(sessionId, "infra_config");
790
- console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
791
- if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
792
- // Normalize claim → cloud for the sandbox layer (same env vars)
793
- infra = {
794
- mode: "cloud",
795
- databaseUrl: gateValue.databaseUrl,
796
- electricUrl: gateValue.electricUrl,
797
- sourceId: gateValue.sourceId,
798
- secret: gateValue.secret,
799
- };
800
- if (gateValue.mode === "claim") {
801
- claimId = gateValue.claimId;
894
+ if (body.freeform) {
895
+ // Freeform sessions don't need Electric infrastructure
896
+ infra = { mode: "none" };
897
+ console.log(`[session:${sessionId}] Freeform session skipping infra gate`);
898
+ }
899
+ else {
900
+ console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
901
+ try {
902
+ const gateValue = await createGate(sessionId, "infra_config");
903
+ console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
904
+ if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
905
+ // Normalize claim → cloud for the sandbox layer (same env vars)
906
+ infra = {
907
+ mode: "cloud",
908
+ databaseUrl: gateValue.databaseUrl,
909
+ electricUrl: gateValue.electricUrl,
910
+ sourceId: gateValue.sourceId,
911
+ secret: gateValue.secret,
912
+ };
913
+ if (gateValue.mode === "claim") {
914
+ claimId = gateValue.claimId;
915
+ }
916
+ }
917
+ else {
918
+ infra = { mode: "local" };
919
+ }
920
+ // Extract repo config if provided
921
+ if (gateValue.repoAccount && gateValue.repoName?.trim()) {
922
+ repoConfig = {
923
+ account: gateValue.repoAccount,
924
+ repoName: gateValue.repoName,
925
+ visibility: gateValue.repoVisibility ?? "private",
926
+ };
927
+ config.sessions.update(sessionId, {
928
+ git: {
929
+ branch: "main",
930
+ remoteUrl: null,
931
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
932
+ repoVisibility: repoConfig.visibility,
933
+ lastCommitHash: null,
934
+ lastCommitMessage: null,
935
+ lastCheckpointAt: null,
936
+ },
937
+ });
802
938
  }
803
939
  }
804
- else {
940
+ catch (err) {
941
+ console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
805
942
  infra = { mode: "local" };
806
943
  }
807
- // Extract repo config if provided
808
- if (gateValue.repoAccount && gateValue.repoName?.trim()) {
809
- repoConfig = {
810
- account: gateValue.repoAccount,
811
- repoName: gateValue.repoName,
812
- visibility: gateValue.repoVisibility ?? "private",
813
- };
814
- config.sessions.update(sessionId, {
815
- git: {
816
- branch: "main",
817
- remoteUrl: null,
818
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
819
- repoVisibility: repoConfig.visibility,
820
- lastCommitHash: null,
821
- lastCommitMessage: null,
822
- lastCheckpointAt: null,
823
- },
824
- });
825
- }
826
- }
827
- catch (err) {
828
- console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
829
- infra = { mode: "local" };
830
944
  }
831
945
  // 2. Create sandbox — emit progress events so the UI shows feedback
832
946
  await bridge.emit({
@@ -839,9 +953,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
839
953
  const handle = await config.sandbox.create(sessionId, {
840
954
  projectName,
841
955
  infra,
842
- apiKey: body.apiKey,
843
- oauthToken: body.oauthToken,
844
- 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
+ }),
845
965
  });
846
966
  console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
847
967
  await bridge.emit({
@@ -859,85 +979,169 @@ echo "Start claude in this project — the session will appear in the studio UI.
859
979
  // 3. Write CLAUDE.md and create a ClaudeCode bridge.
860
980
  {
861
981
  console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
862
- // Copy pre-scaffolded project from the image and customize per-session
863
- await bridge.emit({
864
- type: "log",
865
- level: "build",
866
- message: "Setting up project...",
867
- ts: ts(),
868
- });
869
- try {
870
- if (config.sandbox.runtime === "docker") {
871
- // Docker: copy the pre-built scaffold base (baked into the image)
872
- await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
873
- await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
874
- }
875
- else {
876
- // Sprites/Daytona: run scaffold from globally installed electric-agent
877
- await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
878
- }
879
- console.log(`[session:${sessionId}] Project setup complete`);
982
+ if (!body.freeform) {
983
+ // Copy pre-scaffolded project from the image and customize per-session
880
984
  await bridge.emit({
881
985
  type: "log",
882
- level: "done",
883
- message: "Project ready",
986
+ level: "build",
987
+ message: "Setting up project...",
884
988
  ts: ts(),
885
989
  });
886
- }
887
- catch (err) {
888
- console.error(`[session:${sessionId}] Project setup failed:`, err);
889
- await bridge.emit({
890
- type: "log",
891
- level: "error",
892
- message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
893
- ts: ts(),
990
+ try {
991
+ if (config.sandbox.runtime === "docker") {
992
+ // Docker: copy the pre-built scaffold base (baked into the image)
993
+ await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
994
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
995
+ }
996
+ else {
997
+ // Sprites: run scaffold from globally installed electric-agent
998
+ await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
999
+ }
1000
+ console.log(`[session:${sessionId}] Project setup complete`);
1001
+ await bridge.emit({
1002
+ type: "log",
1003
+ level: "done",
1004
+ message: "Project ready",
1005
+ ts: ts(),
1006
+ });
1007
+ // Log the agent package version installed in the sandbox
1008
+ try {
1009
+ const agentVersion = (await config.sandbox.exec(handle, "electric-agent --version 2>/dev/null | tail -1")).trim();
1010
+ await bridge.emit({
1011
+ type: "log",
1012
+ level: "verbose",
1013
+ message: `electric-agent@${agentVersion}`,
1014
+ ts: ts(),
1015
+ });
1016
+ }
1017
+ catch {
1018
+ // Non-critical — don't block session creation
1019
+ }
1020
+ }
1021
+ catch (err) {
1022
+ console.error(`[session:${sessionId}] Project setup failed:`, err);
1023
+ await bridge.emit({
1024
+ type: "log",
1025
+ level: "error",
1026
+ message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
1027
+ ts: ts(),
1028
+ });
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
+ }
1078
+ // Write CLAUDE.md to the sandbox workspace.
1079
+ // Our generator includes hardcoded playbook paths and reading order
1080
+ // so we don't depend on @tanstack/intent generating a skill block.
1081
+ const claudeMd = generateClaudeMd({
1082
+ description: body.description,
1083
+ projectName,
1084
+ projectDir: handle.projectDir,
1085
+ runtime: config.sandbox.runtime,
1086
+ production: !config.devMode,
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
+ : {}),
894
1098
  });
895
- }
896
- // Write CLAUDE.md to the sandbox workspace
897
- const claudeMd = generateClaudeMd({
898
- description: body.description,
899
- projectName,
900
- projectDir: handle.projectDir,
901
- runtime: config.sandbox.runtime,
902
- ...(repoConfig
903
- ? {
904
- git: {
905
- mode: "create",
906
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
907
- visibility: repoConfig.visibility,
908
- },
1099
+ try {
1100
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
1101
+ }
1102
+ catch (err) {
1103
+ console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
1104
+ }
1105
+ // Ensure the create-app skill is present in the project.
1106
+ // The npm-installed electric-agent may be an older version that
1107
+ // doesn't include .claude/skills/ in its template directory.
1108
+ if (createAppSkillContent) {
1109
+ try {
1110
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
1111
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1112
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
909
1113
  }
910
- : {}),
911
- });
912
- try {
913
- await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
914
- }
915
- catch (err) {
916
- console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
1114
+ catch (err) {
1115
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1116
+ }
1117
+ }
917
1118
  }
918
- // Ensure the create-app skill is present in the project.
919
- // The npm-installed electric-agent may be an older version that
920
- // doesn't include .claude/skills/ in its template directory.
921
- if (createAppSkillContent) {
1119
+ // Ensure the room-messaging skill is present so agents have
1120
+ // persistent access to the multi-agent protocol reference.
1121
+ if (roomMessagingSkillContent) {
922
1122
  try {
923
- const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
924
- const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1123
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1124
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
925
1125
  await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
926
1126
  }
927
1127
  catch (err) {
928
- console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1128
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
929
1129
  }
930
1130
  }
1131
+ const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
1132
+ const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
931
1133
  const claudeConfig = config.sandbox.runtime === "sprites"
932
1134
  ? {
933
- prompt: `/create-app ${body.description}`,
1135
+ prompt: sessionPrompt,
934
1136
  cwd: handle.projectDir,
935
1137
  studioUrl: resolveStudioUrl(config.port),
1138
+ hookToken: sessionHookToken,
936
1139
  }
937
1140
  : {
938
- prompt: `/create-app ${body.description}`,
1141
+ prompt: sessionPrompt,
939
1142
  cwd: handle.projectDir,
940
1143
  studioPort: config.port,
1144
+ hookToken: sessionHookToken,
941
1145
  };
942
1146
  bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
943
1147
  }
@@ -1011,7 +1215,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1011
1215
  await bridge.emit({
1012
1216
  type: "log",
1013
1217
  level: "build",
1014
- message: `Running: claude "/create-app ${body.description}"`,
1218
+ message: body.freeform
1219
+ ? `Running: claude "${body.description}"`
1220
+ : `Running: claude "/create-app ${body.description}"`,
1015
1221
  ts: ts(),
1016
1222
  });
1017
1223
  console.log(`[session:${sessionId}] Starting bridge listener...`);
@@ -1029,6 +1235,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
1029
1235
  asyncFlow().catch(async (err) => {
1030
1236
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
1031
1237
  config.sessions.update(sessionId, { status: "error" });
1238
+ try {
1239
+ await bridge.emit({
1240
+ type: "log",
1241
+ level: "error",
1242
+ message: `Session failed: ${err instanceof Error ? err.message : String(err)}`,
1243
+ ts: ts(),
1244
+ });
1245
+ }
1246
+ catch {
1247
+ // Bridge may not be usable if the failure happened early
1248
+ }
1032
1249
  });
1033
1250
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1034
1251
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -1039,73 +1256,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1039
1256
  const session = config.sessions.get(sessionId);
1040
1257
  if (!session)
1041
1258
  return c.json({ error: "Session not found" }, 404);
1042
- const body = (await c.req.json());
1043
- if (!body.request) {
1044
- return c.json({ error: "request is required" }, 400);
1045
- }
1046
- // Intercept operational commands (start/stop/restart the app/server)
1047
- const normalised = body.request
1048
- .toLowerCase()
1049
- .replace(/[^a-z ]/g, "")
1050
- .trim();
1051
- const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
1052
- const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
1053
- const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
1054
- const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
1055
- if (isStartCmd || isStopCmd || isRestartCmd) {
1056
- const bridge = getOrCreateBridge(config, sessionId);
1057
- await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1058
- try {
1059
- const handle = config.sandbox.get(sessionId);
1060
- if (isStopCmd) {
1061
- if (handle && config.sandbox.isAlive(handle))
1062
- await config.sandbox.stopApp(handle);
1063
- await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
1064
- }
1065
- else {
1066
- if (!handle || !config.sandbox.isAlive(handle)) {
1067
- return c.json({ error: "Container is not running" }, 400);
1068
- }
1069
- if (isRestartCmd)
1070
- await config.sandbox.stopApp(handle);
1071
- await config.sandbox.startApp(handle);
1072
- await bridge.emit({
1073
- type: "log",
1074
- level: "done",
1075
- message: "App started",
1076
- ts: ts(),
1077
- });
1078
- await bridge.emit({
1079
- type: "app_status",
1080
- status: "running",
1081
- port: session.appPort,
1082
- previewUrl: session.previewUrl,
1083
- ts: ts(),
1084
- });
1085
- }
1086
- }
1087
- catch (err) {
1088
- const msg = err instanceof Error ? err.message : "Operation failed";
1089
- await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
1090
- }
1091
- return c.json({ ok: true });
1092
- }
1093
- // Intercept git commands (commit, push, create PR)
1094
- const gitOp = detectGitOp(body.request);
1095
- if (gitOp) {
1096
- const bridge = getOrCreateBridge(config, sessionId);
1097
- await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1098
- const handle = config.sandbox.get(sessionId);
1099
- if (!handle || !config.sandbox.isAlive(handle)) {
1100
- return c.json({ error: "Container is not running" }, 400);
1101
- }
1102
- // Send git requests as user messages via Claude Code bridge
1103
- await bridge.sendCommand({
1104
- command: "iterate",
1105
- request: body.request,
1106
- });
1107
- return c.json({ ok: true });
1108
- }
1259
+ const body = await validateBody(c, iterateSessionSchema);
1260
+ if (isResponse(body))
1261
+ return body;
1109
1262
  const handle = config.sandbox.get(sessionId);
1110
1263
  if (!handle || !config.sandbox.isAlive(handle)) {
1111
1264
  return c.json({ error: "Container is not running" }, 400);
@@ -1122,6 +1275,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
1122
1275
  });
1123
1276
  return c.json({ ok: true });
1124
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
+ });
1125
1300
  // Respond to a gate (approval, clarification, continue, revision)
1126
1301
  app.post("/api/sessions/:id/respond", async (c) => {
1127
1302
  const sessionId = c.req.param("id");
@@ -1171,6 +1346,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
1171
1346
  }
1172
1347
  // No pending gate — fall through to bridge.sendGateResponse()
1173
1348
  }
1349
+ // Outbound message gates (room agent → room stream): resolved in-process
1350
+ if (gate === "outbound_message_gate") {
1351
+ const gateId = body.gateId;
1352
+ const action = body.action;
1353
+ if (!gateId || !action) {
1354
+ return c.json({ error: "gateId and action are required for outbound_message_gate" }, 400);
1355
+ }
1356
+ const resolved = resolveGate(sessionId, gateId, {
1357
+ action,
1358
+ editedBody: body.editedBody,
1359
+ });
1360
+ if (resolved) {
1361
+ return c.json({ ok: true });
1362
+ }
1363
+ return c.json({ error: "No pending gate found" }, 404);
1364
+ }
1174
1365
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1175
1366
  const serverGates = new Set(["infra_config"]);
1176
1367
  // Forward agent gate responses via the bridge
@@ -1396,7 +1587,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1396
1587
  });
1397
1588
  // Create a standalone sandbox (not tied to session creation flow)
1398
1589
  app.post("/api/sandboxes", async (c) => {
1399
- const body = (await c.req.json());
1590
+ const body = await validateBody(c, createSandboxSchema);
1591
+ if (isResponse(body))
1592
+ return body;
1400
1593
  const sessionId = body.sessionId ?? crypto.randomUUID();
1401
1594
  try {
1402
1595
  const handle = await config.sandbox.create(sessionId, {
@@ -1426,30 +1619,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
1426
1619
  await config.sandbox.destroy(handle);
1427
1620
  return c.json({ ok: true });
1428
1621
  });
1429
- // --- Shared Sessions ---
1430
- // Protect /api/shared-sessions/:id/* (all sub-routes)
1431
- // Exempt: "join" (Hono matches join/:code as :id/*)
1432
- const sharedSessionExemptIds = new Set(["join"]);
1433
- app.use("/api/shared-sessions/:id/*", async (c, next) => {
1622
+ // --- Room Routes (agent-to-agent messaging) ---
1623
+ // Extract room token from X-Room-Token header or ?token= query param.
1624
+ // This is separate from extractToken() (which reads Authorization) so that
1625
+ // Authorization remains available for session tokens on endpoints that need both.
1626
+ function extractRoomToken(c) {
1627
+ return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
1628
+ }
1629
+ // Protect room-scoped routes via X-Room-Token header
1630
+ app.use("/api/rooms/:id/*", async (c, next) => {
1434
1631
  const id = c.req.param("id");
1435
- if (sharedSessionExemptIds.has(id))
1436
- return next();
1437
- const token = extractToken(c);
1438
- if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1632
+ const token = extractRoomToken(c);
1633
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1439
1634
  return c.json({ error: "Invalid or missing room token" }, 401);
1440
1635
  }
1441
1636
  return next();
1442
1637
  });
1443
- // Create a shared session
1444
- app.post("/api/shared-sessions", async (c) => {
1445
- const body = (await c.req.json());
1446
- if (!body.name || !body.participant?.id || !body.participant?.displayName) {
1447
- return c.json({ error: "name and participant (id, displayName) are required" }, 400);
1638
+ app.use("/api/rooms/:id", async (c, next) => {
1639
+ if (c.req.method !== "GET" && c.req.method !== "DELETE")
1640
+ return next();
1641
+ const id = c.req.param("id");
1642
+ const token = extractRoomToken(c);
1643
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1644
+ return c.json({ error: "Invalid or missing room token" }, 401);
1448
1645
  }
1449
- const id = crypto.randomUUID();
1450
- const code = generateInviteCode();
1451
- // Create the shared session durable stream
1452
- const conn = sharedSessionStream(config, id);
1646
+ return next();
1647
+ });
1648
+ // Create a room
1649
+ app.post("/api/rooms", async (c) => {
1650
+ const body = await validateBody(c, createRoomSchema);
1651
+ if (isResponse(body))
1652
+ return body;
1653
+ const roomId = crypto.randomUUID();
1654
+ // Create the room's durable stream
1655
+ const conn = roomStream(config, roomId);
1453
1656
  try {
1454
1657
  await DurableStream.create({
1455
1658
  url: conn.url,
@@ -1458,191 +1661,360 @@ echo "Start claude in this project — the session will appear in the studio UI.
1458
1661
  });
1459
1662
  }
1460
1663
  catch (err) {
1461
- console.error(`[shared-session] Failed to create durable stream:`, err);
1462
- return c.json({ error: "Failed to create shared session stream" }, 500);
1664
+ console.error(`[room] Failed to create durable stream:`, err);
1665
+ return c.json({ error: "Failed to create room stream" }, 500);
1463
1666
  }
1464
- // Write shared_session_created event
1465
- const stream = new DurableStream({
1466
- url: conn.url,
1467
- headers: conn.headers,
1468
- contentType: "application/json",
1667
+ // Create and start the router
1668
+ const router = new RoomRouter(roomId, body.name, config.streamConfig, {
1669
+ maxRounds: body.maxRounds,
1469
1670
  });
1470
- const createdEvent = {
1471
- type: "shared_session_created",
1472
- name: body.name,
1473
- code,
1474
- createdBy: body.participant,
1475
- ts: ts(),
1476
- };
1477
- await stream.append(JSON.stringify(createdEvent));
1478
- // Write participant_joined for the creator
1479
- const joinedEvent = {
1480
- type: "participant_joined",
1481
- participant: body.participant,
1482
- ts: ts(),
1483
- };
1484
- await stream.append(JSON.stringify(joinedEvent));
1485
- // Save to room registry
1671
+ await router.start();
1672
+ roomRouters.set(roomId, router);
1673
+ // Save to room registry for persistence
1674
+ const code = generateInviteCode();
1486
1675
  await config.rooms.addRoom({
1487
- id,
1676
+ id: roomId,
1488
1677
  code,
1489
1678
  name: body.name,
1490
1679
  createdAt: new Date().toISOString(),
1491
1680
  revoked: false,
1492
1681
  });
1493
- const roomToken = deriveSessionToken(config.streamConfig.secret, id);
1494
- console.log(`[shared-session] Created: id=${id} code=${code}`);
1495
- return c.json({ id, code, roomToken }, 201);
1682
+ const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1683
+ console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
1684
+ return c.json({ roomId, code, roomToken }, 201);
1496
1685
  });
1497
- // Resolve invite code shared session ID + room token
1498
- app.get("/api/shared-sessions/join/: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) => {
1688
+ const id = c.req.param("id");
1499
1689
  const code = c.req.param("code");
1500
- const entry = config.rooms.getRoomByCode(code);
1501
- if (!entry)
1502
- return c.json({ error: "Shared session not found" }, 404);
1503
- const roomToken = deriveSessionToken(config.streamConfig.secret, entry.id);
1504
- return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked, roomToken });
1690
+ const room = config.rooms.getRoom(id);
1691
+ if (!room || room.code !== code)
1692
+ return c.json({ error: "Room not found" }, 404);
1693
+ if (room.revoked)
1694
+ return c.json({ error: "Room has been revoked" }, 410);
1695
+ const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
1696
+ return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
1505
1697
  });
1506
- // Join a shared session as participant
1507
- app.post("/api/shared-sessions/:id/join", async (c) => {
1508
- const id = c.req.param("id");
1509
- const entry = config.rooms.getRoom(id);
1510
- if (!entry)
1511
- return c.json({ error: "Shared session not found" }, 404);
1512
- if (entry.revoked)
1513
- return c.json({ error: "Invite code has been revoked" }, 403);
1514
- const body = (await c.req.json());
1515
- if (!body.participant?.id || !body.participant?.displayName) {
1516
- return c.json({ error: "participant (id, displayName) is required" }, 400);
1517
- }
1518
- const conn = sharedSessionStream(config, id);
1519
- const stream = new DurableStream({
1520
- url: conn.url,
1521
- headers: conn.headers,
1522
- contentType: "application/json",
1698
+ // Get room state
1699
+ app.get("/api/rooms/:id", (c) => {
1700
+ const roomId = c.req.param("id");
1701
+ const router = roomRouters.get(roomId);
1702
+ if (!router)
1703
+ return c.json({ error: "Room not found" }, 404);
1704
+ return c.json({
1705
+ roomId,
1706
+ state: router.state,
1707
+ roundCount: router.roundCount,
1708
+ participants: router.participants.map((p) => ({
1709
+ sessionId: p.sessionId,
1710
+ name: p.name,
1711
+ role: p.role,
1712
+ running: p.bridge.isRunning(),
1713
+ })),
1523
1714
  });
1524
- const event = {
1525
- type: "participant_joined",
1526
- participant: body.participant,
1527
- ts: ts(),
1528
- };
1529
- await stream.append(JSON.stringify(event));
1530
- return c.json({ ok: true });
1531
1715
  });
1532
- // Leave a shared session
1533
- app.post("/api/shared-sessions/:id/leave", async (c) => {
1534
- const id = c.req.param("id");
1535
- const body = (await c.req.json());
1536
- if (!body.participantId) {
1537
- return c.json({ error: "participantId is required" }, 400);
1716
+ // Add an agent to a room
1717
+ app.post("/api/rooms/:id/agents", async (c) => {
1718
+ const roomId = c.req.param("id");
1719
+ const router = roomRouters.get(roomId);
1720
+ if (!router)
1721
+ return c.json({ error: "Room not found" }, 404);
1722
+ const body = await validateBody(c, addAgentSchema);
1723
+ if (isResponse(body))
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
+ }
1538
1734
  }
1539
- const conn = sharedSessionStream(config, id);
1540
- const stream = new DurableStream({
1541
- url: conn.url,
1542
- headers: conn.headers,
1543
- contentType: "application/json",
1544
- });
1545
- const event = {
1546
- type: "participant_left",
1547
- participantId: body.participantId,
1548
- ts: ts(),
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;
1738
+ const sessionId = crypto.randomUUID();
1739
+ const randomSuffix = sessionId.slice(0, 6);
1740
+ const agentName = body.name?.trim() || `agent-${randomSuffix}`;
1741
+ const projectName = `room-${agentName}-${sessionId.slice(0, 8)}`;
1742
+ console.log(`[room:${roomId}] Adding agent: name=${agentName} session=${sessionId}`);
1743
+ // Create the session's durable stream
1744
+ const conn = sessionStream(config, sessionId);
1745
+ try {
1746
+ await DurableStream.create({
1747
+ url: conn.url,
1748
+ headers: conn.headers,
1749
+ contentType: "application/json",
1750
+ });
1751
+ }
1752
+ catch (err) {
1753
+ console.error(`[room:${roomId}] Failed to create session stream:`, err);
1754
+ return c.json({ error: "Failed to create session stream" }, 500);
1755
+ }
1756
+ // Create bridge
1757
+ const bridge = getOrCreateBridge(config, sessionId);
1758
+ // Record session
1759
+ const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
1760
+ const session = {
1761
+ id: sessionId,
1762
+ projectName,
1763
+ sandboxProjectDir,
1764
+ description: `Room agent: ${agentName} (${body.role ?? "participant"})`,
1765
+ createdAt: new Date().toISOString(),
1766
+ lastActiveAt: new Date().toISOString(),
1767
+ status: "running",
1549
1768
  };
1550
- await stream.append(JSON.stringify(event));
1551
- return c.json({ ok: true });
1769
+ config.sessions.add(session);
1770
+ // Return early so the client can store the session token and show the
1771
+ // session in the sidebar immediately. The sandbox setup continues in
1772
+ // the background — events stream to the session's durable stream so
1773
+ // the UI stays up to date.
1774
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1775
+ (async () => {
1776
+ await bridge.emit({
1777
+ type: "log",
1778
+ level: "build",
1779
+ message: `Creating sandbox for room agent "${agentName}"...`,
1780
+ ts: ts(),
1781
+ });
1782
+ try {
1783
+ const handle = await config.sandbox.create(sessionId, {
1784
+ projectName,
1785
+ infra: { mode: "local" },
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
+ }),
1795
+ });
1796
+ config.sessions.update(sessionId, {
1797
+ appPort: handle.port,
1798
+ sandboxProjectDir: handle.projectDir,
1799
+ previewUrl: handle.previewUrl,
1800
+ });
1801
+ // Inject room-messaging skill so agents know the @room protocol
1802
+ if (roomMessagingSkillContent) {
1803
+ try {
1804
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1805
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
1806
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1807
+ // Append room-messaging reference to CLAUDE.md so the agent knows to read it
1808
+ const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
1809
+ const refB64 = Buffer.from(roomRef).toString("base64");
1810
+ await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
1811
+ }
1812
+ catch (err) {
1813
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
1814
+ }
1815
+ }
1816
+ // Resolve role skill (behavioral guidelines + tool permissions)
1817
+ const roleSkill = resolveRoleSkill(body.role);
1818
+ // Inject role skill file into sandbox
1819
+ if (roleSkill) {
1820
+ try {
1821
+ const skillDir = `${handle.projectDir}/.claude/skills/role`;
1822
+ const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
1823
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1824
+ }
1825
+ catch (err) {
1826
+ console.error(`[session:${sessionId}] Failed to write role skill:`, err);
1827
+ }
1828
+ }
1829
+ // Build prompt — reference the role skill if available
1830
+ const rolePromptSuffix = roleSkill
1831
+ ? `\nRead .claude/skills/role/SKILL.md for your role guidelines before proceeding.`
1832
+ : "";
1833
+ const agentPrompt = `You are "${agentName}"${body.role ? `, role: ${body.role}` : ""}. You are joining a multi-agent room.${rolePromptSuffix}`;
1834
+ // Create Claude Code bridge (with role-specific tool permissions)
1835
+ const agentHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1836
+ const claudeConfig = config.sandbox.runtime === "sprites"
1837
+ ? {
1838
+ prompt: agentPrompt,
1839
+ cwd: handle.projectDir,
1840
+ studioUrl: resolveStudioUrl(config.port),
1841
+ hookToken: agentHookToken,
1842
+ agentName: agentName,
1843
+ ...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
1844
+ }
1845
+ : {
1846
+ prompt: agentPrompt,
1847
+ cwd: handle.projectDir,
1848
+ studioPort: config.port,
1849
+ hookToken: agentHookToken,
1850
+ agentName: agentName,
1851
+ ...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
1852
+ };
1853
+ const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1854
+ // Track Claude Code session ID and cost
1855
+ ccBridge.onAgentEvent((event) => {
1856
+ if (event.type === "session_start") {
1857
+ const ccSessionId = event.session_id;
1858
+ if (ccSessionId) {
1859
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1860
+ }
1861
+ }
1862
+ if (event.type === "session_end") {
1863
+ accumulateSessionCost(config, sessionId, event);
1864
+ }
1865
+ // Route assistant_message output to the room router
1866
+ if (event.type === "assistant_message" && "text" in event) {
1867
+ router
1868
+ .handleAgentOutput(sessionId, event.text)
1869
+ .catch((err) => {
1870
+ console.error(`[room:${roomId}] handleAgentOutput error:`, err);
1871
+ });
1872
+ }
1873
+ });
1874
+ await bridge.emit({
1875
+ type: "log",
1876
+ level: "done",
1877
+ message: `Sandbox ready for "${agentName}"`,
1878
+ ts: ts(),
1879
+ });
1880
+ await ccBridge.start();
1881
+ // Add participant to room router
1882
+ const participant = {
1883
+ sessionId,
1884
+ name: agentName,
1885
+ role: body.role,
1886
+ bridge: ccBridge,
1887
+ };
1888
+ await router.addParticipant(participant, body.gated ?? false);
1889
+ // If there's an initial prompt, send it directly to this agent only (not broadcast)
1890
+ if (body.initialPrompt) {
1891
+ await ccBridge.sendCommand({ command: "iterate", request: body.initialPrompt });
1892
+ }
1893
+ }
1894
+ catch (err) {
1895
+ const msg = err instanceof Error ? err.message : "Failed to create agent sandbox";
1896
+ console.error(`[room:${roomId}] Agent creation failed:`, err);
1897
+ await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
1898
+ }
1899
+ })();
1900
+ return c.json({ sessionId, participantName: agentName, sessionToken }, 201);
1552
1901
  });
1553
- // Heartbeat ping for room presence (in-memory, not persisted to stream)
1554
- app.post("/api/shared-sessions/:id/ping", async (c) => {
1555
- const id = c.req.param("id");
1556
- const body = (await c.req.json());
1557
- if (!body.participantId) {
1558
- return c.json({ error: "participantId is required" }, 400);
1902
+ // Add an existing running session to a room
1903
+ app.post("/api/rooms/:id/sessions", async (c) => {
1904
+ const roomId = c.req.param("id");
1905
+ const router = roomRouters.get(roomId);
1906
+ if (!router)
1907
+ return c.json({ error: "Room not found" }, 404);
1908
+ const body = await validateBody(c, addSessionToRoomSchema);
1909
+ if (isResponse(body))
1910
+ return body;
1911
+ const { sessionId } = body;
1912
+ // Require a valid session token — caller must already own this session.
1913
+ // Room auth is handled by middleware via X-Room-Token; Authorization
1914
+ // carries the session ownership proof here.
1915
+ const authHeader = c.req.header("Authorization");
1916
+ const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
1917
+ if (!sessionToken ||
1918
+ !validateSessionToken(config.streamConfig.secret, sessionId, sessionToken)) {
1919
+ return c.json({ error: "Invalid or missing session token" }, 401);
1559
1920
  }
1560
- let room = roomPresence.get(id);
1561
- if (!room) {
1562
- room = new Map();
1563
- roomPresence.set(id, room);
1921
+ // Verify the session exists
1922
+ const sessionInfo = config.sessions.get(sessionId);
1923
+ if (!sessionInfo) {
1924
+ return c.json({ error: "Session not found" }, 404);
1564
1925
  }
1565
- room.set(body.participantId, {
1566
- displayName: body.displayName || body.participantId.slice(0, 8),
1567
- lastPing: Date.now(),
1568
- });
1569
- return c.json({ ok: true });
1570
- });
1571
- // Get active participants (pinged within last 90 seconds)
1572
- app.get("/api/shared-sessions/:id/presence", (c) => {
1573
- const id = c.req.param("id");
1574
- const room = roomPresence.get(id);
1575
- const STALE_MS = 90_000;
1576
- const now = Date.now();
1577
- const active = [];
1578
- if (room) {
1579
- for (const [pid, info] of room) {
1580
- if (now - info.lastPing < STALE_MS) {
1581
- active.push({ id: pid, displayName: info.displayName });
1926
+ // Get the sandbox handle — must be running
1927
+ const handle = config.sandbox.get(sessionId);
1928
+ if (!handle) {
1929
+ return c.json({ error: "Session sandbox not found or not running" }, 400);
1930
+ }
1931
+ // Get or create bridge (it should already exist for a running session)
1932
+ const bridge = getOrCreateBridge(config, sessionId);
1933
+ console.log(`[room:${roomId}] Adding existing session: name=${body.name} session=${sessionId}`);
1934
+ try {
1935
+ // Inject room-messaging skill
1936
+ if (roomMessagingSkillContent) {
1937
+ try {
1938
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1939
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
1940
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1941
+ // Append room-messaging reference to CLAUDE.md so the agent knows to read it
1942
+ const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
1943
+ const refB64 = Buffer.from(roomRef).toString("base64");
1944
+ await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
1582
1945
  }
1583
- else {
1584
- room.delete(pid);
1946
+ catch (err) {
1947
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
1585
1948
  }
1586
1949
  }
1950
+ // The existing bridge is already a Claude Code bridge — wire up room output handling
1951
+ bridge.onAgentEvent((event) => {
1952
+ if (event.type === "assistant_message" && "text" in event) {
1953
+ router
1954
+ .handleAgentOutput(sessionId, event.text)
1955
+ .catch((err) => {
1956
+ console.error(`[room:${roomId}] handleAgentOutput error:`, err);
1957
+ });
1958
+ }
1959
+ });
1960
+ // Add participant to room router
1961
+ const participant = {
1962
+ sessionId,
1963
+ name: body.name,
1964
+ bridge,
1965
+ };
1966
+ await router.addParticipant(participant, false);
1967
+ // If there's an initial prompt, send it directly to this agent
1968
+ if (body.initialPrompt) {
1969
+ await bridge.sendCommand({ command: "iterate", request: body.initialPrompt });
1970
+ }
1587
1971
  }
1588
- return c.json({ participants: active });
1589
- });
1590
- // Link a session to a shared session (room)
1591
- // The client sends session metadata since sessions are private (localStorage).
1592
- app.post("/api/shared-sessions/:id/sessions", async (c) => {
1593
- const id = c.req.param("id");
1594
- const body = (await c.req.json());
1595
- if (!body.sessionId || !body.linkedBy) {
1596
- return c.json({ error: "sessionId and linkedBy are required" }, 400);
1972
+ catch (err) {
1973
+ const msg = err instanceof Error ? err.message : "Failed to add session to room";
1974
+ console.error(`[room:${roomId}] Add session failed:`, err);
1975
+ return c.json({ error: msg }, 500);
1597
1976
  }
1598
- const conn = sharedSessionStream(config, id);
1599
- const stream = new DurableStream({
1600
- url: conn.url,
1601
- headers: conn.headers,
1602
- contentType: "application/json",
1603
- });
1604
- const event = {
1605
- type: "session_linked",
1606
- sessionId: body.sessionId,
1607
- sessionName: body.sessionName || "",
1608
- sessionDescription: body.sessionDescription || "",
1609
- linkedBy: body.linkedBy,
1610
- ts: ts(),
1611
- };
1612
- await stream.append(JSON.stringify(event));
1613
- return c.json({ ok: true });
1614
- });
1615
- // Get a session token for a linked session (allows room participants to read session streams)
1616
- app.get("/api/shared-sessions/:id/sessions/:sessionId/token", (c) => {
1617
- const sessionId = c.req.param("sessionId");
1618
- const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1619
- return c.json({ sessionToken });
1977
+ // No need to return sessionToken — caller already proved they have it
1978
+ return c.json({ sessionId, participantName: body.name }, 201);
1620
1979
  });
1621
- // Unlink a session from a shared session
1622
- app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
1623
- const id = c.req.param("id");
1980
+ // Send a message directly to a specific session in a room (bypasses room stream)
1981
+ app.post("/api/rooms/:id/sessions/:sessionId/iterate", async (c) => {
1982
+ const roomId = c.req.param("id");
1624
1983
  const sessionId = c.req.param("sessionId");
1625
- const conn = sharedSessionStream(config, id);
1626
- const stream = new DurableStream({
1627
- url: conn.url,
1628
- headers: conn.headers,
1629
- contentType: "application/json",
1984
+ const router = roomRouters.get(roomId);
1985
+ if (!router)
1986
+ return c.json({ error: "Room not found" }, 404);
1987
+ const participant = router.participants.find((p) => p.sessionId === sessionId);
1988
+ if (!participant)
1989
+ return c.json({ error: "Session not found in this room" }, 404);
1990
+ const body = await validateBody(c, iterateRoomSessionSchema);
1991
+ if (isResponse(body))
1992
+ return body;
1993
+ await participant.bridge.sendCommand({
1994
+ command: "iterate",
1995
+ request: body.request,
1630
1996
  });
1631
- const event = {
1632
- type: "session_unlinked",
1633
- sessionId,
1634
- ts: ts(),
1635
- };
1636
- await stream.append(JSON.stringify(event));
1637
1997
  return c.json({ ok: true });
1638
1998
  });
1639
- // SSE proxy for shared session events
1640
- app.get("/api/shared-sessions/:id/events", async (c) => {
1641
- const id = c.req.param("id");
1642
- const entry = config.rooms.getRoom(id);
1643
- if (!entry)
1644
- return c.json({ error: "Shared session not found" }, 404);
1645
- const connection = sharedSessionStream(config, id);
1999
+ // Send a message to a room (from human or API)
2000
+ app.post("/api/rooms/:id/messages", async (c) => {
2001
+ const roomId = c.req.param("id");
2002
+ const router = roomRouters.get(roomId);
2003
+ if (!router)
2004
+ return c.json({ error: "Room not found" }, 404);
2005
+ const body = await validateBody(c, sendRoomMessageSchema);
2006
+ if (isResponse(body))
2007
+ return body;
2008
+ await router.sendMessage(body.from, body.body, body.to);
2009
+ return c.json({ ok: true });
2010
+ });
2011
+ // SSE proxy for room events
2012
+ app.get("/api/rooms/:id/events", async (c) => {
2013
+ const roomId = c.req.param("id");
2014
+ const router = roomRouters.get(roomId);
2015
+ if (!router)
2016
+ return c.json({ error: "Room not found" }, 404);
2017
+ const connection = roomStream(config, roomId);
1646
2018
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1647
2019
  const reader = new DurableStream({
1648
2020
  url: connection.url,
@@ -1681,23 +2053,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
1681
2053
  },
1682
2054
  });
1683
2055
  });
1684
- // Revoke a shared session's invite code
1685
- app.post("/api/shared-sessions/:id/revoke", async (c) => {
1686
- const id = c.req.param("id");
1687
- const revoked = await config.rooms.revokeRoom(id);
1688
- if (!revoked)
1689
- return c.json({ error: "Shared session not found" }, 404);
1690
- const conn = sharedSessionStream(config, id);
2056
+ // Close a room
2057
+ app.post("/api/rooms/:id/close", async (c) => {
2058
+ const roomId = c.req.param("id");
2059
+ const router = roomRouters.get(roomId);
2060
+ if (!router)
2061
+ return c.json({ error: "Room not found" }, 404);
2062
+ // Emit room_closed event
2063
+ const conn = roomStream(config, roomId);
1691
2064
  const stream = new DurableStream({
1692
2065
  url: conn.url,
1693
2066
  headers: conn.headers,
1694
2067
  contentType: "application/json",
1695
2068
  });
1696
2069
  const event = {
1697
- type: "code_revoked",
2070
+ type: "room_closed",
2071
+ closedBy: "human",
2072
+ summary: "Room closed by user",
1698
2073
  ts: ts(),
1699
2074
  };
1700
2075
  await stream.append(JSON.stringify(event));
2076
+ router.close();
1701
2077
  return c.json({ ok: true });
1702
2078
  });
1703
2079
  // --- SSE Proxy ---
@@ -1767,6 +2143,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
1767
2143
  },
1768
2144
  });
1769
2145
  });
2146
+ // --- Stream Append Proxy ---
2147
+ // Proxy endpoint for writing events to a session's durable stream.
2148
+ // Authenticates via session token so the caller never needs DS_SECRET.
2149
+ // Used by sandbox agents to write events back to the session stream.
2150
+ app.post("/api/sessions/:id/stream/append", async (c) => {
2151
+ const sessionId = c.req.param("id");
2152
+ const contentType = c.req.header("content-type") ?? "";
2153
+ if (!contentType.includes("application/json")) {
2154
+ return c.json({ error: "Content-Type must be application/json" }, 415);
2155
+ }
2156
+ const body = await c.req.text();
2157
+ if (!body) {
2158
+ return c.json({ error: "Request body is required" }, 400);
2159
+ }
2160
+ // Guard against oversized payloads (64 KB limit)
2161
+ if (body.length > 65_536) {
2162
+ return c.json({ error: "Payload too large" }, 413);
2163
+ }
2164
+ // Validate JSON before forwarding to the stream
2165
+ try {
2166
+ JSON.parse(body);
2167
+ }
2168
+ catch {
2169
+ return c.json({ error: "Invalid JSON body" }, 400);
2170
+ }
2171
+ const connection = sessionStream(config, sessionId);
2172
+ try {
2173
+ const writer = new DurableStream({
2174
+ url: connection.url,
2175
+ headers: connection.headers,
2176
+ contentType: "application/json",
2177
+ });
2178
+ await writer.append(body);
2179
+ return c.json({ ok: true });
2180
+ }
2181
+ catch (err) {
2182
+ console.error(`[stream-proxy] Append failed: session=${sessionId}`, err);
2183
+ return c.json({ error: "Failed to append to stream" }, 500);
2184
+ }
2185
+ });
1770
2186
  // --- Git/GitHub Routes ---
1771
2187
  // Get git status for a session
1772
2188
  app.get("/api/sessions/:id/git-status", async (c) => {
@@ -1814,7 +2230,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1814
2230
  if (!handle || !sandboxDir) {
1815
2231
  return c.json({ error: "Container not available" }, 404);
1816
2232
  }
1817
- if (!filePath.startsWith(sandboxDir)) {
2233
+ const resolvedPath = path.resolve(filePath);
2234
+ const resolvedDir = path.resolve(sandboxDir) + path.sep;
2235
+ if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
1818
2236
  return c.json({ error: "Path outside project directory" }, 403);
1819
2237
  }
1820
2238
  const content = await config.sandbox.readFile(handle, filePath);
@@ -1825,6 +2243,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1825
2243
  });
1826
2244
  // List GitHub accounts (personal + orgs) — requires client-provided token
1827
2245
  app.get("/api/github/accounts", (c) => {
2246
+ if (!config.devMode)
2247
+ return c.json({ error: "Not available" }, 403);
1828
2248
  const token = c.req.header("X-GH-Token");
1829
2249
  if (!token)
1830
2250
  return c.json({ accounts: [] });
@@ -1836,8 +2256,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1836
2256
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
1837
2257
  }
1838
2258
  });
1839
- // 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)
1840
2260
  app.get("/api/github/repos", (c) => {
2261
+ if (!config.devMode)
2262
+ return c.json({ error: "Not available" }, 403);
1841
2263
  const token = c.req.header("X-GH-Token");
1842
2264
  if (!token)
1843
2265
  return c.json({ repos: [] });
@@ -1850,6 +2272,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1850
2272
  }
1851
2273
  });
1852
2274
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
2275
+ if (!config.devMode)
2276
+ return c.json({ error: "Not available" }, 403);
1853
2277
  const owner = c.req.param("owner");
1854
2278
  const repo = c.req.param("repo");
1855
2279
  const token = c.req.header("X-GH-Token");
@@ -1863,33 +2287,38 @@ echo "Start claude in this project — the session will appear in the studio UI.
1863
2287
  return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
1864
2288
  }
1865
2289
  });
1866
- // Read Claude credentials from macOS Keychain (dev convenience)
1867
- app.get("/api/credentials/keychain", (c) => {
1868
- if (process.platform !== "darwin") {
1869
- return c.json({ apiKey: null });
1870
- }
1871
- try {
1872
- const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
1873
- const parsed = JSON.parse(raw);
1874
- const token = parsed.claudeAiOauth?.accessToken ?? null;
1875
- if (token) {
1876
- console.log(`[dev] Loaded OAuth token from keychain: ${token.slice(0, 20)}...${token.slice(-10)}`);
2290
+ // Read Claude credentials from macOS Keychain (dev convenience).
2291
+ // Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
2292
+ if (config.devMode) {
2293
+ app.get("/api/credentials/keychain", (c) => {
2294
+ if (process.platform !== "darwin") {
2295
+ return c.json({ apiKey: null });
1877
2296
  }
1878
- else {
1879
- console.log("[dev] No OAuth token found in keychain");
2297
+ try {
2298
+ const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
2299
+ const parsed = JSON.parse(raw);
2300
+ const token = parsed.claudeAiOauth?.accessToken ?? null;
2301
+ if (token) {
2302
+ console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
2303
+ }
2304
+ else {
2305
+ console.log("[dev] No OAuth token found in keychain");
2306
+ }
2307
+ return c.json({ oauthToken: token });
1880
2308
  }
1881
- return c.json({ oauthToken: token });
1882
- }
1883
- catch {
1884
- return c.json({ oauthToken: null });
1885
- }
1886
- });
1887
- // Resume a project from a GitHub repo
2309
+ catch {
2310
+ return c.json({ oauthToken: null });
2311
+ }
2312
+ });
2313
+ }
2314
+ // Resume a project from a GitHub repo (dev mode only)
1888
2315
  app.post("/api/sessions/resume", async (c) => {
1889
- const body = (await c.req.json());
1890
- if (!body.repoUrl) {
1891
- return c.json({ error: "repoUrl is required" }, 400);
2316
+ if (!config.devMode) {
2317
+ return c.json({ error: "Resume from repo not available" }, 403);
1892
2318
  }
2319
+ const body = await validateBody(c, resumeSessionSchema);
2320
+ if (isResponse(body))
2321
+ return body;
1893
2322
  const sessionId = crypto.randomUUID();
1894
2323
  const repoName = body.repoUrl
1895
2324
  .split("/")
@@ -1969,6 +2398,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1969
2398
  projectName: repoName,
1970
2399
  projectDir: handle.projectDir,
1971
2400
  runtime: config.sandbox.runtime,
2401
+ production: !config.devMode,
1972
2402
  git: {
1973
2403
  mode: "existing",
1974
2404
  repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
@@ -1992,18 +2422,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
1992
2422
  console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1993
2423
  }
1994
2424
  }
2425
+ // Ensure the room-messaging skill is present so agents have
2426
+ // persistent access to the multi-agent protocol reference.
2427
+ if (roomMessagingSkillContent) {
2428
+ try {
2429
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
2430
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2431
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2432
+ }
2433
+ catch (err) {
2434
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
2435
+ }
2436
+ }
1995
2437
  // 3. Create Claude Code bridge with a resume prompt
1996
2438
  const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
2439
+ const resumeHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1997
2440
  const claudeConfig = config.sandbox.runtime === "sprites"
1998
2441
  ? {
1999
2442
  prompt: resumePrompt,
2000
2443
  cwd: handle.projectDir,
2001
2444
  studioUrl: resolveStudioUrl(config.port),
2445
+ hookToken: resumeHookToken,
2002
2446
  }
2003
2447
  : {
2004
2448
  prompt: resumePrompt,
2005
2449
  cwd: handle.projectDir,
2006
2450
  studioPort: config.port,
2451
+ hookToken: resumeHookToken,
2007
2452
  };
2008
2453
  const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
2009
2454
  // 4. Register event listeners (reuse pattern from normal flow)
@@ -2083,6 +2528,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
2083
2528
  asyncFlow().catch(async (err) => {
2084
2529
  console.error(`[session:${sessionId}] Resume flow failed:`, err);
2085
2530
  config.sessions.update(sessionId, { status: "error" });
2531
+ try {
2532
+ await bridge.emit({
2533
+ type: "log",
2534
+ level: "error",
2535
+ message: `Resume failed: ${err instanceof Error ? err.message : String(err)}`,
2536
+ ts: ts(),
2537
+ });
2538
+ }
2539
+ catch {
2540
+ // Bridge may not be usable if the failure happened early
2541
+ }
2086
2542
  });
2087
2543
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
2088
2544
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -2107,6 +2563,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2107
2563
  return app;
2108
2564
  }
2109
2565
  export async function startWebServer(opts) {
2566
+ const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
2567
+ if (devMode) {
2568
+ console.log("[studio] Dev mode enabled — keychain endpoint active");
2569
+ }
2110
2570
  const config = {
2111
2571
  port: opts.port ?? 4400,
2112
2572
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
@@ -2115,6 +2575,7 @@ export async function startWebServer(opts) {
2115
2575
  sandbox: opts.sandbox,
2116
2576
  streamConfig: opts.streamConfig,
2117
2577
  bridgeMode: opts.bridgeMode ?? "claude-code",
2578
+ devMode,
2118
2579
  };
2119
2580
  fs.mkdirSync(config.dataDir, { recursive: true });
2120
2581
  const app = createApp(config);