@electric-agent/studio 1.3.0 → 1.5.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 (43) hide show
  1. package/dist/bridge/claude-code-docker.d.ts +3 -0
  2. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  3. package/dist/bridge/claude-code-docker.js +33 -30
  4. package/dist/bridge/claude-code-docker.js.map +1 -1
  5. package/dist/bridge/claude-code-sprites.d.ts +3 -0
  6. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  7. package/dist/bridge/claude-code-sprites.js +52 -46
  8. package/dist/bridge/claude-code-sprites.js.map +1 -1
  9. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  10. package/dist/bridge/claude-md-generator.js +3 -1
  11. package/dist/bridge/claude-md-generator.js.map +1 -1
  12. package/dist/bridge/gate-response.d.ts +10 -0
  13. package/dist/bridge/gate-response.d.ts.map +1 -0
  14. package/dist/bridge/gate-response.js +40 -0
  15. package/dist/bridge/gate-response.js.map +1 -0
  16. package/dist/bridge/hosted.d.ts +1 -0
  17. package/dist/bridge/hosted.d.ts.map +1 -1
  18. package/dist/bridge/hosted.js +4 -0
  19. package/dist/bridge/hosted.js.map +1 -1
  20. package/dist/bridge/index.d.ts +1 -0
  21. package/dist/bridge/index.d.ts.map +1 -1
  22. package/dist/bridge/index.js +1 -0
  23. package/dist/bridge/index.js.map +1 -1
  24. package/dist/bridge/stream-json-parser.js +1 -0
  25. package/dist/bridge/stream-json-parser.js.map +1 -1
  26. package/dist/bridge/types.d.ts +6 -0
  27. package/dist/bridge/types.d.ts.map +1 -1
  28. package/dist/client/assets/index-DDzmxYub.js +234 -0
  29. package/dist/client/assets/index-DcP7prsZ.css +1 -0
  30. package/dist/client/index.html +2 -2
  31. package/dist/git.d.ts +8 -5
  32. package/dist/git.d.ts.map +1 -1
  33. package/dist/git.js +8 -5
  34. package/dist/git.js.map +1 -1
  35. package/dist/sandbox/docker.d.ts.map +1 -1
  36. package/dist/sandbox/docker.js +1 -0
  37. package/dist/sandbox/docker.js.map +1 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +208 -37
  40. package/dist/server.js.map +1 -1
  41. package/package.json +2 -2
  42. package/dist/client/assets/index-Bq9zwhHj.css +0 -1
  43. package/dist/client/assets/index-Dgpqg5fv.js +0 -234
package/dist/server.js CHANGED
@@ -164,6 +164,7 @@ function mapHookToEngineEvent(body) {
164
164
  tool_use_id: toolUseId,
165
165
  question: firstQuestion?.question || toolInput.question || "",
166
166
  options: firstQuestion?.options,
167
+ questions: questions ?? undefined,
167
168
  ts: now,
168
169
  };
169
170
  }
@@ -432,7 +433,7 @@ export function createApp(config) {
432
433
  console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
433
434
  try {
434
435
  const gateTimeout = 5 * 60 * 1000; // 5 minutes
435
- const answer = await Promise.race([
436
+ const result = await Promise.race([
436
437
  createGate(sessionId, `ask_user_question:${toolUseId}`),
437
438
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
438
439
  ]);
@@ -443,7 +444,7 @@ export function createApp(config) {
443
444
  permissionDecision: "allow",
444
445
  updatedInput: {
445
446
  questions: body.tool_input?.questions,
446
- answers: { [hookEvent.question]: answer.answer },
447
+ answers: result.answers,
447
448
  },
448
449
  },
449
450
  });
@@ -562,7 +563,7 @@ export function createApp(config) {
562
563
  console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
563
564
  try {
564
565
  const gateTimeout = 5 * 60 * 1000;
565
- const answer = await Promise.race([
566
+ const result = await Promise.race([
566
567
  createGate(sessionId, `ask_user_question:${toolUseId}`),
567
568
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
568
569
  ]);
@@ -574,7 +575,7 @@ export function createApp(config) {
574
575
  permissionDecision: "allow",
575
576
  updatedInput: {
576
577
  questions: body.tool_input?.questions,
577
- answers: { [hookEvent.question]: answer.answer },
578
+ answers: result.answers,
578
579
  },
579
580
  },
580
581
  });
@@ -717,8 +718,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
717
718
  // Write user prompt to the stream so it shows in the UI
718
719
  await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
719
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
720
722
  let ghAccounts = [];
721
- if (isGhAuthenticated(body.ghToken)) {
723
+ if (body.ghToken && isGhAuthenticated(body.ghToken)) {
722
724
  try {
723
725
  ghAccounts = ghListAccounts(body.ghToken);
724
726
  }
@@ -932,14 +934,16 @@ echo "Start claude in this project — the session will appear in the studio UI.
932
934
  }
933
935
  config.sessions.update(sessionId, updates);
934
936
  // Check if the app is running after completion
935
- // and emit app_ready so the UI shows the preview link
937
+ // and emit app_status so the UI shows the preview link
936
938
  if (success) {
937
939
  try {
938
940
  const appRunning = await config.sandbox.isAppRunning(handle);
939
941
  if (appRunning) {
940
942
  await bridge.emit({
941
- type: "app_ready",
943
+ type: "app_status",
944
+ status: "running",
942
945
  port: handle.port ?? session.appPort,
946
+ previewUrl: handle.previewUrl ?? session.previewUrl,
943
947
  ts: ts(),
944
948
  });
945
949
  }
@@ -949,6 +953,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
949
953
  }
950
954
  }
951
955
  });
956
+ // Show the command being sent to Claude Code
957
+ await bridge.emit({
958
+ type: "log",
959
+ level: "build",
960
+ message: `Running: claude "/create-app ${body.description}"`,
961
+ ts: ts(),
962
+ });
952
963
  console.log(`[session:${sessionId}] Starting bridge listener...`);
953
964
  await bridge.start();
954
965
  console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
@@ -1015,7 +1026,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
1015
1026
  message: "App started",
1016
1027
  ts: ts(),
1017
1028
  });
1018
- await bridge.emit({ type: "app_ready", port: session.appPort, ts: ts() });
1029
+ await bridge.emit({
1030
+ type: "app_status",
1031
+ status: "running",
1032
+ port: session.appPort,
1033
+ previewUrl: session.previewUrl,
1034
+ ts: ts(),
1035
+ });
1019
1036
  }
1020
1037
  }
1021
1038
  catch (err) {
@@ -1082,8 +1099,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1082
1099
  if (!toolUseId) {
1083
1100
  return c.json({ error: "toolUseId is required for ask_user_question" }, 400);
1084
1101
  }
1085
- const answer = body.answer || "";
1086
- const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
1102
+ // Accept either answers (Record<string, string>) or legacy answer (string)
1103
+ const answers = body.answers ??
1104
+ (body.answer ? { [body.question || "answer"]: body.answer } : {});
1105
+ const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answers });
1087
1106
  if (resolved) {
1088
1107
  // Hook session — gate was blocking, now resolved
1089
1108
  try {
@@ -1234,6 +1253,24 @@ echo "Start claude in this project — the session will appear in the studio UI.
1234
1253
  }
1235
1254
  return c.json({ success: true });
1236
1255
  });
1256
+ // Interrupt the running Claude Code process without destroying the session.
1257
+ // The sandbox stays alive and the bridge remains open for follow-up messages.
1258
+ app.post("/api/sessions/:id/interrupt", async (c) => {
1259
+ const sessionId = c.req.param("id");
1260
+ const bridge = bridges.get(sessionId);
1261
+ if (bridge) {
1262
+ bridge.interrupt();
1263
+ // Emit session_end so the UI knows the process stopped
1264
+ await bridge.emit({
1265
+ type: "session_end",
1266
+ success: false,
1267
+ ts: ts(),
1268
+ });
1269
+ }
1270
+ rejectAllGates(sessionId);
1271
+ config.sessions.update(sessionId, { status: "complete" });
1272
+ return c.json({ ok: true });
1273
+ });
1237
1274
  // Cancel a running session
1238
1275
  app.post("/api/sessions/:id/cancel", async (c) => {
1239
1276
  const sessionId = c.req.param("id");
@@ -1694,9 +1731,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1694
1731
  }
1695
1732
  return c.json({ content });
1696
1733
  });
1697
- // List GitHub accounts (personal + orgs)
1734
+ // List GitHub accounts (personal + orgs) — requires client-provided token
1698
1735
  app.get("/api/github/accounts", (c) => {
1699
- const token = c.req.header("X-GH-Token") || undefined;
1736
+ const token = c.req.header("X-GH-Token");
1737
+ if (!token)
1738
+ return c.json({ accounts: [] });
1700
1739
  try {
1701
1740
  const accounts = ghListAccounts(token);
1702
1741
  return c.json({ accounts });
@@ -1705,9 +1744,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1705
1744
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
1706
1745
  }
1707
1746
  });
1708
- // List GitHub repos for the authenticated user
1747
+ // List GitHub repos for the authenticated user — requires client-provided token
1709
1748
  app.get("/api/github/repos", (c) => {
1710
- const token = c.req.header("X-GH-Token") || undefined;
1749
+ const token = c.req.header("X-GH-Token");
1750
+ if (!token)
1751
+ return c.json({ repos: [] });
1711
1752
  try {
1712
1753
  const repos = ghListRepos(50, token);
1713
1754
  return c.json({ repos });
@@ -1719,7 +1760,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1719
1760
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
1720
1761
  const owner = c.req.param("owner");
1721
1762
  const repo = c.req.param("repo");
1722
- const token = c.req.header("X-GH-Token") || undefined;
1763
+ const token = c.req.header("X-GH-Token");
1764
+ if (!token)
1765
+ return c.json({ branches: [] });
1723
1766
  try {
1724
1767
  const branches = ghListBranches(`${owner}/${repo}`, token);
1725
1768
  return c.json({ branches });
@@ -1772,24 +1815,47 @@ echo "Start claude in this project — the session will appear in the studio UI.
1772
1815
  catch {
1773
1816
  return c.json({ error: "Failed to create event stream" }, 500);
1774
1817
  }
1775
- try {
1818
+ // Create the initial session bridge for emitting progress events
1819
+ const bridge = getOrCreateBridge(config, sessionId);
1820
+ // Record session as running (like normal session creation)
1821
+ const sandboxProjectDir = `/home/agent/workspace/${repoName}`;
1822
+ const session = {
1823
+ id: sessionId,
1824
+ projectName: body.branch ? `${repoName}/${body.branch}` : repoName,
1825
+ sandboxProjectDir,
1826
+ description: `Resumed from ${body.repoUrl}`,
1827
+ createdAt: new Date().toISOString(),
1828
+ lastActiveAt: new Date().toISOString(),
1829
+ status: "running",
1830
+ };
1831
+ config.sessions.add(session);
1832
+ // Write user prompt to the stream so it shows in the UI
1833
+ await bridge.emit({
1834
+ type: "user_prompt",
1835
+ message: `Resume from ${body.repoUrl}`,
1836
+ ts: ts(),
1837
+ });
1838
+ // Launch async flow: clone repo → set up Claude Code → start exploring
1839
+ const asyncFlow = async () => {
1840
+ // 1. Clone the repo into a sandbox
1841
+ await bridge.emit({
1842
+ type: "log",
1843
+ level: "build",
1844
+ message: "Cloning repository...",
1845
+ ts: ts(),
1846
+ });
1776
1847
  const handle = await config.sandbox.createFromRepo(sessionId, body.repoUrl, {
1777
1848
  branch: body.branch,
1778
1849
  apiKey: body.apiKey,
1779
1850
  oauthToken: body.oauthToken,
1780
1851
  ghToken: body.ghToken,
1781
1852
  });
1782
- // Get git state from cloned repo inside the container
1853
+ // Get git state from cloned repo
1783
1854
  const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
1784
- const session = {
1785
- id: sessionId,
1786
- projectName: repoName,
1787
- sandboxProjectDir: handle.projectDir,
1788
- description: `Resumed from ${body.repoUrl}`,
1789
- createdAt: new Date().toISOString(),
1790
- lastActiveAt: new Date().toISOString(),
1791
- status: "complete",
1855
+ config.sessions.update(sessionId, {
1792
1856
  appPort: handle.port,
1857
+ sandboxProjectDir: handle.projectDir,
1858
+ previewUrl: handle.previewUrl,
1793
1859
  git: {
1794
1860
  branch: gs.branch ?? body.branch ?? "main",
1795
1861
  remoteUrl: body.repoUrl,
@@ -1798,23 +1864,128 @@ echo "Start claude in this project — the session will appear in the studio UI.
1798
1864
  lastCommitMessage: gs.lastCommitMessage ?? null,
1799
1865
  lastCheckpointAt: null,
1800
1866
  },
1801
- };
1802
- config.sessions.add(session);
1803
- // Write initial message to stream
1804
- const bridge = getOrCreateBridge(config, sessionId);
1867
+ });
1805
1868
  await bridge.emit({
1806
1869
  type: "log",
1807
1870
  level: "done",
1808
- message: `Resumed from ${body.repoUrl}`,
1871
+ message: "Repository cloned",
1809
1872
  ts: ts(),
1810
1873
  });
1811
- const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1812
- return c.json({ sessionId, session, sessionToken, appPort: handle.port }, 201);
1813
- }
1814
- catch (e) {
1815
- const msg = e instanceof Error ? e.message : "Failed to resume from repo";
1816
- return c.json({ error: msg }, 500);
1817
- }
1874
+ // 2. Write CLAUDE.md to the sandbox workspace
1875
+ const claudeMd = generateClaudeMd({
1876
+ description: `Resumed from ${body.repoUrl}`,
1877
+ projectName: repoName,
1878
+ projectDir: handle.projectDir,
1879
+ runtime: config.sandbox.runtime,
1880
+ });
1881
+ try {
1882
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
1883
+ }
1884
+ catch (err) {
1885
+ console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
1886
+ }
1887
+ // Ensure the create-app skill is present in the project
1888
+ if (createAppSkillContent) {
1889
+ try {
1890
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
1891
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1892
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1893
+ }
1894
+ catch (err) {
1895
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1896
+ }
1897
+ }
1898
+ // 3. Create Claude Code bridge with a resume prompt
1899
+ const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
1900
+ const claudeConfig = config.sandbox.runtime === "sprites"
1901
+ ? {
1902
+ prompt: resumePrompt,
1903
+ cwd: handle.projectDir,
1904
+ studioUrl: resolveStudioUrl(config.port),
1905
+ }
1906
+ : {
1907
+ prompt: resumePrompt,
1908
+ cwd: handle.projectDir,
1909
+ studioPort: config.port,
1910
+ };
1911
+ const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
1912
+ // 4. Register event listeners (reuse pattern from normal flow)
1913
+ ccBridge.onAgentEvent((event) => {
1914
+ if (event.type === "session_start") {
1915
+ const ccSessionId = event.session_id;
1916
+ console.log(`[session:${sessionId}] Captured Claude Code session ID: ${ccSessionId}`);
1917
+ if (ccSessionId) {
1918
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
1919
+ }
1920
+ }
1921
+ });
1922
+ ccBridge.onComplete(async (success) => {
1923
+ const updates = {
1924
+ status: success ? "complete" : "error",
1925
+ };
1926
+ try {
1927
+ const latestGs = await config.sandbox.gitStatus(handle, handle.projectDir);
1928
+ if (latestGs.initialized) {
1929
+ const existing = config.sessions.get(sessionId);
1930
+ updates.git = {
1931
+ branch: latestGs.branch ?? "main",
1932
+ remoteUrl: existing?.git?.remoteUrl ?? null,
1933
+ repoName: existing?.git?.repoName ?? null,
1934
+ repoVisibility: existing?.git?.repoVisibility,
1935
+ lastCommitHash: latestGs.lastCommitHash ?? null,
1936
+ lastCommitMessage: latestGs.lastCommitMessage ?? null,
1937
+ lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
1938
+ };
1939
+ }
1940
+ }
1941
+ catch {
1942
+ // Container may already be stopped
1943
+ }
1944
+ config.sessions.update(sessionId, updates);
1945
+ // Check if the app is running after completion
1946
+ if (success) {
1947
+ try {
1948
+ const appRunning = await config.sandbox.isAppRunning(handle);
1949
+ if (appRunning) {
1950
+ await ccBridge.emit({
1951
+ type: "app_status",
1952
+ status: "running",
1953
+ port: handle.port ?? session.appPort,
1954
+ previewUrl: handle.previewUrl ?? session.previewUrl,
1955
+ ts: ts(),
1956
+ });
1957
+ }
1958
+ }
1959
+ catch {
1960
+ // Container may already be stopped
1961
+ }
1962
+ }
1963
+ });
1964
+ // 5. Start the bridge and send command
1965
+ await ccBridge.emit({
1966
+ type: "log",
1967
+ level: "build",
1968
+ message: "Starting Claude Code...",
1969
+ ts: ts(),
1970
+ });
1971
+ console.log(`[session:${sessionId}] Starting bridge listener...`);
1972
+ await ccBridge.start();
1973
+ console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
1974
+ const newCmd = {
1975
+ command: "new",
1976
+ description: resumePrompt,
1977
+ projectName: repoName,
1978
+ baseDir: "/home/agent/workspace",
1979
+ };
1980
+ await ccBridge.sendCommand(newCmd);
1981
+ console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
1982
+ };
1983
+ asyncFlow().catch(async (err) => {
1984
+ console.error(`[session:${sessionId}] Resume flow failed:`, err);
1985
+ config.sessions.update(sessionId, { status: "error" });
1986
+ });
1987
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1988
+ return c.json({ sessionId, session, sessionToken }, 201);
1818
1989
  });
1819
1990
  // Serve static SPA files (if built)
1820
1991
  const clientDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "./client");