@electric-agent/studio 1.3.4 → 1.7.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 (39) hide show
  1. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  2. package/dist/bridge/claude-code-docker.js +13 -28
  3. package/dist/bridge/claude-code-docker.js.map +1 -1
  4. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  5. package/dist/bridge/claude-code-sprites.js +13 -27
  6. package/dist/bridge/claude-code-sprites.js.map +1 -1
  7. package/dist/bridge/claude-md-generator.d.ts +13 -5
  8. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  9. package/dist/bridge/claude-md-generator.js +37 -118
  10. package/dist/bridge/claude-md-generator.js.map +1 -1
  11. package/dist/bridge/gate-response.d.ts +10 -0
  12. package/dist/bridge/gate-response.d.ts.map +1 -0
  13. package/dist/bridge/gate-response.js +40 -0
  14. package/dist/bridge/gate-response.js.map +1 -0
  15. package/dist/bridge/index.d.ts +1 -0
  16. package/dist/bridge/index.d.ts.map +1 -1
  17. package/dist/bridge/index.js +1 -0
  18. package/dist/bridge/index.js.map +1 -1
  19. package/dist/bridge/stream-json-parser.js +13 -5
  20. package/dist/bridge/stream-json-parser.js.map +1 -1
  21. package/dist/client/assets/index-D5-jqAV-.js +234 -0
  22. package/dist/client/assets/index-YyyiO26y.css +1 -0
  23. package/dist/client/index.html +2 -2
  24. package/dist/git.d.ts +8 -5
  25. package/dist/git.d.ts.map +1 -1
  26. package/dist/git.js +8 -5
  27. package/dist/git.js.map +1 -1
  28. package/dist/sandbox/docker.d.ts.map +1 -1
  29. package/dist/sandbox/docker.js +1 -0
  30. package/dist/sandbox/docker.js.map +1 -1
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +293 -47
  33. package/dist/server.js.map +1 -1
  34. package/dist/sessions.d.ts +6 -0
  35. package/dist/sessions.d.ts.map +1 -1
  36. package/dist/sessions.js.map +1 -1
  37. package/package.json +2 -2
  38. package/dist/client/assets/index-B6arNdVE.css +0 -1
  39. package/dist/client/assets/index-CxBu-PUg.js +0 -234
package/dist/server.js CHANGED
@@ -22,6 +22,8 @@ import { generateInviteCode } from "./shared-sessions.js";
22
22
  import { getSharedStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
23
23
  /** Active session bridges — one per running session */
24
24
  const bridges = new Map();
25
+ /** In-memory room presence: roomId → participantId → { displayName, lastPing } */
26
+ const roomPresence = new Map();
25
27
  /** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
26
28
  const inflightHookCreations = new Map();
27
29
  function parseRepoNameFromUrl(url) {
@@ -64,6 +66,30 @@ function resolveStudioUrl(port) {
64
66
  // Fallback — won't work from sprites VMs, but at least logs a useful URL
65
67
  return `http://localhost:${port}`;
66
68
  }
69
+ /**
70
+ * Accumulate cost and turn metrics from a session_end event into the session's totals.
71
+ * Called each time a Claude Code run finishes (initial + iterate runs).
72
+ */
73
+ function accumulateSessionCost(config, sessionId, event) {
74
+ if (event.type !== "session_end")
75
+ return;
76
+ const { cost_usd, num_turns, duration_ms } = event;
77
+ if (cost_usd == null && num_turns == null && duration_ms == null)
78
+ return;
79
+ const existing = config.sessions.get(sessionId);
80
+ const updates = {};
81
+ if (cost_usd != null) {
82
+ updates.totalCostUsd = (existing?.totalCostUsd ?? 0) + cost_usd;
83
+ }
84
+ if (num_turns != null) {
85
+ updates.totalTurns = (existing?.totalTurns ?? 0) + num_turns;
86
+ }
87
+ if (duration_ms != null) {
88
+ updates.totalDurationMs = (existing?.totalDurationMs ?? 0) + duration_ms;
89
+ }
90
+ config.sessions.update(sessionId, updates);
91
+ console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
92
+ }
67
93
  /**
68
94
  * Create a Claude Code bridge for a session.
69
95
  * Spawns `claude` CLI with stream-json I/O inside the sandbox.
@@ -164,6 +190,7 @@ function mapHookToEngineEvent(body) {
164
190
  tool_use_id: toolUseId,
165
191
  question: firstQuestion?.question || toolInput.question || "",
166
192
  options: firstQuestion?.options,
193
+ questions: questions ?? undefined,
167
194
  ts: now,
168
195
  };
169
196
  }
@@ -197,12 +224,26 @@ function mapHookToEngineEvent(body) {
197
224
  text: body.last_assistant_message || "",
198
225
  ts: now,
199
226
  };
200
- case "SessionEnd":
201
- return {
227
+ case "SessionEnd": {
228
+ const endEvent = {
202
229
  type: "session_end",
203
230
  success: true,
204
231
  ts: now,
205
232
  };
233
+ // Claude Code SessionEnd hook may include session stats
234
+ const session = body.session;
235
+ if (session) {
236
+ if (typeof session.cost_usd === "number")
237
+ endEvent.cost_usd = session.cost_usd;
238
+ if (typeof session.num_turns === "number")
239
+ endEvent.num_turns = session.num_turns;
240
+ if (typeof session.duration_ms === "number")
241
+ endEvent.duration_ms = session.duration_ms;
242
+ if (typeof session.duration_api_ms === "number")
243
+ endEvent.duration_api_ms = session.duration_api_ms;
244
+ }
245
+ return endEvent;
246
+ }
206
247
  case "UserPromptSubmit":
207
248
  return {
208
249
  type: "user_prompt",
@@ -420,6 +461,7 @@ export function createApp(config) {
420
461
  config.sessions.update(sessionId, {});
421
462
  // SessionEnd: mark session complete and close the bridge
422
463
  if (hookEvent.type === "session_end") {
464
+ accumulateSessionCost(config, sessionId, hookEvent);
423
465
  if (!isClaudeCodeBridge) {
424
466
  config.sessions.update(sessionId, { status: "complete" });
425
467
  closeBridge(sessionId);
@@ -432,7 +474,7 @@ export function createApp(config) {
432
474
  console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
433
475
  try {
434
476
  const gateTimeout = 5 * 60 * 1000; // 5 minutes
435
- const answer = await Promise.race([
477
+ const result = await Promise.race([
436
478
  createGate(sessionId, `ask_user_question:${toolUseId}`),
437
479
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
438
480
  ]);
@@ -443,7 +485,7 @@ export function createApp(config) {
443
485
  permissionDecision: "allow",
444
486
  updatedInput: {
445
487
  questions: body.tool_input?.questions,
446
- answers: { [hookEvent.question]: answer.answer },
488
+ answers: result.answers,
447
489
  },
448
490
  },
449
491
  });
@@ -552,6 +594,7 @@ export function createApp(config) {
552
594
  config.sessions.update(sessionId, {});
553
595
  // SessionEnd: mark complete and close bridge (keep mapping for potential re-open)
554
596
  if (hookEvent.type === "session_end") {
597
+ accumulateSessionCost(config, sessionId, hookEvent);
555
598
  config.sessions.update(sessionId, { status: "complete" });
556
599
  closeBridge(sessionId);
557
600
  return c.json({ ok: true, sessionId });
@@ -562,7 +605,7 @@ export function createApp(config) {
562
605
  console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
563
606
  try {
564
607
  const gateTimeout = 5 * 60 * 1000;
565
- const answer = await Promise.race([
608
+ const result = await Promise.race([
566
609
  createGate(sessionId, `ask_user_question:${toolUseId}`),
567
610
  new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
568
611
  ]);
@@ -574,7 +617,7 @@ export function createApp(config) {
574
617
  permissionDecision: "allow",
575
618
  updatedInput: {
576
619
  questions: body.tool_input?.questions,
577
- answers: { [hookEvent.question]: answer.answer },
620
+ answers: result.answers,
578
621
  },
579
622
  },
580
623
  });
@@ -717,8 +760,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
717
760
  // Write user prompt to the stream so it shows in the UI
718
761
  await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
719
762
  // Gather GitHub accounts for the merged setup gate
763
+ // Only check if the client provided a token — never fall back to server-side GH_TOKEN
720
764
  let ghAccounts = [];
721
- if (isGhAuthenticated(body.ghToken)) {
765
+ if (body.ghToken && isGhAuthenticated(body.ghToken)) {
722
766
  try {
723
767
  ghAccounts = ghListAccounts(body.ghToken);
724
768
  }
@@ -855,6 +899,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
855
899
  projectName,
856
900
  projectDir: handle.projectDir,
857
901
  runtime: config.sandbox.runtime,
902
+ ...(repoConfig
903
+ ? {
904
+ git: {
905
+ mode: "create",
906
+ repoName: `${repoConfig.account}/${repoConfig.repoName}`,
907
+ visibility: repoConfig.visibility,
908
+ },
909
+ }
910
+ : {}),
858
911
  });
859
912
  try {
860
913
  await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
@@ -898,7 +951,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
898
951
  });
899
952
  }
900
953
  // 5. Start listening for agent events via the bridge
901
- // Track Claude Code session ID for --resume on iterate
954
+ // Track Claude Code session ID and cost from agent events
902
955
  bridge.onAgentEvent((event) => {
903
956
  if (event.type === "session_start") {
904
957
  const ccSessionId = event.session_id;
@@ -907,6 +960,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
907
960
  config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
908
961
  }
909
962
  }
963
+ if (event.type === "session_end") {
964
+ accumulateSessionCost(config, sessionId, event);
965
+ }
910
966
  });
911
967
  bridge.onComplete(async (success) => {
912
968
  const updates = {
@@ -932,14 +988,16 @@ echo "Start claude in this project — the session will appear in the studio UI.
932
988
  }
933
989
  config.sessions.update(sessionId, updates);
934
990
  // Check if the app is running after completion
935
- // and emit app_ready so the UI shows the preview link
991
+ // and emit app_status so the UI shows the preview link
936
992
  if (success) {
937
993
  try {
938
994
  const appRunning = await config.sandbox.isAppRunning(handle);
939
995
  if (appRunning) {
940
996
  await bridge.emit({
941
- type: "app_ready",
997
+ type: "app_status",
998
+ status: "running",
942
999
  port: handle.port ?? session.appPort,
1000
+ previewUrl: handle.previewUrl ?? session.previewUrl,
943
1001
  ts: ts(),
944
1002
  });
945
1003
  }
@@ -960,17 +1018,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
960
1018
  await bridge.start();
961
1019
  console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
962
1020
  // 5. Send the new command via the bridge
963
- const newCmd = {
1021
+ await bridge.sendCommand({
964
1022
  command: "new",
965
1023
  description: body.description,
966
1024
  projectName,
967
1025
  baseDir: "/home/agent/workspace",
968
- };
969
- if (repoConfig) {
970
- newCmd.gitRepoName = `${repoConfig.account}/${repoConfig.repoName}`;
971
- newCmd.gitRepoVisibility = repoConfig.visibility;
972
- }
973
- await bridge.sendCommand(newCmd);
1026
+ });
974
1027
  console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
975
1028
  };
976
1029
  asyncFlow().catch(async (err) => {
@@ -1022,7 +1075,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
1022
1075
  message: "App started",
1023
1076
  ts: ts(),
1024
1077
  });
1025
- await bridge.emit({ type: "app_ready", port: session.appPort, ts: ts() });
1078
+ await bridge.emit({
1079
+ type: "app_status",
1080
+ status: "running",
1081
+ port: session.appPort,
1082
+ previewUrl: session.previewUrl,
1083
+ ts: ts(),
1084
+ });
1026
1085
  }
1027
1086
  }
1028
1087
  catch (err) {
@@ -1089,8 +1148,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1089
1148
  if (!toolUseId) {
1090
1149
  return c.json({ error: "toolUseId is required for ask_user_question" }, 400);
1091
1150
  }
1092
- const answer = body.answer || "";
1093
- const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
1151
+ // Accept either answers (Record<string, string>) or legacy answer (string)
1152
+ const answers = body.answers ??
1153
+ (body.answer ? { [body.question || "answer"]: body.answer } : {});
1154
+ const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answers });
1094
1155
  if (resolved) {
1095
1156
  // Hook session — gate was blocking, now resolved
1096
1157
  try {
@@ -1489,6 +1550,43 @@ echo "Start claude in this project — the session will appear in the studio UI.
1489
1550
  await stream.append(JSON.stringify(event));
1490
1551
  return c.json({ ok: true });
1491
1552
  });
1553
+ // Heartbeat ping for room presence (in-memory, not persisted to stream)
1554
+ app.post("/api/shared-sessions/:id/ping", async (c) => {
1555
+ const id = c.req.param("id");
1556
+ const body = (await c.req.json());
1557
+ if (!body.participantId) {
1558
+ return c.json({ error: "participantId is required" }, 400);
1559
+ }
1560
+ let room = roomPresence.get(id);
1561
+ if (!room) {
1562
+ room = new Map();
1563
+ roomPresence.set(id, room);
1564
+ }
1565
+ room.set(body.participantId, {
1566
+ displayName: body.displayName || body.participantId.slice(0, 8),
1567
+ lastPing: Date.now(),
1568
+ });
1569
+ return c.json({ ok: true });
1570
+ });
1571
+ // Get active participants (pinged within last 90 seconds)
1572
+ app.get("/api/shared-sessions/:id/presence", (c) => {
1573
+ const id = c.req.param("id");
1574
+ const room = roomPresence.get(id);
1575
+ const STALE_MS = 90_000;
1576
+ const now = Date.now();
1577
+ const active = [];
1578
+ if (room) {
1579
+ for (const [pid, info] of room) {
1580
+ if (now - info.lastPing < STALE_MS) {
1581
+ active.push({ id: pid, displayName: info.displayName });
1582
+ }
1583
+ else {
1584
+ room.delete(pid);
1585
+ }
1586
+ }
1587
+ }
1588
+ return c.json({ participants: active });
1589
+ });
1492
1590
  // Link a session to a shared session (room)
1493
1591
  // The client sends session metadata since sessions are private (localStorage).
1494
1592
  app.post("/api/shared-sessions/:id/sessions", async (c) => {
@@ -1514,6 +1612,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1514
1612
  await stream.append(JSON.stringify(event));
1515
1613
  return c.json({ ok: true });
1516
1614
  });
1615
+ // Get a session token for a linked session (allows room participants to read session streams)
1616
+ app.get("/api/shared-sessions/:id/sessions/:sessionId/token", (c) => {
1617
+ const sessionId = c.req.param("sessionId");
1618
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1619
+ return c.json({ sessionToken });
1620
+ });
1517
1621
  // Unlink a session from a shared session
1518
1622
  app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
1519
1623
  const id = c.req.param("id");
@@ -1719,9 +1823,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1719
1823
  }
1720
1824
  return c.json({ content });
1721
1825
  });
1722
- // List GitHub accounts (personal + orgs)
1826
+ // List GitHub accounts (personal + orgs) — requires client-provided token
1723
1827
  app.get("/api/github/accounts", (c) => {
1724
- const token = c.req.header("X-GH-Token") || undefined;
1828
+ const token = c.req.header("X-GH-Token");
1829
+ if (!token)
1830
+ return c.json({ accounts: [] });
1725
1831
  try {
1726
1832
  const accounts = ghListAccounts(token);
1727
1833
  return c.json({ accounts });
@@ -1730,9 +1836,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1730
1836
  return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
1731
1837
  }
1732
1838
  });
1733
- // List GitHub repos for the authenticated user
1839
+ // List GitHub repos for the authenticated user — requires client-provided token
1734
1840
  app.get("/api/github/repos", (c) => {
1735
- const token = c.req.header("X-GH-Token") || undefined;
1841
+ const token = c.req.header("X-GH-Token");
1842
+ if (!token)
1843
+ return c.json({ repos: [] });
1736
1844
  try {
1737
1845
  const repos = ghListRepos(50, token);
1738
1846
  return c.json({ repos });
@@ -1744,7 +1852,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1744
1852
  app.get("/api/github/repos/:owner/:repo/branches", (c) => {
1745
1853
  const owner = c.req.param("owner");
1746
1854
  const repo = c.req.param("repo");
1747
- const token = c.req.header("X-GH-Token") || undefined;
1855
+ const token = c.req.header("X-GH-Token");
1856
+ if (!token)
1857
+ return c.json({ branches: [] });
1748
1858
  try {
1749
1859
  const branches = ghListBranches(`${owner}/${repo}`, token);
1750
1860
  return c.json({ branches });
@@ -1797,24 +1907,47 @@ echo "Start claude in this project — the session will appear in the studio UI.
1797
1907
  catch {
1798
1908
  return c.json({ error: "Failed to create event stream" }, 500);
1799
1909
  }
1800
- try {
1910
+ // Create the initial session bridge for emitting progress events
1911
+ const bridge = getOrCreateBridge(config, sessionId);
1912
+ // Record session as running (like normal session creation)
1913
+ const sandboxProjectDir = `/home/agent/workspace/${repoName}`;
1914
+ const session = {
1915
+ id: sessionId,
1916
+ projectName: body.branch ? `${repoName}/${body.branch}` : repoName,
1917
+ sandboxProjectDir,
1918
+ description: `Resumed from ${body.repoUrl}`,
1919
+ createdAt: new Date().toISOString(),
1920
+ lastActiveAt: new Date().toISOString(),
1921
+ status: "running",
1922
+ };
1923
+ config.sessions.add(session);
1924
+ // Write user prompt to the stream so it shows in the UI
1925
+ await bridge.emit({
1926
+ type: "user_prompt",
1927
+ message: `Resume from ${body.repoUrl}`,
1928
+ ts: ts(),
1929
+ });
1930
+ // Launch async flow: clone repo → set up Claude Code → start exploring
1931
+ const asyncFlow = async () => {
1932
+ // 1. Clone the repo into a sandbox
1933
+ await bridge.emit({
1934
+ type: "log",
1935
+ level: "build",
1936
+ message: "Cloning repository...",
1937
+ ts: ts(),
1938
+ });
1801
1939
  const handle = await config.sandbox.createFromRepo(sessionId, body.repoUrl, {
1802
1940
  branch: body.branch,
1803
1941
  apiKey: body.apiKey,
1804
1942
  oauthToken: body.oauthToken,
1805
1943
  ghToken: body.ghToken,
1806
1944
  });
1807
- // Get git state from cloned repo inside the container
1945
+ // Get git state from cloned repo
1808
1946
  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",
1947
+ config.sessions.update(sessionId, {
1817
1948
  appPort: handle.port,
1949
+ sandboxProjectDir: handle.projectDir,
1950
+ previewUrl: handle.previewUrl,
1818
1951
  git: {
1819
1952
  branch: gs.branch ?? body.branch ?? "main",
1820
1953
  remoteUrl: body.repoUrl,
@@ -1823,23 +1956,136 @@ echo "Start claude in this project — the session will appear in the studio UI.
1823
1956
  lastCommitMessage: gs.lastCommitMessage ?? null,
1824
1957
  lastCheckpointAt: null,
1825
1958
  },
1826
- };
1827
- config.sessions.add(session);
1828
- // Write initial message to stream
1829
- const bridge = getOrCreateBridge(config, sessionId);
1959
+ });
1830
1960
  await bridge.emit({
1831
1961
  type: "log",
1832
1962
  level: "done",
1833
- message: `Resumed from ${body.repoUrl}`,
1963
+ message: "Repository cloned",
1834
1964
  ts: ts(),
1835
1965
  });
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
- }
1966
+ // 2. Write CLAUDE.md to the sandbox workspace
1967
+ const claudeMd = generateClaudeMd({
1968
+ description: `Resumed from ${body.repoUrl}`,
1969
+ projectName: repoName,
1970
+ projectDir: handle.projectDir,
1971
+ runtime: config.sandbox.runtime,
1972
+ git: {
1973
+ mode: "existing",
1974
+ repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
1975
+ branch: gs.branch ?? body.branch ?? "main",
1976
+ },
1977
+ });
1978
+ try {
1979
+ await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
1980
+ }
1981
+ catch (err) {
1982
+ console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
1983
+ }
1984
+ // Ensure the create-app skill is present in the project
1985
+ if (createAppSkillContent) {
1986
+ try {
1987
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
1988
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
1989
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
1990
+ }
1991
+ catch (err) {
1992
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
1993
+ }
1994
+ }
1995
+ // 3. Create Claude Code bridge with a resume prompt
1996
+ const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
1997
+ const claudeConfig = config.sandbox.runtime === "sprites"
1998
+ ? {
1999
+ prompt: resumePrompt,
2000
+ cwd: handle.projectDir,
2001
+ studioUrl: resolveStudioUrl(config.port),
2002
+ }
2003
+ : {
2004
+ prompt: resumePrompt,
2005
+ cwd: handle.projectDir,
2006
+ studioPort: config.port,
2007
+ };
2008
+ const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
2009
+ // 4. Register event listeners (reuse pattern from normal flow)
2010
+ ccBridge.onAgentEvent((event) => {
2011
+ if (event.type === "session_start") {
2012
+ const ccSessionId = event.session_id;
2013
+ console.log(`[session:${sessionId}] Captured Claude Code session ID: ${ccSessionId}`);
2014
+ if (ccSessionId) {
2015
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
2016
+ }
2017
+ }
2018
+ if (event.type === "session_end") {
2019
+ accumulateSessionCost(config, sessionId, event);
2020
+ }
2021
+ });
2022
+ ccBridge.onComplete(async (success) => {
2023
+ const updates = {
2024
+ status: success ? "complete" : "error",
2025
+ };
2026
+ try {
2027
+ const latestGs = await config.sandbox.gitStatus(handle, handle.projectDir);
2028
+ if (latestGs.initialized) {
2029
+ const existing = config.sessions.get(sessionId);
2030
+ updates.git = {
2031
+ branch: latestGs.branch ?? "main",
2032
+ remoteUrl: existing?.git?.remoteUrl ?? null,
2033
+ repoName: existing?.git?.repoName ?? null,
2034
+ repoVisibility: existing?.git?.repoVisibility,
2035
+ lastCommitHash: latestGs.lastCommitHash ?? null,
2036
+ lastCommitMessage: latestGs.lastCommitMessage ?? null,
2037
+ lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
2038
+ };
2039
+ }
2040
+ }
2041
+ catch {
2042
+ // Container may already be stopped
2043
+ }
2044
+ config.sessions.update(sessionId, updates);
2045
+ // Check if the app is running after completion
2046
+ if (success) {
2047
+ try {
2048
+ const appRunning = await config.sandbox.isAppRunning(handle);
2049
+ if (appRunning) {
2050
+ await ccBridge.emit({
2051
+ type: "app_status",
2052
+ status: "running",
2053
+ port: handle.port ?? session.appPort,
2054
+ previewUrl: handle.previewUrl ?? session.previewUrl,
2055
+ ts: ts(),
2056
+ });
2057
+ }
2058
+ }
2059
+ catch {
2060
+ // Container may already be stopped
2061
+ }
2062
+ }
2063
+ });
2064
+ // 5. Start the bridge and send command
2065
+ await ccBridge.emit({
2066
+ type: "log",
2067
+ level: "build",
2068
+ message: "Starting Claude Code...",
2069
+ ts: ts(),
2070
+ });
2071
+ console.log(`[session:${sessionId}] Starting bridge listener...`);
2072
+ await ccBridge.start();
2073
+ console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
2074
+ const newCmd = {
2075
+ command: "new",
2076
+ description: resumePrompt,
2077
+ projectName: repoName,
2078
+ baseDir: "/home/agent/workspace",
2079
+ };
2080
+ await ccBridge.sendCommand(newCmd);
2081
+ console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
2082
+ };
2083
+ asyncFlow().catch(async (err) => {
2084
+ console.error(`[session:${sessionId}] Resume flow failed:`, err);
2085
+ config.sessions.update(sessionId, { status: "error" });
2086
+ });
2087
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
2088
+ return c.json({ sessionId, session, sessionToken }, 201);
1843
2089
  });
1844
2090
  // Serve static SPA files (if built)
1845
2091
  const clientDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "./client");