@electric-agent/studio 1.3.4 → 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.
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
  }
@@ -1022,7 +1026,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
1022
1026
  message: "App started",
1023
1027
  ts: ts(),
1024
1028
  });
1025
- 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
+ });
1026
1036
  }
1027
1037
  }
1028
1038
  catch (err) {
@@ -1089,8 +1099,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1089
1099
  if (!toolUseId) {
1090
1100
  return c.json({ error: "toolUseId is required for ask_user_question" }, 400);
1091
1101
  }
1092
- const answer = body.answer || "";
1093
- 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 });
1094
1106
  if (resolved) {
1095
1107
  // Hook session — gate was blocking, now resolved
1096
1108
  try {
@@ -1719,9 +1731,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1719
1731
  }
1720
1732
  return c.json({ content });
1721
1733
  });
1722
- // List GitHub accounts (personal + orgs)
1734
+ // List GitHub accounts (personal + orgs) — requires client-provided token
1723
1735
  app.get("/api/github/accounts", (c) => {
1724
- 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: [] });
1725
1739
  try {
1726
1740
  const accounts = ghListAccounts(token);
1727
1741
  return c.json({ accounts });
@@ -1730,9 +1744,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1730
1744
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
1731
1745
  }
1732
1746
  });
1733
- // List GitHub repos for the authenticated user
1747
+ // List GitHub repos for the authenticated user — requires client-provided token
1734
1748
  app.get("/api/github/repos", (c) => {
1735
- 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: [] });
1736
1752
  try {
1737
1753
  const repos = ghListRepos(50, token);
1738
1754
  return c.json({ repos });
@@ -1744,7 +1760,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1744
1760
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
1745
1761
  const owner = c.req.param("owner");
1746
1762
  const repo = c.req.param("repo");
1747
- 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: [] });
1748
1766
  try {
1749
1767
  const branches = ghListBranches(`${owner}/${repo}`, token);
1750
1768
  return c.json({ branches });
@@ -1797,24 +1815,47 @@ echo "Start claude in this project — the session will appear in the studio UI.
1797
1815
  catch {
1798
1816
  return c.json({ error: "Failed to create event stream" }, 500);
1799
1817
  }
1800
- 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
+ });
1801
1847
  const handle = await config.sandbox.createFromRepo(sessionId, body.repoUrl, {
1802
1848
  branch: body.branch,
1803
1849
  apiKey: body.apiKey,
1804
1850
  oauthToken: body.oauthToken,
1805
1851
  ghToken: body.ghToken,
1806
1852
  });
1807
- // Get git state from cloned repo inside the container
1853
+ // Get git state from cloned repo
1808
1854
  const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
1809
- const session = {
1810
- id: sessionId,
1811
- projectName: repoName,
1812
- sandboxProjectDir: handle.projectDir,
1813
- description: `Resumed from ${body.repoUrl}`,
1814
- createdAt: new Date().toISOString(),
1815
- lastActiveAt: new Date().toISOString(),
1816
- status: "complete",
1855
+ config.sessions.update(sessionId, {
1817
1856
  appPort: handle.port,
1857
+ sandboxProjectDir: handle.projectDir,
1858
+ previewUrl: handle.previewUrl,
1818
1859
  git: {
1819
1860
  branch: gs.branch ?? body.branch ?? "main",
1820
1861
  remoteUrl: body.repoUrl,
@@ -1823,23 +1864,128 @@ echo "Start claude in this project — the session will appear in the studio UI.
1823
1864
  lastCommitMessage: gs.lastCommitMessage ?? null,
1824
1865
  lastCheckpointAt: null,
1825
1866
  },
1826
- };
1827
- config.sessions.add(session);
1828
- // Write initial message to stream
1829
- const bridge = getOrCreateBridge(config, sessionId);
1867
+ });
1830
1868
  await bridge.emit({
1831
1869
  type: "log",
1832
1870
  level: "done",
1833
- message: `Resumed from ${body.repoUrl}`,
1871
+ message: "Repository cloned",
1834
1872
  ts: ts(),
1835
1873
  });
1836
- const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1837
- return c.json({ sessionId, session, sessionToken, appPort: handle.port }, 201);
1838
- }
1839
- catch (e) {
1840
- const msg = e instanceof Error ? e.message : "Failed to resume from repo";
1841
- return c.json({ error: msg }, 500);
1842
- }
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);
1843
1989
  });
1844
1990
  // Serve static SPA files (if built)
1845
1991
  const clientDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "./client");