@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.
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +13 -28
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +13 -27
- package/dist/bridge/claude-code-sprites.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +13 -5
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +37 -118
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/gate-response.d.ts +10 -0
- package/dist/bridge/gate-response.d.ts.map +1 -0
- package/dist/bridge/gate-response.js +40 -0
- package/dist/bridge/gate-response.js.map +1 -0
- package/dist/bridge/index.d.ts +1 -0
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +1 -0
- package/dist/bridge/index.js.map +1 -1
- package/dist/bridge/stream-json-parser.js +13 -5
- package/dist/bridge/stream-json-parser.js.map +1 -1
- package/dist/client/assets/index-D5-jqAV-.js +234 -0
- package/dist/client/assets/index-YyyiO26y.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/git.d.ts +8 -5
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +8 -5
- package/dist/git.js.map +1 -1
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +1 -0
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +293 -47
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +6 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/package.json +2 -2
- package/dist/client/assets/index-B6arNdVE.css +0 -1
- 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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
1093
|
-
const
|
|
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")
|
|
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")
|
|
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")
|
|
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
|
-
|
|
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
|
|
1945
|
+
// Get git state from cloned repo
|
|
1808
1946
|
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
1809
|
-
|
|
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:
|
|
1963
|
+
message: "Repository cloned",
|
|
1834
1964
|
ts: ts(),
|
|
1835
1965
|
});
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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");
|