@electric-agent/studio 1.12.1 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import { serve } from "@hono/node-server";
8
8
  import { serveStatic } from "@hono/node-server/serve-static";
9
9
  import { Hono } from "hono";
10
10
  import { ActiveSessions } from "./active-sessions.js";
11
- import { addAgentSchema, addSessionToRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
11
+ import { addAgentSchema, addSessionToRoomSchema, createAppRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
12
12
  import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
13
13
  import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
14
14
  import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
@@ -20,6 +20,7 @@ import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "
20
20
  import { createOrgRepo, getInstallationToken } from "./github-app.js";
21
21
  import { generateInviteCode } from "./invite-code.js";
22
22
  import { resolveProjectDir } from "./project-utils.js";
23
+ import { Registry } from "./registry.js";
23
24
  import { RoomRouter } from "./room-router.js";
24
25
  import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
25
26
  import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
@@ -544,6 +545,7 @@ export function createApp(config) {
544
545
  if (hookEvent.type === "ask_user_question") {
545
546
  const toolUseId = hookEvent.tool_use_id;
546
547
  console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
548
+ config.sessions.update(sessionId, { needsInput: true });
547
549
  try {
548
550
  const gateTimeout = 5 * 60 * 1000; // 5 minutes
549
551
  const result = await Promise.race([
@@ -551,6 +553,7 @@ export function createApp(config) {
551
553
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
552
554
  ]);
553
555
  console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
556
+ config.sessions.update(sessionId, { needsInput: false });
554
557
  return c.json({
555
558
  hookSpecificOutput: {
556
559
  hookEventName: "PreToolUse",
@@ -564,6 +567,7 @@ export function createApp(config) {
564
567
  }
565
568
  catch (err) {
566
569
  console.error(`[hook-event] ask_user_question gate error:`, err);
570
+ config.sessions.update(sessionId, { needsInput: false });
567
571
  return c.json({ ok: true }); // Don't block Claude Code on timeout
568
572
  }
569
573
  }
@@ -685,6 +689,7 @@ export function createApp(config) {
685
689
  if (hookEvent.type === "ask_user_question") {
686
690
  const toolUseId = hookEvent.tool_use_id;
687
691
  console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
692
+ config.sessions.update(sessionId, { needsInput: true });
688
693
  try {
689
694
  const gateTimeout = 5 * 60 * 1000;
690
695
  const result = await Promise.race([
@@ -692,6 +697,7 @@ export function createApp(config) {
692
697
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
693
698
  ]);
694
699
  console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
700
+ config.sessions.update(sessionId, { needsInput: false });
695
701
  return c.json({
696
702
  sessionId,
697
703
  hookSpecificOutput: {
@@ -706,6 +712,7 @@ export function createApp(config) {
706
712
  }
707
713
  catch (err) {
708
714
  console.error(`[hook] ask_user_question gate error:`, err);
715
+ config.sessions.update(sessionId, { needsInput: false });
709
716
  return c.json({ ok: true, sessionId });
710
717
  }
711
718
  }
@@ -805,8 +812,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
805
812
  if (isResponse(body))
806
813
  return body;
807
814
  // In prod mode, use server-side API key; ignore user-provided credentials
808
- const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
809
- const oauthToken = config.devMode ? body.oauthToken : undefined;
815
+ const apiKey = config.devMode
816
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
817
+ : process.env.ANTHROPIC_API_KEY;
818
+ const oauthToken = config.devMode
819
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
820
+ : undefined;
810
821
  const ghToken = config.devMode ? body.ghToken : undefined;
811
822
  // Block freeform sessions in production mode
812
823
  if (body.freeform && !config.devMode) {
@@ -956,7 +967,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
956
967
  apiKey,
957
968
  oauthToken,
958
969
  ghToken,
959
- ...(!config.devMode && {
970
+ ...((!config.devMode || GITHUB_APP_ID) && {
960
971
  prodMode: {
961
972
  sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
962
973
  studioUrl: resolveStudioUrl(config.port),
@@ -1027,9 +1038,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1027
1038
  ts: ts(),
1028
1039
  });
1029
1040
  }
1030
- // In prod mode, create GitHub repo and initialize git in the sandbox
1041
+ // Create GitHub repo via GitHub App when credentials are available
1031
1042
  let prodGitConfig;
1032
- if (!config.devMode && GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
1043
+ if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
1033
1044
  try {
1034
1045
  // Repo name matches the project name (already has random slug)
1035
1046
  const repoSlug = projectName;
@@ -1263,8 +1274,47 @@ echo "Start claude in this project — the session will appear in the studio UI.
1263
1274
  if (!handle || !config.sandbox.isAlive(handle)) {
1264
1275
  return c.json({ error: "Container is not running" }, 400);
1265
1276
  }
1277
+ // Ensure we have a CC bridge (not just a stream writer).
1278
+ // After server restart, bridges are lost — getOrCreateBridge would create
1279
+ // a HostedStreamBridge that can only write to the stream but can't spawn
1280
+ // Claude Code processes. We need a ClaudeCodeDockerBridge to restart the agent.
1281
+ let bridge = bridges.get(sessionId);
1282
+ if (!bridge) {
1283
+ const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
1284
+ const claudeConfig = config.sandbox.runtime === "sprites"
1285
+ ? {
1286
+ prompt: body.request,
1287
+ cwd: session.sandboxProjectDir || handle.projectDir,
1288
+ studioUrl: resolveStudioUrl(config.port),
1289
+ hookToken,
1290
+ }
1291
+ : {
1292
+ prompt: body.request,
1293
+ cwd: session.sandboxProjectDir || handle.projectDir,
1294
+ studioPort: config.port,
1295
+ hookToken,
1296
+ };
1297
+ bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1298
+ // Re-register basic event tracking callbacks
1299
+ bridge.onAgentEvent((event) => {
1300
+ if (event.type === "session_start") {
1301
+ const ccSessionId = event.session_id;
1302
+ if (ccSessionId) {
1303
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1304
+ }
1305
+ }
1306
+ if (event.type === "session_end") {
1307
+ accumulateSessionCost(config, sessionId, event);
1308
+ }
1309
+ });
1310
+ bridge.onComplete(async (success) => {
1311
+ config.sessions.update(sessionId, {
1312
+ status: success ? "complete" : "error",
1313
+ });
1314
+ });
1315
+ console.log(`[iterate] Recreated CC bridge for session ${sessionId} after restart`);
1316
+ }
1266
1317
  // Write user prompt to the stream
1267
- const bridge = getOrCreateBridge(config, sessionId);
1268
1318
  await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
1269
1319
  config.sessions.update(sessionId, { status: "running" });
1270
1320
  await bridge.sendCommand({
@@ -1627,8 +1677,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1627
1677
  return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
1628
1678
  }
1629
1679
  // Protect room-scoped routes via X-Room-Token header
1680
+ // "create-app" is a creation endpoint — no room token exists yet
1681
+ const roomAuthExemptIds = new Set(["create-app"]);
1630
1682
  app.use("/api/rooms/:id/*", async (c, next) => {
1631
1683
  const id = c.req.param("id");
1684
+ if (roomAuthExemptIds.has(id))
1685
+ return next();
1632
1686
  const token = extractRoomToken(c);
1633
1687
  if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1634
1688
  return c.json({ error: "Invalid or missing room token" }, 401);
@@ -1636,15 +1690,697 @@ echo "Start claude in this project — the session will appear in the studio UI.
1636
1690
  return next();
1637
1691
  });
1638
1692
  app.use("/api/rooms/:id", async (c, next) => {
1693
+ const id = c.req.param("id");
1694
+ if (roomAuthExemptIds.has(id))
1695
+ return next();
1639
1696
  if (c.req.method !== "GET" && c.req.method !== "DELETE")
1640
1697
  return next();
1641
- const id = c.req.param("id");
1642
1698
  const token = extractRoomToken(c);
1643
1699
  if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
1644
1700
  return c.json({ error: "Invalid or missing room token" }, 401);
1645
1701
  }
1646
1702
  return next();
1647
1703
  });
1704
+ // Create a room with 3 agents for multi-agent app creation
1705
+ app.post("/api/rooms/create-app", async (c) => {
1706
+ const body = await validateBody(c, createAppRoomSchema);
1707
+ if (isResponse(body))
1708
+ return body;
1709
+ // In prod mode, use server-side API key; ignore user-provided credentials
1710
+ const apiKey = config.devMode
1711
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
1712
+ : process.env.ANTHROPIC_API_KEY;
1713
+ const oauthToken = config.devMode
1714
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
1715
+ : undefined;
1716
+ const ghToken = config.devMode ? body.ghToken : undefined;
1717
+ // Rate-limit session creation in production mode
1718
+ if (!config.devMode) {
1719
+ const ip = extractClientIp(c);
1720
+ if (!checkSessionRateLimit(ip)) {
1721
+ return c.json({ error: "Too many sessions. Please try again later." }, 429);
1722
+ }
1723
+ if (checkGlobalSessionCap(config.sessions)) {
1724
+ return c.json({ error: "Service at capacity, please try again later" }, 503);
1725
+ }
1726
+ }
1727
+ const roomId = crypto.randomUUID();
1728
+ const roomName = body.name || `app-${roomId.slice(0, 8)}`;
1729
+ // Create the room's durable stream
1730
+ const roomConn = roomStream(config, roomId);
1731
+ try {
1732
+ await DurableStream.create({
1733
+ url: roomConn.url,
1734
+ headers: roomConn.headers,
1735
+ contentType: "application/json",
1736
+ });
1737
+ }
1738
+ catch (err) {
1739
+ console.error(`[room:create-app] Failed to create room stream:`, err);
1740
+ return c.json({ error: "Failed to create room stream" }, 500);
1741
+ }
1742
+ // Create and start the router
1743
+ const router = new RoomRouter(roomId, roomName, config.streamConfig);
1744
+ await router.start();
1745
+ roomRouters.set(roomId, router);
1746
+ // Save to room registry
1747
+ const code = generateInviteCode();
1748
+ await config.rooms.addRoom({
1749
+ id: roomId,
1750
+ code,
1751
+ name: roomName,
1752
+ createdAt: new Date().toISOString(),
1753
+ revoked: false,
1754
+ });
1755
+ // Define the 3 agents with randomized display names
1756
+ const agentSuffixes = [
1757
+ "fox",
1758
+ "owl",
1759
+ "lynx",
1760
+ "wolf",
1761
+ "bear",
1762
+ "hawk",
1763
+ "pine",
1764
+ "oak",
1765
+ "elm",
1766
+ "ivy",
1767
+ "ray",
1768
+ "arc",
1769
+ "reef",
1770
+ "dusk",
1771
+ "ash",
1772
+ "sage",
1773
+ ];
1774
+ const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
1775
+ const usedSuffixes = new Set();
1776
+ const uniquePick = () => {
1777
+ let s = pick();
1778
+ while (usedSuffixes.has(s))
1779
+ s = pick();
1780
+ usedSuffixes.add(s);
1781
+ return s;
1782
+ };
1783
+ const agentDefs = [
1784
+ { name: `coder-${uniquePick()}`, role: "coder" },
1785
+ { name: `reviewer-${uniquePick()}`, role: "reviewer" },
1786
+ { name: `designer-${uniquePick()}`, role: "ui-designer" },
1787
+ ];
1788
+ // Create session IDs and streams upfront for all 3 agents
1789
+ const sessions = [];
1790
+ for (const agentDef of agentDefs) {
1791
+ const sessionId = crypto.randomUUID();
1792
+ const conn = sessionStream(config, sessionId);
1793
+ try {
1794
+ await DurableStream.create({
1795
+ url: conn.url,
1796
+ headers: conn.headers,
1797
+ contentType: "application/json",
1798
+ });
1799
+ }
1800
+ catch (err) {
1801
+ console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
1802
+ return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
1803
+ }
1804
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1805
+ sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
1806
+ }
1807
+ const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
1808
+ console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
1809
+ // Return immediately so the client can show the room + sessions
1810
+ // The async flow handles sandbox creation, skill injection, and agent startup
1811
+ // Sessions are created in agentDefs order: [coder, reviewer, ui-designer]
1812
+ const coderSession = sessions[0];
1813
+ const reviewerSession = sessions[1];
1814
+ const uiDesignerSession = sessions[2];
1815
+ const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
1816
+ // Record all sessions
1817
+ for (const s of sessions) {
1818
+ const projectName = s.role === "coder" && config.devMode
1819
+ ? body.name ||
1820
+ body.description
1821
+ .slice(0, 40)
1822
+ .replace(/[^a-z0-9]+/gi, "-")
1823
+ .replace(/^-|-$/g, "")
1824
+ .toLowerCase()
1825
+ : `room-${s.name}-${s.sessionId.slice(0, 8)}`;
1826
+ const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
1827
+ const session = {
1828
+ id: s.sessionId,
1829
+ projectName,
1830
+ sandboxProjectDir,
1831
+ description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
1832
+ createdAt: new Date().toISOString(),
1833
+ lastActiveAt: new Date().toISOString(),
1834
+ status: "running",
1835
+ };
1836
+ config.sessions.add(session);
1837
+ }
1838
+ // Write user prompt to coder's stream
1839
+ await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
1840
+ // Gather GitHub accounts for the infra config gate (dev mode only)
1841
+ let ghAccounts = [];
1842
+ if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
1843
+ try {
1844
+ ghAccounts = ghListAccounts(ghToken);
1845
+ }
1846
+ catch {
1847
+ // gh not available
1848
+ }
1849
+ }
1850
+ // Emit infra config gate on coder's stream
1851
+ const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
1852
+ await coderBridge.emit({
1853
+ type: "infra_config_prompt",
1854
+ projectName: coderProjectName,
1855
+ ghAccounts,
1856
+ runtime: config.sandbox.runtime,
1857
+ ts: ts(),
1858
+ });
1859
+ // Async flow: wait for gate, create sandboxes, start agents
1860
+ const asyncFlow = async () => {
1861
+ // 1. Wait for infra config gate on coder's session
1862
+ await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
1863
+ console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
1864
+ let infra;
1865
+ let repoConfig = null;
1866
+ let claimId;
1867
+ try {
1868
+ const gateValue = await createGate(coderSession.sessionId, "infra_config");
1869
+ console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
1870
+ if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
1871
+ infra = {
1872
+ mode: "cloud",
1873
+ databaseUrl: gateValue.databaseUrl,
1874
+ electricUrl: gateValue.electricUrl,
1875
+ sourceId: gateValue.sourceId,
1876
+ secret: gateValue.secret,
1877
+ };
1878
+ if (gateValue.mode === "claim") {
1879
+ claimId = gateValue.claimId;
1880
+ }
1881
+ }
1882
+ else {
1883
+ infra = { mode: "local" };
1884
+ }
1885
+ // Extract repo config if provided
1886
+ if (gateValue.repoAccount && gateValue.repoName?.trim()) {
1887
+ repoConfig = {
1888
+ account: gateValue.repoAccount,
1889
+ repoName: gateValue.repoName,
1890
+ visibility: gateValue.repoVisibility ?? "private",
1891
+ };
1892
+ config.sessions.update(coderSession.sessionId, {
1893
+ git: {
1894
+ branch: "main",
1895
+ remoteUrl: null,
1896
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
1897
+ repoVisibility: repoConfig.visibility,
1898
+ lastCommitHash: null,
1899
+ lastCommitMessage: null,
1900
+ lastCheckpointAt: null,
1901
+ },
1902
+ });
1903
+ }
1904
+ }
1905
+ catch (err) {
1906
+ console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
1907
+ infra = { mode: "local" };
1908
+ }
1909
+ // 2. Create sandboxes in parallel
1910
+ // Coder gets full scaffold, reviewer/ui-designer get minimal
1911
+ await router.sendMessage("system", "Creating sandboxes");
1912
+ await coderBridge.emit({
1913
+ type: "log",
1914
+ level: "build",
1915
+ message: "Creating sandboxes for all agents...",
1916
+ ts: ts(),
1917
+ });
1918
+ const sandboxOpts = (sid) => ({
1919
+ ...((!config.devMode || GITHUB_APP_ID) && {
1920
+ prodMode: {
1921
+ sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
1922
+ studioUrl: resolveStudioUrl(config.port),
1923
+ },
1924
+ }),
1925
+ });
1926
+ const coderInfo = config.sessions.get(coderSession.sessionId);
1927
+ if (!coderInfo)
1928
+ throw new Error("Coder session not found in registry");
1929
+ const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
1930
+ if (!reviewerInfo)
1931
+ throw new Error("Reviewer session not found in registry");
1932
+ const uiDesignerInfo = config.sessions.get(uiDesignerSession.sessionId);
1933
+ if (!uiDesignerInfo)
1934
+ throw new Error("UI designer session not found in registry");
1935
+ const [coderHandle, reviewerHandle, uiDesignerHandle] = await Promise.all([
1936
+ config.sandbox.create(coderSession.sessionId, {
1937
+ projectName: coderInfo.projectName,
1938
+ infra,
1939
+ apiKey,
1940
+ oauthToken,
1941
+ ghToken,
1942
+ ...sandboxOpts(coderSession.sessionId),
1943
+ }),
1944
+ config.sandbox.create(reviewerSession.sessionId, {
1945
+ projectName: reviewerInfo.projectName,
1946
+ infra: { mode: "none" },
1947
+ apiKey,
1948
+ oauthToken,
1949
+ ghToken,
1950
+ ...sandboxOpts(reviewerSession.sessionId),
1951
+ }),
1952
+ config.sandbox.create(uiDesignerSession.sessionId, {
1953
+ projectName: uiDesignerInfo.projectName,
1954
+ infra: { mode: "none" },
1955
+ apiKey,
1956
+ oauthToken,
1957
+ ghToken,
1958
+ ...sandboxOpts(uiDesignerSession.sessionId),
1959
+ }),
1960
+ ]);
1961
+ const handles = [
1962
+ { session: coderSession, handle: coderHandle },
1963
+ { session: reviewerSession, handle: reviewerHandle },
1964
+ { session: uiDesignerSession, handle: uiDesignerHandle },
1965
+ ];
1966
+ // Update session info with sandbox details
1967
+ for (const { session: s, handle } of handles) {
1968
+ config.sessions.update(s.sessionId, {
1969
+ appPort: handle.port,
1970
+ sandboxProjectDir: handle.projectDir,
1971
+ previewUrl: handle.previewUrl,
1972
+ ...(s.role === "coder" && claimId ? { claimId } : {}),
1973
+ });
1974
+ }
1975
+ await coderBridge.emit({
1976
+ type: "log",
1977
+ level: "done",
1978
+ message: "All sandboxes ready",
1979
+ ts: ts(),
1980
+ });
1981
+ // 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
1982
+ {
1983
+ const handle = coderHandle;
1984
+ // Copy scaffold
1985
+ await coderBridge.emit({
1986
+ type: "log",
1987
+ level: "build",
1988
+ message: "Setting up project...",
1989
+ ts: ts(),
1990
+ });
1991
+ try {
1992
+ if (config.sandbox.runtime === "docker") {
1993
+ await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
1994
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
1995
+ }
1996
+ else {
1997
+ await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${coderInfo.projectName}' --skip-git`);
1998
+ }
1999
+ await coderBridge.emit({
2000
+ type: "log",
2001
+ level: "done",
2002
+ message: "Project ready",
2003
+ ts: ts(),
2004
+ });
2005
+ }
2006
+ catch (err) {
2007
+ console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
2008
+ await coderBridge.emit({
2009
+ type: "log",
2010
+ level: "error",
2011
+ message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
2012
+ ts: ts(),
2013
+ });
2014
+ }
2015
+ // GitHub repo creation (uses GitHub App when credentials are available)
2016
+ let repoUrl = null;
2017
+ let prodGitConfig;
2018
+ if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
2019
+ try {
2020
+ const repoSlug = coderInfo.projectName;
2021
+ await coderBridge.emit({
2022
+ type: "log",
2023
+ level: "build",
2024
+ message: "Creating GitHub repository...",
2025
+ ts: ts(),
2026
+ });
2027
+ const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
2028
+ const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
2029
+ if (repo) {
2030
+ const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
2031
+ await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
2032
+ prodGitConfig = {
2033
+ mode: "pre-created",
2034
+ repoName: actualRepoName,
2035
+ repoUrl: repo.htmlUrl,
2036
+ };
2037
+ repoUrl = repo.htmlUrl;
2038
+ config.sessions.update(coderSession.sessionId, {
2039
+ git: {
2040
+ branch: "main",
2041
+ remoteUrl: repo.htmlUrl,
2042
+ repoName: actualRepoName,
2043
+ lastCommitHash: null,
2044
+ lastCommitMessage: null,
2045
+ lastCheckpointAt: null,
2046
+ },
2047
+ });
2048
+ await coderBridge.emit({
2049
+ type: "log",
2050
+ level: "done",
2051
+ message: `GitHub repo created: ${repo.htmlUrl}`,
2052
+ ts: ts(),
2053
+ });
2054
+ }
2055
+ }
2056
+ catch (err) {
2057
+ console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
2058
+ }
2059
+ }
2060
+ else if (repoConfig) {
2061
+ repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
2062
+ }
2063
+ // Write CLAUDE.md to coder sandbox
2064
+ const claudeMd = generateClaudeMd({
2065
+ description: body.description,
2066
+ projectName: coderInfo.projectName,
2067
+ projectDir: handle.projectDir,
2068
+ runtime: config.sandbox.runtime,
2069
+ production: !config.devMode,
2070
+ ...(prodGitConfig
2071
+ ? { git: prodGitConfig }
2072
+ : repoConfig
2073
+ ? {
2074
+ git: {
2075
+ mode: "create",
2076
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
2077
+ visibility: repoConfig.visibility,
2078
+ },
2079
+ }
2080
+ : {}),
2081
+ });
2082
+ try {
2083
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
2084
+ }
2085
+ catch (err) {
2086
+ console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
2087
+ }
2088
+ // Write create-app skill to coder sandbox
2089
+ if (createAppSkillContent) {
2090
+ try {
2091
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
2092
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
2093
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2094
+ }
2095
+ catch (err) {
2096
+ console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
2097
+ }
2098
+ }
2099
+ // Write room-messaging skill to coder sandbox
2100
+ if (roomMessagingSkillContent) {
2101
+ try {
2102
+ const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
2103
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2104
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2105
+ }
2106
+ catch (err) {
2107
+ console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
2108
+ }
2109
+ }
2110
+ // 4. Create Claude Code bridge for coder
2111
+ const coderPrompt = `/create-app ${body.description}`;
2112
+ const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
2113
+ const coderClaudeConfig = config.sandbox.runtime === "sprites"
2114
+ ? {
2115
+ prompt: coderPrompt,
2116
+ cwd: handle.projectDir,
2117
+ studioUrl: resolveStudioUrl(config.port),
2118
+ hookToken: coderHookToken,
2119
+ agentName: coderSession.name,
2120
+ }
2121
+ : {
2122
+ prompt: coderPrompt,
2123
+ cwd: handle.projectDir,
2124
+ studioPort: config.port,
2125
+ hookToken: coderHookToken,
2126
+ agentName: coderSession.name,
2127
+ };
2128
+ const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
2129
+ // Track coder events
2130
+ coderCcBridge.onAgentEvent((event) => {
2131
+ if (event.type === "session_start") {
2132
+ const ccSessionId = event.session_id;
2133
+ if (ccSessionId) {
2134
+ config.sessions.update(coderSession.sessionId, {
2135
+ lastCoderSessionId: ccSessionId,
2136
+ });
2137
+ }
2138
+ }
2139
+ if (event.type === "session_end") {
2140
+ accumulateSessionCost(config, coderSession.sessionId, event);
2141
+ }
2142
+ // Route assistant_message output to the room router
2143
+ if (event.type === "assistant_message" && "text" in event) {
2144
+ router
2145
+ .handleAgentOutput(coderSession.sessionId, event.text)
2146
+ .catch((err) => {
2147
+ console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
2148
+ });
2149
+ }
2150
+ // Notify room when coder is waiting for user input
2151
+ if (event.type === "ask_user_question") {
2152
+ config.sessions.update(coderSession.sessionId, { needsInput: true });
2153
+ router
2154
+ .sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
2155
+ .catch((err) => {
2156
+ console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
2157
+ });
2158
+ }
2159
+ if (event.type === "gate_resolved") {
2160
+ config.sessions.update(coderSession.sessionId, { needsInput: false });
2161
+ router
2162
+ .sendMessage("system", `${coderSession.name} received input — resuming.`)
2163
+ .catch(() => { });
2164
+ }
2165
+ });
2166
+ // Coder completion handler: notify room on success or failure
2167
+ coderCcBridge.onComplete(async (success) => {
2168
+ const updates = {
2169
+ status: success ? "complete" : "error",
2170
+ };
2171
+ let repoInfo = "";
2172
+ try {
2173
+ const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
2174
+ if (gs.initialized) {
2175
+ const existing = config.sessions.get(coderSession.sessionId);
2176
+ updates.git = {
2177
+ branch: gs.branch ?? "main",
2178
+ remoteUrl: existing?.git?.remoteUrl ?? null,
2179
+ repoName: existing?.git?.repoName ?? null,
2180
+ repoVisibility: existing?.git?.repoVisibility,
2181
+ lastCommitHash: gs.lastCommitHash ?? null,
2182
+ lastCommitMessage: gs.lastCommitMessage ?? null,
2183
+ lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
2184
+ };
2185
+ if (existing?.git?.repoName) {
2186
+ repoInfo = ` Repo: https://github.com/${existing.git.repoName}`;
2187
+ }
2188
+ }
2189
+ }
2190
+ catch {
2191
+ // Sandbox may be stopped
2192
+ }
2193
+ config.sessions.update(coderSession.sessionId, updates);
2194
+ const msg = success
2195
+ ? `@room DONE: App is ready.${repoInfo}`
2196
+ : "@room Coder session ended unexpectedly.";
2197
+ router.handleAgentOutput(coderSession.sessionId, msg).catch((err) => {
2198
+ console.error(`[room:create-app:${roomId}] Failed to send coder completion message:`, err);
2199
+ });
2200
+ });
2201
+ await coderBridge.emit({
2202
+ type: "log",
2203
+ level: "build",
2204
+ message: `Running: claude "/create-app ${body.description}"`,
2205
+ ts: ts(),
2206
+ });
2207
+ await coderCcBridge.start();
2208
+ // Add coder as room participant
2209
+ const coderParticipant = {
2210
+ sessionId: coderSession.sessionId,
2211
+ name: coderSession.name,
2212
+ role: "coder",
2213
+ bridge: coderCcBridge,
2214
+ };
2215
+ await router.addParticipant(coderParticipant, false);
2216
+ // Send the initial command to the coder
2217
+ await coderCcBridge.sendCommand({
2218
+ command: "new",
2219
+ description: body.description,
2220
+ projectName: coderInfo.projectName,
2221
+ baseDir: "/home/agent/workspace",
2222
+ });
2223
+ // Store the repoUrl for reviewer/ui-designer prompts
2224
+ // (we continue setting up those agents now)
2225
+ const finalRepoUrl = repoUrl;
2226
+ // 5. Set up reviewer and ui-designer sandboxes
2227
+ const supportAgents = [
2228
+ { session: reviewerSession, handle: reviewerHandle },
2229
+ { session: uiDesignerSession, handle: uiDesignerHandle },
2230
+ ];
2231
+ for (const { session: agentSession, handle: agentHandle } of supportAgents) {
2232
+ const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
2233
+ // Write a minimal CLAUDE.md
2234
+ const minimalClaudeMd = "Room agent workspace";
2235
+ try {
2236
+ await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
2237
+ }
2238
+ catch (err) {
2239
+ console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
2240
+ }
2241
+ // Write room-messaging skill
2242
+ if (roomMessagingSkillContent) {
2243
+ try {
2244
+ const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
2245
+ const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
2246
+ await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2247
+ }
2248
+ catch (err) {
2249
+ console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
2250
+ }
2251
+ }
2252
+ // Resolve and inject role skill
2253
+ const roleSkill = resolveRoleSkill(agentSession.role);
2254
+ if (roleSkill) {
2255
+ try {
2256
+ const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
2257
+ const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
2258
+ await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
2259
+ }
2260
+ catch (err) {
2261
+ console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
2262
+ }
2263
+ }
2264
+ // Build prompt
2265
+ const repoRef = finalRepoUrl ? ` The GitHub repo is: ${finalRepoUrl}.` : "";
2266
+ const agentPrompt = agentSession.role === "reviewer"
2267
+ ? `You are "reviewer", a code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`
2268
+ : `You are "ui-designer", a UI design agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`;
2269
+ // Create Claude Code bridge
2270
+ const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
2271
+ const agentClaudeConfig = config.sandbox.runtime === "sprites"
2272
+ ? {
2273
+ prompt: agentPrompt,
2274
+ cwd: agentHandle.projectDir,
2275
+ studioUrl: resolveStudioUrl(config.port),
2276
+ hookToken: agentHookToken,
2277
+ agentName: agentSession.name,
2278
+ ...(roleSkill?.allowedTools && {
2279
+ allowedTools: roleSkill.allowedTools,
2280
+ }),
2281
+ }
2282
+ : {
2283
+ prompt: agentPrompt,
2284
+ cwd: agentHandle.projectDir,
2285
+ studioPort: config.port,
2286
+ hookToken: agentHookToken,
2287
+ agentName: agentSession.name,
2288
+ ...(roleSkill?.allowedTools && {
2289
+ allowedTools: roleSkill.allowedTools,
2290
+ }),
2291
+ };
2292
+ const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
2293
+ // Track events
2294
+ ccBridge.onAgentEvent((event) => {
2295
+ console.log(`[room:create-app:${roomId}] ${agentSession.name} event: type=${event.type}${event.type === "assistant_message" && "text" in event ? ` text=${event.text.slice(0, 120)}` : ""}`);
2296
+ if (event.type === "session_start") {
2297
+ const ccSessionId = event.session_id;
2298
+ if (ccSessionId) {
2299
+ config.sessions.update(agentSession.sessionId, {
2300
+ lastCoderSessionId: ccSessionId,
2301
+ });
2302
+ }
2303
+ }
2304
+ if (event.type === "session_end") {
2305
+ accumulateSessionCost(config, agentSession.sessionId, event);
2306
+ }
2307
+ if (event.type === "assistant_message" && "text" in event) {
2308
+ const text = event.text;
2309
+ console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
2310
+ router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
2311
+ console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
2312
+ });
2313
+ }
2314
+ if (event.type === "ask_user_question") {
2315
+ config.sessions.update(agentSession.sessionId, { needsInput: true });
2316
+ router
2317
+ .sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
2318
+ .catch((err) => {
2319
+ console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
2320
+ });
2321
+ }
2322
+ if (event.type === "gate_resolved") {
2323
+ config.sessions.update(agentSession.sessionId, { needsInput: false });
2324
+ router
2325
+ .sendMessage("system", `${agentSession.name} received input — resuming.`)
2326
+ .catch(() => { });
2327
+ }
2328
+ });
2329
+ ccBridge.onComplete(async (success) => {
2330
+ config.sessions.update(agentSession.sessionId, {
2331
+ status: success ? "complete" : "error",
2332
+ });
2333
+ });
2334
+ await agentBridge.emit({
2335
+ type: "log",
2336
+ level: "done",
2337
+ message: `Sandbox ready for "${agentSession.name}"`,
2338
+ ts: ts(),
2339
+ });
2340
+ await ccBridge.start();
2341
+ // Add as room participant (not gated — messages flow freely)
2342
+ const participant = {
2343
+ sessionId: agentSession.sessionId,
2344
+ name: agentSession.name,
2345
+ role: agentSession.role,
2346
+ bridge: ccBridge,
2347
+ };
2348
+ await router.addParticipant(participant, false);
2349
+ }
2350
+ console.log(`[room:create-app:${roomId}] All 3 agents started and added to room`);
2351
+ await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} and ${uiDesignerSession.name} waiting.`);
2352
+ }
2353
+ };
2354
+ asyncFlow().catch(async (err) => {
2355
+ console.error(`[room:create-app:${roomId}] Flow failed:`, err);
2356
+ for (const s of sessions) {
2357
+ config.sessions.update(s.sessionId, { status: "error" });
2358
+ }
2359
+ try {
2360
+ await coderBridge.emit({
2361
+ type: "log",
2362
+ level: "error",
2363
+ message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
2364
+ ts: ts(),
2365
+ });
2366
+ }
2367
+ catch {
2368
+ // Bridge may not be usable
2369
+ }
2370
+ });
2371
+ return c.json({
2372
+ roomId,
2373
+ code,
2374
+ name: roomName,
2375
+ roomToken,
2376
+ sessions: sessions.map((s) => ({
2377
+ sessionId: s.sessionId,
2378
+ name: s.name,
2379
+ role: s.role,
2380
+ sessionToken: s.sessionToken,
2381
+ })),
2382
+ }, 201);
2383
+ });
1648
2384
  // Create a room
1649
2385
  app.post("/api/rooms", async (c) => {
1650
2386
  const body = await validateBody(c, createRoomSchema);
@@ -1699,18 +2435,31 @@ echo "Start claude in this project — the session will appear in the studio UI.
1699
2435
  app.get("/api/rooms/:id", (c) => {
1700
2436
  const roomId = c.req.param("id");
1701
2437
  const router = roomRouters.get(roomId);
1702
- if (!router)
2438
+ if (router) {
2439
+ return c.json({
2440
+ roomId,
2441
+ state: router.state,
2442
+ roundCount: router.roundCount,
2443
+ participants: router.participants.map((p) => ({
2444
+ sessionId: p.sessionId,
2445
+ name: p.name,
2446
+ role: p.role,
2447
+ running: p.bridge.isRunning(),
2448
+ needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
2449
+ })),
2450
+ });
2451
+ }
2452
+ // No active router — check if room exists in the registry (e.g. after server restart)
2453
+ const roomEntry = config.rooms.getRoom(roomId);
2454
+ if (!roomEntry)
1703
2455
  return c.json({ error: "Room not found" }, 404);
2456
+ // Return basic room state without live participants
2457
+ // Sessions are still readable via their individual SSE streams
1704
2458
  return c.json({
1705
2459
  roomId,
1706
- state: router.state,
1707
- roundCount: router.roundCount,
1708
- participants: router.participants.map((p) => ({
1709
- sessionId: p.sessionId,
1710
- name: p.name,
1711
- role: p.role,
1712
- running: p.bridge.isRunning(),
1713
- })),
2460
+ state: "closed",
2461
+ roundCount: 0,
2462
+ participants: [],
1714
2463
  });
1715
2464
  });
1716
2465
  // Add an agent to a room
@@ -1732,8 +2481,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1732
2481
  return c.json({ error: "Service at capacity, please try again later" }, 503);
1733
2482
  }
1734
2483
  }
1735
- const apiKey = config.devMode ? body.apiKey : process.env.ANTHROPIC_API_KEY;
1736
- const oauthToken = config.devMode ? body.oauthToken : undefined;
2484
+ const apiKey = config.devMode
2485
+ ? body.apiKey || process.env.ANTHROPIC_API_KEY
2486
+ : process.env.ANTHROPIC_API_KEY;
2487
+ const oauthToken = config.devMode
2488
+ ? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
2489
+ : undefined;
1737
2490
  const ghToken = config.devMode ? body.ghToken : undefined;
1738
2491
  const sessionId = crypto.randomUUID();
1739
2492
  const randomSuffix = sessionId.slice(0, 6);
@@ -1786,7 +2539,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1786
2539
  apiKey,
1787
2540
  oauthToken,
1788
2541
  ghToken,
1789
- ...(!config.devMode && {
2542
+ ...((!config.devMode || GITHUB_APP_ID) && {
1790
2543
  prodMode: {
1791
2544
  sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
1792
2545
  studioUrl: resolveStudioUrl(config.port),
@@ -2008,12 +2761,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
2008
2761
  await router.sendMessage(body.from, body.body, body.to);
2009
2762
  return c.json({ ok: true });
2010
2763
  });
2011
- // SSE proxy for room events
2764
+ // SSE proxy for room events (works even after server restart — reads from durable stream)
2012
2765
  app.get("/api/rooms/:id/events", async (c) => {
2013
2766
  const roomId = c.req.param("id");
2014
- const router = roomRouters.get(roomId);
2015
- if (!router)
2767
+ // Verify room exists in registry or has active router
2768
+ if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
2016
2769
  return c.json({ error: "Room not found" }, 404);
2770
+ }
2017
2771
  const connection = roomStream(config, roomId);
2018
2772
  const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
2019
2773
  const reader = new DurableStream({
@@ -2567,16 +3321,38 @@ export async function startWebServer(opts) {
2567
3321
  if (devMode) {
2568
3322
  console.log("[studio] Dev mode enabled — keychain endpoint active");
2569
3323
  }
3324
+ // Hydrate session registry from durable stream (survives restarts)
3325
+ const registry = await Registry.create(opts.streamConfig);
2570
3326
  const config = {
2571
3327
  port: opts.port ?? 4400,
2572
3328
  dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
2573
- sessions: new ActiveSessions(),
3329
+ sessions: ActiveSessions.fromRegistry(registry),
2574
3330
  rooms: opts.rooms,
2575
3331
  sandbox: opts.sandbox,
2576
3332
  streamConfig: opts.streamConfig,
2577
3333
  bridgeMode: opts.bridgeMode ?? "claude-code",
2578
3334
  devMode,
2579
3335
  };
3336
+ // Reconnect to surviving sandbox containers (Docker only)
3337
+ if (config.sandbox.runtime === "docker") {
3338
+ const dockerProvider = config.sandbox;
3339
+ const allSessions = registry.listSessions();
3340
+ dockerProvider.reconnect(allSessions);
3341
+ // Mark sessions with live containers as "complete" (not stale),
3342
+ // and sessions without containers as "error"
3343
+ for (const session of allSessions) {
3344
+ if (session.status === "running") {
3345
+ const handle = dockerProvider.get(session.id);
3346
+ config.sessions.update(session.id, {
3347
+ status: handle ? "complete" : "error",
3348
+ });
3349
+ }
3350
+ }
3351
+ }
3352
+ else {
3353
+ // Non-Docker: mark all running sessions as stale
3354
+ registry.cleanupStaleSessions(0);
3355
+ }
2580
3356
  fs.mkdirSync(config.dataDir, { recursive: true });
2581
3357
  const app = createApp(config);
2582
3358
  const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";