@electric-agent/studio 1.0.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/daytona.d.ts +35 -0
- package/dist/bridge/daytona.d.ts.map +1 -0
- package/dist/bridge/daytona.js +146 -0
- package/dist/bridge/daytona.js.map +1 -0
- package/dist/bridge/docker-stdio.d.ts +30 -0
- package/dist/bridge/docker-stdio.d.ts.map +1 -0
- package/dist/bridge/docker-stdio.js +141 -0
- package/dist/bridge/docker-stdio.js.map +1 -0
- package/dist/bridge/hosted.d.ts +28 -0
- package/dist/bridge/hosted.d.ts.map +1 -0
- package/dist/bridge/hosted.js +113 -0
- package/dist/bridge/hosted.js.map +1 -0
- package/dist/bridge/index.d.ts +6 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +5 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/bridge/sprites.d.ts +32 -0
- package/dist/bridge/sprites.d.ts.map +1 -0
- package/dist/bridge/sprites.js +138 -0
- package/dist/bridge/sprites.js.map +1 -0
- package/dist/bridge/types.d.ts +97 -0
- package/dist/bridge/types.d.ts.map +1 -0
- package/dist/bridge/types.js +2 -0
- package/dist/bridge/types.js.map +1 -0
- package/dist/client/assets/OpenSauceOne-Bold-BeiFYFR5.woff2 +0 -0
- package/dist/client/assets/OpenSauceOne-ExtraBold-DO6BqiNe.woff2 +0 -0
- package/dist/client/assets/OpenSauceOne-Light-NEdTeQp-.woff2 +0 -0
- package/dist/client/assets/OpenSauceOne-Medium-Cu5cjAHY.woff2 +0 -0
- package/dist/client/assets/OpenSauceOne-Regular-BivIUdzJ.woff2 +0 -0
- package/dist/client/assets/SourceCodePro-Regular-CoIbWt_c.woff2 +0 -0
- package/dist/client/assets/index-CK__1-6e.css +1 -0
- package/dist/client/assets/index-DKL-jl7t.js +241 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/img/brand/favicon.png +0 -0
- package/dist/client/img/brand/favicon.svg +4 -0
- package/dist/client/img/brand/icon.svg +4 -0
- package/dist/client/img/brand/logo.inverse.svg +13 -0
- package/dist/client/img/brand/logo.svg +13 -0
- package/dist/client/index.html +16 -0
- package/dist/electric-api.d.ts +14 -0
- package/dist/electric-api.d.ts.map +1 -0
- package/dist/electric-api.js +70 -0
- package/dist/electric-api.js.map +1 -0
- package/dist/gate.d.ts +28 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +72 -0
- package/dist/gate.js.map +1 -0
- package/dist/git.d.ts +30 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +115 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/project-utils.d.ts +9 -0
- package/dist/project-utils.d.ts.map +1 -0
- package/dist/project-utils.js +17 -0
- package/dist/project-utils.js.map +1 -0
- package/dist/sandbox/daytona-push.d.ts +3 -0
- package/dist/sandbox/daytona-push.d.ts.map +1 -0
- package/dist/sandbox/daytona-push.js +56 -0
- package/dist/sandbox/daytona-push.js.map +1 -0
- package/dist/sandbox/daytona-registry.d.ts +41 -0
- package/dist/sandbox/daytona-registry.d.ts.map +1 -0
- package/dist/sandbox/daytona-registry.js +127 -0
- package/dist/sandbox/daytona-registry.js.map +1 -0
- package/dist/sandbox/daytona.d.ts +41 -0
- package/dist/sandbox/daytona.d.ts.map +1 -0
- package/dist/sandbox/daytona.js +282 -0
- package/dist/sandbox/daytona.js.map +1 -0
- package/dist/sandbox/docker.d.ts +29 -0
- package/dist/sandbox/docker.d.ts.map +1 -0
- package/dist/sandbox/docker.js +465 -0
- package/dist/sandbox/docker.js.map +1 -0
- package/dist/sandbox/index.d.ts +5 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/index.js +4 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/sprites-bootstrap.d.ts +26 -0
- package/dist/sandbox/sprites-bootstrap.d.ts.map +1 -0
- package/dist/sandbox/sprites-bootstrap.js +127 -0
- package/dist/sandbox/sprites-bootstrap.js.map +1 -0
- package/dist/sandbox/sprites.d.ts +55 -0
- package/dist/sandbox/sprites.d.ts.map +1 -0
- package/dist/sandbox/sprites.js +323 -0
- package/dist/sandbox/sprites.js.map +1 -0
- package/dist/sandbox/types.d.ts +73 -0
- package/dist/sandbox/types.d.ts.map +1 -0
- package/dist/sandbox/types.js +5 -0
- package/dist/sandbox/types.js.map +1 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1266 -0
- package/dist/server.js.map +1 -0
- package/dist/sessions.d.ts +46 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +66 -0
- package/dist/sessions.js.map +1 -0
- package/dist/streams.d.ts +34 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +42 -0
- package/dist/streams.js.map +1 -0
- package/package.json +84 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DurableStream } from "@durable-streams/client";
|
|
6
|
+
import { ts } from "@electric-agent/protocol";
|
|
7
|
+
import { serve } from "@hono/node-server";
|
|
8
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
|
+
import { Hono } from "hono";
|
|
10
|
+
import { cors } from "hono/cors";
|
|
11
|
+
import { DaytonaSessionBridge } from "./bridge/daytona.js";
|
|
12
|
+
import { DockerStdioBridge } from "./bridge/docker-stdio.js";
|
|
13
|
+
import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
14
|
+
import { SpritesStdioBridge } from "./bridge/sprites.js";
|
|
15
|
+
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
16
|
+
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
17
|
+
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
18
|
+
import { resolveProjectDir } from "./project-utils.js";
|
|
19
|
+
import { addSession, cleanupStaleSessions, deleteSession, getSession, readSessionIndex, updateSessionInfo, } from "./sessions.js";
|
|
20
|
+
import { getStreamConnectionInfo, getStreamEnvVars, } from "./streams.js";
|
|
21
|
+
/** Active session bridges — one per running session */
|
|
22
|
+
const bridges = new Map();
|
|
23
|
+
function parseRepoNameFromUrl(url) {
|
|
24
|
+
if (!url)
|
|
25
|
+
return null;
|
|
26
|
+
const match = url.match(/github\.com[/:](.+?)(?:\.git)?$/);
|
|
27
|
+
return match?.[1] ?? null;
|
|
28
|
+
}
|
|
29
|
+
/** Get stream connection info for a session (URL + auth headers) */
|
|
30
|
+
function sessionStream(config, sessionId) {
|
|
31
|
+
return getStreamConnectionInfo(sessionId, config.streamConfig);
|
|
32
|
+
}
|
|
33
|
+
/** Create or retrieve the SessionBridge for a session */
|
|
34
|
+
function getOrCreateBridge(config, sessionId) {
|
|
35
|
+
let bridge = bridges.get(sessionId);
|
|
36
|
+
if (!bridge) {
|
|
37
|
+
const conn = sessionStream(config, sessionId);
|
|
38
|
+
bridge = new HostedStreamBridge(sessionId, conn);
|
|
39
|
+
bridges.set(sessionId, bridge);
|
|
40
|
+
}
|
|
41
|
+
return bridge;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a stdio-based bridge for a session after the sandbox has been created.
|
|
45
|
+
* Replaces any existing hosted bridge for the session.
|
|
46
|
+
*/
|
|
47
|
+
function createStdioBridge(config, sessionId) {
|
|
48
|
+
const conn = sessionStream(config, sessionId);
|
|
49
|
+
let bridge;
|
|
50
|
+
if (config.sandbox.runtime === "daytona") {
|
|
51
|
+
const daytonaProvider = config.sandbox;
|
|
52
|
+
const sandbox = daytonaProvider.getSandboxObject(sessionId);
|
|
53
|
+
if (!sandbox) {
|
|
54
|
+
throw new Error(`No Daytona sandbox object for session ${sessionId}`);
|
|
55
|
+
}
|
|
56
|
+
bridge = new DaytonaSessionBridge(sessionId, conn, sandbox);
|
|
57
|
+
}
|
|
58
|
+
else if (config.sandbox.runtime === "sprites") {
|
|
59
|
+
const spritesProvider = config.sandbox;
|
|
60
|
+
const sprite = spritesProvider.getSpriteObject(sessionId);
|
|
61
|
+
if (!sprite) {
|
|
62
|
+
throw new Error(`No Sprites sandbox object for session ${sessionId}`);
|
|
63
|
+
}
|
|
64
|
+
bridge = new SpritesStdioBridge(sessionId, conn, sprite);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const dockerProvider = config.sandbox;
|
|
68
|
+
const containerId = dockerProvider.getContainerId(sessionId);
|
|
69
|
+
if (!containerId) {
|
|
70
|
+
throw new Error(`No Docker container found for session ${sessionId}`);
|
|
71
|
+
}
|
|
72
|
+
bridge = new DockerStdioBridge(sessionId, conn, containerId);
|
|
73
|
+
}
|
|
74
|
+
// Replace any existing bridge
|
|
75
|
+
closeBridge(sessionId);
|
|
76
|
+
bridges.set(sessionId, bridge);
|
|
77
|
+
return bridge;
|
|
78
|
+
}
|
|
79
|
+
/** Close and remove a bridge */
|
|
80
|
+
function closeBridge(sessionId) {
|
|
81
|
+
const bridge = bridges.get(sessionId);
|
|
82
|
+
if (bridge) {
|
|
83
|
+
bridge.close();
|
|
84
|
+
bridges.delete(sessionId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Detect git operations from natural language prompts.
|
|
89
|
+
* Returns structured gitOp fields if matched, null otherwise.
|
|
90
|
+
*/
|
|
91
|
+
function detectGitOp(request) {
|
|
92
|
+
const lower = request.toLowerCase().trim();
|
|
93
|
+
// Commit: "commit", "commit the code", "commit changes", "commit with message ..."
|
|
94
|
+
if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
|
|
95
|
+
// Extract commit message after "commit" keyword, or after "message:" / "msg:"
|
|
96
|
+
const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
|
|
97
|
+
const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
|
|
98
|
+
return { gitOp: "commit", gitMessage: message || undefined };
|
|
99
|
+
}
|
|
100
|
+
// Push: "push", "push to github", "push to remote", "git push"
|
|
101
|
+
if (/^(git\s+)?push\b/.test(lower)) {
|
|
102
|
+
return { gitOp: "push" };
|
|
103
|
+
}
|
|
104
|
+
// Create PR: "create pr", "open pr", "make pr", "create pull request"
|
|
105
|
+
if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
|
|
106
|
+
// Try to extract title after the PR keyword
|
|
107
|
+
const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
|
|
108
|
+
return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
114
|
+
*
|
|
115
|
+
* After Phase 1 renames, the mapping is nearly 1:1. Claude Code passes
|
|
116
|
+
* hook data on stdin as JSON with a `hook_event_name` field.
|
|
117
|
+
*
|
|
118
|
+
* Returns null for unknown hook types (caller should silently skip).
|
|
119
|
+
*/
|
|
120
|
+
function mapHookToEngineEvent(body) {
|
|
121
|
+
const hookName = body.hook_event_name;
|
|
122
|
+
const now = ts();
|
|
123
|
+
switch (hookName) {
|
|
124
|
+
case "SessionStart":
|
|
125
|
+
return {
|
|
126
|
+
type: "session_start",
|
|
127
|
+
session_id: body.session_id || "",
|
|
128
|
+
cwd: body.cwd,
|
|
129
|
+
ts: now,
|
|
130
|
+
};
|
|
131
|
+
case "PreToolUse": {
|
|
132
|
+
const toolName = body.tool_name || "unknown";
|
|
133
|
+
const toolUseId = body.tool_use_id || `hook_${Date.now()}`;
|
|
134
|
+
const toolInput = body.tool_input || {};
|
|
135
|
+
if (toolName === "TodoWrite") {
|
|
136
|
+
return {
|
|
137
|
+
type: "todo_write",
|
|
138
|
+
tool_use_id: toolUseId,
|
|
139
|
+
todos: toolInput.todos || [],
|
|
140
|
+
ts: now,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (toolName === "AskUserQuestion") {
|
|
144
|
+
const questions = toolInput.questions;
|
|
145
|
+
const firstQuestion = questions?.[0];
|
|
146
|
+
return {
|
|
147
|
+
type: "ask_user_question",
|
|
148
|
+
tool_use_id: toolUseId,
|
|
149
|
+
question: firstQuestion?.question || toolInput.question || "",
|
|
150
|
+
options: firstQuestion?.options,
|
|
151
|
+
ts: now,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
type: "pre_tool_use",
|
|
156
|
+
tool_name: toolName,
|
|
157
|
+
tool_use_id: toolUseId,
|
|
158
|
+
tool_input: toolInput,
|
|
159
|
+
ts: now,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
case "PostToolUse":
|
|
163
|
+
return {
|
|
164
|
+
type: "post_tool_use",
|
|
165
|
+
tool_use_id: body.tool_use_id || "",
|
|
166
|
+
tool_name: body.tool_name,
|
|
167
|
+
tool_response: body.tool_response || "",
|
|
168
|
+
ts: now,
|
|
169
|
+
};
|
|
170
|
+
case "PostToolUseFailure":
|
|
171
|
+
return {
|
|
172
|
+
type: "post_tool_use_failure",
|
|
173
|
+
tool_use_id: body.tool_use_id || "",
|
|
174
|
+
tool_name: body.tool_name || "unknown",
|
|
175
|
+
error: body.error || "Unknown error",
|
|
176
|
+
ts: now,
|
|
177
|
+
};
|
|
178
|
+
case "Stop":
|
|
179
|
+
return {
|
|
180
|
+
type: "assistant_message",
|
|
181
|
+
text: body.last_assistant_message || "",
|
|
182
|
+
ts: now,
|
|
183
|
+
};
|
|
184
|
+
case "SessionEnd":
|
|
185
|
+
return {
|
|
186
|
+
type: "session_end",
|
|
187
|
+
success: true,
|
|
188
|
+
ts: now,
|
|
189
|
+
};
|
|
190
|
+
case "UserPromptSubmit":
|
|
191
|
+
return {
|
|
192
|
+
type: "user_prompt",
|
|
193
|
+
message: body.prompt || "",
|
|
194
|
+
ts: now,
|
|
195
|
+
};
|
|
196
|
+
case "SubagentStart":
|
|
197
|
+
case "SubagentStop":
|
|
198
|
+
return {
|
|
199
|
+
type: "log",
|
|
200
|
+
level: "task",
|
|
201
|
+
message: `${hookName}: ${body.agent_type || "agent"}`,
|
|
202
|
+
ts: now,
|
|
203
|
+
};
|
|
204
|
+
default:
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export function createApp(config) {
|
|
209
|
+
const app = new Hono();
|
|
210
|
+
// CORS for local development
|
|
211
|
+
app.use("*", cors({ origin: "*" }));
|
|
212
|
+
// --- API Routes ---
|
|
213
|
+
// Health check
|
|
214
|
+
app.get("/api/health", (c) => {
|
|
215
|
+
const checks = {};
|
|
216
|
+
let healthy = true;
|
|
217
|
+
// Stream config
|
|
218
|
+
if (config.streamConfig.url && config.streamConfig.serviceId && config.streamConfig.secret) {
|
|
219
|
+
checks.streams = "ok";
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
checks.streams = "error";
|
|
223
|
+
healthy = false;
|
|
224
|
+
}
|
|
225
|
+
// Sandbox runtime
|
|
226
|
+
checks.sandbox = config.sandbox.runtime;
|
|
227
|
+
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
228
|
+
});
|
|
229
|
+
// Provision Electric Cloud resources via the Claim API
|
|
230
|
+
app.post("/api/provision-electric", async (c) => {
|
|
231
|
+
try {
|
|
232
|
+
const result = await provisionElectricResources();
|
|
233
|
+
return c.json({
|
|
234
|
+
sourceId: result.source_id,
|
|
235
|
+
secret: result.secret,
|
|
236
|
+
databaseUrl: result.DATABASE_URL,
|
|
237
|
+
electricUrl: DEFAULT_ELECTRIC_URL,
|
|
238
|
+
claimId: result.claimId,
|
|
239
|
+
claimUrl: getClaimUrl(result.claimId),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
const message = err instanceof Error ? err.message : "Provisioning failed";
|
|
244
|
+
console.error("[provision-electric] Error:", message);
|
|
245
|
+
return c.json({ error: message }, 500);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
// List all sessions (lazily clean up stale ones)
|
|
249
|
+
app.get("/api/sessions", (c) => {
|
|
250
|
+
cleanupStaleSessions(config.dataDir);
|
|
251
|
+
const index = readSessionIndex(config.dataDir);
|
|
252
|
+
return c.json(index);
|
|
253
|
+
});
|
|
254
|
+
// Get single session
|
|
255
|
+
app.get("/api/sessions/:id", (c) => {
|
|
256
|
+
const session = getSession(config.dataDir, c.req.param("id"));
|
|
257
|
+
if (!session)
|
|
258
|
+
return c.json({ error: "Session not found" }, 404);
|
|
259
|
+
return c.json(session);
|
|
260
|
+
});
|
|
261
|
+
// --- Local Claude Code session endpoints ---
|
|
262
|
+
// Create a local session (no sandbox, just a stream + session index entry).
|
|
263
|
+
// Used for the hook-to-stream bridge: Claude Code running locally forwards
|
|
264
|
+
// hook events to the web UI via POST /api/sessions/:id/hook-event.
|
|
265
|
+
app.post("/api/sessions/local", async (c) => {
|
|
266
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
267
|
+
const sessionId = crypto.randomUUID();
|
|
268
|
+
// Create the durable stream
|
|
269
|
+
const conn = sessionStream(config, sessionId);
|
|
270
|
+
try {
|
|
271
|
+
await DurableStream.create({
|
|
272
|
+
url: conn.url,
|
|
273
|
+
headers: conn.headers,
|
|
274
|
+
contentType: "application/json",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
console.error(`[local-session] Failed to create durable stream:`, err);
|
|
279
|
+
return c.json({ error: "Failed to create event stream" }, 500);
|
|
280
|
+
}
|
|
281
|
+
// Record session (no sandbox, no appPort)
|
|
282
|
+
const session = {
|
|
283
|
+
id: sessionId,
|
|
284
|
+
projectName: "local-session",
|
|
285
|
+
sandboxProjectDir: "",
|
|
286
|
+
description: body.description || "Local Claude Code session",
|
|
287
|
+
createdAt: new Date().toISOString(),
|
|
288
|
+
lastActiveAt: new Date().toISOString(),
|
|
289
|
+
status: "running",
|
|
290
|
+
};
|
|
291
|
+
addSession(config.dataDir, session);
|
|
292
|
+
// Pre-create a bridge so hook-event can emit to it immediately
|
|
293
|
+
getOrCreateBridge(config, sessionId);
|
|
294
|
+
console.log(`[local-session] Created session: ${sessionId}`);
|
|
295
|
+
return c.json({ sessionId }, 201);
|
|
296
|
+
});
|
|
297
|
+
// Auto-register a local session on first hook event (SessionStart).
|
|
298
|
+
// Eliminates the manual `curl POST /api/sessions/local` step.
|
|
299
|
+
app.post("/api/sessions/auto", async (c) => {
|
|
300
|
+
const body = (await c.req.json());
|
|
301
|
+
const hookName = body.hook_event_name;
|
|
302
|
+
if (hookName !== "SessionStart") {
|
|
303
|
+
return c.json({ error: "Only SessionStart events can auto-register a session" }, 400);
|
|
304
|
+
}
|
|
305
|
+
const sessionId = crypto.randomUUID();
|
|
306
|
+
// Create the durable stream
|
|
307
|
+
const conn = sessionStream(config, sessionId);
|
|
308
|
+
try {
|
|
309
|
+
await DurableStream.create({
|
|
310
|
+
url: conn.url,
|
|
311
|
+
headers: conn.headers,
|
|
312
|
+
contentType: "application/json",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
console.error(`[auto-session] Failed to create durable stream:`, err);
|
|
317
|
+
return c.json({ error: "Failed to create event stream" }, 500);
|
|
318
|
+
}
|
|
319
|
+
// Derive project name from cwd
|
|
320
|
+
const cwd = body.cwd;
|
|
321
|
+
const projectName = cwd ? path.basename(cwd) : "local-session";
|
|
322
|
+
const claudeSessionId = body.session_id;
|
|
323
|
+
const session = {
|
|
324
|
+
id: sessionId,
|
|
325
|
+
projectName,
|
|
326
|
+
sandboxProjectDir: cwd || "",
|
|
327
|
+
description: `Local session: ${projectName}`,
|
|
328
|
+
createdAt: new Date().toISOString(),
|
|
329
|
+
lastActiveAt: new Date().toISOString(),
|
|
330
|
+
status: "running",
|
|
331
|
+
claudeSessionId: claudeSessionId || undefined,
|
|
332
|
+
};
|
|
333
|
+
addSession(config.dataDir, session);
|
|
334
|
+
// Create bridge and emit the SessionStart event
|
|
335
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
336
|
+
const hookEvent = mapHookToEngineEvent(body);
|
|
337
|
+
if (hookEvent) {
|
|
338
|
+
await bridge.emit(hookEvent);
|
|
339
|
+
}
|
|
340
|
+
console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
|
|
341
|
+
return c.json({ sessionId }, 201);
|
|
342
|
+
});
|
|
343
|
+
// Receive a hook event from Claude Code (via forward.sh) and write it
|
|
344
|
+
// to the session's durable stream as an EngineEvent.
|
|
345
|
+
// For AskUserQuestion, this blocks until the user answers in the web UI.
|
|
346
|
+
app.post("/api/sessions/:id/hook-event", async (c) => {
|
|
347
|
+
const sessionId = c.req.param("id");
|
|
348
|
+
const body = (await c.req.json());
|
|
349
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
350
|
+
// Map Claude Code hook JSON → EngineEvent
|
|
351
|
+
const hookEvent = mapHookToEngineEvent(body);
|
|
352
|
+
if (!hookEvent) {
|
|
353
|
+
return c.json({ ok: true }); // Unknown hook type — silently skip
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await bridge.emit(hookEvent);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
console.error(`[hook-event] Failed to emit:`, err);
|
|
360
|
+
return c.json({ error: "Failed to write event" }, 500);
|
|
361
|
+
}
|
|
362
|
+
// Bump lastActiveAt on every hook event
|
|
363
|
+
updateSessionInfo(config.dataDir, sessionId, {});
|
|
364
|
+
// SessionEnd: mark session complete and close the bridge
|
|
365
|
+
if (hookEvent.type === "session_end") {
|
|
366
|
+
updateSessionInfo(config.dataDir, sessionId, { status: "complete" });
|
|
367
|
+
closeBridge(sessionId);
|
|
368
|
+
return c.json({ ok: true });
|
|
369
|
+
}
|
|
370
|
+
// AskUserQuestion: block until the user answers via the web UI
|
|
371
|
+
if (hookEvent.type === "ask_user_question") {
|
|
372
|
+
const toolUseId = hookEvent.tool_use_id;
|
|
373
|
+
console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
374
|
+
try {
|
|
375
|
+
const gateTimeout = 5 * 60 * 1000; // 5 minutes
|
|
376
|
+
const answer = await Promise.race([
|
|
377
|
+
createGate(sessionId, `ask_user_question:${toolUseId}`),
|
|
378
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
379
|
+
]);
|
|
380
|
+
console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
|
|
381
|
+
return c.json({
|
|
382
|
+
hookSpecificOutput: {
|
|
383
|
+
hookEventName: "PreToolUse",
|
|
384
|
+
permissionDecision: "allow",
|
|
385
|
+
updatedInput: {
|
|
386
|
+
questions: body.tool_input?.questions,
|
|
387
|
+
answers: { [hookEvent.question]: answer.answer },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
console.error(`[hook-event] ask_user_question gate error:`, err);
|
|
394
|
+
return c.json({ ok: true }); // Don't block Claude Code on timeout
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return c.json({ ok: true });
|
|
398
|
+
});
|
|
399
|
+
// Start new project
|
|
400
|
+
app.post("/api/sessions", async (c) => {
|
|
401
|
+
const body = (await c.req.json());
|
|
402
|
+
if (!body.description) {
|
|
403
|
+
return c.json({ error: "description is required" }, 400);
|
|
404
|
+
}
|
|
405
|
+
const sessionId = crypto.randomUUID();
|
|
406
|
+
const inferredName = body.name ||
|
|
407
|
+
(config.inferProjectName
|
|
408
|
+
? await config.inferProjectName(body.description)
|
|
409
|
+
: body.description
|
|
410
|
+
.slice(0, 40)
|
|
411
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
412
|
+
.replace(/^-|-$/g, "")
|
|
413
|
+
.toLowerCase());
|
|
414
|
+
const baseDir = body.baseDir || process.cwd();
|
|
415
|
+
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
416
|
+
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
417
|
+
// Create the durable stream
|
|
418
|
+
const conn = sessionStream(config, sessionId);
|
|
419
|
+
try {
|
|
420
|
+
await DurableStream.create({
|
|
421
|
+
url: conn.url,
|
|
422
|
+
headers: conn.headers,
|
|
423
|
+
contentType: "application/json",
|
|
424
|
+
});
|
|
425
|
+
console.log(`[session] Durable stream created: ${conn.url}`);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
console.error(`[session] Failed to create durable stream:`, err);
|
|
429
|
+
return c.json({ error: "Failed to create event stream" }, 500);
|
|
430
|
+
}
|
|
431
|
+
// Create the initial session bridge (may be replaced with stdio bridge after sandbox creation)
|
|
432
|
+
let bridge = getOrCreateBridge(config, sessionId);
|
|
433
|
+
// Record session
|
|
434
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
435
|
+
const session = {
|
|
436
|
+
id: sessionId,
|
|
437
|
+
projectName,
|
|
438
|
+
sandboxProjectDir,
|
|
439
|
+
description: body.description,
|
|
440
|
+
createdAt: new Date().toISOString(),
|
|
441
|
+
lastActiveAt: new Date().toISOString(),
|
|
442
|
+
status: "running",
|
|
443
|
+
};
|
|
444
|
+
addSession(config.dataDir, session);
|
|
445
|
+
// Write user prompt to the stream so it shows in the UI
|
|
446
|
+
await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
447
|
+
// Gather GitHub accounts for the merged setup gate
|
|
448
|
+
let ghAccounts = [];
|
|
449
|
+
if (isGhAuthenticated(body.ghToken)) {
|
|
450
|
+
try {
|
|
451
|
+
ghAccounts = ghListAccounts(body.ghToken);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// gh not available — no repo setup
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Emit combined infra + repo setup gate
|
|
458
|
+
await bridge.emit({
|
|
459
|
+
type: "infra_config_prompt",
|
|
460
|
+
projectName,
|
|
461
|
+
ghAccounts,
|
|
462
|
+
runtime: config.sandbox.runtime,
|
|
463
|
+
ts: ts(),
|
|
464
|
+
});
|
|
465
|
+
// Launch async flow: wait for setup gate → create sandbox → start agent
|
|
466
|
+
const asyncFlow = async () => {
|
|
467
|
+
// 1. Wait for combined infra + repo config
|
|
468
|
+
let infra;
|
|
469
|
+
let repoConfig = null;
|
|
470
|
+
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
471
|
+
let claimId;
|
|
472
|
+
try {
|
|
473
|
+
const gateValue = await createGate(sessionId, "infra_config");
|
|
474
|
+
console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
475
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
476
|
+
// Normalize claim → cloud for the sandbox layer (same env vars)
|
|
477
|
+
infra = {
|
|
478
|
+
mode: "cloud",
|
|
479
|
+
databaseUrl: gateValue.databaseUrl,
|
|
480
|
+
electricUrl: gateValue.electricUrl,
|
|
481
|
+
sourceId: gateValue.sourceId,
|
|
482
|
+
secret: gateValue.secret,
|
|
483
|
+
};
|
|
484
|
+
if (gateValue.mode === "claim") {
|
|
485
|
+
claimId = gateValue.claimId;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
infra = { mode: "local" };
|
|
490
|
+
}
|
|
491
|
+
// Extract repo config if provided
|
|
492
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
493
|
+
repoConfig = {
|
|
494
|
+
account: gateValue.repoAccount,
|
|
495
|
+
repoName: gateValue.repoName,
|
|
496
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
497
|
+
};
|
|
498
|
+
updateSessionInfo(config.dataDir, sessionId, {
|
|
499
|
+
git: {
|
|
500
|
+
branch: "main",
|
|
501
|
+
remoteUrl: null,
|
|
502
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
503
|
+
repoVisibility: repoConfig.visibility,
|
|
504
|
+
lastCommitHash: null,
|
|
505
|
+
lastCommitMessage: null,
|
|
506
|
+
lastCheckpointAt: null,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
513
|
+
infra = { mode: "local" };
|
|
514
|
+
}
|
|
515
|
+
// 2. Create sandbox — emit progress events so the UI shows feedback
|
|
516
|
+
await bridge.emit({
|
|
517
|
+
type: "log",
|
|
518
|
+
level: "build",
|
|
519
|
+
message: `Creating ${config.sandbox.runtime} sandbox...`,
|
|
520
|
+
ts: ts(),
|
|
521
|
+
});
|
|
522
|
+
// Only pass stream env vars when using hosted stream bridge (not stdio)
|
|
523
|
+
const streamEnv = config.bridgeMode === "stdio" ? undefined : getStreamEnvVars(sessionId, config.streamConfig);
|
|
524
|
+
console.log(`[session:${sessionId}] Creating sandbox: runtime=${config.sandbox.runtime} project=${projectName} bridgeMode=${config.bridgeMode}`);
|
|
525
|
+
const handle = await config.sandbox.create(sessionId, {
|
|
526
|
+
projectName,
|
|
527
|
+
infra,
|
|
528
|
+
streamEnv,
|
|
529
|
+
deferAgentStart: config.bridgeMode === "stdio",
|
|
530
|
+
apiKey: body.apiKey,
|
|
531
|
+
oauthToken: body.oauthToken,
|
|
532
|
+
ghToken: body.ghToken,
|
|
533
|
+
});
|
|
534
|
+
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
535
|
+
await bridge.emit({
|
|
536
|
+
type: "log",
|
|
537
|
+
level: "done",
|
|
538
|
+
message: `Sandbox ready (${config.sandbox.runtime})`,
|
|
539
|
+
ts: ts(),
|
|
540
|
+
});
|
|
541
|
+
updateSessionInfo(config.dataDir, sessionId, {
|
|
542
|
+
appPort: handle.port,
|
|
543
|
+
sandboxProjectDir: handle.projectDir,
|
|
544
|
+
previewUrl: handle.previewUrl,
|
|
545
|
+
...(claimId ? { claimId } : {}),
|
|
546
|
+
});
|
|
547
|
+
// 3. If stdio bridge mode, create the stdio bridge now that the sandbox exists.
|
|
548
|
+
// If stream bridge mode with Sprites, launch the agent process in the sprite
|
|
549
|
+
// (it connects directly to the hosted Durable Stream via DS_URL env vars).
|
|
550
|
+
if (config.bridgeMode === "stdio") {
|
|
551
|
+
console.log(`[session:${sessionId}] Creating stdio bridge...`);
|
|
552
|
+
bridge = createStdioBridge(config, sessionId);
|
|
553
|
+
}
|
|
554
|
+
else if (config.sandbox.runtime === "sprites") {
|
|
555
|
+
await bridge.emit({
|
|
556
|
+
type: "log",
|
|
557
|
+
level: "build",
|
|
558
|
+
message: "Starting agent in sandbox...",
|
|
559
|
+
ts: ts(),
|
|
560
|
+
});
|
|
561
|
+
console.log(`[session:${sessionId}] Starting agent process in sprite...`);
|
|
562
|
+
try {
|
|
563
|
+
const spritesProvider = config.sandbox;
|
|
564
|
+
await spritesProvider.startAgent(handle);
|
|
565
|
+
// Give the agent time to start and connect to the stream
|
|
566
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
567
|
+
console.log(`[session:${sessionId}] Agent process launched in sprite`);
|
|
568
|
+
await bridge.emit({
|
|
569
|
+
type: "log",
|
|
570
|
+
level: "done",
|
|
571
|
+
message: "Agent started",
|
|
572
|
+
ts: ts(),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
console.error(`[session:${sessionId}] Failed to start agent in sprite:`, err);
|
|
577
|
+
await bridge.emit({
|
|
578
|
+
type: "log",
|
|
579
|
+
level: "error",
|
|
580
|
+
message: `Failed to start agent: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
581
|
+
ts: ts(),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// 4. Log repo config
|
|
586
|
+
if (repoConfig) {
|
|
587
|
+
await bridge.emit({
|
|
588
|
+
type: "log",
|
|
589
|
+
level: "done",
|
|
590
|
+
message: `GitHub repo: ${repoConfig.account}/${repoConfig.repoName} (${repoConfig.visibility}) — will be created after scaffolding`,
|
|
591
|
+
ts: ts(),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
// 5. Start listening for agent events via the bridge
|
|
595
|
+
bridge.onComplete(async (success) => {
|
|
596
|
+
const updates = {
|
|
597
|
+
status: success ? "complete" : "error",
|
|
598
|
+
};
|
|
599
|
+
try {
|
|
600
|
+
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
601
|
+
if (gs.initialized) {
|
|
602
|
+
const existing = getSession(config.dataDir, sessionId);
|
|
603
|
+
updates.git = {
|
|
604
|
+
branch: gs.branch ?? "main",
|
|
605
|
+
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
606
|
+
repoName: existing?.git?.repoName ?? null,
|
|
607
|
+
repoVisibility: existing?.git?.repoVisibility,
|
|
608
|
+
lastCommitHash: gs.lastCommitHash ?? null,
|
|
609
|
+
lastCommitMessage: gs.lastCommitMessage ?? null,
|
|
610
|
+
lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// Container may already be stopped
|
|
616
|
+
}
|
|
617
|
+
updateSessionInfo(config.dataDir, sessionId, updates);
|
|
618
|
+
});
|
|
619
|
+
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
620
|
+
await bridge.start();
|
|
621
|
+
console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
|
|
622
|
+
// 5. Send the new command via the bridge
|
|
623
|
+
const newCmd = {
|
|
624
|
+
command: "new",
|
|
625
|
+
description: body.description,
|
|
626
|
+
projectName,
|
|
627
|
+
baseDir: "/home/agent/workspace",
|
|
628
|
+
};
|
|
629
|
+
if (repoConfig) {
|
|
630
|
+
newCmd.gitRepoName = `${repoConfig.account}/${repoConfig.repoName}`;
|
|
631
|
+
newCmd.gitRepoVisibility = repoConfig.visibility;
|
|
632
|
+
}
|
|
633
|
+
await bridge.sendCommand(newCmd);
|
|
634
|
+
console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
|
|
635
|
+
};
|
|
636
|
+
asyncFlow().catch((err) => {
|
|
637
|
+
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
638
|
+
updateSessionInfo(config.dataDir, sessionId, { status: "error" });
|
|
639
|
+
});
|
|
640
|
+
return c.json({ sessionId }, 201);
|
|
641
|
+
});
|
|
642
|
+
// Send iteration request
|
|
643
|
+
app.post("/api/sessions/:id/iterate", async (c) => {
|
|
644
|
+
const sessionId = c.req.param("id");
|
|
645
|
+
const session = getSession(config.dataDir, sessionId);
|
|
646
|
+
if (!session)
|
|
647
|
+
return c.json({ error: "Session not found" }, 404);
|
|
648
|
+
const body = (await c.req.json());
|
|
649
|
+
if (!body.request) {
|
|
650
|
+
return c.json({ error: "request is required" }, 400);
|
|
651
|
+
}
|
|
652
|
+
// Intercept operational commands (start/stop/restart the app/server)
|
|
653
|
+
const normalised = body.request
|
|
654
|
+
.toLowerCase()
|
|
655
|
+
.replace(/[^a-z ]/g, "")
|
|
656
|
+
.trim();
|
|
657
|
+
const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
|
|
658
|
+
const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
|
|
659
|
+
const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
|
|
660
|
+
const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
|
|
661
|
+
if (isStartCmd || isStopCmd || isRestartCmd) {
|
|
662
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
663
|
+
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
664
|
+
try {
|
|
665
|
+
const handle = config.sandbox.get(sessionId);
|
|
666
|
+
if (isStopCmd) {
|
|
667
|
+
if (handle && config.sandbox.isAlive(handle))
|
|
668
|
+
await config.sandbox.stopApp(handle);
|
|
669
|
+
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
673
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
674
|
+
}
|
|
675
|
+
if (isRestartCmd)
|
|
676
|
+
await config.sandbox.stopApp(handle);
|
|
677
|
+
await config.sandbox.startApp(handle);
|
|
678
|
+
await bridge.emit({
|
|
679
|
+
type: "log",
|
|
680
|
+
level: "done",
|
|
681
|
+
message: "App started",
|
|
682
|
+
ts: ts(),
|
|
683
|
+
});
|
|
684
|
+
await bridge.emit({ type: "app_ready", port: session.appPort, ts: ts() });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
const msg = err instanceof Error ? err.message : "Operation failed";
|
|
689
|
+
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
690
|
+
}
|
|
691
|
+
return c.json({ ok: true });
|
|
692
|
+
}
|
|
693
|
+
// Intercept git commands (commit, push, create PR)
|
|
694
|
+
const gitOp = detectGitOp(body.request);
|
|
695
|
+
if (gitOp) {
|
|
696
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
697
|
+
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
698
|
+
const handle = config.sandbox.get(sessionId);
|
|
699
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
700
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
701
|
+
}
|
|
702
|
+
await bridge.sendCommand({
|
|
703
|
+
command: "git",
|
|
704
|
+
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
705
|
+
...gitOp,
|
|
706
|
+
});
|
|
707
|
+
return c.json({ ok: true });
|
|
708
|
+
}
|
|
709
|
+
const handle = config.sandbox.get(sessionId);
|
|
710
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
711
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
712
|
+
}
|
|
713
|
+
// Write user prompt to the stream
|
|
714
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
715
|
+
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
716
|
+
updateSessionInfo(config.dataDir, sessionId, { status: "running" });
|
|
717
|
+
await bridge.sendCommand({
|
|
718
|
+
command: "iterate",
|
|
719
|
+
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
720
|
+
request: body.request,
|
|
721
|
+
resumeSessionId: session.lastCoderSessionId,
|
|
722
|
+
});
|
|
723
|
+
return c.json({ ok: true });
|
|
724
|
+
});
|
|
725
|
+
// Respond to a gate (approval, clarification, continue, revision)
|
|
726
|
+
app.post("/api/sessions/:id/respond", async (c) => {
|
|
727
|
+
const sessionId = c.req.param("id");
|
|
728
|
+
console.log(`[respond] incoming request for session=${sessionId}`);
|
|
729
|
+
const body = (await c.req.json());
|
|
730
|
+
const gate = body.gate;
|
|
731
|
+
console.log(`[respond] gate=${gate} body=${JSON.stringify(body)}`);
|
|
732
|
+
if (!gate) {
|
|
733
|
+
return c.json({ error: "gate is required" }, 400);
|
|
734
|
+
}
|
|
735
|
+
// Client may pass a human-readable summary of the decision for replay display
|
|
736
|
+
const summary = body._summary || undefined;
|
|
737
|
+
// AskUserQuestion gates: resolve the blocking hook-event and emit gate_resolved
|
|
738
|
+
if (gate === "ask_user_question") {
|
|
739
|
+
const toolUseId = body.toolUseId;
|
|
740
|
+
if (!toolUseId) {
|
|
741
|
+
return c.json({ error: "toolUseId is required for ask_user_question" }, 400);
|
|
742
|
+
}
|
|
743
|
+
const answer = body.answer || "";
|
|
744
|
+
const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
|
|
745
|
+
if (!resolved) {
|
|
746
|
+
return c.json({ error: "No pending ask_user_question gate found" }, 404);
|
|
747
|
+
}
|
|
748
|
+
// Emit gate_resolved for replay
|
|
749
|
+
try {
|
|
750
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
751
|
+
await bridge.emit({ type: "gate_resolved", gate: "ask_user_question", summary, ts: ts() });
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
// Non-critical
|
|
755
|
+
}
|
|
756
|
+
return c.json({ ok: true });
|
|
757
|
+
}
|
|
758
|
+
// Server-side gates are resolved in-process (they run on the server, not inside the container)
|
|
759
|
+
const serverGates = new Set(["infra_config"]);
|
|
760
|
+
// Forward agent gate responses via the bridge
|
|
761
|
+
if (!serverGates.has(gate)) {
|
|
762
|
+
const bridge = bridges.get(sessionId);
|
|
763
|
+
if (!bridge) {
|
|
764
|
+
return c.json({ error: "No active bridge found" }, 404);
|
|
765
|
+
}
|
|
766
|
+
const { gate: _, _summary: _s, ...value } = body;
|
|
767
|
+
await bridge.sendGateResponse(gate, value);
|
|
768
|
+
// Persist gate resolution for replay
|
|
769
|
+
try {
|
|
770
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, ts: ts() });
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// Non-critical
|
|
774
|
+
}
|
|
775
|
+
return c.json({ ok: true });
|
|
776
|
+
}
|
|
777
|
+
// Resolve in-process gate
|
|
778
|
+
let value;
|
|
779
|
+
switch (gate) {
|
|
780
|
+
case "infra_config":
|
|
781
|
+
if (body.mode === "cloud" || body.mode === "claim") {
|
|
782
|
+
value = {
|
|
783
|
+
mode: body.mode,
|
|
784
|
+
databaseUrl: body.databaseUrl,
|
|
785
|
+
electricUrl: body.electricUrl,
|
|
786
|
+
sourceId: body.sourceId,
|
|
787
|
+
secret: body.secret,
|
|
788
|
+
claimId: body.claimId,
|
|
789
|
+
repoAccount: body.repoAccount,
|
|
790
|
+
repoName: body.repoName,
|
|
791
|
+
repoVisibility: body.repoVisibility,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
value = {
|
|
796
|
+
mode: "local",
|
|
797
|
+
repoAccount: body.repoAccount,
|
|
798
|
+
repoName: body.repoName,
|
|
799
|
+
repoVisibility: body.repoVisibility,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
default:
|
|
804
|
+
return c.json({ error: `Unknown gate: ${gate}` }, 400);
|
|
805
|
+
}
|
|
806
|
+
console.log(`[respond] session=${sessionId} gate=${gate} value=${JSON.stringify(value)}`);
|
|
807
|
+
const resolved = resolveGate(sessionId, gate, value);
|
|
808
|
+
if (!resolved) {
|
|
809
|
+
console.log(`[respond] NO pending gate found for ${sessionId}:${gate}`);
|
|
810
|
+
return c.json({ error: "No pending gate found" }, 404);
|
|
811
|
+
}
|
|
812
|
+
// Build structured details for the infra_config gate so the UI can
|
|
813
|
+
// display them on both live sessions and session replay.
|
|
814
|
+
let details;
|
|
815
|
+
if (gate === "infra_config") {
|
|
816
|
+
const modeLabels = {
|
|
817
|
+
claim: "Provisioned (Cloud)",
|
|
818
|
+
local: "Local (Docker)",
|
|
819
|
+
cloud: "Electric Cloud (BYO)",
|
|
820
|
+
};
|
|
821
|
+
details = { Infrastructure: modeLabels[body.mode] ?? String(body.mode) };
|
|
822
|
+
if (body.mode === "cloud" || body.mode === "claim") {
|
|
823
|
+
if (body.databaseUrl)
|
|
824
|
+
details["Connection string"] = body.databaseUrl;
|
|
825
|
+
if (body.sourceId)
|
|
826
|
+
details["Source ID"] = body.sourceId;
|
|
827
|
+
}
|
|
828
|
+
if (body.mode === "claim" && body.claimId) {
|
|
829
|
+
details["Claim link"] = getClaimUrl(body.claimId);
|
|
830
|
+
}
|
|
831
|
+
if (body.repoAccount && body.repoName?.trim()) {
|
|
832
|
+
details.Repository = `${body.repoAccount}/${body.repoName}`;
|
|
833
|
+
details.Visibility = body.repoVisibility || "private";
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Persist gate resolution so replays mark the gate as resolved
|
|
837
|
+
try {
|
|
838
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
839
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, details, ts: ts() });
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// Non-critical
|
|
843
|
+
}
|
|
844
|
+
console.log(`[respond] gate ${gate} resolved successfully`);
|
|
845
|
+
return c.json({ ok: true });
|
|
846
|
+
});
|
|
847
|
+
// Check app status
|
|
848
|
+
app.get("/api/sessions/:id/app-status", async (c) => {
|
|
849
|
+
const sessionId = c.req.param("id");
|
|
850
|
+
const session = getSession(config.dataDir, sessionId);
|
|
851
|
+
if (!session)
|
|
852
|
+
return c.json({ error: "Session not found" }, 404);
|
|
853
|
+
const handle = config.sandbox.get(sessionId);
|
|
854
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
855
|
+
return c.json({ running: false, port: session.appPort, previewUrl: session.previewUrl });
|
|
856
|
+
}
|
|
857
|
+
const running = await config.sandbox.isAppRunning(handle);
|
|
858
|
+
return c.json({
|
|
859
|
+
running,
|
|
860
|
+
port: handle.port ?? session.appPort,
|
|
861
|
+
previewUrl: handle.previewUrl ?? session.previewUrl,
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
// Start the generated app
|
|
865
|
+
app.post("/api/sessions/:id/start-app", async (c) => {
|
|
866
|
+
const sessionId = c.req.param("id");
|
|
867
|
+
const session = getSession(config.dataDir, sessionId);
|
|
868
|
+
if (!session)
|
|
869
|
+
return c.json({ error: "Session not found" }, 404);
|
|
870
|
+
const handle = config.sandbox.get(sessionId);
|
|
871
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
872
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
873
|
+
}
|
|
874
|
+
const ok = await config.sandbox.startApp(handle);
|
|
875
|
+
return c.json({ ok });
|
|
876
|
+
});
|
|
877
|
+
// Stop the generated app
|
|
878
|
+
app.post("/api/sessions/:id/stop-app", async (c) => {
|
|
879
|
+
const sessionId = c.req.param("id");
|
|
880
|
+
const session = getSession(config.dataDir, sessionId);
|
|
881
|
+
if (!session)
|
|
882
|
+
return c.json({ error: "Session not found" }, 404);
|
|
883
|
+
const handle = config.sandbox.get(sessionId);
|
|
884
|
+
if (handle && config.sandbox.isAlive(handle)) {
|
|
885
|
+
await config.sandbox.stopApp(handle);
|
|
886
|
+
}
|
|
887
|
+
return c.json({ success: true });
|
|
888
|
+
});
|
|
889
|
+
// Cancel a running session
|
|
890
|
+
app.post("/api/sessions/:id/cancel", async (c) => {
|
|
891
|
+
const sessionId = c.req.param("id");
|
|
892
|
+
closeBridge(sessionId);
|
|
893
|
+
const handle = config.sandbox.get(sessionId);
|
|
894
|
+
if (handle)
|
|
895
|
+
await config.sandbox.destroy(handle);
|
|
896
|
+
rejectAllGates(sessionId);
|
|
897
|
+
updateSessionInfo(config.dataDir, sessionId, { status: "cancelled" });
|
|
898
|
+
return c.json({ ok: true });
|
|
899
|
+
});
|
|
900
|
+
// Delete a session
|
|
901
|
+
app.delete("/api/sessions/:id", async (c) => {
|
|
902
|
+
const sessionId = c.req.param("id");
|
|
903
|
+
closeBridge(sessionId);
|
|
904
|
+
const handle = config.sandbox.get(sessionId);
|
|
905
|
+
if (handle)
|
|
906
|
+
await config.sandbox.destroy(handle);
|
|
907
|
+
rejectAllGates(sessionId);
|
|
908
|
+
const deleted = deleteSession(config.dataDir, sessionId);
|
|
909
|
+
if (!deleted)
|
|
910
|
+
return c.json({ error: "Session not found" }, 404);
|
|
911
|
+
return c.json({ ok: true });
|
|
912
|
+
});
|
|
913
|
+
// --- Sandbox CRUD Routes ---
|
|
914
|
+
// List all active sandboxes
|
|
915
|
+
app.get("/api/sandboxes", (c) => {
|
|
916
|
+
const sandboxes = config.sandbox.list().map((h) => ({
|
|
917
|
+
sessionId: h.sessionId,
|
|
918
|
+
runtime: h.runtime,
|
|
919
|
+
port: h.port,
|
|
920
|
+
projectDir: h.projectDir,
|
|
921
|
+
previewUrl: h.previewUrl,
|
|
922
|
+
alive: config.sandbox.isAlive(h),
|
|
923
|
+
}));
|
|
924
|
+
return c.json({ sandboxes });
|
|
925
|
+
});
|
|
926
|
+
// Get a specific sandbox's status
|
|
927
|
+
app.get("/api/sandboxes/:sessionId", async (c) => {
|
|
928
|
+
const sessionId = c.req.param("sessionId");
|
|
929
|
+
const handle = config.sandbox.get(sessionId);
|
|
930
|
+
if (!handle)
|
|
931
|
+
return c.json({ error: "Sandbox not found" }, 404);
|
|
932
|
+
const alive = config.sandbox.isAlive(handle);
|
|
933
|
+
const appRunning = alive ? await config.sandbox.isAppRunning(handle) : false;
|
|
934
|
+
return c.json({
|
|
935
|
+
sessionId: handle.sessionId,
|
|
936
|
+
runtime: handle.runtime,
|
|
937
|
+
port: handle.port,
|
|
938
|
+
projectDir: handle.projectDir,
|
|
939
|
+
previewUrl: handle.previewUrl,
|
|
940
|
+
alive,
|
|
941
|
+
appRunning,
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
// Create a standalone sandbox (not tied to session creation flow)
|
|
945
|
+
app.post("/api/sandboxes", async (c) => {
|
|
946
|
+
const body = (await c.req.json());
|
|
947
|
+
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
948
|
+
const streamEnv = getStreamEnvVars(sessionId, config.streamConfig);
|
|
949
|
+
try {
|
|
950
|
+
const handle = await config.sandbox.create(sessionId, {
|
|
951
|
+
projectName: body.projectName,
|
|
952
|
+
infra: body.infra,
|
|
953
|
+
streamEnv,
|
|
954
|
+
});
|
|
955
|
+
return c.json({
|
|
956
|
+
sessionId: handle.sessionId,
|
|
957
|
+
runtime: handle.runtime,
|
|
958
|
+
port: handle.port,
|
|
959
|
+
projectDir: handle.projectDir,
|
|
960
|
+
previewUrl: handle.previewUrl,
|
|
961
|
+
}, 201);
|
|
962
|
+
}
|
|
963
|
+
catch (e) {
|
|
964
|
+
const msg = e instanceof Error ? e.message : "Failed to create sandbox";
|
|
965
|
+
return c.json({ error: msg }, 500);
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
// Delete a sandbox
|
|
969
|
+
app.delete("/api/sandboxes/:sessionId", async (c) => {
|
|
970
|
+
const sessionId = c.req.param("sessionId");
|
|
971
|
+
const handle = config.sandbox.get(sessionId);
|
|
972
|
+
if (!handle)
|
|
973
|
+
return c.json({ error: "Sandbox not found" }, 404);
|
|
974
|
+
closeBridge(sessionId);
|
|
975
|
+
await config.sandbox.destroy(handle);
|
|
976
|
+
return c.json({ ok: true });
|
|
977
|
+
});
|
|
978
|
+
// --- SSE Proxy ---
|
|
979
|
+
// Server-side SSE proxy: reads from the hosted durable stream and proxies
|
|
980
|
+
// events to the React client. The client never sees DS credentials.
|
|
981
|
+
app.get("/api/sessions/:id/events", async (c) => {
|
|
982
|
+
const sessionId = c.req.param("id");
|
|
983
|
+
console.log(`[sse] Client connected: session=${sessionId}`);
|
|
984
|
+
const session = getSession(config.dataDir, sessionId);
|
|
985
|
+
if (!session) {
|
|
986
|
+
console.log(`[sse] Session not found: ${sessionId}`);
|
|
987
|
+
return c.json({ error: "Session not found" }, 404);
|
|
988
|
+
}
|
|
989
|
+
// Get the stream connection info
|
|
990
|
+
const connection = sessionStream(config, sessionId);
|
|
991
|
+
// Last-Event-ID allows reconnection from where the client left off
|
|
992
|
+
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
993
|
+
console.log(`[sse] Reading stream from offset=${lastEventId} url=${connection.url}`);
|
|
994
|
+
const reader = new DurableStream({
|
|
995
|
+
url: connection.url,
|
|
996
|
+
headers: connection.headers,
|
|
997
|
+
contentType: "application/json",
|
|
998
|
+
});
|
|
999
|
+
const { readable, writable } = new TransformStream();
|
|
1000
|
+
const writer = writable.getWriter();
|
|
1001
|
+
const encoder = new TextEncoder();
|
|
1002
|
+
let cancelled = false;
|
|
1003
|
+
let eventCount = 0;
|
|
1004
|
+
const response = await reader.stream({
|
|
1005
|
+
offset: lastEventId,
|
|
1006
|
+
live: true,
|
|
1007
|
+
});
|
|
1008
|
+
const cancel = response.subscribeJson((batch) => {
|
|
1009
|
+
if (cancelled)
|
|
1010
|
+
return;
|
|
1011
|
+
for (const item of batch.items) {
|
|
1012
|
+
// Skip internal protocol messages (commands sent to agent, gate responses)
|
|
1013
|
+
// but allow server-emitted EngineEvents (like infra_config_prompt) through
|
|
1014
|
+
const msgType = item.type;
|
|
1015
|
+
if (msgType === "command" || msgType === "gate_response") {
|
|
1016
|
+
console.log(`[sse] Filtered protocol message: type=${msgType} source=${item.source} session=${sessionId}`);
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
eventCount++;
|
|
1020
|
+
console.log(`[sse] Proxying event #${eventCount}: type=${msgType} source=${item.source} session=${sessionId}`);
|
|
1021
|
+
// Strip the source field before sending to client
|
|
1022
|
+
const { source: _, ...eventData } = item;
|
|
1023
|
+
const data = JSON.stringify(eventData);
|
|
1024
|
+
writer.write(encoder.encode(`id:${batch.offset}\ndata:${data}\n\n`)).catch(() => {
|
|
1025
|
+
cancelled = true;
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
// Clean up when client disconnects
|
|
1030
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
1031
|
+
console.log(`[sse] Client disconnected: session=${sessionId} (sent ${eventCount} events)`);
|
|
1032
|
+
cancelled = true;
|
|
1033
|
+
cancel();
|
|
1034
|
+
writer.close().catch(() => { });
|
|
1035
|
+
});
|
|
1036
|
+
return new Response(readable, {
|
|
1037
|
+
headers: {
|
|
1038
|
+
"Content-Type": "text/event-stream",
|
|
1039
|
+
"Cache-Control": "no-cache",
|
|
1040
|
+
Connection: "keep-alive",
|
|
1041
|
+
"Access-Control-Allow-Origin": "*",
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
// --- Git/GitHub Routes ---
|
|
1046
|
+
// Get git status for a session
|
|
1047
|
+
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
1048
|
+
const sessionId = c.req.param("id");
|
|
1049
|
+
const session = getSession(config.dataDir, sessionId);
|
|
1050
|
+
if (!session)
|
|
1051
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1052
|
+
const handle = config.sandbox.get(sessionId);
|
|
1053
|
+
if (!handle) {
|
|
1054
|
+
return c.json({ error: "Container not available" }, 404);
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
const status = await config.sandbox.gitStatus(handle, session.sandboxProjectDir || handle.projectDir);
|
|
1058
|
+
return c.json(status);
|
|
1059
|
+
}
|
|
1060
|
+
catch (e) {
|
|
1061
|
+
return c.json({ error: e instanceof Error ? e.message : "Failed to get git status" }, 500);
|
|
1062
|
+
}
|
|
1063
|
+
});
|
|
1064
|
+
// List all files in the project directory
|
|
1065
|
+
app.get("/api/sessions/:id/files", async (c) => {
|
|
1066
|
+
const sessionId = c.req.param("id");
|
|
1067
|
+
const session = getSession(config.dataDir, sessionId);
|
|
1068
|
+
if (!session)
|
|
1069
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1070
|
+
const handle = config.sandbox.get(sessionId);
|
|
1071
|
+
const sandboxDir = session.sandboxProjectDir;
|
|
1072
|
+
if (!handle || !sandboxDir) {
|
|
1073
|
+
return c.json({ files: [], prefix: sandboxDir ?? "" });
|
|
1074
|
+
}
|
|
1075
|
+
const files = await config.sandbox.listFiles(handle, sandboxDir);
|
|
1076
|
+
return c.json({ files, prefix: sandboxDir });
|
|
1077
|
+
});
|
|
1078
|
+
// Read a file's content
|
|
1079
|
+
app.get("/api/sessions/:id/file-content", async (c) => {
|
|
1080
|
+
const sessionId = c.req.param("id");
|
|
1081
|
+
const session = getSession(config.dataDir, sessionId);
|
|
1082
|
+
if (!session)
|
|
1083
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1084
|
+
const filePath = c.req.query("path");
|
|
1085
|
+
if (!filePath)
|
|
1086
|
+
return c.json({ error: "path query parameter required" }, 400);
|
|
1087
|
+
const handle = config.sandbox.get(sessionId);
|
|
1088
|
+
const sandboxDir = session.sandboxProjectDir;
|
|
1089
|
+
if (!handle || !sandboxDir) {
|
|
1090
|
+
return c.json({ error: "Container not available" }, 404);
|
|
1091
|
+
}
|
|
1092
|
+
if (!filePath.startsWith(sandboxDir)) {
|
|
1093
|
+
return c.json({ error: "Path outside project directory" }, 403);
|
|
1094
|
+
}
|
|
1095
|
+
const content = await config.sandbox.readFile(handle, filePath);
|
|
1096
|
+
if (content === null) {
|
|
1097
|
+
return c.json({ error: "File not found or unreadable" }, 404);
|
|
1098
|
+
}
|
|
1099
|
+
return c.json({ content });
|
|
1100
|
+
});
|
|
1101
|
+
// List GitHub accounts (personal + orgs)
|
|
1102
|
+
app.get("/api/github/accounts", (c) => {
|
|
1103
|
+
const token = c.req.header("X-GH-Token") || undefined;
|
|
1104
|
+
try {
|
|
1105
|
+
const accounts = ghListAccounts(token);
|
|
1106
|
+
return c.json({ accounts });
|
|
1107
|
+
}
|
|
1108
|
+
catch (e) {
|
|
1109
|
+
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
// List GitHub repos for the authenticated user
|
|
1113
|
+
app.get("/api/github/repos", (c) => {
|
|
1114
|
+
const token = c.req.header("X-GH-Token") || undefined;
|
|
1115
|
+
try {
|
|
1116
|
+
const repos = ghListRepos(50, token);
|
|
1117
|
+
return c.json({ repos });
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
return c.json({ error: e instanceof Error ? e.message : "Failed to list repos" }, 500);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
1124
|
+
const owner = c.req.param("owner");
|
|
1125
|
+
const repo = c.req.param("repo");
|
|
1126
|
+
const token = c.req.header("X-GH-Token") || undefined;
|
|
1127
|
+
try {
|
|
1128
|
+
const branches = ghListBranches(`${owner}/${repo}`, token);
|
|
1129
|
+
return c.json({ branches });
|
|
1130
|
+
}
|
|
1131
|
+
catch (e) {
|
|
1132
|
+
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
1136
|
+
app.get("/api/credentials/keychain", (c) => {
|
|
1137
|
+
if (process.platform !== "darwin") {
|
|
1138
|
+
return c.json({ apiKey: null });
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1142
|
+
const parsed = JSON.parse(raw);
|
|
1143
|
+
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
1144
|
+
if (token) {
|
|
1145
|
+
console.log(`[dev] Loaded OAuth token from keychain: ${token.slice(0, 20)}...${token.slice(-10)}`);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
console.log("[dev] No OAuth token found in keychain");
|
|
1149
|
+
}
|
|
1150
|
+
return c.json({ oauthToken: token });
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return c.json({ oauthToken: null });
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
// Resume a project from a GitHub repo
|
|
1157
|
+
app.post("/api/sessions/resume", async (c) => {
|
|
1158
|
+
const body = (await c.req.json());
|
|
1159
|
+
if (!body.repoUrl) {
|
|
1160
|
+
return c.json({ error: "repoUrl is required" }, 400);
|
|
1161
|
+
}
|
|
1162
|
+
const sessionId = crypto.randomUUID();
|
|
1163
|
+
const repoName = body.repoUrl
|
|
1164
|
+
.split("/")
|
|
1165
|
+
.pop()
|
|
1166
|
+
?.replace(/\.git$/, "") || "resumed-project";
|
|
1167
|
+
// Create durable stream
|
|
1168
|
+
const conn = sessionStream(config, sessionId);
|
|
1169
|
+
try {
|
|
1170
|
+
await DurableStream.create({
|
|
1171
|
+
url: conn.url,
|
|
1172
|
+
headers: conn.headers,
|
|
1173
|
+
contentType: "application/json",
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
return c.json({ error: "Failed to create event stream" }, 500);
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
const handle = await config.sandbox.createFromRepo(sessionId, body.repoUrl, {
|
|
1181
|
+
branch: body.branch,
|
|
1182
|
+
apiKey: body.apiKey,
|
|
1183
|
+
oauthToken: body.oauthToken,
|
|
1184
|
+
ghToken: body.ghToken,
|
|
1185
|
+
});
|
|
1186
|
+
// Get git state from cloned repo inside the container
|
|
1187
|
+
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
1188
|
+
const session = {
|
|
1189
|
+
id: sessionId,
|
|
1190
|
+
projectName: repoName,
|
|
1191
|
+
sandboxProjectDir: handle.projectDir,
|
|
1192
|
+
description: `Resumed from ${body.repoUrl}`,
|
|
1193
|
+
createdAt: new Date().toISOString(),
|
|
1194
|
+
lastActiveAt: new Date().toISOString(),
|
|
1195
|
+
status: "complete",
|
|
1196
|
+
appPort: handle.port,
|
|
1197
|
+
git: {
|
|
1198
|
+
branch: gs.branch ?? body.branch ?? "main",
|
|
1199
|
+
remoteUrl: body.repoUrl,
|
|
1200
|
+
repoName: parseRepoNameFromUrl(body.repoUrl),
|
|
1201
|
+
lastCommitHash: gs.lastCommitHash ?? null,
|
|
1202
|
+
lastCommitMessage: gs.lastCommitMessage ?? null,
|
|
1203
|
+
lastCheckpointAt: null,
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
addSession(config.dataDir, session);
|
|
1207
|
+
// Write initial message to stream
|
|
1208
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1209
|
+
await bridge.emit({
|
|
1210
|
+
type: "log",
|
|
1211
|
+
level: "done",
|
|
1212
|
+
message: `Resumed from ${body.repoUrl}`,
|
|
1213
|
+
ts: ts(),
|
|
1214
|
+
});
|
|
1215
|
+
return c.json({ sessionId, appPort: handle.port }, 201);
|
|
1216
|
+
}
|
|
1217
|
+
catch (e) {
|
|
1218
|
+
const msg = e instanceof Error ? e.message : "Failed to resume from repo";
|
|
1219
|
+
return c.json({ error: msg }, 500);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
// Serve static SPA files (if built)
|
|
1223
|
+
const clientDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "./client");
|
|
1224
|
+
if (fs.existsSync(clientDir)) {
|
|
1225
|
+
app.use("/*", serveStatic({ root: clientDir }));
|
|
1226
|
+
app.get("*", (c) => {
|
|
1227
|
+
const indexPath = path.join(clientDir, "index.html");
|
|
1228
|
+
if (fs.existsSync(indexPath)) {
|
|
1229
|
+
return c.html(fs.readFileSync(indexPath, "utf-8"));
|
|
1230
|
+
}
|
|
1231
|
+
return c.text("Web UI not built. Run: npm run build:web", 404);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
app.get("/", (c) => {
|
|
1236
|
+
return c.text("Web UI not built. Run: npm run build:web", 404);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
return app;
|
|
1240
|
+
}
|
|
1241
|
+
export async function startWebServer(opts) {
|
|
1242
|
+
const config = {
|
|
1243
|
+
port: opts.port ?? 4400,
|
|
1244
|
+
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
1245
|
+
sandbox: opts.sandbox,
|
|
1246
|
+
streamConfig: opts.streamConfig,
|
|
1247
|
+
bridgeMode: opts.bridgeMode ?? "stream",
|
|
1248
|
+
inferProjectName: opts.inferProjectName,
|
|
1249
|
+
};
|
|
1250
|
+
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
1251
|
+
// Clean up stale sessions from previous runs
|
|
1252
|
+
const cleaned = cleanupStaleSessions(config.dataDir);
|
|
1253
|
+
if (cleaned > 0) {
|
|
1254
|
+
console.log(`[startup] Cleaned up ${cleaned} stale session(s)`);
|
|
1255
|
+
}
|
|
1256
|
+
const app = createApp(config);
|
|
1257
|
+
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|
|
1258
|
+
serve({
|
|
1259
|
+
fetch: app.fetch,
|
|
1260
|
+
port: config.port,
|
|
1261
|
+
hostname,
|
|
1262
|
+
});
|
|
1263
|
+
console.log(`Web UI server running at http://${hostname}:${config.port}`);
|
|
1264
|
+
console.log(`Streams: ${config.streamConfig.url} (service: ${config.streamConfig.serviceId})`);
|
|
1265
|
+
}
|
|
1266
|
+
//# sourceMappingURL=server.js.map
|