@electric-agent/studio 1.0.0 → 1.1.1
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 +74 -0
- package/dist/bridge/claude-code-docker.d.ts.map +1 -0
- package/dist/bridge/claude-code-docker.js +305 -0
- package/dist/bridge/claude-code-docker.js.map +1 -0
- package/dist/bridge/claude-code-sprites.d.ts +64 -0
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -0
- package/dist/bridge/claude-code-sprites.js +293 -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 +303 -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 +625 -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
|
}
|
|
@@ -592,6 +903,15 @@ export function createApp(config) {
|
|
|
592
903
|
});
|
|
593
904
|
}
|
|
594
905
|
// 5. Start listening for agent events via the bridge
|
|
906
|
+
// Track Claude Code session ID for --resume on iterate
|
|
907
|
+
bridge.onAgentEvent((event) => {
|
|
908
|
+
if (event.type === "session_start" && "session_id" in event) {
|
|
909
|
+
const ccSessionId = event.session_id;
|
|
910
|
+
if (ccSessionId) {
|
|
911
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
});
|
|
595
915
|
bridge.onComplete(async (success) => {
|
|
596
916
|
const updates = {
|
|
597
917
|
status: success ? "complete" : "error",
|
|
@@ -599,7 +919,7 @@ export function createApp(config) {
|
|
|
599
919
|
try {
|
|
600
920
|
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
601
921
|
if (gs.initialized) {
|
|
602
|
-
const existing =
|
|
922
|
+
const existing = config.sessions.get(sessionId);
|
|
603
923
|
updates.git = {
|
|
604
924
|
branch: gs.branch ?? "main",
|
|
605
925
|
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
@@ -614,7 +934,24 @@ export function createApp(config) {
|
|
|
614
934
|
catch {
|
|
615
935
|
// Container may already be stopped
|
|
616
936
|
}
|
|
617
|
-
|
|
937
|
+
config.sessions.update(sessionId, updates);
|
|
938
|
+
// For Claude Code mode: check if the app is running after completion
|
|
939
|
+
// and emit app_ready so the UI shows the preview link
|
|
940
|
+
if (sessionBridgeMode === "claude-code" && success) {
|
|
941
|
+
try {
|
|
942
|
+
const appRunning = await config.sandbox.isAppRunning(handle);
|
|
943
|
+
if (appRunning) {
|
|
944
|
+
await bridge.emit({
|
|
945
|
+
type: "app_ready",
|
|
946
|
+
port: handle.port ?? session.appPort,
|
|
947
|
+
ts: ts(),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
// Container may already be stopped
|
|
953
|
+
}
|
|
954
|
+
}
|
|
618
955
|
});
|
|
619
956
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
620
957
|
await bridge.start();
|
|
@@ -633,16 +970,16 @@ export function createApp(config) {
|
|
|
633
970
|
await bridge.sendCommand(newCmd);
|
|
634
971
|
console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
|
|
635
972
|
};
|
|
636
|
-
asyncFlow().catch((err) => {
|
|
973
|
+
asyncFlow().catch(async (err) => {
|
|
637
974
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
638
|
-
|
|
975
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
639
976
|
});
|
|
640
|
-
return c.json({ sessionId }, 201);
|
|
977
|
+
return c.json({ sessionId, session }, 201);
|
|
641
978
|
});
|
|
642
979
|
// Send iteration request
|
|
643
980
|
app.post("/api/sessions/:id/iterate", async (c) => {
|
|
644
981
|
const sessionId = c.req.param("id");
|
|
645
|
-
const session =
|
|
982
|
+
const session = config.sessions.get(sessionId);
|
|
646
983
|
if (!session)
|
|
647
984
|
return c.json({ error: "Session not found" }, 404);
|
|
648
985
|
const body = (await c.req.json());
|
|
@@ -699,11 +1036,20 @@ export function createApp(config) {
|
|
|
699
1036
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
700
1037
|
return c.json({ error: "Container is not running" }, 400);
|
|
701
1038
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1039
|
+
if (session.agentMode === "claude-code") {
|
|
1040
|
+
// In Claude Code mode, send git requests as user messages
|
|
1041
|
+
await bridge.sendCommand({
|
|
1042
|
+
command: "iterate",
|
|
1043
|
+
request: body.request,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
await bridge.sendCommand({
|
|
1048
|
+
command: "git",
|
|
1049
|
+
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
1050
|
+
...gitOp,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
707
1053
|
return c.json({ ok: true });
|
|
708
1054
|
}
|
|
709
1055
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -713,7 +1059,7 @@ export function createApp(config) {
|
|
|
713
1059
|
// Write user prompt to the stream
|
|
714
1060
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
715
1061
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
716
|
-
|
|
1062
|
+
config.sessions.update(sessionId, { status: "running" });
|
|
717
1063
|
await bridge.sendCommand({
|
|
718
1064
|
command: "iterate",
|
|
719
1065
|
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
@@ -734,6 +1080,12 @@ export function createApp(config) {
|
|
|
734
1080
|
}
|
|
735
1081
|
// Client may pass a human-readable summary of the decision for replay display
|
|
736
1082
|
const summary = body._summary || undefined;
|
|
1083
|
+
// Extract participant info from headers for gate attribution
|
|
1084
|
+
const participantId = c.req.header("X-Participant-Id");
|
|
1085
|
+
const participantName = c.req.header("X-Participant-Name");
|
|
1086
|
+
const resolvedBy = participantId && participantName
|
|
1087
|
+
? { id: participantId, displayName: participantName }
|
|
1088
|
+
: undefined;
|
|
737
1089
|
// AskUserQuestion gates: resolve the blocking hook-event and emit gate_resolved
|
|
738
1090
|
if (gate === "ask_user_question") {
|
|
739
1091
|
const toolUseId = body.toolUseId;
|
|
@@ -748,7 +1100,13 @@ export function createApp(config) {
|
|
|
748
1100
|
// Emit gate_resolved for replay
|
|
749
1101
|
try {
|
|
750
1102
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
751
|
-
await bridge.emit({
|
|
1103
|
+
await bridge.emit({
|
|
1104
|
+
type: "gate_resolved",
|
|
1105
|
+
gate: "ask_user_question",
|
|
1106
|
+
summary,
|
|
1107
|
+
resolvedBy,
|
|
1108
|
+
ts: ts(),
|
|
1109
|
+
});
|
|
752
1110
|
}
|
|
753
1111
|
catch {
|
|
754
1112
|
// Non-critical
|
|
@@ -767,7 +1125,7 @@ export function createApp(config) {
|
|
|
767
1125
|
await bridge.sendGateResponse(gate, value);
|
|
768
1126
|
// Persist gate resolution for replay
|
|
769
1127
|
try {
|
|
770
|
-
await bridge.emit({ type: "gate_resolved", gate, summary, ts: ts() });
|
|
1128
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, resolvedBy, ts: ts() });
|
|
771
1129
|
}
|
|
772
1130
|
catch {
|
|
773
1131
|
// Non-critical
|
|
@@ -836,7 +1194,7 @@ export function createApp(config) {
|
|
|
836
1194
|
// Persist gate resolution so replays mark the gate as resolved
|
|
837
1195
|
try {
|
|
838
1196
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
839
|
-
await bridge.emit({ type: "gate_resolved", gate, summary, details, ts: ts() });
|
|
1197
|
+
await bridge.emit({ type: "gate_resolved", gate, summary, details, resolvedBy, ts: ts() });
|
|
840
1198
|
}
|
|
841
1199
|
catch {
|
|
842
1200
|
// Non-critical
|
|
@@ -847,7 +1205,7 @@ export function createApp(config) {
|
|
|
847
1205
|
// Check app status
|
|
848
1206
|
app.get("/api/sessions/:id/app-status", async (c) => {
|
|
849
1207
|
const sessionId = c.req.param("id");
|
|
850
|
-
const session =
|
|
1208
|
+
const session = config.sessions.get(sessionId);
|
|
851
1209
|
if (!session)
|
|
852
1210
|
return c.json({ error: "Session not found" }, 404);
|
|
853
1211
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -864,7 +1222,7 @@ export function createApp(config) {
|
|
|
864
1222
|
// Start the generated app
|
|
865
1223
|
app.post("/api/sessions/:id/start-app", async (c) => {
|
|
866
1224
|
const sessionId = c.req.param("id");
|
|
867
|
-
const session =
|
|
1225
|
+
const session = config.sessions.get(sessionId);
|
|
868
1226
|
if (!session)
|
|
869
1227
|
return c.json({ error: "Session not found" }, 404);
|
|
870
1228
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -877,7 +1235,7 @@ export function createApp(config) {
|
|
|
877
1235
|
// Stop the generated app
|
|
878
1236
|
app.post("/api/sessions/:id/stop-app", async (c) => {
|
|
879
1237
|
const sessionId = c.req.param("id");
|
|
880
|
-
const session =
|
|
1238
|
+
const session = config.sessions.get(sessionId);
|
|
881
1239
|
if (!session)
|
|
882
1240
|
return c.json({ error: "Session not found" }, 404);
|
|
883
1241
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -894,7 +1252,7 @@ export function createApp(config) {
|
|
|
894
1252
|
if (handle)
|
|
895
1253
|
await config.sandbox.destroy(handle);
|
|
896
1254
|
rejectAllGates(sessionId);
|
|
897
|
-
|
|
1255
|
+
config.sessions.update(sessionId, { status: "cancelled" });
|
|
898
1256
|
return c.json({ ok: true });
|
|
899
1257
|
});
|
|
900
1258
|
// Delete a session
|
|
@@ -905,7 +1263,7 @@ export function createApp(config) {
|
|
|
905
1263
|
if (handle)
|
|
906
1264
|
await config.sandbox.destroy(handle);
|
|
907
1265
|
rejectAllGates(sessionId);
|
|
908
|
-
const deleted =
|
|
1266
|
+
const deleted = config.sessions.delete(sessionId);
|
|
909
1267
|
if (!deleted)
|
|
910
1268
|
return c.json({ error: "Session not found" }, 404);
|
|
911
1269
|
return c.json({ ok: true });
|
|
@@ -975,18 +1333,230 @@ export function createApp(config) {
|
|
|
975
1333
|
await config.sandbox.destroy(handle);
|
|
976
1334
|
return c.json({ ok: true });
|
|
977
1335
|
});
|
|
1336
|
+
// --- Shared Sessions ---
|
|
1337
|
+
// Create a shared session
|
|
1338
|
+
app.post("/api/shared-sessions", async (c) => {
|
|
1339
|
+
const body = (await c.req.json());
|
|
1340
|
+
if (!body.name || !body.participant?.id || !body.participant?.displayName) {
|
|
1341
|
+
return c.json({ error: "name and participant (id, displayName) are required" }, 400);
|
|
1342
|
+
}
|
|
1343
|
+
const id = crypto.randomUUID();
|
|
1344
|
+
const code = generateInviteCode();
|
|
1345
|
+
// Create the shared session durable stream
|
|
1346
|
+
const conn = sharedSessionStream(config, id);
|
|
1347
|
+
try {
|
|
1348
|
+
await DurableStream.create({
|
|
1349
|
+
url: conn.url,
|
|
1350
|
+
headers: conn.headers,
|
|
1351
|
+
contentType: "application/json",
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
catch (err) {
|
|
1355
|
+
console.error(`[shared-session] Failed to create durable stream:`, err);
|
|
1356
|
+
return c.json({ error: "Failed to create shared session stream" }, 500);
|
|
1357
|
+
}
|
|
1358
|
+
// Write shared_session_created event
|
|
1359
|
+
const stream = new DurableStream({
|
|
1360
|
+
url: conn.url,
|
|
1361
|
+
headers: conn.headers,
|
|
1362
|
+
contentType: "application/json",
|
|
1363
|
+
});
|
|
1364
|
+
const createdEvent = {
|
|
1365
|
+
type: "shared_session_created",
|
|
1366
|
+
name: body.name,
|
|
1367
|
+
code,
|
|
1368
|
+
createdBy: body.participant,
|
|
1369
|
+
ts: ts(),
|
|
1370
|
+
};
|
|
1371
|
+
await stream.append(JSON.stringify(createdEvent));
|
|
1372
|
+
// Write participant_joined for the creator
|
|
1373
|
+
const joinedEvent = {
|
|
1374
|
+
type: "participant_joined",
|
|
1375
|
+
participant: body.participant,
|
|
1376
|
+
ts: ts(),
|
|
1377
|
+
};
|
|
1378
|
+
await stream.append(JSON.stringify(joinedEvent));
|
|
1379
|
+
// Save to room registry
|
|
1380
|
+
await config.rooms.addRoom({
|
|
1381
|
+
id,
|
|
1382
|
+
code,
|
|
1383
|
+
name: body.name,
|
|
1384
|
+
createdAt: new Date().toISOString(),
|
|
1385
|
+
revoked: false,
|
|
1386
|
+
});
|
|
1387
|
+
console.log(`[shared-session] Created: id=${id} code=${code}`);
|
|
1388
|
+
return c.json({ id, code }, 201);
|
|
1389
|
+
});
|
|
1390
|
+
// Resolve invite code → shared session ID
|
|
1391
|
+
app.get("/api/shared-sessions/join/:code", (c) => {
|
|
1392
|
+
const code = c.req.param("code");
|
|
1393
|
+
const entry = config.rooms.getRoomByCode(code);
|
|
1394
|
+
if (!entry)
|
|
1395
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1396
|
+
return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked });
|
|
1397
|
+
});
|
|
1398
|
+
// Join a shared session as participant
|
|
1399
|
+
app.post("/api/shared-sessions/:id/join", async (c) => {
|
|
1400
|
+
const id = c.req.param("id");
|
|
1401
|
+
const entry = config.rooms.getRoom(id);
|
|
1402
|
+
if (!entry)
|
|
1403
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1404
|
+
if (entry.revoked)
|
|
1405
|
+
return c.json({ error: "Invite code has been revoked" }, 403);
|
|
1406
|
+
const body = (await c.req.json());
|
|
1407
|
+
if (!body.participant?.id || !body.participant?.displayName) {
|
|
1408
|
+
return c.json({ error: "participant (id, displayName) is required" }, 400);
|
|
1409
|
+
}
|
|
1410
|
+
const conn = sharedSessionStream(config, id);
|
|
1411
|
+
const stream = new DurableStream({
|
|
1412
|
+
url: conn.url,
|
|
1413
|
+
headers: conn.headers,
|
|
1414
|
+
contentType: "application/json",
|
|
1415
|
+
});
|
|
1416
|
+
const event = {
|
|
1417
|
+
type: "participant_joined",
|
|
1418
|
+
participant: body.participant,
|
|
1419
|
+
ts: ts(),
|
|
1420
|
+
};
|
|
1421
|
+
await stream.append(JSON.stringify(event));
|
|
1422
|
+
return c.json({ ok: true });
|
|
1423
|
+
});
|
|
1424
|
+
// Leave a shared session
|
|
1425
|
+
app.post("/api/shared-sessions/:id/leave", async (c) => {
|
|
1426
|
+
const id = c.req.param("id");
|
|
1427
|
+
const body = (await c.req.json());
|
|
1428
|
+
if (!body.participantId) {
|
|
1429
|
+
return c.json({ error: "participantId is required" }, 400);
|
|
1430
|
+
}
|
|
1431
|
+
const conn = sharedSessionStream(config, id);
|
|
1432
|
+
const stream = new DurableStream({
|
|
1433
|
+
url: conn.url,
|
|
1434
|
+
headers: conn.headers,
|
|
1435
|
+
contentType: "application/json",
|
|
1436
|
+
});
|
|
1437
|
+
const event = {
|
|
1438
|
+
type: "participant_left",
|
|
1439
|
+
participantId: body.participantId,
|
|
1440
|
+
ts: ts(),
|
|
1441
|
+
};
|
|
1442
|
+
await stream.append(JSON.stringify(event));
|
|
1443
|
+
return c.json({ ok: true });
|
|
1444
|
+
});
|
|
1445
|
+
// Link a session to a shared session (room)
|
|
1446
|
+
// The client sends session metadata since sessions are private (localStorage).
|
|
1447
|
+
app.post("/api/shared-sessions/:id/sessions", async (c) => {
|
|
1448
|
+
const id = c.req.param("id");
|
|
1449
|
+
const body = (await c.req.json());
|
|
1450
|
+
if (!body.sessionId || !body.linkedBy) {
|
|
1451
|
+
return c.json({ error: "sessionId and linkedBy are required" }, 400);
|
|
1452
|
+
}
|
|
1453
|
+
const conn = sharedSessionStream(config, id);
|
|
1454
|
+
const stream = new DurableStream({
|
|
1455
|
+
url: conn.url,
|
|
1456
|
+
headers: conn.headers,
|
|
1457
|
+
contentType: "application/json",
|
|
1458
|
+
});
|
|
1459
|
+
const event = {
|
|
1460
|
+
type: "session_linked",
|
|
1461
|
+
sessionId: body.sessionId,
|
|
1462
|
+
sessionName: body.sessionName || "",
|
|
1463
|
+
sessionDescription: body.sessionDescription || "",
|
|
1464
|
+
linkedBy: body.linkedBy,
|
|
1465
|
+
ts: ts(),
|
|
1466
|
+
};
|
|
1467
|
+
await stream.append(JSON.stringify(event));
|
|
1468
|
+
return c.json({ ok: true });
|
|
1469
|
+
});
|
|
1470
|
+
// Unlink a session from a shared session
|
|
1471
|
+
app.delete("/api/shared-sessions/:id/sessions/:sessionId", async (c) => {
|
|
1472
|
+
const id = c.req.param("id");
|
|
1473
|
+
const sessionId = c.req.param("sessionId");
|
|
1474
|
+
const conn = sharedSessionStream(config, id);
|
|
1475
|
+
const stream = new DurableStream({
|
|
1476
|
+
url: conn.url,
|
|
1477
|
+
headers: conn.headers,
|
|
1478
|
+
contentType: "application/json",
|
|
1479
|
+
});
|
|
1480
|
+
const event = {
|
|
1481
|
+
type: "session_unlinked",
|
|
1482
|
+
sessionId,
|
|
1483
|
+
ts: ts(),
|
|
1484
|
+
};
|
|
1485
|
+
await stream.append(JSON.stringify(event));
|
|
1486
|
+
return c.json({ ok: true });
|
|
1487
|
+
});
|
|
1488
|
+
// SSE proxy for shared session events
|
|
1489
|
+
app.get("/api/shared-sessions/:id/events", async (c) => {
|
|
1490
|
+
const id = c.req.param("id");
|
|
1491
|
+
const entry = config.rooms.getRoom(id);
|
|
1492
|
+
if (!entry)
|
|
1493
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1494
|
+
const connection = sharedSessionStream(config, id);
|
|
1495
|
+
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
1496
|
+
const reader = new DurableStream({
|
|
1497
|
+
url: connection.url,
|
|
1498
|
+
headers: connection.headers,
|
|
1499
|
+
contentType: "application/json",
|
|
1500
|
+
});
|
|
1501
|
+
const { readable, writable } = new TransformStream();
|
|
1502
|
+
const writer = writable.getWriter();
|
|
1503
|
+
const encoder = new TextEncoder();
|
|
1504
|
+
let cancelled = false;
|
|
1505
|
+
const response = await reader.stream({
|
|
1506
|
+
offset: lastEventId,
|
|
1507
|
+
live: true,
|
|
1508
|
+
});
|
|
1509
|
+
const cancel = response.subscribeJson((batch) => {
|
|
1510
|
+
if (cancelled)
|
|
1511
|
+
return;
|
|
1512
|
+
for (const item of batch.items) {
|
|
1513
|
+
const data = JSON.stringify(item);
|
|
1514
|
+
writer.write(encoder.encode(`id:${batch.offset}\ndata:${data}\n\n`)).catch(() => {
|
|
1515
|
+
cancelled = true;
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
1520
|
+
cancelled = true;
|
|
1521
|
+
cancel();
|
|
1522
|
+
writer.close().catch(() => { });
|
|
1523
|
+
});
|
|
1524
|
+
return new Response(readable, {
|
|
1525
|
+
headers: {
|
|
1526
|
+
"Content-Type": "text/event-stream",
|
|
1527
|
+
"Cache-Control": "no-cache",
|
|
1528
|
+
Connection: "keep-alive",
|
|
1529
|
+
"Access-Control-Allow-Origin": "*",
|
|
1530
|
+
},
|
|
1531
|
+
});
|
|
1532
|
+
});
|
|
1533
|
+
// Revoke a shared session's invite code
|
|
1534
|
+
app.post("/api/shared-sessions/:id/revoke", async (c) => {
|
|
1535
|
+
const id = c.req.param("id");
|
|
1536
|
+
const revoked = await config.rooms.revokeRoom(id);
|
|
1537
|
+
if (!revoked)
|
|
1538
|
+
return c.json({ error: "Shared session not found" }, 404);
|
|
1539
|
+
const conn = sharedSessionStream(config, id);
|
|
1540
|
+
const stream = new DurableStream({
|
|
1541
|
+
url: conn.url,
|
|
1542
|
+
headers: conn.headers,
|
|
1543
|
+
contentType: "application/json",
|
|
1544
|
+
});
|
|
1545
|
+
const event = {
|
|
1546
|
+
type: "code_revoked",
|
|
1547
|
+
ts: ts(),
|
|
1548
|
+
};
|
|
1549
|
+
await stream.append(JSON.stringify(event));
|
|
1550
|
+
return c.json({ ok: true });
|
|
1551
|
+
});
|
|
978
1552
|
// --- SSE Proxy ---
|
|
979
1553
|
// Server-side SSE proxy: reads from the hosted durable stream and proxies
|
|
980
1554
|
// events to the React client. The client never sees DS credentials.
|
|
981
1555
|
app.get("/api/sessions/:id/events", async (c) => {
|
|
982
1556
|
const sessionId = c.req.param("id");
|
|
983
1557
|
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
|
|
1558
|
+
// Get the stream connection info (no session lookup needed —
|
|
1559
|
+
// the DS stream may exist from a previous server lifetime)
|
|
990
1560
|
const connection = sessionStream(config, sessionId);
|
|
991
1561
|
// Last-Event-ID allows reconnection from where the client left off
|
|
992
1562
|
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
@@ -1046,7 +1616,7 @@ export function createApp(config) {
|
|
|
1046
1616
|
// Get git status for a session
|
|
1047
1617
|
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
1048
1618
|
const sessionId = c.req.param("id");
|
|
1049
|
-
const session =
|
|
1619
|
+
const session = config.sessions.get(sessionId);
|
|
1050
1620
|
if (!session)
|
|
1051
1621
|
return c.json({ error: "Session not found" }, 404);
|
|
1052
1622
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -1064,7 +1634,7 @@ export function createApp(config) {
|
|
|
1064
1634
|
// List all files in the project directory
|
|
1065
1635
|
app.get("/api/sessions/:id/files", async (c) => {
|
|
1066
1636
|
const sessionId = c.req.param("id");
|
|
1067
|
-
const session =
|
|
1637
|
+
const session = config.sessions.get(sessionId);
|
|
1068
1638
|
if (!session)
|
|
1069
1639
|
return c.json({ error: "Session not found" }, 404);
|
|
1070
1640
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -1078,7 +1648,7 @@ export function createApp(config) {
|
|
|
1078
1648
|
// Read a file's content
|
|
1079
1649
|
app.get("/api/sessions/:id/file-content", async (c) => {
|
|
1080
1650
|
const sessionId = c.req.param("id");
|
|
1081
|
-
const session =
|
|
1651
|
+
const session = config.sessions.get(sessionId);
|
|
1082
1652
|
if (!session)
|
|
1083
1653
|
return c.json({ error: "Session not found" }, 404);
|
|
1084
1654
|
const filePath = c.req.query("path");
|
|
@@ -1203,7 +1773,7 @@ export function createApp(config) {
|
|
|
1203
1773
|
lastCheckpointAt: null,
|
|
1204
1774
|
},
|
|
1205
1775
|
};
|
|
1206
|
-
|
|
1776
|
+
config.sessions.add(session);
|
|
1207
1777
|
// Write initial message to stream
|
|
1208
1778
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
1209
1779
|
await bridge.emit({
|
|
@@ -1212,7 +1782,7 @@ export function createApp(config) {
|
|
|
1212
1782
|
message: `Resumed from ${body.repoUrl}`,
|
|
1213
1783
|
ts: ts(),
|
|
1214
1784
|
});
|
|
1215
|
-
return c.json({ sessionId, appPort: handle.port }, 201);
|
|
1785
|
+
return c.json({ sessionId, session, appPort: handle.port }, 201);
|
|
1216
1786
|
}
|
|
1217
1787
|
catch (e) {
|
|
1218
1788
|
const msg = e instanceof Error ? e.message : "Failed to resume from repo";
|
|
@@ -1242,17 +1812,14 @@ export async function startWebServer(opts) {
|
|
|
1242
1812
|
const config = {
|
|
1243
1813
|
port: opts.port ?? 4400,
|
|
1244
1814
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
1815
|
+
sessions: new ActiveSessions(),
|
|
1816
|
+
rooms: opts.rooms,
|
|
1245
1817
|
sandbox: opts.sandbox,
|
|
1246
1818
|
streamConfig: opts.streamConfig,
|
|
1247
1819
|
bridgeMode: opts.bridgeMode ?? "stream",
|
|
1248
1820
|
inferProjectName: opts.inferProjectName,
|
|
1249
1821
|
};
|
|
1250
1822
|
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
1823
|
const app = createApp(config);
|
|
1257
1824
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|
|
1258
1825
|
serve({
|