@electric-agent/studio 1.7.0 → 1.12.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 (105) hide show
  1. package/dist/api-schemas.d.ts +225 -0
  2. package/dist/api-schemas.d.ts.map +1 -0
  3. package/dist/api-schemas.js +95 -0
  4. package/dist/api-schemas.js.map +1 -0
  5. package/dist/bridge/claude-code-base.d.ts +121 -0
  6. package/dist/bridge/claude-code-base.d.ts.map +1 -0
  7. package/dist/bridge/claude-code-base.js +263 -0
  8. package/dist/bridge/claude-code-base.js.map +1 -0
  9. package/dist/bridge/claude-code-docker.d.ts +13 -73
  10. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  11. package/dist/bridge/claude-code-docker.js +91 -302
  12. package/dist/bridge/claude-code-docker.js.map +1 -1
  13. package/dist/bridge/claude-code-sprites.d.ts +12 -59
  14. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  15. package/dist/bridge/claude-code-sprites.js +88 -281
  16. package/dist/bridge/claude-code-sprites.js.map +1 -1
  17. package/dist/bridge/claude-md-generator.d.ts +10 -1
  18. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  19. package/dist/bridge/claude-md-generator.js +47 -98
  20. package/dist/bridge/claude-md-generator.js.map +1 -1
  21. package/dist/bridge/codex-docker.d.ts +56 -51
  22. package/dist/bridge/codex-docker.js +222 -230
  23. package/dist/bridge/codex-json-parser.d.ts +11 -11
  24. package/dist/bridge/codex-json-parser.js +231 -238
  25. package/dist/bridge/codex-md-generator.d.ts +3 -3
  26. package/dist/bridge/codex-md-generator.js +42 -32
  27. package/dist/bridge/codex-sprites.d.ts +50 -45
  28. package/dist/bridge/codex-sprites.js +212 -222
  29. package/dist/bridge/daytona.d.ts +25 -25
  30. package/dist/bridge/daytona.js +131 -136
  31. package/dist/bridge/docker-stdio.d.ts +21 -21
  32. package/dist/bridge/docker-stdio.js +126 -132
  33. package/dist/bridge/hosted.d.ts +3 -2
  34. package/dist/bridge/hosted.d.ts.map +1 -1
  35. package/dist/bridge/hosted.js +4 -0
  36. package/dist/bridge/hosted.js.map +1 -1
  37. package/dist/bridge/message-parser.d.ts +24 -0
  38. package/dist/bridge/message-parser.d.ts.map +1 -0
  39. package/dist/bridge/message-parser.js +39 -0
  40. package/dist/bridge/message-parser.js.map +1 -0
  41. package/dist/bridge/role-skills.d.ts +25 -0
  42. package/dist/bridge/role-skills.d.ts.map +1 -0
  43. package/dist/bridge/role-skills.js +120 -0
  44. package/dist/bridge/role-skills.js.map +1 -0
  45. package/dist/bridge/room-messaging-skill.d.ts +11 -0
  46. package/dist/bridge/room-messaging-skill.d.ts.map +1 -0
  47. package/dist/bridge/room-messaging-skill.js +41 -0
  48. package/dist/bridge/room-messaging-skill.js.map +1 -0
  49. package/dist/bridge/sprites.d.ts +22 -22
  50. package/dist/bridge/sprites.js +123 -128
  51. package/dist/bridge/types.d.ts +4 -10
  52. package/dist/bridge/types.d.ts.map +1 -1
  53. package/dist/client/assets/index-BfvQSMwH.css +1 -0
  54. package/dist/client/assets/index-CiwD5LkP.js +235 -0
  55. package/dist/client/index.html +2 -2
  56. package/dist/index.d.ts +4 -3
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -3
  59. package/dist/index.js.map +1 -1
  60. package/dist/invite-code.d.ts +5 -0
  61. package/dist/invite-code.d.ts.map +1 -0
  62. package/dist/invite-code.js +14 -0
  63. package/dist/invite-code.js.map +1 -0
  64. package/dist/project-utils.d.ts.map +1 -1
  65. package/dist/project-utils.js.map +1 -1
  66. package/dist/registry.d.ts +11 -4
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +1 -1
  69. package/dist/registry.js.map +1 -1
  70. package/dist/room-router.d.ts +73 -0
  71. package/dist/room-router.d.ts.map +1 -0
  72. package/dist/room-router.js +345 -0
  73. package/dist/room-router.js.map +1 -0
  74. package/dist/sandbox/docker.d.ts.map +1 -1
  75. package/dist/sandbox/docker.js +5 -6
  76. package/dist/sandbox/docker.js.map +1 -1
  77. package/dist/sandbox/index.d.ts +0 -1
  78. package/dist/sandbox/index.d.ts.map +1 -1
  79. package/dist/sandbox/index.js +0 -1
  80. package/dist/sandbox/index.js.map +1 -1
  81. package/dist/sandbox/sprites.d.ts.map +1 -1
  82. package/dist/sandbox/sprites.js +40 -10
  83. package/dist/sandbox/sprites.js.map +1 -1
  84. package/dist/sandbox/types.d.ts +4 -2
  85. package/dist/sandbox/types.d.ts.map +1 -1
  86. package/dist/server.d.ts +12 -0
  87. package/dist/server.d.ts.map +1 -1
  88. package/dist/server.js +761 -346
  89. package/dist/server.js.map +1 -1
  90. package/dist/session-auth.d.ts +9 -0
  91. package/dist/session-auth.d.ts.map +1 -1
  92. package/dist/session-auth.js +30 -0
  93. package/dist/session-auth.js.map +1 -1
  94. package/dist/sessions.d.ts +1 -1
  95. package/dist/streams.d.ts +2 -6
  96. package/dist/streams.d.ts.map +1 -1
  97. package/dist/streams.js +6 -17
  98. package/dist/streams.js.map +1 -1
  99. package/dist/validate.d.ts +10 -0
  100. package/dist/validate.d.ts.map +1 -0
  101. package/dist/validate.js +24 -0
  102. package/dist/validate.js.map +1 -0
  103. package/package.json +6 -9
  104. package/dist/client/assets/index-D5-jqAV-.js +0 -234
  105. package/dist/client/assets/index-YyyiO26y.css +0 -1
package/dist/server.js CHANGED
@@ -7,23 +7,26 @@ 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 { generateInviteCode } from "./invite-code.js";
19
21
  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";
22
+ import { RoomRouter } from "./room-router.js";
23
+ import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
24
+ import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
25
+ import { isResponse, validateBody } from "./validate.js";
23
26
  /** Active session bridges — one per running session */
24
27
  const bridges = new Map();
25
- /** In-memory room presence: roomId participantId { displayName, lastPing } */
26
- const roomPresence = new Map();
28
+ /** Active room routers one per room with agent-to-agent messaging */
29
+ const roomRouters = new Map();
27
30
  /** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
28
31
  const inflightHookCreations = new Map();
29
32
  function parseRepoNameFromUrl(url) {
@@ -36,9 +39,9 @@ function parseRepoNameFromUrl(url) {
36
39
  function sessionStream(config, sessionId) {
37
40
  return getStreamConnectionInfo(sessionId, config.streamConfig);
38
41
  }
39
- /** Get stream connection info for a shared session */
40
- function sharedSessionStream(config, sharedSessionId) {
41
- return getSharedStreamConnectionInfo(sharedSessionId, config.streamConfig);
42
+ /** Get stream connection info for a room */
43
+ function roomStream(config, roomId) {
44
+ return getRoomStreamConnectionInfo(roomId, config.streamConfig);
42
45
  }
43
46
  /** Create or retrieve the SessionBridge for a session */
44
47
  function getOrCreateBridge(config, sessionId) {
@@ -66,9 +69,39 @@ function resolveStudioUrl(port) {
66
69
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
67
70
  return `http://localhost:${port}`;
68
71
  }
72
+ // ---------------------------------------------------------------------------
73
+ // Rate limiting — in-memory sliding window per IP
74
+ // ---------------------------------------------------------------------------
75
+ const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
76
+ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
77
+ const sessionCreationsByIp = new Map();
78
+ function extractClientIp(c) {
79
+ return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
80
+ c.req.header("cf-connecting-ip") ||
81
+ "unknown");
82
+ }
83
+ function checkSessionRateLimit(ip) {
84
+ const now = Date.now();
85
+ const cutoff = now - RATE_LIMIT_WINDOW_MS;
86
+ let timestamps = sessionCreationsByIp.get(ip) ?? [];
87
+ // Prune stale entries
88
+ timestamps = timestamps.filter((t) => t > cutoff);
89
+ if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
90
+ sessionCreationsByIp.set(ip, timestamps);
91
+ return false;
92
+ }
93
+ timestamps.push(now);
94
+ sessionCreationsByIp.set(ip, timestamps);
95
+ return true;
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Per-session cost budget
99
+ // ---------------------------------------------------------------------------
100
+ const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
69
101
  /**
70
102
  * Accumulate cost and turn metrics from a session_end event into the session's totals.
71
103
  * Called each time a Claude Code run finishes (initial + iterate runs).
104
+ * In production mode, enforces a per-session cost budget.
72
105
  */
73
106
  function accumulateSessionCost(config, sessionId, event) {
74
107
  if (event.type !== "session_end")
@@ -89,12 +122,39 @@ function accumulateSessionCost(config, sessionId, event) {
89
122
  }
90
123
  config.sessions.update(sessionId, updates);
91
124
  console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
125
+ // Enforce budget in production mode
126
+ if (!config.devMode &&
127
+ updates.totalCostUsd != null &&
128
+ updates.totalCostUsd > MAX_SESSION_COST_USD) {
129
+ console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
130
+ const bridge = bridges.get(sessionId);
131
+ if (bridge) {
132
+ bridge
133
+ .emit({
134
+ type: "budget_exceeded",
135
+ budget_usd: MAX_SESSION_COST_USD,
136
+ spent_usd: updates.totalCostUsd,
137
+ ts: ts(),
138
+ })
139
+ .catch(() => { });
140
+ }
141
+ config.sessions.update(sessionId, { status: "error" });
142
+ closeBridge(sessionId);
143
+ }
92
144
  }
93
145
  /**
94
146
  * Create a Claude Code bridge for a session.
95
147
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
148
+ * In production mode, enforces tool restrictions and hardcodes the model.
96
149
  */
97
150
  function createClaudeCodeBridge(config, sessionId, claudeConfig) {
151
+ // Production mode: restrict tools and hardcode model
152
+ if (!config.devMode) {
153
+ if (!claudeConfig.allowedTools) {
154
+ claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
155
+ }
156
+ claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
157
+ }
98
158
  const conn = sessionStream(config, sessionId);
99
159
  let bridge;
100
160
  if (config.sandbox.runtime === "sprites") {
@@ -264,8 +324,6 @@ function mapHookToEngineEvent(body) {
264
324
  }
265
325
  export function createApp(config) {
266
326
  const app = new Hono();
267
- // CORS for local development
268
- app.use("*", cors({ origin: "*" }));
269
327
  // --- API Routes ---
270
328
  // Health check
271
329
  app.get("/api/health", (c) => {
@@ -283,6 +341,10 @@ export function createApp(config) {
283
341
  checks.sandbox = config.sandbox.runtime;
284
342
  return c.json({ healthy, checks }, healthy ? 200 : 503);
285
343
  });
344
+ // Public config — exposes non-sensitive flags to the client
345
+ app.get("/api/config", (c) => {
346
+ return c.json({ devMode: config.devMode });
347
+ });
286
348
  // Provision Electric Cloud resources via the Claim API
287
349
  app.post("/api/provision-electric", async (c) => {
288
350
  try {
@@ -307,7 +369,7 @@ export function createApp(config) {
307
369
  // Hono's wildcard middleware matches creation routes like /api/sessions/local as
308
370
  // :id="local", so we must explicitly skip those.
309
371
  const authExemptIds = new Set(["local", "auto", "resume"]);
310
- const hookExemptSuffixes = new Set(["/hook-event"]);
372
+ // Hook-event auth is handled in the endpoint handler via validateHookToken
311
373
  /** Extract session token from Authorization header or query param. */
312
374
  function extractToken(c) {
313
375
  const authHeader = c.req.header("Authorization");
@@ -321,7 +383,8 @@ export function createApp(config) {
321
383
  if (authExemptIds.has(id))
322
384
  return next();
323
385
  const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
324
- if (hookExemptSuffixes.has(subPath))
386
+ // Hook-event uses a purpose-scoped hook token (validated in the handler)
387
+ if (subPath === "/hook-event")
325
388
  return next();
326
389
  const token = extractToken(c);
327
390
  if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
@@ -382,8 +445,9 @@ export function createApp(config) {
382
445
  // Pre-create a bridge so hook-event can emit to it immediately
383
446
  getOrCreateBridge(config, sessionId);
384
447
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
448
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
385
449
  console.log(`[local-session] Created session: ${sessionId}`);
386
- return c.json({ sessionId, sessionToken }, 201);
450
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
387
451
  });
388
452
  // Auto-register a local session on first hook event (SessionStart).
389
453
  // Eliminates the manual `curl POST /api/sessions/local` step.
@@ -429,14 +493,20 @@ export function createApp(config) {
429
493
  await bridge.emit(hookEvent);
430
494
  }
431
495
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
496
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
432
497
  console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
433
- return c.json({ sessionId, sessionToken }, 201);
498
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
434
499
  });
435
500
  // Receive a hook event from Claude Code (via forward.sh) and write it
436
501
  // to the session's durable stream as an EngineEvent.
437
502
  // For AskUserQuestion, this blocks until the user answers in the web UI.
438
503
  app.post("/api/sessions/:id/hook-event", async (c) => {
439
504
  const sessionId = c.req.param("id");
505
+ // Validate hook token (scoped per-session, separate from session token)
506
+ const token = extractToken(c);
507
+ if (!token || !validateHookToken(config.streamConfig.secret, sessionId, token)) {
508
+ return c.json({ error: "Invalid or missing hook token" }, 401);
509
+ }
440
510
  const body = (await c.req.json());
441
511
  const bridge = getOrCreateBridge(config, sessionId);
442
512
  // Map Claude Code hook JSON → EngineEvent
@@ -498,6 +568,16 @@ export function createApp(config) {
498
568
  return c.json({ ok: true });
499
569
  });
500
570
  // --- Unified Hook Endpoint (transcript_path correlation) ---
571
+ // Protect the unified hook endpoint with a global hook secret derived from
572
+ // the DS secret. The hook setup script embeds this secret in the forwarder
573
+ // so that only local Claude Code instances can post events.
574
+ app.use("/api/hook", async (c, next) => {
575
+ const token = extractToken(c);
576
+ if (!token || !validateGlobalHookSecret(config.streamConfig.secret, token)) {
577
+ return c.json({ error: "Invalid or missing hook secret" }, 401);
578
+ }
579
+ return next();
580
+ });
501
581
  // Single endpoint for all Claude Code hook events. Uses transcript_path
502
582
  // from the hook JSON as the correlation key — stable across resume/compact,
503
583
  // changes on /clear. Replaces the need for client-side session tracking.
@@ -635,6 +715,7 @@ export function createApp(config) {
635
715
  // Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
636
716
  app.get("/api/hooks/setup", (c) => {
637
717
  const port = config.port;
718
+ const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
638
719
  const script = `#!/bin/bash
639
720
  # Electric Agent — Claude Code hook installer (project-scoped)
640
721
  # Installs the hook forwarder into the current project's .claude/ directory.
@@ -655,10 +736,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
655
736
  # Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
656
737
 
657
738
  EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
739
+ EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
658
740
  BODY="$(cat)"
659
741
 
660
742
  RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
661
743
  -H "Content-Type: application/json" \\
744
+ -H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
662
745
  -d "\${BODY}" \\
663
746
  --max-time 360 \\
664
747
  --connect-timeout 2 \\
@@ -672,8 +755,9 @@ fi
672
755
  exit 0
673
756
  HOOKEOF
674
757
 
675
- # Replace placeholder with actual port
758
+ # Replace placeholders with actual values
676
759
  sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
760
+ sed -i.bak "s/EA_HOOK_SECRET_PLACEHOLDER/${hookSecret}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
677
761
  chmod +x "\${FORWARD_SH}"
678
762
 
679
763
  # Merge hook config into project-level settings.local.json
@@ -715,9 +799,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
715
799
  });
716
800
  // Start new project
717
801
  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);
802
+ const body = await validateBody(c, createSessionSchema);
803
+ if (isResponse(body))
804
+ return body;
805
+ // Block freeform sessions in production mode
806
+ if (body.freeform && !config.devMode) {
807
+ return c.json({ error: "Freeform sessions are not available" }, 403);
808
+ }
809
+ // Rate-limit session creation in production mode
810
+ if (!config.devMode) {
811
+ const ip = extractClientIp(c);
812
+ if (!checkSessionRateLimit(ip)) {
813
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
814
+ }
721
815
  }
722
816
  const sessionId = crypto.randomUUID();
723
817
  const inferredName = body.name ||
@@ -759,74 +853,84 @@ echo "Start claude in this project — the session will appear in the studio UI.
759
853
  config.sessions.add(session);
760
854
  // Write user prompt to the stream so it shows in the UI
761
855
  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
856
+ // Freeform sessions skip the infra config gate — no Electric/DB setup needed
764
857
  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
858
+ if (!body.freeform) {
859
+ // Gather GitHub accounts for the merged setup gate
860
+ // Only check if the client provided a token — never fall back to server-side GH_TOKEN
861
+ if (body.ghToken && isGhAuthenticated(body.ghToken)) {
862
+ try {
863
+ ghAccounts = ghListAccounts(body.ghToken);
864
+ }
865
+ catch {
866
+ // gh not available — no repo setup
867
+ }
771
868
  }
869
+ // Emit combined infra + repo setup gate
870
+ await bridge.emit({
871
+ type: "infra_config_prompt",
872
+ projectName,
873
+ ghAccounts,
874
+ runtime: config.sandbox.runtime,
875
+ ts: ts(),
876
+ });
772
877
  }
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
878
  // Launch async flow: wait for setup gate → create sandbox → start agent
782
879
  const asyncFlow = async () => {
783
- // 1. Wait for combined infra + repo config
880
+ // 1. Wait for combined infra + repo config (skip for freeform)
784
881
  let infra;
785
882
  let repoConfig = null;
786
- console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
787
883
  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;
884
+ if (body.freeform) {
885
+ // Freeform sessions don't need Electric infrastructure
886
+ infra = { mode: "none" };
887
+ console.log(`[session:${sessionId}] Freeform session skipping infra gate`);
888
+ }
889
+ else {
890
+ console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
891
+ try {
892
+ const gateValue = await createGate(sessionId, "infra_config");
893
+ console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
894
+ if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
895
+ // Normalize claim → cloud for the sandbox layer (same env vars)
896
+ infra = {
897
+ mode: "cloud",
898
+ databaseUrl: gateValue.databaseUrl,
899
+ electricUrl: gateValue.electricUrl,
900
+ sourceId: gateValue.sourceId,
901
+ secret: gateValue.secret,
902
+ };
903
+ if (gateValue.mode === "claim") {
904
+ claimId = gateValue.claimId;
905
+ }
906
+ }
907
+ else {
908
+ infra = { mode: "local" };
909
+ }
910
+ // Extract repo config if provided
911
+ if (gateValue.repoAccount && gateValue.repoName?.trim()) {
912
+ repoConfig = {
913
+ account: gateValue.repoAccount,
914
+ repoName: gateValue.repoName,
915
+ visibility: gateValue.repoVisibility ?? "private",
916
+ };
917
+ config.sessions.update(sessionId, {
918
+ git: {
919
+ branch: "main",
920
+ remoteUrl: null,
921
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
922
+ repoVisibility: repoConfig.visibility,
923
+ lastCommitHash: null,
924
+ lastCommitMessage: null,
925
+ lastCheckpointAt: null,
926
+ },
927
+ });
802
928
  }
803
929
  }
804
- else {
930
+ catch (err) {
931
+ console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
805
932
  infra = { mode: "local" };
806
933
  }
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
934
  }
831
935
  // 2. Create sandbox — emit progress events so the UI shows feedback
832
936
  await bridge.emit({
@@ -859,85 +963,119 @@ echo "Start claude in this project — the session will appear in the studio UI.
859
963
  // 3. Write CLAUDE.md and create a ClaudeCode bridge.
860
964
  {
861
965
  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`);
966
+ if (!body.freeform) {
967
+ // Copy pre-scaffolded project from the image and customize per-session
880
968
  await bridge.emit({
881
969
  type: "log",
882
- level: "done",
883
- message: "Project ready",
970
+ level: "build",
971
+ message: "Setting up project...",
884
972
  ts: ts(),
885
973
  });
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(),
974
+ try {
975
+ if (config.sandbox.runtime === "docker") {
976
+ // Docker: copy the pre-built scaffold base (baked into the image)
977
+ await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
978
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
979
+ }
980
+ else {
981
+ // Sprites: run scaffold from globally installed electric-agent
982
+ await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
983
+ }
984
+ console.log(`[session:${sessionId}] Project setup complete`);
985
+ await bridge.emit({
986
+ type: "log",
987
+ level: "done",
988
+ message: "Project ready",
989
+ ts: ts(),
990
+ });
991
+ // Log the agent package version installed in the sandbox
992
+ try {
993
+ const agentVersion = (await config.sandbox.exec(handle, "electric-agent --version 2>/dev/null | tail -1")).trim();
994
+ await bridge.emit({
995
+ type: "log",
996
+ level: "verbose",
997
+ message: `electric-agent@${agentVersion}`,
998
+ ts: ts(),
999
+ });
1000
+ }
1001
+ catch {
1002
+ // Non-critical — don't block session creation
1003
+ }
1004
+ }
1005
+ catch (err) {
1006
+ console.error(`[session:${sessionId}] Project setup failed:`, err);
1007
+ await bridge.emit({
1008
+ type: "log",
1009
+ level: "error",
1010
+ message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
1011
+ ts: ts(),
1012
+ });
1013
+ }
1014
+ // Write CLAUDE.md to the sandbox workspace.
1015
+ // Our generator includes hardcoded playbook paths and reading order
1016
+ // so we don't depend on @tanstack/intent generating a skill block.
1017
+ const claudeMd = generateClaudeMd({
1018
+ description: body.description,
1019
+ projectName,
1020
+ projectDir: handle.projectDir,
1021
+ runtime: config.sandbox.runtime,
1022
+ production: !config.devMode,
1023
+ ...(repoConfig
1024
+ ? {
1025
+ git: {
1026
+ mode: "create",
1027
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1028
+ visibility: repoConfig.visibility,
1029
+ },
1030
+ }
1031
+ : {}),
894
1032
  });
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
- },
1033
+ try {
1034
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
1035
+ }
1036
+ catch (err) {
1037
+ console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
1038
+ }
1039
+ // Ensure the create-app skill is present in the project.
1040
+ // The npm-installed electric-agent may be an older version that
1041
+ // doesn't include .claude/skills/ in its template directory.
1042
+ if (createAppSkillContent) {
1043
+ try {
1044
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
1045
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1046
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
909
1047
  }
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);
1048
+ catch (err) {
1049
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1050
+ }
1051
+ }
917
1052
  }
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) {
1053
+ // Ensure the room-messaging skill is present so agents have
1054
+ // persistent access to the multi-agent protocol reference.
1055
+ if (roomMessagingSkillContent) {
922
1056
  try {
923
- const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
924
- const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1057
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1058
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
925
1059
  await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
926
1060
  }
927
1061
  catch (err) {
928
- console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1062
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
929
1063
  }
930
1064
  }
1065
+ const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
1066
+ const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
931
1067
  const claudeConfig = config.sandbox.runtime === "sprites"
932
1068
  ? {
933
- prompt: `/create-app ${body.description}`,
1069
+ prompt: sessionPrompt,
934
1070
  cwd: handle.projectDir,
935
1071
  studioUrl: resolveStudioUrl(config.port),
1072
+ hookToken: sessionHookToken,
936
1073
  }
937
1074
  : {
938
- prompt: `/create-app ${body.description}`,
1075
+ prompt: sessionPrompt,
939
1076
  cwd: handle.projectDir,
940
1077
  studioPort: config.port,
1078
+ hookToken: sessionHookToken,
941
1079
  };
942
1080
  bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
943
1081
  }
@@ -1011,7 +1149,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1011
1149
  await bridge.emit({
1012
1150
  type: "log",
1013
1151
  level: "build",
1014
- message: `Running: claude "/create-app ${body.description}"`,
1152
+ message: body.freeform
1153
+ ? `Running: claude "${body.description}"`
1154
+ : `Running: claude "/create-app ${body.description}"`,
1015
1155
  ts: ts(),
1016
1156
  });
1017
1157
  console.log(`[session:${sessionId}] Starting bridge listener...`);
@@ -1029,6 +1169,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
1029
1169
  asyncFlow().catch(async (err) => {
1030
1170
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
1031
1171
  config.sessions.update(sessionId, { status: "error" });
1172
+ try {
1173
+ await bridge.emit({
1174
+ type: "log",
1175
+ level: "error",
1176
+ message: `Session failed: ${err instanceof Error ? err.message : String(err)}`,
1177
+ ts: ts(),
1178
+ });
1179
+ }
1180
+ catch {
1181
+ // Bridge may not be usable if the failure happened early
1182
+ }
1032
1183
  });
1033
1184
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1034
1185
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -1039,10 +1190,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1039
1190
  const session = config.sessions.get(sessionId);
1040
1191
  if (!session)
1041
1192
  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
- }
1193
+ const body = await validateBody(c, iterateSessionSchema);
1194
+ if (isResponse(body))
1195
+ return body;
1046
1196
  // Intercept operational commands (start/stop/restart the app/server)
1047
1197
  const normalised = body.request
1048
1198
  .toLowerCase()
@@ -1171,6 +1321,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
1171
1321
  }
1172
1322
  // No pending gate — fall through to bridge.sendGateResponse()
1173
1323
  }
1324
+ // Outbound message gates (room agent → room stream): resolved in-process
1325
+ if (gate === "outbound_message_gate") {
1326
+ const gateId = body.gateId;
1327
+ const action = body.action;
1328
+ if (!gateId || !action) {
1329
+ return c.json({ error: "gateId and action are required for outbound_message_gate" }, 400);
1330
+ }
1331
+ const resolved = resolveGate(sessionId, gateId, {
1332
+ action,
1333
+ editedBody: body.editedBody,
1334
+ });
1335
+ if (resolved) {
1336
+ return c.json({ ok: true });
1337
+ }
1338
+ return c.json({ error: "No pending gate found" }, 404);
1339
+ }
1174
1340
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1175
1341
  const serverGates = new Set(["infra_config"]);
1176
1342
  // Forward agent gate responses via the bridge
@@ -1396,7 +1562,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1396
1562
  });
1397
1563
  // Create a standalone sandbox (not tied to session creation flow)
1398
1564
  app.post("/api/sandboxes", async (c) => {
1399
- const body = (await c.req.json());
1565
+ const body = await validateBody(c, createSandboxSchema);
1566
+ if (isResponse(body))
1567
+ return body;
1400
1568
  const sessionId = body.sessionId ?? crypto.randomUUID();
1401
1569
  try {
1402
1570
  const handle = await config.sandbox.create(sessionId, {
@@ -1426,30 +1594,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
1426
1594
  await config.sandbox.destroy(handle);
1427
1595
  return c.json({ ok: true });
1428
1596
  });
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) => {
1597
+ // --- Room Routes (agent-to-agent messaging) ---
1598
+ // Extract room token from X-Room-Token header or ?token= query param.
1599
+ // This is separate from extractToken() (which reads Authorization) so that
1600
+ // Authorization remains available for session tokens on endpoints that need both.
1601
+ function extractRoomToken(c) {
1602
+ return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
1603
+ }
1604
+ // Protect room-scoped routes via X-Room-Token header
1605
+ app.use("/api/rooms/:id/*", async (c, next) => {
1434
1606
  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)) {
1607
+ const token = extractRoomToken(c);
1608
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1439
1609
  return c.json({ error: "Invalid or missing room token" }, 401);
1440
1610
  }
1441
1611
  return next();
1442
1612
  });
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);
1613
+ app.use("/api/rooms/:id", async (c, next) => {
1614
+ if (c.req.method !== "GET" && c.req.method !== "DELETE")
1615
+ return next();
1616
+ const id = c.req.param("id");
1617
+ const token = extractRoomToken(c);
1618
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1619
+ return c.json({ error: "Invalid or missing room token" }, 401);
1448
1620
  }
1449
- const id = crypto.randomUUID();
1450
- const code = generateInviteCode();
1451
- // Create the shared session durable stream
1452
- const conn = sharedSessionStream(config, id);
1621
+ return next();
1622
+ });
1623
+ // Create a room
1624
+ app.post("/api/rooms", async (c) => {
1625
+ const body = await validateBody(c, createRoomSchema);
1626
+ if (isResponse(body))
1627
+ return body;
1628
+ const roomId = crypto.randomUUID();
1629
+ // Create the room's durable stream
1630
+ const conn = roomStream(config, roomId);
1453
1631
  try {
1454
1632
  await DurableStream.create({
1455
1633
  url: conn.url,
@@ -1458,191 +1636,341 @@ echo "Start claude in this project — the session will appear in the studio UI.
1458
1636
  });
1459
1637
  }
1460
1638
  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);
1639
+ console.error(`[room] Failed to create durable stream:`, err);
1640
+ return c.json({ error: "Failed to create room stream" }, 500);
1463
1641
  }
1464
- // Write shared_session_created event
1465
- const stream = new DurableStream({
1466
- url: conn.url,
1467
- headers: conn.headers,
1468
- contentType: "application/json",
1642
+ // Create and start the router
1643
+ const router = new RoomRouter(roomId, body.name, config.streamConfig, {
1644
+ maxRounds: body.maxRounds,
1469
1645
  });
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
1646
+ await router.start();
1647
+ roomRouters.set(roomId, router);
1648
+ // Save to room registry for persistence
1649
+ const code = generateInviteCode();
1486
1650
  await config.rooms.addRoom({
1487
- id,
1651
+ id: roomId,
1488
1652
  code,
1489
1653
  name: body.name,
1490
1654
  createdAt: new Date().toISOString(),
1491
1655
  revoked: false,
1492
1656
  });
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);
1657
+ const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1658
+ console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
1659
+ return c.json({ roomId, code, roomToken }, 201);
1496
1660
  });
1497
- // Resolve invite code shared session ID + room token
1498
- app.get("/api/shared-sessions/join/:code", (c) => {
1661
+ // Join an agent room by id + invite code
1662
+ app.get("/api/rooms/join/:id/:code", (c) => {
1663
+ const id = c.req.param("id");
1499
1664
  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 });
1665
+ const room = config.rooms.getRoom(id);
1666
+ if (!room || room.code !== code)
1667
+ return c.json({ error: "Room not found" }, 404);
1668
+ if (room.revoked)
1669
+ return c.json({ error: "Room has been revoked" }, 410);
1670
+ const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
1671
+ return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
1505
1672
  });
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",
1673
+ // Get room state
1674
+ app.get("/api/rooms/:id", (c) => {
1675
+ const roomId = c.req.param("id");
1676
+ const router = roomRouters.get(roomId);
1677
+ if (!router)
1678
+ return c.json({ error: "Room not found" }, 404);
1679
+ return c.json({
1680
+ roomId,
1681
+ state: router.state,
1682
+ roundCount: router.roundCount,
1683
+ participants: router.participants.map((p) => ({
1684
+ sessionId: p.sessionId,
1685
+ name: p.name,
1686
+ role: p.role,
1687
+ running: p.bridge.isRunning(),
1688
+ })),
1523
1689
  });
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
1690
  });
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);
1691
+ // Add an agent to a room
1692
+ app.post("/api/rooms/:id/agents", async (c) => {
1693
+ const roomId = c.req.param("id");
1694
+ const router = roomRouters.get(roomId);
1695
+ if (!router)
1696
+ return c.json({ error: "Room not found" }, 404);
1697
+ const body = await validateBody(c, addAgentSchema);
1698
+ if (isResponse(body))
1699
+ return body;
1700
+ const sessionId = crypto.randomUUID();
1701
+ const randomSuffix = sessionId.slice(0, 6);
1702
+ const agentName = body.name?.trim() || `agent-${randomSuffix}`;
1703
+ const projectName = `room-${agentName}-${sessionId.slice(0, 8)}`;
1704
+ console.log(`[room:${roomId}] Adding agent: name=${agentName} session=${sessionId}`);
1705
+ // Create the session's durable stream
1706
+ const conn = sessionStream(config, sessionId);
1707
+ try {
1708
+ await DurableStream.create({
1709
+ url: conn.url,
1710
+ headers: conn.headers,
1711
+ contentType: "application/json",
1712
+ });
1538
1713
  }
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(),
1714
+ catch (err) {
1715
+ console.error(`[room:${roomId}] Failed to create session stream:`, err);
1716
+ return c.json({ error: "Failed to create session stream" }, 500);
1717
+ }
1718
+ // Create bridge
1719
+ const bridge = getOrCreateBridge(config, sessionId);
1720
+ // Record session
1721
+ const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
1722
+ const session = {
1723
+ id: sessionId,
1724
+ projectName,
1725
+ sandboxProjectDir,
1726
+ description: `Room agent: ${agentName} (${body.role ?? "participant"})`,
1727
+ createdAt: new Date().toISOString(),
1728
+ lastActiveAt: new Date().toISOString(),
1729
+ status: "running",
1549
1730
  };
1550
- await stream.append(JSON.stringify(event));
1551
- return c.json({ ok: true });
1731
+ config.sessions.add(session);
1732
+ // Return early so the client can store the session token and show the
1733
+ // session in the sidebar immediately. The sandbox setup continues in
1734
+ // the background — events stream to the session's durable stream so
1735
+ // the UI stays up to date.
1736
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1737
+ (async () => {
1738
+ await bridge.emit({
1739
+ type: "log",
1740
+ level: "build",
1741
+ message: `Creating sandbox for room agent "${agentName}"...`,
1742
+ ts: ts(),
1743
+ });
1744
+ try {
1745
+ const handle = await config.sandbox.create(sessionId, {
1746
+ projectName,
1747
+ infra: { mode: "local" },
1748
+ apiKey: body.apiKey,
1749
+ oauthToken: body.oauthToken,
1750
+ ghToken: body.ghToken,
1751
+ });
1752
+ config.sessions.update(sessionId, {
1753
+ appPort: handle.port,
1754
+ sandboxProjectDir: handle.projectDir,
1755
+ previewUrl: handle.previewUrl,
1756
+ });
1757
+ // Inject room-messaging skill so agents know the @room protocol
1758
+ if (roomMessagingSkillContent) {
1759
+ try {
1760
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1761
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
1762
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1763
+ // Append room-messaging reference to CLAUDE.md so the agent knows to read it
1764
+ 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`;
1765
+ const refB64 = Buffer.from(roomRef).toString("base64");
1766
+ await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
1767
+ }
1768
+ catch (err) {
1769
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
1770
+ }
1771
+ }
1772
+ // Resolve role skill (behavioral guidelines + tool permissions)
1773
+ const roleSkill = resolveRoleSkill(body.role);
1774
+ // Inject role skill file into sandbox
1775
+ if (roleSkill) {
1776
+ try {
1777
+ const skillDir = `${handle.projectDir}/.claude/skills/role`;
1778
+ const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
1779
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1780
+ }
1781
+ catch (err) {
1782
+ console.error(`[session:${sessionId}] Failed to write role skill:`, err);
1783
+ }
1784
+ }
1785
+ // Build prompt — reference the role skill if available
1786
+ const rolePromptSuffix = roleSkill
1787
+ ? `\nRead .claude/skills/role/SKILL.md for your role guidelines before proceeding.`
1788
+ : "";
1789
+ const agentPrompt = `You are "${agentName}"${body.role ? `, role: ${body.role}` : ""}. You are joining a multi-agent room.${rolePromptSuffix}`;
1790
+ // Create Claude Code bridge (with role-specific tool permissions)
1791
+ const agentHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1792
+ const claudeConfig = config.sandbox.runtime === "sprites"
1793
+ ? {
1794
+ prompt: agentPrompt,
1795
+ cwd: handle.projectDir,
1796
+ studioUrl: resolveStudioUrl(config.port),
1797
+ hookToken: agentHookToken,
1798
+ agentName: agentName,
1799
+ ...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
1800
+ }
1801
+ : {
1802
+ prompt: agentPrompt,
1803
+ cwd: handle.projectDir,
1804
+ studioPort: config.port,
1805
+ hookToken: agentHookToken,
1806
+ agentName: agentName,
1807
+ ...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
1808
+ };
1809
+ const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1810
+ // Track Claude Code session ID and cost
1811
+ ccBridge.onAgentEvent((event) => {
1812
+ if (event.type === "session_start") {
1813
+ const ccSessionId = event.session_id;
1814
+ if (ccSessionId) {
1815
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1816
+ }
1817
+ }
1818
+ if (event.type === "session_end") {
1819
+ accumulateSessionCost(config, sessionId, event);
1820
+ }
1821
+ // Route assistant_message output to the room router
1822
+ if (event.type === "assistant_message" && "text" in event) {
1823
+ router
1824
+ .handleAgentOutput(sessionId, event.text)
1825
+ .catch((err) => {
1826
+ console.error(`[room:${roomId}] handleAgentOutput error:`, err);
1827
+ });
1828
+ }
1829
+ });
1830
+ await bridge.emit({
1831
+ type: "log",
1832
+ level: "done",
1833
+ message: `Sandbox ready for "${agentName}"`,
1834
+ ts: ts(),
1835
+ });
1836
+ await ccBridge.start();
1837
+ // Add participant to room router
1838
+ const participant = {
1839
+ sessionId,
1840
+ name: agentName,
1841
+ role: body.role,
1842
+ bridge: ccBridge,
1843
+ };
1844
+ await router.addParticipant(participant, body.gated ?? false);
1845
+ // If there's an initial prompt, send it directly to this agent only (not broadcast)
1846
+ if (body.initialPrompt) {
1847
+ await ccBridge.sendCommand({ command: "iterate", request: body.initialPrompt });
1848
+ }
1849
+ }
1850
+ catch (err) {
1851
+ const msg = err instanceof Error ? err.message : "Failed to create agent sandbox";
1852
+ console.error(`[room:${roomId}] Agent creation failed:`, err);
1853
+ await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
1854
+ }
1855
+ })();
1856
+ return c.json({ sessionId, participantName: agentName, sessionToken }, 201);
1552
1857
  });
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);
1858
+ // Add an existing running session to a room
1859
+ app.post("/api/rooms/:id/sessions", async (c) => {
1860
+ const roomId = c.req.param("id");
1861
+ const router = roomRouters.get(roomId);
1862
+ if (!router)
1863
+ return c.json({ error: "Room not found" }, 404);
1864
+ const body = await validateBody(c, addSessionToRoomSchema);
1865
+ if (isResponse(body))
1866
+ return body;
1867
+ const { sessionId } = body;
1868
+ // Require a valid session token — caller must already own this session.
1869
+ // Room auth is handled by middleware via X-Room-Token; Authorization
1870
+ // carries the session ownership proof here.
1871
+ const authHeader = c.req.header("Authorization");
1872
+ const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
1873
+ if (!sessionToken ||
1874
+ !validateSessionToken(config.streamConfig.secret, sessionId, sessionToken)) {
1875
+ return c.json({ error: "Invalid or missing session token" }, 401);
1559
1876
  }
1560
- let room = roomPresence.get(id);
1561
- if (!room) {
1562
- room = new Map();
1563
- roomPresence.set(id, room);
1877
+ // Verify the session exists
1878
+ const sessionInfo = config.sessions.get(sessionId);
1879
+ if (!sessionInfo) {
1880
+ return c.json({ error: "Session not found" }, 404);
1564
1881
  }
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 });
1882
+ // Get the sandbox handle — must be running
1883
+ const handle = config.sandbox.get(sessionId);
1884
+ if (!handle) {
1885
+ return c.json({ error: "Session sandbox not found or not running" }, 400);
1886
+ }
1887
+ // Get or create bridge (it should already exist for a running session)
1888
+ const bridge = getOrCreateBridge(config, sessionId);
1889
+ console.log(`[room:${roomId}] Adding existing session: name=${body.name} session=${sessionId}`);
1890
+ try {
1891
+ // Inject room-messaging skill
1892
+ if (roomMessagingSkillContent) {
1893
+ try {
1894
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1895
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
1896
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1897
+ // Append room-messaging reference to CLAUDE.md so the agent knows to read it
1898
+ 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`;
1899
+ const refB64 = Buffer.from(roomRef).toString("base64");
1900
+ await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
1582
1901
  }
1583
- else {
1584
- room.delete(pid);
1902
+ catch (err) {
1903
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
1585
1904
  }
1586
1905
  }
1906
+ // The existing bridge is already a Claude Code bridge — wire up room output handling
1907
+ bridge.onAgentEvent((event) => {
1908
+ if (event.type === "assistant_message" && "text" in event) {
1909
+ router
1910
+ .handleAgentOutput(sessionId, event.text)
1911
+ .catch((err) => {
1912
+ console.error(`[room:${roomId}] handleAgentOutput error:`, err);
1913
+ });
1914
+ }
1915
+ });
1916
+ // Add participant to room router
1917
+ const participant = {
1918
+ sessionId,
1919
+ name: body.name,
1920
+ bridge,
1921
+ };
1922
+ await router.addParticipant(participant, false);
1923
+ // If there's an initial prompt, send it directly to this agent
1924
+ if (body.initialPrompt) {
1925
+ await bridge.sendCommand({ command: "iterate", request: body.initialPrompt });
1926
+ }
1587
1927
  }
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);
1928
+ catch (err) {
1929
+ const msg = err instanceof Error ? err.message : "Failed to add session to room";
1930
+ console.error(`[room:${roomId}] Add session failed:`, err);
1931
+ return c.json({ error: msg }, 500);
1597
1932
  }
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 });
1933
+ // No need to return sessionToken — caller already proved they have it
1934
+ return c.json({ sessionId, participantName: body.name }, 201);
1614
1935
  });
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 });
1620
- });
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");
1936
+ // Send a message directly to a specific session in a room (bypasses room stream)
1937
+ app.post("/api/rooms/:id/sessions/:sessionId/iterate", async (c) => {
1938
+ const roomId = c.req.param("id");
1624
1939
  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",
1940
+ const router = roomRouters.get(roomId);
1941
+ if (!router)
1942
+ return c.json({ error: "Room not found" }, 404);
1943
+ const participant = router.participants.find((p) => p.sessionId === sessionId);
1944
+ if (!participant)
1945
+ return c.json({ error: "Session not found in this room" }, 404);
1946
+ const body = await validateBody(c, iterateRoomSessionSchema);
1947
+ if (isResponse(body))
1948
+ return body;
1949
+ await participant.bridge.sendCommand({
1950
+ command: "iterate",
1951
+ request: body.request,
1630
1952
  });
1631
- const event = {
1632
- type: "session_unlinked",
1633
- sessionId,
1634
- ts: ts(),
1635
- };
1636
- await stream.append(JSON.stringify(event));
1637
1953
  return c.json({ ok: true });
1638
1954
  });
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);
1955
+ // Send a message to a room (from human or API)
1956
+ app.post("/api/rooms/:id/messages", async (c) => {
1957
+ const roomId = c.req.param("id");
1958
+ const router = roomRouters.get(roomId);
1959
+ if (!router)
1960
+ return c.json({ error: "Room not found" }, 404);
1961
+ const body = await validateBody(c, sendRoomMessageSchema);
1962
+ if (isResponse(body))
1963
+ return body;
1964
+ await router.sendMessage(body.from, body.body, body.to);
1965
+ return c.json({ ok: true });
1966
+ });
1967
+ // SSE proxy for room events
1968
+ app.get("/api/rooms/:id/events", async (c) => {
1969
+ const roomId = c.req.param("id");
1970
+ const router = roomRouters.get(roomId);
1971
+ if (!router)
1972
+ return c.json({ error: "Room not found" }, 404);
1973
+ const connection = roomStream(config, roomId);
1646
1974
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1647
1975
  const reader = new DurableStream({
1648
1976
  url: connection.url,
@@ -1681,23 +2009,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
1681
2009
  },
1682
2010
  });
1683
2011
  });
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);
2012
+ // Close a room
2013
+ app.post("/api/rooms/:id/close", async (c) => {
2014
+ const roomId = c.req.param("id");
2015
+ const router = roomRouters.get(roomId);
2016
+ if (!router)
2017
+ return c.json({ error: "Room not found" }, 404);
2018
+ // Emit room_closed event
2019
+ const conn = roomStream(config, roomId);
1691
2020
  const stream = new DurableStream({
1692
2021
  url: conn.url,
1693
2022
  headers: conn.headers,
1694
2023
  contentType: "application/json",
1695
2024
  });
1696
2025
  const event = {
1697
- type: "code_revoked",
2026
+ type: "room_closed",
2027
+ closedBy: "human",
2028
+ summary: "Room closed by user",
1698
2029
  ts: ts(),
1699
2030
  };
1700
2031
  await stream.append(JSON.stringify(event));
2032
+ router.close();
1701
2033
  return c.json({ ok: true });
1702
2034
  });
1703
2035
  // --- SSE Proxy ---
@@ -1767,6 +2099,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
1767
2099
  },
1768
2100
  });
1769
2101
  });
2102
+ // --- Stream Append Proxy ---
2103
+ // Proxy endpoint for writing events to a session's durable stream.
2104
+ // Authenticates via session token so the caller never needs DS_SECRET.
2105
+ // Used by sandbox agents to write events back to the session stream.
2106
+ app.post("/api/sessions/:id/stream/append", async (c) => {
2107
+ const sessionId = c.req.param("id");
2108
+ const contentType = c.req.header("content-type") ?? "";
2109
+ if (!contentType.includes("application/json")) {
2110
+ return c.json({ error: "Content-Type must be application/json" }, 415);
2111
+ }
2112
+ const body = await c.req.text();
2113
+ if (!body) {
2114
+ return c.json({ error: "Request body is required" }, 400);
2115
+ }
2116
+ // Guard against oversized payloads (64 KB limit)
2117
+ if (body.length > 65_536) {
2118
+ return c.json({ error: "Payload too large" }, 413);
2119
+ }
2120
+ // Validate JSON before forwarding to the stream
2121
+ try {
2122
+ JSON.parse(body);
2123
+ }
2124
+ catch {
2125
+ return c.json({ error: "Invalid JSON body" }, 400);
2126
+ }
2127
+ const connection = sessionStream(config, sessionId);
2128
+ try {
2129
+ const writer = new DurableStream({
2130
+ url: connection.url,
2131
+ headers: connection.headers,
2132
+ contentType: "application/json",
2133
+ });
2134
+ await writer.append(body);
2135
+ return c.json({ ok: true });
2136
+ }
2137
+ catch (err) {
2138
+ console.error(`[stream-proxy] Append failed: session=${sessionId}`, err);
2139
+ return c.json({ error: "Failed to append to stream" }, 500);
2140
+ }
2141
+ });
1770
2142
  // --- Git/GitHub Routes ---
1771
2143
  // Get git status for a session
1772
2144
  app.get("/api/sessions/:id/git-status", async (c) => {
@@ -1814,7 +2186,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1814
2186
  if (!handle || !sandboxDir) {
1815
2187
  return c.json({ error: "Container not available" }, 404);
1816
2188
  }
1817
- if (!filePath.startsWith(sandboxDir)) {
2189
+ const resolvedPath = path.resolve(filePath);
2190
+ const resolvedDir = path.resolve(sandboxDir) + path.sep;
2191
+ if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
1818
2192
  return c.json({ error: "Path outside project directory" }, 403);
1819
2193
  }
1820
2194
  const content = await config.sandbox.readFile(handle, filePath);
@@ -1863,32 +2237,41 @@ echo "Start claude in this project — the session will appear in the studio UI.
1863
2237
  return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
1864
2238
  }
1865
2239
  });
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)}`);
2240
+ // Read Claude credentials from macOS Keychain (dev convenience).
2241
+ // Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
2242
+ if (config.devMode) {
2243
+ app.get("/api/credentials/keychain", (c) => {
2244
+ if (process.platform !== "darwin") {
2245
+ return c.json({ apiKey: null });
1877
2246
  }
1878
- else {
1879
- console.log("[dev] No OAuth token found in keychain");
2247
+ try {
2248
+ const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
2249
+ const parsed = JSON.parse(raw);
2250
+ const token = parsed.claudeAiOauth?.accessToken ?? null;
2251
+ if (token) {
2252
+ console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
2253
+ }
2254
+ else {
2255
+ console.log("[dev] No OAuth token found in keychain");
2256
+ }
2257
+ return c.json({ oauthToken: token });
1880
2258
  }
1881
- return c.json({ oauthToken: token });
1882
- }
1883
- catch {
1884
- return c.json({ oauthToken: null });
1885
- }
1886
- });
2259
+ catch {
2260
+ return c.json({ oauthToken: null });
2261
+ }
2262
+ });
2263
+ }
1887
2264
  // Resume a project from a GitHub repo
1888
2265
  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);
2266
+ const body = await validateBody(c, resumeSessionSchema);
2267
+ if (isResponse(body))
2268
+ return body;
2269
+ // Rate-limit session creation in production mode
2270
+ if (!config.devMode) {
2271
+ const ip = extractClientIp(c);
2272
+ if (!checkSessionRateLimit(ip)) {
2273
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
2274
+ }
1892
2275
  }
1893
2276
  const sessionId = crypto.randomUUID();
1894
2277
  const repoName = body.repoUrl
@@ -1969,6 +2352,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1969
2352
  projectName: repoName,
1970
2353
  projectDir: handle.projectDir,
1971
2354
  runtime: config.sandbox.runtime,
2355
+ production: !config.devMode,
1972
2356
  git: {
1973
2357
  mode: "existing",
1974
2358
  repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
@@ -1992,18 +2376,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
1992
2376
  console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1993
2377
  }
1994
2378
  }
2379
+ // Ensure the room-messaging skill is present so agents have
2380
+ // persistent access to the multi-agent protocol reference.
2381
+ if (roomMessagingSkillContent) {
2382
+ try {
2383
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
2384
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2385
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2386
+ }
2387
+ catch (err) {
2388
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
2389
+ }
2390
+ }
1995
2391
  // 3. Create Claude Code bridge with a resume prompt
1996
2392
  const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
2393
+ const resumeHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1997
2394
  const claudeConfig = config.sandbox.runtime === "sprites"
1998
2395
  ? {
1999
2396
  prompt: resumePrompt,
2000
2397
  cwd: handle.projectDir,
2001
2398
  studioUrl: resolveStudioUrl(config.port),
2399
+ hookToken: resumeHookToken,
2002
2400
  }
2003
2401
  : {
2004
2402
  prompt: resumePrompt,
2005
2403
  cwd: handle.projectDir,
2006
2404
  studioPort: config.port,
2405
+ hookToken: resumeHookToken,
2007
2406
  };
2008
2407
  const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
2009
2408
  // 4. Register event listeners (reuse pattern from normal flow)
@@ -2083,6 +2482,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
2083
2482
  asyncFlow().catch(async (err) => {
2084
2483
  console.error(`[session:${sessionId}] Resume flow failed:`, err);
2085
2484
  config.sessions.update(sessionId, { status: "error" });
2485
+ try {
2486
+ await bridge.emit({
2487
+ type: "log",
2488
+ level: "error",
2489
+ message: `Resume failed: ${err instanceof Error ? err.message : String(err)}`,
2490
+ ts: ts(),
2491
+ });
2492
+ }
2493
+ catch {
2494
+ // Bridge may not be usable if the failure happened early
2495
+ }
2086
2496
  });
2087
2497
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
2088
2498
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -2107,6 +2517,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2107
2517
  return app;
2108
2518
  }
2109
2519
  export async function startWebServer(opts) {
2520
+ const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
2521
+ if (devMode) {
2522
+ console.log("[studio] Dev mode enabled — keychain endpoint active");
2523
+ }
2110
2524
  const config = {
2111
2525
  port: opts.port ?? 4400,
2112
2526
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
@@ -2115,6 +2529,7 @@ export async function startWebServer(opts) {
2115
2529
  sandbox: opts.sandbox,
2116
2530
  streamConfig: opts.streamConfig,
2117
2531
  bridgeMode: opts.bridgeMode ?? "claude-code",
2532
+ devMode,
2118
2533
  };
2119
2534
  fs.mkdirSync(config.dataDir, { recursive: true });
2120
2535
  const app = createApp(config);