@electric-agent/studio 1.0.0 → 1.1.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/active-sessions.d.ts +28 -0
- package/dist/active-sessions.d.ts.map +1 -0
- package/dist/active-sessions.js +50 -0
- package/dist/active-sessions.js.map +1 -0
- package/dist/bridge/claude-code-docker.d.ts +59 -0
- package/dist/bridge/claude-code-docker.d.ts.map +1 -0
- package/dist/bridge/claude-code-docker.js +258 -0
- package/dist/bridge/claude-code-docker.js.map +1 -0
- package/dist/bridge/claude-code-sprites.d.ts +49 -0
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -0
- package/dist/bridge/claude-code-sprites.js +231 -0
- package/dist/bridge/claude-code-sprites.js.map +1 -0
- package/dist/bridge/claude-md-generator.d.ts +24 -0
- package/dist/bridge/claude-md-generator.d.ts.map +1 -0
- package/dist/bridge/claude-md-generator.js +299 -0
- package/dist/bridge/claude-md-generator.js.map +1 -0
- package/dist/bridge/index.d.ts +3 -0
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +3 -0
- package/dist/bridge/index.js.map +1 -1
- package/dist/bridge/stream-json-parser.d.ts +30 -0
- package/dist/bridge/stream-json-parser.d.ts.map +1 -0
- package/dist/bridge/stream-json-parser.js +207 -0
- package/dist/bridge/stream-json-parser.js.map +1 -0
- package/dist/client/assets/index-BeZ6CTGd.css +1 -0
- package/dist/client/assets/index-DRLXdDNp.js +241 -0
- package/dist/client/index.html +2 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/project-utils.d.ts +2 -1
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js +2 -6
- package/dist/project-utils.js.map +1 -1
- package/dist/registry.d.ts +52 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +204 -0
- package/dist/registry.js.map +1 -0
- package/dist/room-registry.d.ts +40 -0
- package/dist/room-registry.d.ts.map +1 -0
- package/dist/room-registry.js +112 -0
- package/dist/room-registry.js.map +1 -0
- package/dist/sandbox/sprites-bootstrap.d.ts.map +1 -1
- package/dist/sandbox/sprites-bootstrap.js +7 -1
- package/dist/sandbox/sprites-bootstrap.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +5 -0
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +22 -2
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/server.d.ts +9 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +616 -58
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/dist/shared-sessions.d.ts +16 -0
- package/dist/shared-sessions.d.ts.map +1 -0
- package/dist/shared-sessions.js +52 -0
- package/dist/shared-sessions.js.map +1 -0
- package/dist/streams.d.ts +8 -0
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +22 -0
- package/dist/streams.js.map +1 -1
- package/package.json +15 -2
- package/dist/client/assets/index-CK__1-6e.css +0 -1
- package/dist/client/assets/index-DKL-jl7t.js +0 -241
package/dist/server.js
CHANGED
|
@@ -8,6 +8,10 @@ import { serve } from "@hono/node-server";
|
|
|
8
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
import { cors } from "hono/cors";
|
|
11
|
+
import { ActiveSessions } from "./active-sessions.js";
|
|
12
|
+
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
13
|
+
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
14
|
+
import { generateClaudeMd } from "./bridge/claude-md-generator.js";
|
|
11
15
|
import { DaytonaSessionBridge } from "./bridge/daytona.js";
|
|
12
16
|
import { DockerStdioBridge } from "./bridge/docker-stdio.js";
|
|
13
17
|
import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
@@ -16,10 +20,12 @@ import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from ".
|
|
|
16
20
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
17
21
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
18
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
19
|
-
import {
|
|
20
|
-
import { getStreamConnectionInfo, getStreamEnvVars, } from "./streams.js";
|
|
23
|
+
import { generateInviteCode } from "./shared-sessions.js";
|
|
24
|
+
import { getSharedStreamConnectionInfo, getStreamConnectionInfo, getStreamEnvVars, } from "./streams.js";
|
|
21
25
|
/** Active session bridges — one per running session */
|
|
22
26
|
const bridges = new Map();
|
|
27
|
+
/** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
|
|
28
|
+
const inflightHookCreations = new Map();
|
|
23
29
|
function parseRepoNameFromUrl(url) {
|
|
24
30
|
if (!url)
|
|
25
31
|
return null;
|
|
@@ -30,6 +36,10 @@ function parseRepoNameFromUrl(url) {
|
|
|
30
36
|
function sessionStream(config, sessionId) {
|
|
31
37
|
return getStreamConnectionInfo(sessionId, config.streamConfig);
|
|
32
38
|
}
|
|
39
|
+
/** Get stream connection info for a shared session */
|
|
40
|
+
function sharedSessionStream(config, sharedSessionId) {
|
|
41
|
+
return getSharedStreamConnectionInfo(sharedSessionId, config.streamConfig);
|
|
42
|
+
}
|
|
33
43
|
/** Create or retrieve the SessionBridge for a session */
|
|
34
44
|
function getOrCreateBridge(config, sessionId) {
|
|
35
45
|
let bridge = bridges.get(sessionId);
|
|
@@ -76,6 +86,34 @@ function createStdioBridge(config, sessionId) {
|
|
|
76
86
|
bridges.set(sessionId, bridge);
|
|
77
87
|
return bridge;
|
|
78
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a Claude Code bridge for a session.
|
|
91
|
+
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
92
|
+
*/
|
|
93
|
+
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
94
|
+
const conn = sessionStream(config, sessionId);
|
|
95
|
+
let bridge;
|
|
96
|
+
if (config.sandbox.runtime === "sprites") {
|
|
97
|
+
const spritesProvider = config.sandbox;
|
|
98
|
+
const sprite = spritesProvider.getSpriteObject(sessionId);
|
|
99
|
+
if (!sprite) {
|
|
100
|
+
throw new Error(`No Sprites sandbox object for session ${sessionId}`);
|
|
101
|
+
}
|
|
102
|
+
bridge = new ClaudeCodeSpritesBridge(sessionId, conn, sprite, claudeConfig);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Docker (default for claude-code mode)
|
|
106
|
+
const dockerProvider = config.sandbox;
|
|
107
|
+
const containerId = dockerProvider.getContainerId(sessionId);
|
|
108
|
+
if (!containerId) {
|
|
109
|
+
throw new Error(`No Docker container found for session ${sessionId}`);
|
|
110
|
+
}
|
|
111
|
+
bridge = new ClaudeCodeDockerBridge(sessionId, conn, containerId, claudeConfig);
|
|
112
|
+
}
|
|
113
|
+
closeBridge(sessionId);
|
|
114
|
+
bridges.set(sessionId, bridge);
|
|
115
|
+
return bridge;
|
|
116
|
+
}
|
|
79
117
|
/** Close and remove a bridge */
|
|
80
118
|
function closeBridge(sessionId) {
|
|
81
119
|
const bridge = bridges.get(sessionId);
|
|
@@ -245,15 +283,9 @@ export function createApp(config) {
|
|
|
245
283
|
return c.json({ error: message }, 500);
|
|
246
284
|
}
|
|
247
285
|
});
|
|
248
|
-
//
|
|
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
|
|
286
|
+
// Get single session (from in-memory active sessions)
|
|
255
287
|
app.get("/api/sessions/:id", (c) => {
|
|
256
|
-
const session =
|
|
288
|
+
const session = config.sessions.get(c.req.param("id"));
|
|
257
289
|
if (!session)
|
|
258
290
|
return c.json({ error: "Session not found" }, 404);
|
|
259
291
|
return c.json(session);
|
|
@@ -288,7 +320,7 @@ export function createApp(config) {
|
|
|
288
320
|
lastActiveAt: new Date().toISOString(),
|
|
289
321
|
status: "running",
|
|
290
322
|
};
|
|
291
|
-
|
|
323
|
+
config.sessions.add(session);
|
|
292
324
|
// Pre-create a bridge so hook-event can emit to it immediately
|
|
293
325
|
getOrCreateBridge(config, sessionId);
|
|
294
326
|
console.log(`[local-session] Created session: ${sessionId}`);
|
|
@@ -330,7 +362,7 @@ export function createApp(config) {
|
|
|
330
362
|
status: "running",
|
|
331
363
|
claudeSessionId: claudeSessionId || undefined,
|
|
332
364
|
};
|
|
333
|
-
|
|
365
|
+
config.sessions.add(session);
|
|
334
366
|
// Create bridge and emit the SessionStart event
|
|
335
367
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
336
368
|
const hookEvent = mapHookToEngineEvent(body);
|
|
@@ -360,10 +392,10 @@ export function createApp(config) {
|
|
|
360
392
|
return c.json({ error: "Failed to write event" }, 500);
|
|
361
393
|
}
|
|
362
394
|
// Bump lastActiveAt on every hook event
|
|
363
|
-
|
|
395
|
+
config.sessions.update(sessionId, {});
|
|
364
396
|
// SessionEnd: mark session complete and close the bridge
|
|
365
397
|
if (hookEvent.type === "session_end") {
|
|
366
|
-
|
|
398
|
+
config.sessions.update(sessionId, { status: "complete" });
|
|
367
399
|
closeBridge(sessionId);
|
|
368
400
|
return c.json({ ok: true });
|
|
369
401
|
}
|
|
@@ -396,12 +428,229 @@ export function createApp(config) {
|
|
|
396
428
|
}
|
|
397
429
|
return c.json({ ok: true });
|
|
398
430
|
});
|
|
431
|
+
// --- Unified Hook Endpoint (transcript_path correlation) ---
|
|
432
|
+
// Single endpoint for all Claude Code hook events. Uses transcript_path
|
|
433
|
+
// from the hook JSON as the correlation key — stable across resume/compact,
|
|
434
|
+
// changes on /clear. Replaces the need for client-side session tracking.
|
|
435
|
+
app.post("/api/hook", async (c) => {
|
|
436
|
+
const body = (await c.req.json());
|
|
437
|
+
const transcriptPath = body.transcript_path;
|
|
438
|
+
// Look up or create session via transcript_path
|
|
439
|
+
let sessionId;
|
|
440
|
+
if (transcriptPath) {
|
|
441
|
+
sessionId = config.sessions.getByTranscript(transcriptPath);
|
|
442
|
+
}
|
|
443
|
+
if (!sessionId) {
|
|
444
|
+
// Check inflight creation to prevent duplicate sessions from concurrent hooks
|
|
445
|
+
if (transcriptPath && inflightHookCreations.has(transcriptPath)) {
|
|
446
|
+
// Another request is already creating a session for this transcript — wait for it
|
|
447
|
+
sessionId = await inflightHookCreations.get(transcriptPath);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!sessionId) {
|
|
451
|
+
// Create a new session (with inflight guard)
|
|
452
|
+
const createPromise = (async () => {
|
|
453
|
+
const newId = crypto.randomUUID();
|
|
454
|
+
// Create the durable stream
|
|
455
|
+
const conn = sessionStream(config, newId);
|
|
456
|
+
try {
|
|
457
|
+
await DurableStream.create({
|
|
458
|
+
url: conn.url,
|
|
459
|
+
headers: conn.headers,
|
|
460
|
+
contentType: "application/json",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
console.error(`[hook] Failed to create durable stream:`, err);
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
// Derive project name from cwd
|
|
468
|
+
const cwd = body.cwd;
|
|
469
|
+
const projectName = cwd ? path.basename(cwd) : "local-session";
|
|
470
|
+
const session = {
|
|
471
|
+
id: newId,
|
|
472
|
+
projectName,
|
|
473
|
+
sandboxProjectDir: cwd || "",
|
|
474
|
+
description: `Local session: ${projectName}`,
|
|
475
|
+
createdAt: new Date().toISOString(),
|
|
476
|
+
lastActiveAt: new Date().toISOString(),
|
|
477
|
+
status: "running",
|
|
478
|
+
};
|
|
479
|
+
config.sessions.add(session);
|
|
480
|
+
// Durably map transcript_path → session
|
|
481
|
+
if (transcriptPath) {
|
|
482
|
+
config.sessions.mapTranscript(transcriptPath, newId);
|
|
483
|
+
}
|
|
484
|
+
console.log(`[hook] Created session: ${newId} (project: ${session.projectName}, transcript: ${transcriptPath ?? "none"})`);
|
|
485
|
+
return newId;
|
|
486
|
+
})();
|
|
487
|
+
if (transcriptPath) {
|
|
488
|
+
inflightHookCreations.set(transcriptPath, createPromise);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
sessionId = await createPromise;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return c.json({ error: "Failed to create event stream" }, 500);
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
if (transcriptPath) {
|
|
498
|
+
inflightHookCreations.delete(transcriptPath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Ensure bridge exists
|
|
503
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
504
|
+
// On SessionStart (resume/compact), re-activate the session
|
|
505
|
+
const hookName = body.hook_event_name;
|
|
506
|
+
if (hookName === "SessionStart") {
|
|
507
|
+
const session = config.sessions.get(sessionId);
|
|
508
|
+
if (session && session.status !== "running") {
|
|
509
|
+
config.sessions.update(sessionId, { status: "running" });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Map hook JSON → EngineEvent
|
|
513
|
+
const hookEvent = mapHookToEngineEvent(body);
|
|
514
|
+
if (!hookEvent) {
|
|
515
|
+
return c.json({ ok: true, sessionId });
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
await bridge.emit(hookEvent);
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
console.error(`[hook] Failed to emit:`, err);
|
|
522
|
+
return c.json({ error: "Failed to write event" }, 500);
|
|
523
|
+
}
|
|
524
|
+
// Bump lastActiveAt
|
|
525
|
+
config.sessions.update(sessionId, {});
|
|
526
|
+
// SessionEnd: mark complete and close bridge (keep mapping for potential re-open)
|
|
527
|
+
if (hookEvent.type === "session_end") {
|
|
528
|
+
config.sessions.update(sessionId, { status: "complete" });
|
|
529
|
+
closeBridge(sessionId);
|
|
530
|
+
return c.json({ ok: true, sessionId });
|
|
531
|
+
}
|
|
532
|
+
// AskUserQuestion: block until the user answers via the web UI
|
|
533
|
+
if (hookEvent.type === "ask_user_question") {
|
|
534
|
+
const toolUseId = hookEvent.tool_use_id;
|
|
535
|
+
console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
536
|
+
try {
|
|
537
|
+
const gateTimeout = 5 * 60 * 1000;
|
|
538
|
+
const answer = await Promise.race([
|
|
539
|
+
createGate(sessionId, `ask_user_question:${toolUseId}`),
|
|
540
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
541
|
+
]);
|
|
542
|
+
console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
|
|
543
|
+
return c.json({
|
|
544
|
+
sessionId,
|
|
545
|
+
hookSpecificOutput: {
|
|
546
|
+
hookEventName: "PreToolUse",
|
|
547
|
+
permissionDecision: "allow",
|
|
548
|
+
updatedInput: {
|
|
549
|
+
questions: body.tool_input?.questions,
|
|
550
|
+
answers: { [hookEvent.question]: answer.answer },
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
console.error(`[hook] ask_user_question gate error:`, err);
|
|
557
|
+
return c.json({ ok: true, sessionId });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return c.json({ ok: true, sessionId });
|
|
561
|
+
});
|
|
562
|
+
// --- Hook Setup Installer ---
|
|
563
|
+
// Returns a shell script that installs forward.sh and configures Claude Code hooks
|
|
564
|
+
// in the current project directory (.claude/hooks/ and .claude/settings.local.json).
|
|
565
|
+
// Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
|
|
566
|
+
app.get("/api/hooks/setup", (c) => {
|
|
567
|
+
const port = config.port;
|
|
568
|
+
const script = `#!/bin/bash
|
|
569
|
+
# Electric Agent — Claude Code hook installer (project-scoped)
|
|
570
|
+
# Installs the hook forwarder into the current project's .claude/ directory.
|
|
571
|
+
|
|
572
|
+
set -e
|
|
573
|
+
|
|
574
|
+
HOOKS_DIR=".claude/hooks"
|
|
575
|
+
SETTINGS_FILE=".claude/settings.local.json"
|
|
576
|
+
FORWARD_SH="\${HOOKS_DIR}/forward.sh"
|
|
577
|
+
EA_PORT="${port}"
|
|
578
|
+
|
|
579
|
+
mkdir -p "\${HOOKS_DIR}"
|
|
580
|
+
|
|
581
|
+
# Write the forwarder script
|
|
582
|
+
cat > "\${FORWARD_SH}" << 'HOOKEOF'
|
|
583
|
+
#!/bin/bash
|
|
584
|
+
# Forward Claude Code hook events to Electric Agent studio.
|
|
585
|
+
# Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
|
|
586
|
+
|
|
587
|
+
EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
|
|
588
|
+
BODY="$(cat)"
|
|
589
|
+
|
|
590
|
+
RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
|
|
591
|
+
-H "Content-Type: application/json" \\
|
|
592
|
+
-d "\${BODY}" \\
|
|
593
|
+
--max-time 360 \\
|
|
594
|
+
--connect-timeout 2 \\
|
|
595
|
+
2>/dev/null)
|
|
596
|
+
|
|
597
|
+
# If the response contains hookSpecificOutput, print it so Claude Code reads it
|
|
598
|
+
if echo "\${RESPONSE}" | grep -q '"hookSpecificOutput"'; then
|
|
599
|
+
echo "\${RESPONSE}"
|
|
600
|
+
fi
|
|
601
|
+
|
|
602
|
+
exit 0
|
|
603
|
+
HOOKEOF
|
|
604
|
+
|
|
605
|
+
# Replace placeholder with actual port
|
|
606
|
+
sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
607
|
+
chmod +x "\${FORWARD_SH}"
|
|
608
|
+
|
|
609
|
+
# Merge hook config into project-level settings.local.json
|
|
610
|
+
HOOK_ENTRY="\${FORWARD_SH}"
|
|
611
|
+
|
|
612
|
+
if command -v node > /dev/null 2>&1; then
|
|
613
|
+
node -e "
|
|
614
|
+
const fs = require('fs');
|
|
615
|
+
const file = process.argv[1];
|
|
616
|
+
const hook = process.argv[2];
|
|
617
|
+
let settings = {};
|
|
618
|
+
try { settings = JSON.parse(fs.readFileSync(file, 'utf-8')); } catch {}
|
|
619
|
+
if (!settings.hooks) settings.hooks = {};
|
|
620
|
+
const events = ['PreToolUse','PostToolUse','PostToolUseFailure','Stop','SessionStart','SessionEnd','UserPromptSubmit','SubagentStart','SubagentStop'];
|
|
621
|
+
for (const ev of events) {
|
|
622
|
+
if (!settings.hooks[ev]) settings.hooks[ev] = [];
|
|
623
|
+
const arr = settings.hooks[ev];
|
|
624
|
+
if (!arr.some(h => h.command === hook)) {
|
|
625
|
+
arr.push({ type: 'command', command: hook });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\\\\n');
|
|
629
|
+
" "\${SETTINGS_FILE}" "\${HOOK_ENTRY}"
|
|
630
|
+
else
|
|
631
|
+
echo "Warning: node not found. Please add the hook manually to \${SETTINGS_FILE}"
|
|
632
|
+
echo "See: https://docs.anthropic.com/en/docs/claude-code/hooks"
|
|
633
|
+
exit 1
|
|
634
|
+
fi
|
|
635
|
+
|
|
636
|
+
echo ""
|
|
637
|
+
echo "Electric Agent hooks installed in project: $(pwd)"
|
|
638
|
+
echo " Forwarder: $(pwd)/\${FORWARD_SH}"
|
|
639
|
+
echo " Settings: $(pwd)/\${SETTINGS_FILE}"
|
|
640
|
+
echo " Server: http://localhost:\${EA_PORT}"
|
|
641
|
+
echo ""
|
|
642
|
+
echo "Start claude in this project — the session will appear in the studio UI."
|
|
643
|
+
`;
|
|
644
|
+
return c.text(script, 200, { "Content-Type": "text/plain" });
|
|
645
|
+
});
|
|
399
646
|
// Start new project
|
|
400
647
|
app.post("/api/sessions", async (c) => {
|
|
401
648
|
const body = (await c.req.json());
|
|
402
649
|
if (!body.description) {
|
|
403
650
|
return c.json({ error: "description is required" }, 400);
|
|
404
651
|
}
|
|
652
|
+
// Per-session bridge mode: "claude-code" if explicitly requested, else server default
|
|
653
|
+
const sessionBridgeMode = body.agentMode === "claude-code" ? "claude-code" : config.bridgeMode;
|
|
405
654
|
const sessionId = crypto.randomUUID();
|
|
406
655
|
const inferredName = body.name ||
|
|
407
656
|
(config.inferProjectName
|
|
@@ -440,8 +689,9 @@ export function createApp(config) {
|
|
|
440
689
|
createdAt: new Date().toISOString(),
|
|
441
690
|
lastActiveAt: new Date().toISOString(),
|
|
442
691
|
status: "running",
|
|
692
|
+
agentMode: sessionBridgeMode === "claude-code" ? "claude-code" : "electric-agent",
|
|
443
693
|
};
|
|
444
|
-
|
|
694
|
+
config.sessions.add(session);
|
|
445
695
|
// Write user prompt to the stream so it shows in the UI
|
|
446
696
|
await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
447
697
|
// Gather GitHub accounts for the merged setup gate
|
|
@@ -495,7 +745,7 @@ export function createApp(config) {
|
|
|
495
745
|
repoName: gateValue.repoName,
|
|
496
746
|
visibility: gateValue.repoVisibility ?? "private",
|
|
497
747
|
};
|
|
498
|
-
|
|
748
|
+
config.sessions.update(sessionId, {
|
|
499
749
|
git: {
|
|
500
750
|
branch: "main",
|
|
501
751
|
remoteUrl: null,
|
|
@@ -519,14 +769,16 @@ export function createApp(config) {
|
|
|
519
769
|
message: `Creating ${config.sandbox.runtime} sandbox...`,
|
|
520
770
|
ts: ts(),
|
|
521
771
|
});
|
|
522
|
-
// Only pass stream env vars when using hosted stream bridge (not stdio)
|
|
523
|
-
const streamEnv =
|
|
524
|
-
|
|
772
|
+
// Only pass stream env vars when using hosted stream bridge (not stdio or claude-code)
|
|
773
|
+
const streamEnv = sessionBridgeMode === "stdio" || sessionBridgeMode === "claude-code"
|
|
774
|
+
? undefined
|
|
775
|
+
: getStreamEnvVars(sessionId, config.streamConfig);
|
|
776
|
+
console.log(`[session:${sessionId}] Creating sandbox: runtime=${config.sandbox.runtime} project=${projectName} bridgeMode=${sessionBridgeMode}`);
|
|
525
777
|
const handle = await config.sandbox.create(sessionId, {
|
|
526
778
|
projectName,
|
|
527
779
|
infra,
|
|
528
780
|
streamEnv,
|
|
529
|
-
deferAgentStart:
|
|
781
|
+
deferAgentStart: sessionBridgeMode === "stdio" || sessionBridgeMode === "claude-code",
|
|
530
782
|
apiKey: body.apiKey,
|
|
531
783
|
oauthToken: body.oauthToken,
|
|
532
784
|
ghToken: body.ghToken,
|
|
@@ -538,16 +790,75 @@ export function createApp(config) {
|
|
|
538
790
|
message: `Sandbox ready (${config.sandbox.runtime})`,
|
|
539
791
|
ts: ts(),
|
|
540
792
|
});
|
|
541
|
-
|
|
793
|
+
config.sessions.update(sessionId, {
|
|
542
794
|
appPort: handle.port,
|
|
543
795
|
sandboxProjectDir: handle.projectDir,
|
|
544
796
|
previewUrl: handle.previewUrl,
|
|
545
797
|
...(claimId ? { claimId } : {}),
|
|
546
798
|
});
|
|
547
799
|
// 3. If stdio bridge mode, create the stdio bridge now that the sandbox exists.
|
|
800
|
+
// If claude-code mode, write CLAUDE.md and create a ClaudeCode bridge.
|
|
801
|
+
// If stdio mode, create the stdio bridge now that the sandbox exists.
|
|
548
802
|
// If stream bridge mode with Sprites, launch the agent process in the sprite
|
|
549
803
|
// (it connects directly to the hosted Durable Stream via DS_URL env vars).
|
|
550
|
-
if (
|
|
804
|
+
if (sessionBridgeMode === "claude-code") {
|
|
805
|
+
console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
|
|
806
|
+
// Copy pre-scaffolded project from the image and customize per-session
|
|
807
|
+
await bridge.emit({
|
|
808
|
+
type: "log",
|
|
809
|
+
level: "build",
|
|
810
|
+
message: "Setting up project...",
|
|
811
|
+
ts: ts(),
|
|
812
|
+
});
|
|
813
|
+
try {
|
|
814
|
+
if (config.sandbox.runtime === "docker") {
|
|
815
|
+
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
816
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
817
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
// Sprites/Daytona: run scaffold from globally installed electric-agent
|
|
821
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
822
|
+
}
|
|
823
|
+
// Ensure _agent/ working memory directory exists
|
|
824
|
+
await config.sandbox.exec(handle, `mkdir -p '${handle.projectDir}/_agent' && echo '# Error Log\n' > '${handle.projectDir}/_agent/errors.md' && echo '# Session State\n' > '${handle.projectDir}/_agent/session.md'`);
|
|
825
|
+
console.log(`[session:${sessionId}] Project setup complete`);
|
|
826
|
+
await bridge.emit({
|
|
827
|
+
type: "log",
|
|
828
|
+
level: "done",
|
|
829
|
+
message: "Project ready",
|
|
830
|
+
ts: ts(),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
console.error(`[session:${sessionId}] Project setup failed:`, err);
|
|
835
|
+
await bridge.emit({
|
|
836
|
+
type: "log",
|
|
837
|
+
level: "error",
|
|
838
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
839
|
+
ts: ts(),
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
// Write CLAUDE.md to the sandbox workspace
|
|
843
|
+
const claudeMd = generateClaudeMd({
|
|
844
|
+
description: body.description,
|
|
845
|
+
projectName,
|
|
846
|
+
projectDir: handle.projectDir,
|
|
847
|
+
runtime: config.sandbox.runtime,
|
|
848
|
+
});
|
|
849
|
+
try {
|
|
850
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
854
|
+
}
|
|
855
|
+
const claudeConfig = {
|
|
856
|
+
prompt: body.description,
|
|
857
|
+
cwd: handle.projectDir,
|
|
858
|
+
};
|
|
859
|
+
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
860
|
+
}
|
|
861
|
+
else if (sessionBridgeMode === "stdio") {
|
|
551
862
|
console.log(`[session:${sessionId}] Creating stdio bridge...`);
|
|
552
863
|
bridge = createStdioBridge(config, sessionId);
|
|
553
864
|
}
|
|
@@ -599,7 +910,7 @@ export function createApp(config) {
|
|
|
599
910
|
try {
|
|
600
911
|
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
601
912
|
if (gs.initialized) {
|
|
602
|
-
const existing =
|
|
913
|
+
const existing = config.sessions.get(sessionId);
|
|
603
914
|
updates.git = {
|
|
604
915
|
branch: gs.branch ?? "main",
|
|
605
916
|
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
@@ -614,7 +925,24 @@ export function createApp(config) {
|
|
|
614
925
|
catch {
|
|
615
926
|
// Container may already be stopped
|
|
616
927
|
}
|
|
617
|
-
|
|
928
|
+
config.sessions.update(sessionId, updates);
|
|
929
|
+
// For Claude Code mode: check if the app is running after completion
|
|
930
|
+
// and emit app_ready so the UI shows the preview link
|
|
931
|
+
if (sessionBridgeMode === "claude-code" && success) {
|
|
932
|
+
try {
|
|
933
|
+
const appRunning = await config.sandbox.isAppRunning(handle);
|
|
934
|
+
if (appRunning) {
|
|
935
|
+
await bridge.emit({
|
|
936
|
+
type: "app_ready",
|
|
937
|
+
port: handle.port ?? session.appPort,
|
|
938
|
+
ts: ts(),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
// Container may already be stopped
|
|
944
|
+
}
|
|
945
|
+
}
|
|
618
946
|
});
|
|
619
947
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
620
948
|
await bridge.start();
|
|
@@ -633,16 +961,16 @@ export function createApp(config) {
|
|
|
633
961
|
await bridge.sendCommand(newCmd);
|
|
634
962
|
console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
|
|
635
963
|
};
|
|
636
|
-
asyncFlow().catch((err) => {
|
|
964
|
+
asyncFlow().catch(async (err) => {
|
|
637
965
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
638
|
-
|
|
966
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
639
967
|
});
|
|
640
|
-
return c.json({ sessionId }, 201);
|
|
968
|
+
return c.json({ sessionId, session }, 201);
|
|
641
969
|
});
|
|
642
970
|
// Send iteration request
|
|
643
971
|
app.post("/api/sessions/:id/iterate", async (c) => {
|
|
644
972
|
const sessionId = c.req.param("id");
|
|
645
|
-
const session =
|
|
973
|
+
const session = config.sessions.get(sessionId);
|
|
646
974
|
if (!session)
|
|
647
975
|
return c.json({ error: "Session not found" }, 404);
|
|
648
976
|
const body = (await c.req.json());
|
|
@@ -699,11 +1027,20 @@ export function createApp(config) {
|
|
|
699
1027
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
700
1028
|
return c.json({ error: "Container is not running" }, 400);
|
|
701
1029
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1030
|
+
if (session.agentMode === "claude-code") {
|
|
1031
|
+
// In Claude Code mode, send git requests as user messages
|
|
1032
|
+
await bridge.sendCommand({
|
|
1033
|
+
command: "iterate",
|
|
1034
|
+
request: body.request,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
else {
|
|
1038
|
+
await bridge.sendCommand({
|
|
1039
|
+
command: "git",
|
|
1040
|
+
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
1041
|
+
...gitOp,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
707
1044
|
return c.json({ ok: true });
|
|
708
1045
|
}
|
|
709
1046
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -713,7 +1050,7 @@ export function createApp(config) {
|
|
|
713
1050
|
// Write user prompt to the stream
|
|
714
1051
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
715
1052
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
716
|
-
|
|
1053
|
+
config.sessions.update(sessionId, { status: "running" });
|
|
717
1054
|
await bridge.sendCommand({
|
|
718
1055
|
command: "iterate",
|
|
719
1056
|
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
@@ -734,6 +1071,12 @@ export function createApp(config) {
|
|
|
734
1071
|
}
|
|
735
1072
|
// Client may pass a human-readable summary of the decision for replay display
|
|
736
1073
|
const summary = body._summary || undefined;
|
|
1074
|
+
// Extract participant info from headers for gate attribution
|
|
1075
|
+
const participantId = c.req.header("X-Participant-Id");
|
|
1076
|
+
const participantName = c.req.header("X-Participant-Name");
|
|
1077
|
+
const resolvedBy = participantId && participantName
|
|
1078
|
+
? { id: participantId, displayName: participantName }
|
|
1079
|
+
: undefined;
|
|
737
1080
|
// AskUserQuestion gates: resolve the blocking hook-event and emit gate_resolved
|
|
738
1081
|
if (gate === "ask_user_question") {
|
|
739
1082
|
const toolUseId = body.toolUseId;
|
|
@@ -748,7 +1091,13 @@ export function createApp(config) {
|
|
|
748
1091
|
// Emit gate_resolved for replay
|
|
749
1092
|
try {
|
|
750
1093
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
751
|
-
await bridge.emit({
|
|
1094
|
+
await bridge.emit({
|
|
1095
|
+
type: "gate_resolved",
|
|
1096
|
+
gate: "ask_user_question",
|
|
1097
|
+
summary,
|
|
1098
|
+
resolvedBy,
|
|
1099
|
+
ts: ts(),
|
|
1100
|
+
});
|
|
752
1101
|
}
|
|
753
1102
|
catch {
|
|
754
1103
|
// Non-critical
|
|
@@ -767,7 +1116,7 @@ export function createApp(config) {
|
|
|
767
1116
|
await bridge.sendGateResponse(gate, value);
|
|
768
1117
|
// Persist gate resolution for replay
|
|
769
1118
|
try {
|
|
770
|
-
await bridge.emit({ type: "gate_resolved", gate, summary, ts: ts() });
|
|
1119
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, resolvedBy, ts: ts() });
|
|
771
1120
|
}
|
|
772
1121
|
catch {
|
|
773
1122
|
// Non-critical
|
|
@@ -836,7 +1185,7 @@ export function createApp(config) {
|
|
|
836
1185
|
// Persist gate resolution so replays mark the gate as resolved
|
|
837
1186
|
try {
|
|
838
1187
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
839
|
-
await bridge.emit({ type: "gate_resolved", gate, summary, details, ts: ts() });
|
|
1188
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, details, resolvedBy, ts: ts() });
|
|
840
1189
|
}
|
|
841
1190
|
catch {
|
|
842
1191
|
// Non-critical
|
|
@@ -847,7 +1196,7 @@ export function createApp(config) {
|
|
|
847
1196
|
// Check app status
|
|
848
1197
|
app.get("/api/sessions/:id/app-status", async (c) => {
|
|
849
1198
|
const sessionId = c.req.param("id");
|
|
850
|
-
const session =
|
|
1199
|
+
const session = config.sessions.get(sessionId);
|
|
851
1200
|
if (!session)
|
|
852
1201
|
return c.json({ error: "Session not found" }, 404);
|
|
853
1202
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -864,7 +1213,7 @@ export function createApp(config) {
|
|
|
864
1213
|
// Start the generated app
|
|
865
1214
|
app.post("/api/sessions/:id/start-app", async (c) => {
|
|
866
1215
|
const sessionId = c.req.param("id");
|
|
867
|
-
const session =
|
|
1216
|
+
const session = config.sessions.get(sessionId);
|
|
868
1217
|
if (!session)
|
|
869
1218
|
return c.json({ error: "Session not found" }, 404);
|
|
870
1219
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -877,7 +1226,7 @@ export function createApp(config) {
|
|
|
877
1226
|
// Stop the generated app
|
|
878
1227
|
app.post("/api/sessions/:id/stop-app", async (c) => {
|
|
879
1228
|
const sessionId = c.req.param("id");
|
|
880
|
-
const session =
|
|
1229
|
+
const session = config.sessions.get(sessionId);
|
|
881
1230
|
if (!session)
|
|
882
1231
|
return c.json({ error: "Session not found" }, 404);
|
|
883
1232
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -894,7 +1243,7 @@ export function createApp(config) {
|
|
|
894
1243
|
if (handle)
|
|
895
1244
|
await config.sandbox.destroy(handle);
|
|
896
1245
|
rejectAllGates(sessionId);
|
|
897
|
-
|
|
1246
|
+
config.sessions.update(sessionId, { status: "cancelled" });
|
|
898
1247
|
return c.json({ ok: true });
|
|
899
1248
|
});
|
|
900
1249
|
// Delete a session
|
|
@@ -905,7 +1254,7 @@ export function createApp(config) {
|
|
|
905
1254
|
if (handle)
|
|
906
1255
|
await config.sandbox.destroy(handle);
|
|
907
1256
|
rejectAllGates(sessionId);
|
|
908
|
-
const deleted =
|
|
1257
|
+
const deleted = config.sessions.delete(sessionId);
|
|
909
1258
|
if (!deleted)
|
|
910
1259
|
return c.json({ error: "Session not found" }, 404);
|
|
911
1260
|
return c.json({ ok: true });
|
|
@@ -975,18 +1324,230 @@ export function createApp(config) {
|
|
|
975
1324
|
await config.sandbox.destroy(handle);
|
|
976
1325
|
return c.json({ ok: true });
|
|
977
1326
|
});
|
|
1327
|
+
// --- Shared Sessions ---
|
|
1328
|
+
// Create a shared session
|
|
1329
|
+
app.post("/api/shared-sessions", async (c) => {
|
|
1330
|
+
const body = (await c.req.json());
|
|
1331
|
+
if (!body.name || !body.participant?.id || !body.participant?.displayName) {
|
|
1332
|
+
return c.json({ error: "name and participant (id, displayName) are required" }, 400);
|
|
1333
|
+
}
|
|
1334
|
+
const id = crypto.randomUUID();
|
|
1335
|
+
const code = generateInviteCode();
|
|
1336
|
+
// Create the shared session durable stream
|
|
1337
|
+
const conn = sharedSessionStream(config, id);
|
|
1338
|
+
try {
|
|
1339
|
+
await DurableStream.create({
|
|
1340
|
+
url: conn.url,
|
|
1341
|
+
headers: conn.headers,
|
|
1342
|
+
contentType: "application/json",
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
catch (err) {
|
|
1346
|
+
console.error(`[shared-session] Failed to create durable stream:`, err);
|
|
1347
|
+
return c.json({ error: "Failed to create shared session stream" }, 500);
|
|
1348
|
+
}
|
|
1349
|
+
// Write shared_session_created event
|
|
1350
|
+
const stream = new DurableStream({
|
|
1351
|
+
url: conn.url,
|
|
1352
|
+
headers: conn.headers,
|
|
1353
|
+
contentType: "application/json",
|
|
1354
|
+
});
|
|
1355
|
+
const createdEvent = {
|
|
1356
|
+
type: "shared_session_created",
|
|
1357
|
+
name: body.name,
|
|
1358
|
+
code,
|
|
1359
|
+
createdBy: body.participant,
|
|
1360
|
+
ts: ts(),
|
|
1361
|
+
};
|
|
1362
|
+
await stream.append(JSON.stringify(createdEvent));
|
|
1363
|
+
// Write participant_joined for the creator
|
|
1364
|
+
const joinedEvent = {
|
|
1365
|
+
type: "participant_joined",
|
|
1366
|
+
participant: body.participant,
|
|
1367
|
+
ts: ts(),
|
|
1368
|
+
};
|
|
1369
|
+
await stream.append(JSON.stringify(joinedEvent));
|
|
1370
|
+
// Save to room registry
|
|
1371
|
+
await config.rooms.addRoom({
|
|
1372
|
+
id,
|
|
1373
|
+
code,
|
|
1374
|
+
name: body.name,
|
|
1375
|
+
createdAt: new Date().toISOString(),
|
|
1376
|
+
revoked: false,
|
|
1377
|
+
});
|
|
1378
|
+
console.log(`[shared-session] Created: id=${id} code=${code}`);
|
|
1379
|
+
return c.json({ id, code }, 201);
|
|
1380
|
+
});
|
|
1381
|
+
// Resolve invite code → shared session ID
|
|
1382
|
+
app.get("/api/shared-sessions/join/:code", (c) => {
|
|
1383
|
+
const code = c.req.param("code");
|
|
1384
|
+
const entry = config.rooms.getRoomByCode(code);
|
|
1385
|
+
if (!entry)
|
|
1386
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1387
|
+
return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked });
|
|
1388
|
+
});
|
|
1389
|
+
// Join a shared session as participant
|
|
1390
|
+
app.post("/api/shared-sessions/:id/join", async (c) => {
|
|
1391
|
+
const id = c.req.param("id");
|
|
1392
|
+
const entry = config.rooms.getRoom(id);
|
|
1393
|
+
if (!entry)
|
|
1394
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1395
|
+
if (entry.revoked)
|
|
1396
|
+
return c.json({ error: "Invite code has been revoked" }, 403);
|
|
1397
|
+
const body = (await c.req.json());
|
|
1398
|
+
if (!body.participant?.id || !body.participant?.displayName) {
|
|
1399
|
+
return c.json({ error: "participant (id, displayName) is required" }, 400);
|
|
1400
|
+
}
|
|
1401
|
+
const conn = sharedSessionStream(config, id);
|
|
1402
|
+
const stream = new DurableStream({
|
|
1403
|
+
url: conn.url,
|
|
1404
|
+
headers: conn.headers,
|
|
1405
|
+
contentType: "application/json",
|
|
1406
|
+
});
|
|
1407
|
+
const event = {
|
|
1408
|
+
type: "participant_joined",
|
|
1409
|
+
participant: body.participant,
|
|
1410
|
+
ts: ts(),
|
|
1411
|
+
};
|
|
1412
|
+
await stream.append(JSON.stringify(event));
|
|
1413
|
+
return c.json({ ok: true });
|
|
1414
|
+
});
|
|
1415
|
+
// Leave a shared session
|
|
1416
|
+
app.post("/api/shared-sessions/:id/leave", async (c) => {
|
|
1417
|
+
const id = c.req.param("id");
|
|
1418
|
+
const body = (await c.req.json());
|
|
1419
|
+
if (!body.participantId) {
|
|
1420
|
+
return c.json({ error: "participantId is required" }, 400);
|
|
1421
|
+
}
|
|
1422
|
+
const conn = sharedSessionStream(config, id);
|
|
1423
|
+
const stream = new DurableStream({
|
|
1424
|
+
url: conn.url,
|
|
1425
|
+
headers: conn.headers,
|
|
1426
|
+
contentType: "application/json",
|
|
1427
|
+
});
|
|
1428
|
+
const event = {
|
|
1429
|
+
type: "participant_left",
|
|
1430
|
+
participantId: body.participantId,
|
|
1431
|
+
ts: ts(),
|
|
1432
|
+
};
|
|
1433
|
+
await stream.append(JSON.stringify(event));
|
|
1434
|
+
return c.json({ ok: true });
|
|
1435
|
+
});
|
|
1436
|
+
// Link a session to a shared session (room)
|
|
1437
|
+
// The client sends session metadata since sessions are private (localStorage).
|
|
1438
|
+
app.post("/api/shared-sessions/:id/sessions", async (c) => {
|
|
1439
|
+
const id = c.req.param("id");
|
|
1440
|
+
const body = (await c.req.json());
|
|
1441
|
+
if (!body.sessionId || !body.linkedBy) {
|
|
1442
|
+
return c.json({ error: "sessionId and linkedBy are required" }, 400);
|
|
1443
|
+
}
|
|
1444
|
+
const conn = sharedSessionStream(config, id);
|
|
1445
|
+
const stream = new DurableStream({
|
|
1446
|
+
url: conn.url,
|
|
1447
|
+
headers: conn.headers,
|
|
1448
|
+
contentType: "application/json",
|
|
1449
|
+
});
|
|
1450
|
+
const event = {
|
|
1451
|
+
type: "session_linked",
|
|
1452
|
+
sessionId: body.sessionId,
|
|
1453
|
+
sessionName: body.sessionName || "",
|
|
1454
|
+
sessionDescription: body.sessionDescription || "",
|
|
1455
|
+
linkedBy: body.linkedBy,
|
|
1456
|
+
ts: ts(),
|
|
1457
|
+
};
|
|
1458
|
+
await stream.append(JSON.stringify(event));
|
|
1459
|
+
return c.json({ ok: true });
|
|
1460
|
+
});
|
|
1461
|
+
// Unlink a session from a shared session
|
|
1462
|
+
app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
|
|
1463
|
+
const id = c.req.param("id");
|
|
1464
|
+
const sessionId = c.req.param("sessionId");
|
|
1465
|
+
const conn = sharedSessionStream(config, id);
|
|
1466
|
+
const stream = new DurableStream({
|
|
1467
|
+
url: conn.url,
|
|
1468
|
+
headers: conn.headers,
|
|
1469
|
+
contentType: "application/json",
|
|
1470
|
+
});
|
|
1471
|
+
const event = {
|
|
1472
|
+
type: "session_unlinked",
|
|
1473
|
+
sessionId,
|
|
1474
|
+
ts: ts(),
|
|
1475
|
+
};
|
|
1476
|
+
await stream.append(JSON.stringify(event));
|
|
1477
|
+
return c.json({ ok: true });
|
|
1478
|
+
});
|
|
1479
|
+
// SSE proxy for shared session events
|
|
1480
|
+
app.get("/api/shared-sessions/:id/events", async (c) => {
|
|
1481
|
+
const id = c.req.param("id");
|
|
1482
|
+
const entry = config.rooms.getRoom(id);
|
|
1483
|
+
if (!entry)
|
|
1484
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1485
|
+
const connection = sharedSessionStream(config, id);
|
|
1486
|
+
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
1487
|
+
const reader = new DurableStream({
|
|
1488
|
+
url: connection.url,
|
|
1489
|
+
headers: connection.headers,
|
|
1490
|
+
contentType: "application/json",
|
|
1491
|
+
});
|
|
1492
|
+
const { readable, writable } = new TransformStream();
|
|
1493
|
+
const writer = writable.getWriter();
|
|
1494
|
+
const encoder = new TextEncoder();
|
|
1495
|
+
let cancelled = false;
|
|
1496
|
+
const response = await reader.stream({
|
|
1497
|
+
offset: lastEventId,
|
|
1498
|
+
live: true,
|
|
1499
|
+
});
|
|
1500
|
+
const cancel = response.subscribeJson((batch) => {
|
|
1501
|
+
if (cancelled)
|
|
1502
|
+
return;
|
|
1503
|
+
for (const item of batch.items) {
|
|
1504
|
+
const data = JSON.stringify(item);
|
|
1505
|
+
writer.write(encoder.encode(`id:${batch.offset}\ndata:${data}\n\n`)).catch(() => {
|
|
1506
|
+
cancelled = true;
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
1511
|
+
cancelled = true;
|
|
1512
|
+
cancel();
|
|
1513
|
+
writer.close().catch(() => { });
|
|
1514
|
+
});
|
|
1515
|
+
return new Response(readable, {
|
|
1516
|
+
headers: {
|
|
1517
|
+
"Content-Type": "text/event-stream",
|
|
1518
|
+
"Cache-Control": "no-cache",
|
|
1519
|
+
Connection: "keep-alive",
|
|
1520
|
+
"Access-Control-Allow-Origin": "*",
|
|
1521
|
+
},
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
// Revoke a shared session's invite code
|
|
1525
|
+
app.post("/api/shared-sessions/:id/revoke", async (c) => {
|
|
1526
|
+
const id = c.req.param("id");
|
|
1527
|
+
const revoked = await config.rooms.revokeRoom(id);
|
|
1528
|
+
if (!revoked)
|
|
1529
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1530
|
+
const conn = sharedSessionStream(config, id);
|
|
1531
|
+
const stream = new DurableStream({
|
|
1532
|
+
url: conn.url,
|
|
1533
|
+
headers: conn.headers,
|
|
1534
|
+
contentType: "application/json",
|
|
1535
|
+
});
|
|
1536
|
+
const event = {
|
|
1537
|
+
type: "code_revoked",
|
|
1538
|
+
ts: ts(),
|
|
1539
|
+
};
|
|
1540
|
+
await stream.append(JSON.stringify(event));
|
|
1541
|
+
return c.json({ ok: true });
|
|
1542
|
+
});
|
|
978
1543
|
// --- SSE Proxy ---
|
|
979
1544
|
// Server-side SSE proxy: reads from the hosted durable stream and proxies
|
|
980
1545
|
// events to the React client. The client never sees DS credentials.
|
|
981
1546
|
app.get("/api/sessions/:id/events", async (c) => {
|
|
982
1547
|
const sessionId = c.req.param("id");
|
|
983
1548
|
console.log(`[sse] Client connected: session=${sessionId}`);
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
console.log(`[sse] Session not found: ${sessionId}`);
|
|
987
|
-
return c.json({ error: "Session not found" }, 404);
|
|
988
|
-
}
|
|
989
|
-
// Get the stream connection info
|
|
1549
|
+
// Get the stream connection info (no session lookup needed —
|
|
1550
|
+
// the DS stream may exist from a previous server lifetime)
|
|
990
1551
|
const connection = sessionStream(config, sessionId);
|
|
991
1552
|
// Last-Event-ID allows reconnection from where the client left off
|
|
992
1553
|
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
@@ -1046,7 +1607,7 @@ export function createApp(config) {
|
|
|
1046
1607
|
// Get git status for a session
|
|
1047
1608
|
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
1048
1609
|
const sessionId = c.req.param("id");
|
|
1049
|
-
const session =
|
|
1610
|
+
const session = config.sessions.get(sessionId);
|
|
1050
1611
|
if (!session)
|
|
1051
1612
|
return c.json({ error: "Session not found" }, 404);
|
|
1052
1613
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -1064,7 +1625,7 @@ export function createApp(config) {
|
|
|
1064
1625
|
// List all files in the project directory
|
|
1065
1626
|
app.get("/api/sessions/:id/files", async (c) => {
|
|
1066
1627
|
const sessionId = c.req.param("id");
|
|
1067
|
-
const session =
|
|
1628
|
+
const session = config.sessions.get(sessionId);
|
|
1068
1629
|
if (!session)
|
|
1069
1630
|
return c.json({ error: "Session not found" }, 404);
|
|
1070
1631
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -1078,7 +1639,7 @@ export function createApp(config) {
|
|
|
1078
1639
|
// Read a file's content
|
|
1079
1640
|
app.get("/api/sessions/:id/file-content", async (c) => {
|
|
1080
1641
|
const sessionId = c.req.param("id");
|
|
1081
|
-
const session =
|
|
1642
|
+
const session = config.sessions.get(sessionId);
|
|
1082
1643
|
if (!session)
|
|
1083
1644
|
return c.json({ error: "Session not found" }, 404);
|
|
1084
1645
|
const filePath = c.req.query("path");
|
|
@@ -1203,7 +1764,7 @@ export function createApp(config) {
|
|
|
1203
1764
|
lastCheckpointAt: null,
|
|
1204
1765
|
},
|
|
1205
1766
|
};
|
|
1206
|
-
|
|
1767
|
+
config.sessions.add(session);
|
|
1207
1768
|
// Write initial message to stream
|
|
1208
1769
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
1209
1770
|
await bridge.emit({
|
|
@@ -1212,7 +1773,7 @@ export function createApp(config) {
|
|
|
1212
1773
|
message: `Resumed from ${body.repoUrl}`,
|
|
1213
1774
|
ts: ts(),
|
|
1214
1775
|
});
|
|
1215
|
-
return c.json({ sessionId, appPort: handle.port }, 201);
|
|
1776
|
+
return c.json({ sessionId, session, appPort: handle.port }, 201);
|
|
1216
1777
|
}
|
|
1217
1778
|
catch (e) {
|
|
1218
1779
|
const msg = e instanceof Error ? e.message : "Failed to resume from repo";
|
|
@@ -1242,17 +1803,14 @@ export async function startWebServer(opts) {
|
|
|
1242
1803
|
const config = {
|
|
1243
1804
|
port: opts.port ?? 4400,
|
|
1244
1805
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
1806
|
+
sessions: new ActiveSessions(),
|
|
1807
|
+
rooms: opts.rooms,
|
|
1245
1808
|
sandbox: opts.sandbox,
|
|
1246
1809
|
streamConfig: opts.streamConfig,
|
|
1247
1810
|
bridgeMode: opts.bridgeMode ?? "stream",
|
|
1248
1811
|
inferProjectName: opts.inferProjectName,
|
|
1249
1812
|
};
|
|
1250
1813
|
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
1814
|
const app = createApp(config);
|
|
1257
1815
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|
|
1258
1816
|
serve({
|