@electric-agent/studio 1.5.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 (109) 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 +22 -5
  18. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  19. package/dist/bridge/claude-md-generator.js +81 -213
  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/stream-json-parser.js +12 -5
  52. package/dist/bridge/stream-json-parser.js.map +1 -1
  53. package/dist/bridge/types.d.ts +4 -10
  54. package/dist/bridge/types.d.ts.map +1 -1
  55. package/dist/client/assets/index-BfvQSMwH.css +1 -0
  56. package/dist/client/assets/index-CiwD5LkP.js +235 -0
  57. package/dist/client/index.html +2 -2
  58. package/dist/index.d.ts +4 -3
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -3
  61. package/dist/index.js.map +1 -1
  62. package/dist/invite-code.d.ts +5 -0
  63. package/dist/invite-code.d.ts.map +1 -0
  64. package/dist/invite-code.js +14 -0
  65. package/dist/invite-code.js.map +1 -0
  66. package/dist/project-utils.d.ts.map +1 -1
  67. package/dist/project-utils.js.map +1 -1
  68. package/dist/registry.d.ts +11 -4
  69. package/dist/registry.d.ts.map +1 -1
  70. package/dist/registry.js +1 -1
  71. package/dist/registry.js.map +1 -1
  72. package/dist/room-router.d.ts +73 -0
  73. package/dist/room-router.d.ts.map +1 -0
  74. package/dist/room-router.js +345 -0
  75. package/dist/room-router.js.map +1 -0
  76. package/dist/sandbox/docker.d.ts.map +1 -1
  77. package/dist/sandbox/docker.js +5 -6
  78. package/dist/sandbox/docker.js.map +1 -1
  79. package/dist/sandbox/index.d.ts +0 -1
  80. package/dist/sandbox/index.d.ts.map +1 -1
  81. package/dist/sandbox/index.js +0 -1
  82. package/dist/sandbox/index.js.map +1 -1
  83. package/dist/sandbox/sprites.d.ts.map +1 -1
  84. package/dist/sandbox/sprites.js +40 -10
  85. package/dist/sandbox/sprites.js.map +1 -1
  86. package/dist/sandbox/types.d.ts +4 -2
  87. package/dist/sandbox/types.d.ts.map +1 -1
  88. package/dist/server.d.ts +12 -0
  89. package/dist/server.d.ts.map +1 -1
  90. package/dist/server.js +824 -309
  91. package/dist/server.js.map +1 -1
  92. package/dist/session-auth.d.ts +9 -0
  93. package/dist/session-auth.d.ts.map +1 -1
  94. package/dist/session-auth.js +30 -0
  95. package/dist/session-auth.js.map +1 -1
  96. package/dist/sessions.d.ts +7 -1
  97. package/dist/sessions.d.ts.map +1 -1
  98. package/dist/sessions.js.map +1 -1
  99. package/dist/streams.d.ts +2 -6
  100. package/dist/streams.d.ts.map +1 -1
  101. package/dist/streams.js +6 -17
  102. package/dist/streams.js.map +1 -1
  103. package/dist/validate.d.ts +10 -0
  104. package/dist/validate.d.ts.map +1 -0
  105. package/dist/validate.js +24 -0
  106. package/dist/validate.js.map +1 -0
  107. package/package.json +6 -9
  108. package/dist/client/assets/index-DDzmxYub.js +0 -234
  109. package/dist/client/assets/index-DcP7prsZ.css +0 -1
package/dist/server.js CHANGED
@@ -7,21 +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();
28
+ /** Active room routers — one per room with agent-to-agent messaging */
29
+ const roomRouters = new Map();
25
30
  /** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
26
31
  const inflightHookCreations = new Map();
27
32
  function parseRepoNameFromUrl(url) {
@@ -34,9 +39,9 @@ function parseRepoNameFromUrl(url) {
34
39
  function sessionStream(config, sessionId) {
35
40
  return getStreamConnectionInfo(sessionId, config.streamConfig);
36
41
  }
37
- /** Get stream connection info for a shared session */
38
- function sharedSessionStream(config, sharedSessionId) {
39
- return getSharedStreamConnectionInfo(sharedSessionId, config.streamConfig);
42
+ /** Get stream connection info for a room */
43
+ function roomStream(config, roomId) {
44
+ return getRoomStreamConnectionInfo(roomId, config.streamConfig);
40
45
  }
41
46
  /** Create or retrieve the SessionBridge for a session */
42
47
  function getOrCreateBridge(config, sessionId) {
@@ -64,11 +69,92 @@ function resolveStudioUrl(port) {
64
69
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
65
70
  return `http://localhost:${port}`;
66
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;
101
+ /**
102
+ * Accumulate cost and turn metrics from a session_end event into the session's totals.
103
+ * Called each time a Claude Code run finishes (initial + iterate runs).
104
+ * In production mode, enforces a per-session cost budget.
105
+ */
106
+ function accumulateSessionCost(config, sessionId, event) {
107
+ if (event.type !== "session_end")
108
+ return;
109
+ const { cost_usd, num_turns, duration_ms } = event;
110
+ if (cost_usd == null && num_turns == null && duration_ms == null)
111
+ return;
112
+ const existing = config.sessions.get(sessionId);
113
+ const updates = {};
114
+ if (cost_usd != null) {
115
+ updates.totalCostUsd = (existing?.totalCostUsd ?? 0) + cost_usd;
116
+ }
117
+ if (num_turns != null) {
118
+ updates.totalTurns = (existing?.totalTurns ?? 0) + num_turns;
119
+ }
120
+ if (duration_ms != null) {
121
+ updates.totalDurationMs = (existing?.totalDurationMs ?? 0) + duration_ms;
122
+ }
123
+ config.sessions.update(sessionId, updates);
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
+ }
144
+ }
67
145
  /**
68
146
  * Create a Claude Code bridge for a session.
69
147
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
148
+ * In production mode, enforces tool restrictions and hardcodes the model.
70
149
  */
71
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
+ }
72
158
  const conn = sessionStream(config, sessionId);
73
159
  let bridge;
74
160
  if (config.sandbox.runtime === "sprites") {
@@ -198,12 +284,26 @@ function mapHookToEngineEvent(body) {
198
284
  text: body.last_assistant_message || "",
199
285
  ts: now,
200
286
  };
201
- case "SessionEnd":
202
- return {
287
+ case "SessionEnd": {
288
+ const endEvent = {
203
289
  type: "session_end",
204
290
  success: true,
205
291
  ts: now,
206
292
  };
293
+ // Claude Code SessionEnd hook may include session stats
294
+ const session = body.session;
295
+ if (session) {
296
+ if (typeof session.cost_usd === "number")
297
+ endEvent.cost_usd = session.cost_usd;
298
+ if (typeof session.num_turns === "number")
299
+ endEvent.num_turns = session.num_turns;
300
+ if (typeof session.duration_ms === "number")
301
+ endEvent.duration_ms = session.duration_ms;
302
+ if (typeof session.duration_api_ms === "number")
303
+ endEvent.duration_api_ms = session.duration_api_ms;
304
+ }
305
+ return endEvent;
306
+ }
207
307
  case "UserPromptSubmit":
208
308
  return {
209
309
  type: "user_prompt",
@@ -224,8 +324,6 @@ function mapHookToEngineEvent(body) {
224
324
  }
225
325
  export function createApp(config) {
226
326
  const app = new Hono();
227
- // CORS for local development
228
- app.use("*", cors({ origin: "*" }));
229
327
  // --- API Routes ---
230
328
  // Health check
231
329
  app.get("/api/health", (c) => {
@@ -243,6 +341,10 @@ export function createApp(config) {
243
341
  checks.sandbox = config.sandbox.runtime;
244
342
  return c.json({ healthy, checks }, healthy ? 200 : 503);
245
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
+ });
246
348
  // Provision Electric Cloud resources via the Claim API
247
349
  app.post("/api/provision-electric", async (c) => {
248
350
  try {
@@ -267,7 +369,7 @@ export function createApp(config) {
267
369
  // Hono's wildcard middleware matches creation routes like /api/sessions/local as
268
370
  // :id="local", so we must explicitly skip those.
269
371
  const authExemptIds = new Set(["local", "auto", "resume"]);
270
- const hookExemptSuffixes = new Set(["/hook-event"]);
372
+ // Hook-event auth is handled in the endpoint handler via validateHookToken
271
373
  /** Extract session token from Authorization header or query param. */
272
374
  function extractToken(c) {
273
375
  const authHeader = c.req.header("Authorization");
@@ -281,7 +383,8 @@ export function createApp(config) {
281
383
  if (authExemptIds.has(id))
282
384
  return next();
283
385
  const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
284
- if (hookExemptSuffixes.has(subPath))
386
+ // Hook-event uses a purpose-scoped hook token (validated in the handler)
387
+ if (subPath === "/hook-event")
285
388
  return next();
286
389
  const token = extractToken(c);
287
390
  if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
@@ -342,8 +445,9 @@ export function createApp(config) {
342
445
  // Pre-create a bridge so hook-event can emit to it immediately
343
446
  getOrCreateBridge(config, sessionId);
344
447
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
448
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
345
449
  console.log(`[local-session] Created session: ${sessionId}`);
346
- return c.json({ sessionId, sessionToken }, 201);
450
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
347
451
  });
348
452
  // Auto-register a local session on first hook event (SessionStart).
349
453
  // Eliminates the manual `curl POST /api/sessions/local` step.
@@ -389,14 +493,20 @@ export function createApp(config) {
389
493
  await bridge.emit(hookEvent);
390
494
  }
391
495
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
496
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
392
497
  console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
393
- return c.json({ sessionId, sessionToken }, 201);
498
+ return c.json({ sessionId, sessionToken, hookToken }, 201);
394
499
  });
395
500
  // Receive a hook event from Claude Code (via forward.sh) and write it
396
501
  // to the session's durable stream as an EngineEvent.
397
502
  // For AskUserQuestion, this blocks until the user answers in the web UI.
398
503
  app.post("/api/sessions/:id/hook-event", async (c) => {
399
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
+ }
400
510
  const body = (await c.req.json());
401
511
  const bridge = getOrCreateBridge(config, sessionId);
402
512
  // Map Claude Code hook JSON → EngineEvent
@@ -421,6 +531,7 @@ export function createApp(config) {
421
531
  config.sessions.update(sessionId, {});
422
532
  // SessionEnd: mark session complete and close the bridge
423
533
  if (hookEvent.type === "session_end") {
534
+ accumulateSessionCost(config, sessionId, hookEvent);
424
535
  if (!isClaudeCodeBridge) {
425
536
  config.sessions.update(sessionId, { status: "complete" });
426
537
  closeBridge(sessionId);
@@ -457,6 +568,16 @@ export function createApp(config) {
457
568
  return c.json({ ok: true });
458
569
  });
459
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
+ });
460
581
  // Single endpoint for all Claude Code hook events. Uses transcript_path
461
582
  // from the hook JSON as the correlation key — stable across resume/compact,
462
583
  // changes on /clear. Replaces the need for client-side session tracking.
@@ -553,6 +674,7 @@ export function createApp(config) {
553
674
  config.sessions.update(sessionId, {});
554
675
  // SessionEnd: mark complete and close bridge (keep mapping for potential re-open)
555
676
  if (hookEvent.type === "session_end") {
677
+ accumulateSessionCost(config, sessionId, hookEvent);
556
678
  config.sessions.update(sessionId, { status: "complete" });
557
679
  closeBridge(sessionId);
558
680
  return c.json({ ok: true, sessionId });
@@ -593,6 +715,7 @@ export function createApp(config) {
593
715
  // Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
594
716
  app.get("/api/hooks/setup", (c) => {
595
717
  const port = config.port;
718
+ const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
596
719
  const script = `#!/bin/bash
597
720
  # Electric Agent — Claude Code hook installer (project-scoped)
598
721
  # Installs the hook forwarder into the current project's .claude/ directory.
@@ -613,10 +736,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
613
736
  # Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
614
737
 
615
738
  EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
739
+ EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
616
740
  BODY="$(cat)"
617
741
 
618
742
  RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
619
743
  -H "Content-Type: application/json" \\
744
+ -H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
620
745
  -d "\${BODY}" \\
621
746
  --max-time 360 \\
622
747
  --connect-timeout 2 \\
@@ -630,8 +755,9 @@ fi
630
755
  exit 0
631
756
  HOOKEOF
632
757
 
633
- # Replace placeholder with actual port
758
+ # Replace placeholders with actual values
634
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"
635
761
  chmod +x "\${FORWARD_SH}"
636
762
 
637
763
  # Merge hook config into project-level settings.local.json
@@ -673,9 +799,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
673
799
  });
674
800
  // Start new project
675
801
  app.post("/api/sessions", async (c) => {
676
- const body = (await c.req.json());
677
- if (!body.description) {
678
- 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
+ }
679
815
  }
680
816
  const sessionId = crypto.randomUUID();
681
817
  const inferredName = body.name ||
@@ -717,74 +853,84 @@ echo "Start claude in this project — the session will appear in the studio UI.
717
853
  config.sessions.add(session);
718
854
  // Write user prompt to the stream so it shows in the UI
719
855
  await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
720
- // Gather GitHub accounts for the merged setup gate
721
- // 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
722
857
  let ghAccounts = [];
723
- if (body.ghToken && isGhAuthenticated(body.ghToken)) {
724
- try {
725
- ghAccounts = ghListAccounts(body.ghToken);
726
- }
727
- catch {
728
- // 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
+ }
729
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
+ });
730
877
  }
731
- // Emit combined infra + repo setup gate
732
- await bridge.emit({
733
- type: "infra_config_prompt",
734
- projectName,
735
- ghAccounts,
736
- runtime: config.sandbox.runtime,
737
- ts: ts(),
738
- });
739
878
  // Launch async flow: wait for setup gate → create sandbox → start agent
740
879
  const asyncFlow = async () => {
741
- // 1. Wait for combined infra + repo config
880
+ // 1. Wait for combined infra + repo config (skip for freeform)
742
881
  let infra;
743
882
  let repoConfig = null;
744
- console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
745
883
  let claimId;
746
- try {
747
- const gateValue = await createGate(sessionId, "infra_config");
748
- console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
749
- if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
750
- // Normalize claim → cloud for the sandbox layer (same env vars)
751
- infra = {
752
- mode: "cloud",
753
- databaseUrl: gateValue.databaseUrl,
754
- electricUrl: gateValue.electricUrl,
755
- sourceId: gateValue.sourceId,
756
- secret: gateValue.secret,
757
- };
758
- if (gateValue.mode === "claim") {
759
- 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
+ });
760
928
  }
761
929
  }
762
- else {
930
+ catch (err) {
931
+ console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
763
932
  infra = { mode: "local" };
764
933
  }
765
- // Extract repo config if provided
766
- if (gateValue.repoAccount && gateValue.repoName?.trim()) {
767
- repoConfig = {
768
- account: gateValue.repoAccount,
769
- repoName: gateValue.repoName,
770
- visibility: gateValue.repoVisibility ?? "private",
771
- };
772
- config.sessions.update(sessionId, {
773
- git: {
774
- branch: "main",
775
- remoteUrl: null,
776
- repoName: `${repoConfig.account}/${repoConfig.repoName}`,
777
- repoVisibility: repoConfig.visibility,
778
- lastCommitHash: null,
779
- lastCommitMessage: null,
780
- lastCheckpointAt: null,
781
- },
782
- });
783
- }
784
- }
785
- catch (err) {
786
- console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
787
- infra = { mode: "local" };
788
934
  }
789
935
  // 2. Create sandbox — emit progress events so the UI shows feedback
790
936
  await bridge.emit({
@@ -817,76 +963,119 @@ echo "Start claude in this project — the session will appear in the studio UI.
817
963
  // 3. Write CLAUDE.md and create a ClaudeCode bridge.
818
964
  {
819
965
  console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
820
- // Copy pre-scaffolded project from the image and customize per-session
821
- await bridge.emit({
822
- type: "log",
823
- level: "build",
824
- message: "Setting up project...",
825
- ts: ts(),
826
- });
827
- try {
828
- if (config.sandbox.runtime === "docker") {
829
- // Docker: copy the pre-built scaffold base (baked into the image)
830
- await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
831
- await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
832
- }
833
- else {
834
- // Sprites/Daytona: run scaffold from globally installed electric-agent
835
- await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
836
- }
837
- console.log(`[session:${sessionId}] Project setup complete`);
966
+ if (!body.freeform) {
967
+ // Copy pre-scaffolded project from the image and customize per-session
838
968
  await bridge.emit({
839
969
  type: "log",
840
- level: "done",
841
- message: "Project ready",
970
+ level: "build",
971
+ message: "Setting up project...",
842
972
  ts: ts(),
843
973
  });
844
- }
845
- catch (err) {
846
- console.error(`[session:${sessionId}] Project setup failed:`, err);
847
- await bridge.emit({
848
- type: "log",
849
- level: "error",
850
- message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
851
- 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
+ : {}),
852
1032
  });
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'`);
1047
+ }
1048
+ catch (err) {
1049
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1050
+ }
1051
+ }
853
1052
  }
854
- // Write CLAUDE.md to the sandbox workspace
855
- const claudeMd = generateClaudeMd({
856
- description: body.description,
857
- projectName,
858
- projectDir: handle.projectDir,
859
- runtime: config.sandbox.runtime,
860
- });
861
- try {
862
- await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
863
- }
864
- catch (err) {
865
- console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
866
- }
867
- // Ensure the create-app skill is present in the project.
868
- // The npm-installed electric-agent may be an older version that
869
- // doesn't include .claude/skills/ in its template directory.
870
- 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) {
871
1056
  try {
872
- const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
873
- const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1057
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
1058
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
874
1059
  await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
875
1060
  }
876
1061
  catch (err) {
877
- console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1062
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
878
1063
  }
879
1064
  }
1065
+ const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
1066
+ const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
880
1067
  const claudeConfig = config.sandbox.runtime === "sprites"
881
1068
  ? {
882
- prompt: `/create-app ${body.description}`,
1069
+ prompt: sessionPrompt,
883
1070
  cwd: handle.projectDir,
884
1071
  studioUrl: resolveStudioUrl(config.port),
1072
+ hookToken: sessionHookToken,
885
1073
  }
886
1074
  : {
887
- prompt: `/create-app ${body.description}`,
1075
+ prompt: sessionPrompt,
888
1076
  cwd: handle.projectDir,
889
1077
  studioPort: config.port,
1078
+ hookToken: sessionHookToken,
890
1079
  };
891
1080
  bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
892
1081
  }
@@ -900,7 +1089,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
900
1089
  });
901
1090
  }
902
1091
  // 5. Start listening for agent events via the bridge
903
- // Track Claude Code session ID for --resume on iterate
1092
+ // Track Claude Code session ID and cost from agent events
904
1093
  bridge.onAgentEvent((event) => {
905
1094
  if (event.type === "session_start") {
906
1095
  const ccSessionId = event.session_id;
@@ -909,6 +1098,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
909
1098
  config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
910
1099
  }
911
1100
  }
1101
+ if (event.type === "session_end") {
1102
+ accumulateSessionCost(config, sessionId, event);
1103
+ }
912
1104
  });
913
1105
  bridge.onComplete(async (success) => {
914
1106
  const updates = {
@@ -957,29 +1149,37 @@ echo "Start claude in this project — the session will appear in the studio UI.
957
1149
  await bridge.emit({
958
1150
  type: "log",
959
1151
  level: "build",
960
- message: `Running: claude "/create-app ${body.description}"`,
1152
+ message: body.freeform
1153
+ ? `Running: claude "${body.description}"`
1154
+ : `Running: claude "/create-app ${body.description}"`,
961
1155
  ts: ts(),
962
1156
  });
963
1157
  console.log(`[session:${sessionId}] Starting bridge listener...`);
964
1158
  await bridge.start();
965
1159
  console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
966
1160
  // 5. Send the new command via the bridge
967
- const newCmd = {
1161
+ await bridge.sendCommand({
968
1162
  command: "new",
969
1163
  description: body.description,
970
1164
  projectName,
971
1165
  baseDir: "/home/agent/workspace",
972
- };
973
- if (repoConfig) {
974
- newCmd.gitRepoName = `${repoConfig.account}/${repoConfig.repoName}`;
975
- newCmd.gitRepoVisibility = repoConfig.visibility;
976
- }
977
- await bridge.sendCommand(newCmd);
1166
+ });
978
1167
  console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
979
1168
  };
980
1169
  asyncFlow().catch(async (err) => {
981
1170
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
982
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
+ }
983
1183
  });
984
1184
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
985
1185
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -990,10 +1190,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
990
1190
  const session = config.sessions.get(sessionId);
991
1191
  if (!session)
992
1192
  return c.json({ error: "Session not found" }, 404);
993
- const body = (await c.req.json());
994
- if (!body.request) {
995
- return c.json({ error: "request is required" }, 400);
996
- }
1193
+ const body = await validateBody(c, iterateSessionSchema);
1194
+ if (isResponse(body))
1195
+ return body;
997
1196
  // Intercept operational commands (start/stop/restart the app/server)
998
1197
  const normalised = body.request
999
1198
  .toLowerCase()
@@ -1122,6 +1321,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
1122
1321
  }
1123
1322
  // No pending gate — fall through to bridge.sendGateResponse()
1124
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
+ }
1125
1340
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1126
1341
  const serverGates = new Set(["infra_config"]);
1127
1342
  // Forward agent gate responses via the bridge
@@ -1347,7 +1562,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1347
1562
  });
1348
1563
  // Create a standalone sandbox (not tied to session creation flow)
1349
1564
  app.post("/api/sandboxes", async (c) => {
1350
- const body = (await c.req.json());
1565
+ const body = await validateBody(c, createSandboxSchema);
1566
+ if (isResponse(body))
1567
+ return body;
1351
1568
  const sessionId = body.sessionId ?? crypto.randomUUID();
1352
1569
  try {
1353
1570
  const handle = await config.sandbox.create(sessionId, {
@@ -1377,30 +1594,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
1377
1594
  await config.sandbox.destroy(handle);
1378
1595
  return c.json({ ok: true });
1379
1596
  });
1380
- // --- Shared Sessions ---
1381
- // Protect /api/shared-sessions/:id/* (all sub-routes)
1382
- // Exempt: "join" (Hono matches join/:code as :id/*)
1383
- const sharedSessionExemptIds = new Set(["join"]);
1384
- 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) => {
1385
1606
  const id = c.req.param("id");
1386
- if (sharedSessionExemptIds.has(id))
1387
- return next();
1388
- const token = extractToken(c);
1389
- if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1607
+ const token = extractRoomToken(c);
1608
+ if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1390
1609
  return c.json({ error: "Invalid or missing room token" }, 401);
1391
1610
  }
1392
1611
  return next();
1393
1612
  });
1394
- // Create a shared session
1395
- app.post("/api/shared-sessions", async (c) => {
1396
- const body = (await c.req.json());
1397
- if (!body.name || !body.participant?.id || !body.participant?.displayName) {
1398
- 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);
1399
1620
  }
1400
- const id = crypto.randomUUID();
1401
- const code = generateInviteCode();
1402
- // Create the shared session durable stream
1403
- 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);
1404
1631
  try {
1405
1632
  await DurableStream.create({
1406
1633
  url: conn.url,
@@ -1409,148 +1636,341 @@ echo "Start claude in this project — the session will appear in the studio UI.
1409
1636
  });
1410
1637
  }
1411
1638
  catch (err) {
1412
- console.error(`[shared-session] Failed to create durable stream:`, err);
1413
- 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);
1414
1641
  }
1415
- // Write shared_session_created event
1416
- const stream = new DurableStream({
1417
- url: conn.url,
1418
- headers: conn.headers,
1419
- contentType: "application/json",
1642
+ // Create and start the router
1643
+ const router = new RoomRouter(roomId, body.name, config.streamConfig, {
1644
+ maxRounds: body.maxRounds,
1420
1645
  });
1421
- const createdEvent = {
1422
- type: "shared_session_created",
1423
- name: body.name,
1424
- code,
1425
- createdBy: body.participant,
1426
- ts: ts(),
1427
- };
1428
- await stream.append(JSON.stringify(createdEvent));
1429
- // Write participant_joined for the creator
1430
- const joinedEvent = {
1431
- type: "participant_joined",
1432
- participant: body.participant,
1433
- ts: ts(),
1434
- };
1435
- await stream.append(JSON.stringify(joinedEvent));
1436
- // Save to room registry
1646
+ await router.start();
1647
+ roomRouters.set(roomId, router);
1648
+ // Save to room registry for persistence
1649
+ const code = generateInviteCode();
1437
1650
  await config.rooms.addRoom({
1438
- id,
1651
+ id: roomId,
1439
1652
  code,
1440
1653
  name: body.name,
1441
1654
  createdAt: new Date().toISOString(),
1442
1655
  revoked: false,
1443
1656
  });
1444
- const roomToken = deriveSessionToken(config.streamConfig.secret, id);
1445
- console.log(`[shared-session] Created: id=${id} code=${code}`);
1446
- 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);
1447
1660
  });
1448
- // Resolve invite code shared session ID + room token
1449
- 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");
1450
1664
  const code = c.req.param("code");
1451
- const entry = config.rooms.getRoomByCode(code);
1452
- if (!entry)
1453
- return c.json({ error: "Shared session not found" }, 404);
1454
- const roomToken = deriveSessionToken(config.streamConfig.secret, entry.id);
1455
- 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 });
1456
1672
  });
1457
- // Join a shared session as participant
1458
- app.post("/api/shared-sessions/:id/join", async (c) => {
1459
- const id = c.req.param("id");
1460
- const entry = config.rooms.getRoom(id);
1461
- if (!entry)
1462
- return c.json({ error: "Shared session not found" }, 404);
1463
- if (entry.revoked)
1464
- return c.json({ error: "Invite code has been revoked" }, 403);
1465
- const body = (await c.req.json());
1466
- if (!body.participant?.id || !body.participant?.displayName) {
1467
- return c.json({ error: "participant (id, displayName) is required" }, 400);
1468
- }
1469
- const conn = sharedSessionStream(config, id);
1470
- const stream = new DurableStream({
1471
- url: conn.url,
1472
- headers: conn.headers,
1473
- 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
+ })),
1474
1689
  });
1475
- const event = {
1476
- type: "participant_joined",
1477
- participant: body.participant,
1478
- ts: ts(),
1479
- };
1480
- await stream.append(JSON.stringify(event));
1481
- return c.json({ ok: true });
1482
1690
  });
1483
- // Leave a shared session
1484
- app.post("/api/shared-sessions/:id/leave", async (c) => {
1485
- const id = c.req.param("id");
1486
- const body = (await c.req.json());
1487
- if (!body.participantId) {
1488
- 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
+ });
1489
1713
  }
1490
- const conn = sharedSessionStream(config, id);
1491
- const stream = new DurableStream({
1492
- url: conn.url,
1493
- headers: conn.headers,
1494
- contentType: "application/json",
1495
- });
1496
- const event = {
1497
- type: "participant_left",
1498
- participantId: body.participantId,
1499
- 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",
1500
1730
  };
1501
- await stream.append(JSON.stringify(event));
1502
- 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);
1503
1857
  });
1504
- // Link a session to a shared session (room)
1505
- // The client sends session metadata since sessions are private (localStorage).
1506
- app.post("/api/shared-sessions/:id/sessions", async (c) => {
1507
- const id = c.req.param("id");
1508
- const body = (await c.req.json());
1509
- if (!body.sessionId || !body.linkedBy) {
1510
- return c.json({ error: "sessionId and linkedBy are 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);
1511
1876
  }
1512
- const conn = sharedSessionStream(config, id);
1513
- const stream = new DurableStream({
1514
- url: conn.url,
1515
- headers: conn.headers,
1516
- contentType: "application/json",
1517
- });
1518
- const event = {
1519
- type: "session_linked",
1520
- sessionId: body.sessionId,
1521
- sessionName: body.sessionName || "",
1522
- sessionDescription: body.sessionDescription || "",
1523
- linkedBy: body.linkedBy,
1524
- ts: ts(),
1525
- };
1526
- await stream.append(JSON.stringify(event));
1527
- return c.json({ ok: true });
1877
+ // Verify the session exists
1878
+ const sessionInfo = config.sessions.get(sessionId);
1879
+ if (!sessionInfo) {
1880
+ return c.json({ error: "Session not found" }, 404);
1881
+ }
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'`);
1901
+ }
1902
+ catch (err) {
1903
+ console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
1904
+ }
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
+ }
1927
+ }
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);
1932
+ }
1933
+ // No need to return sessionToken — caller already proved they have it
1934
+ return c.json({ sessionId, participantName: body.name }, 201);
1528
1935
  });
1529
- // Unlink a session from a shared session
1530
- app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
1531
- 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");
1532
1939
  const sessionId = c.req.param("sessionId");
1533
- const conn = sharedSessionStream(config, id);
1534
- const stream = new DurableStream({
1535
- url: conn.url,
1536
- headers: conn.headers,
1537
- 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,
1538
1952
  });
1539
- const event = {
1540
- type: "session_unlinked",
1541
- sessionId,
1542
- ts: ts(),
1543
- };
1544
- await stream.append(JSON.stringify(event));
1545
1953
  return c.json({ ok: true });
1546
1954
  });
1547
- // SSE proxy for shared session events
1548
- app.get("/api/shared-sessions/:id/events", async (c) => {
1549
- const id = c.req.param("id");
1550
- const entry = config.rooms.getRoom(id);
1551
- if (!entry)
1552
- return c.json({ error: "Shared session not found" }, 404);
1553
- 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);
1554
1974
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1555
1975
  const reader = new DurableStream({
1556
1976
  url: connection.url,
@@ -1589,23 +2009,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
1589
2009
  },
1590
2010
  });
1591
2011
  });
1592
- // Revoke a shared session's invite code
1593
- app.post("/api/shared-sessions/:id/revoke", async (c) => {
1594
- const id = c.req.param("id");
1595
- const revoked = await config.rooms.revokeRoom(id);
1596
- if (!revoked)
1597
- return c.json({ error: "Shared session not found" }, 404);
1598
- 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);
1599
2020
  const stream = new DurableStream({
1600
2021
  url: conn.url,
1601
2022
  headers: conn.headers,
1602
2023
  contentType: "application/json",
1603
2024
  });
1604
2025
  const event = {
1605
- type: "code_revoked",
2026
+ type: "room_closed",
2027
+ closedBy: "human",
2028
+ summary: "Room closed by user",
1606
2029
  ts: ts(),
1607
2030
  };
1608
2031
  await stream.append(JSON.stringify(event));
2032
+ router.close();
1609
2033
  return c.json({ ok: true });
1610
2034
  });
1611
2035
  // --- SSE Proxy ---
@@ -1675,6 +2099,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
1675
2099
  },
1676
2100
  });
1677
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
+ });
1678
2142
  // --- Git/GitHub Routes ---
1679
2143
  // Get git status for a session
1680
2144
  app.get("/api/sessions/:id/git-status", async (c) => {
@@ -1722,7 +2186,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1722
2186
  if (!handle || !sandboxDir) {
1723
2187
  return c.json({ error: "Container not available" }, 404);
1724
2188
  }
1725
- 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)) {
1726
2192
  return c.json({ error: "Path outside project directory" }, 403);
1727
2193
  }
1728
2194
  const content = await config.sandbox.readFile(handle, filePath);
@@ -1771,32 +2237,41 @@ echo "Start claude in this project — the session will appear in the studio UI.
1771
2237
  return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
1772
2238
  }
1773
2239
  });
1774
- // Read Claude credentials from macOS Keychain (dev convenience)
1775
- app.get("/api/credentials/keychain", (c) => {
1776
- if (process.platform !== "darwin") {
1777
- return c.json({ apiKey: null });
1778
- }
1779
- try {
1780
- const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
1781
- const parsed = JSON.parse(raw);
1782
- const token = parsed.claudeAiOauth?.accessToken ?? null;
1783
- if (token) {
1784
- 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 });
1785
2246
  }
1786
- else {
1787
- 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 });
1788
2258
  }
1789
- return c.json({ oauthToken: token });
1790
- }
1791
- catch {
1792
- return c.json({ oauthToken: null });
1793
- }
1794
- });
2259
+ catch {
2260
+ return c.json({ oauthToken: null });
2261
+ }
2262
+ });
2263
+ }
1795
2264
  // Resume a project from a GitHub repo
1796
2265
  app.post("/api/sessions/resume", async (c) => {
1797
- const body = (await c.req.json());
1798
- if (!body.repoUrl) {
1799
- 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
+ }
1800
2275
  }
1801
2276
  const sessionId = crypto.randomUUID();
1802
2277
  const repoName = body.repoUrl
@@ -1877,6 +2352,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1877
2352
  projectName: repoName,
1878
2353
  projectDir: handle.projectDir,
1879
2354
  runtime: config.sandbox.runtime,
2355
+ production: !config.devMode,
2356
+ git: {
2357
+ mode: "existing",
2358
+ repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
2359
+ branch: gs.branch ?? body.branch ?? "main",
2360
+ },
1880
2361
  });
1881
2362
  try {
1882
2363
  await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
@@ -1895,18 +2376,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
1895
2376
  console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1896
2377
  }
1897
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
+ }
1898
2391
  // 3. Create Claude Code bridge with a resume prompt
1899
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);
1900
2394
  const claudeConfig = config.sandbox.runtime === "sprites"
1901
2395
  ? {
1902
2396
  prompt: resumePrompt,
1903
2397
  cwd: handle.projectDir,
1904
2398
  studioUrl: resolveStudioUrl(config.port),
2399
+ hookToken: resumeHookToken,
1905
2400
  }
1906
2401
  : {
1907
2402
  prompt: resumePrompt,
1908
2403
  cwd: handle.projectDir,
1909
2404
  studioPort: config.port,
2405
+ hookToken: resumeHookToken,
1910
2406
  };
1911
2407
  const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1912
2408
  // 4. Register event listeners (reuse pattern from normal flow)
@@ -1918,6 +2414,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1918
2414
  config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1919
2415
  }
1920
2416
  }
2417
+ if (event.type === "session_end") {
2418
+ accumulateSessionCost(config, sessionId, event);
2419
+ }
1921
2420
  });
1922
2421
  ccBridge.onComplete(async (success) => {
1923
2422
  const updates = {
@@ -1983,6 +2482,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
1983
2482
  asyncFlow().catch(async (err) => {
1984
2483
  console.error(`[session:${sessionId}] Resume flow failed:`, err);
1985
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
+ }
1986
2496
  });
1987
2497
  const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1988
2498
  return c.json({ sessionId, session, sessionToken }, 201);
@@ -2007,6 +2517,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
2007
2517
  return app;
2008
2518
  }
2009
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
+ }
2010
2524
  const config = {
2011
2525
  port: opts.port ?? 4400,
2012
2526
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
@@ -2015,6 +2529,7 @@ export async function startWebServer(opts) {
2015
2529
  sandbox: opts.sandbox,
2016
2530
  streamConfig: opts.streamConfig,
2017
2531
  bridgeMode: opts.bridgeMode ?? "claude-code",
2532
+ devMode,
2018
2533
  };
2019
2534
  fs.mkdirSync(config.dataDir, { recursive: true });
2020
2535
  const app = createApp(config);