@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.
Files changed (104) hide show
  1. package/dist/bridge/daytona.d.ts +35 -0
  2. package/dist/bridge/daytona.d.ts.map +1 -0
  3. package/dist/bridge/daytona.js +146 -0
  4. package/dist/bridge/daytona.js.map +1 -0
  5. package/dist/bridge/docker-stdio.d.ts +30 -0
  6. package/dist/bridge/docker-stdio.d.ts.map +1 -0
  7. package/dist/bridge/docker-stdio.js +141 -0
  8. package/dist/bridge/docker-stdio.js.map +1 -0
  9. package/dist/bridge/hosted.d.ts +28 -0
  10. package/dist/bridge/hosted.d.ts.map +1 -0
  11. package/dist/bridge/hosted.js +113 -0
  12. package/dist/bridge/hosted.js.map +1 -0
  13. package/dist/bridge/index.d.ts +6 -0
  14. package/dist/bridge/index.d.ts.map +1 -0
  15. package/dist/bridge/index.js +5 -0
  16. package/dist/bridge/index.js.map +1 -0
  17. package/dist/bridge/sprites.d.ts +32 -0
  18. package/dist/bridge/sprites.d.ts.map +1 -0
  19. package/dist/bridge/sprites.js +138 -0
  20. package/dist/bridge/sprites.js.map +1 -0
  21. package/dist/bridge/types.d.ts +97 -0
  22. package/dist/bridge/types.d.ts.map +1 -0
  23. package/dist/bridge/types.js +2 -0
  24. package/dist/bridge/types.js.map +1 -0
  25. package/dist/client/assets/OpenSauceOne-Bold-BeiFYFR5.woff2 +0 -0
  26. package/dist/client/assets/OpenSauceOne-ExtraBold-DO6BqiNe.woff2 +0 -0
  27. package/dist/client/assets/OpenSauceOne-Light-NEdTeQp-.woff2 +0 -0
  28. package/dist/client/assets/OpenSauceOne-Medium-Cu5cjAHY.woff2 +0 -0
  29. package/dist/client/assets/OpenSauceOne-Regular-BivIUdzJ.woff2 +0 -0
  30. package/dist/client/assets/SourceCodePro-Regular-CoIbWt_c.woff2 +0 -0
  31. package/dist/client/assets/index-CK__1-6e.css +1 -0
  32. package/dist/client/assets/index-DKL-jl7t.js +241 -0
  33. package/dist/client/favicon.ico +0 -0
  34. package/dist/client/img/brand/favicon.png +0 -0
  35. package/dist/client/img/brand/favicon.svg +4 -0
  36. package/dist/client/img/brand/icon.svg +4 -0
  37. package/dist/client/img/brand/logo.inverse.svg +13 -0
  38. package/dist/client/img/brand/logo.svg +13 -0
  39. package/dist/client/index.html +16 -0
  40. package/dist/electric-api.d.ts +14 -0
  41. package/dist/electric-api.d.ts.map +1 -0
  42. package/dist/electric-api.js +70 -0
  43. package/dist/electric-api.js.map +1 -0
  44. package/dist/gate.d.ts +28 -0
  45. package/dist/gate.d.ts.map +1 -0
  46. package/dist/gate.js +72 -0
  47. package/dist/gate.js.map +1 -0
  48. package/dist/git.d.ts +30 -0
  49. package/dist/git.d.ts.map +1 -0
  50. package/dist/git.js +115 -0
  51. package/dist/git.js.map +1 -0
  52. package/dist/index.d.ts +8 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +6 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/project-utils.d.ts +9 -0
  57. package/dist/project-utils.d.ts.map +1 -0
  58. package/dist/project-utils.js +17 -0
  59. package/dist/project-utils.js.map +1 -0
  60. package/dist/sandbox/daytona-push.d.ts +3 -0
  61. package/dist/sandbox/daytona-push.d.ts.map +1 -0
  62. package/dist/sandbox/daytona-push.js +56 -0
  63. package/dist/sandbox/daytona-push.js.map +1 -0
  64. package/dist/sandbox/daytona-registry.d.ts +41 -0
  65. package/dist/sandbox/daytona-registry.d.ts.map +1 -0
  66. package/dist/sandbox/daytona-registry.js +127 -0
  67. package/dist/sandbox/daytona-registry.js.map +1 -0
  68. package/dist/sandbox/daytona.d.ts +41 -0
  69. package/dist/sandbox/daytona.d.ts.map +1 -0
  70. package/dist/sandbox/daytona.js +282 -0
  71. package/dist/sandbox/daytona.js.map +1 -0
  72. package/dist/sandbox/docker.d.ts +29 -0
  73. package/dist/sandbox/docker.d.ts.map +1 -0
  74. package/dist/sandbox/docker.js +465 -0
  75. package/dist/sandbox/docker.js.map +1 -0
  76. package/dist/sandbox/index.d.ts +5 -0
  77. package/dist/sandbox/index.d.ts.map +1 -0
  78. package/dist/sandbox/index.js +4 -0
  79. package/dist/sandbox/index.js.map +1 -0
  80. package/dist/sandbox/sprites-bootstrap.d.ts +26 -0
  81. package/dist/sandbox/sprites-bootstrap.d.ts.map +1 -0
  82. package/dist/sandbox/sprites-bootstrap.js +127 -0
  83. package/dist/sandbox/sprites-bootstrap.js.map +1 -0
  84. package/dist/sandbox/sprites.d.ts +55 -0
  85. package/dist/sandbox/sprites.d.ts.map +1 -0
  86. package/dist/sandbox/sprites.js +323 -0
  87. package/dist/sandbox/sprites.js.map +1 -0
  88. package/dist/sandbox/types.d.ts +73 -0
  89. package/dist/sandbox/types.d.ts.map +1 -0
  90. package/dist/sandbox/types.js +5 -0
  91. package/dist/sandbox/types.js.map +1 -0
  92. package/dist/server.d.ts +26 -0
  93. package/dist/server.d.ts.map +1 -0
  94. package/dist/server.js +1266 -0
  95. package/dist/server.js.map +1 -0
  96. package/dist/sessions.d.ts +46 -0
  97. package/dist/sessions.d.ts.map +1 -0
  98. package/dist/sessions.js +66 -0
  99. package/dist/sessions.js.map +1 -0
  100. package/dist/streams.d.ts +34 -0
  101. package/dist/streams.d.ts.map +1 -0
  102. package/dist/streams.js +42 -0
  103. package/dist/streams.js.map +1 -0
  104. 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