@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.
Files changed (68) hide show
  1. package/dist/active-sessions.d.ts +28 -0
  2. package/dist/active-sessions.d.ts.map +1 -0
  3. package/dist/active-sessions.js +50 -0
  4. package/dist/active-sessions.js.map +1 -0
  5. package/dist/bridge/claude-code-docker.d.ts +59 -0
  6. package/dist/bridge/claude-code-docker.d.ts.map +1 -0
  7. package/dist/bridge/claude-code-docker.js +258 -0
  8. package/dist/bridge/claude-code-docker.js.map +1 -0
  9. package/dist/bridge/claude-code-sprites.d.ts +49 -0
  10. package/dist/bridge/claude-code-sprites.d.ts.map +1 -0
  11. package/dist/bridge/claude-code-sprites.js +231 -0
  12. package/dist/bridge/claude-code-sprites.js.map +1 -0
  13. package/dist/bridge/claude-md-generator.d.ts +24 -0
  14. package/dist/bridge/claude-md-generator.d.ts.map +1 -0
  15. package/dist/bridge/claude-md-generator.js +299 -0
  16. package/dist/bridge/claude-md-generator.js.map +1 -0
  17. package/dist/bridge/index.d.ts +3 -0
  18. package/dist/bridge/index.d.ts.map +1 -1
  19. package/dist/bridge/index.js +3 -0
  20. package/dist/bridge/index.js.map +1 -1
  21. package/dist/bridge/stream-json-parser.d.ts +30 -0
  22. package/dist/bridge/stream-json-parser.d.ts.map +1 -0
  23. package/dist/bridge/stream-json-parser.js +207 -0
  24. package/dist/bridge/stream-json-parser.js.map +1 -0
  25. package/dist/client/assets/index-BeZ6CTGd.css +1 -0
  26. package/dist/client/assets/index-DRLXdDNp.js +241 -0
  27. package/dist/client/index.html +2 -2
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +4 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/project-utils.d.ts +2 -1
  33. package/dist/project-utils.d.ts.map +1 -1
  34. package/dist/project-utils.js +2 -6
  35. package/dist/project-utils.js.map +1 -1
  36. package/dist/registry.d.ts +52 -0
  37. package/dist/registry.d.ts.map +1 -0
  38. package/dist/registry.js +204 -0
  39. package/dist/registry.js.map +1 -0
  40. package/dist/room-registry.d.ts +40 -0
  41. package/dist/room-registry.d.ts.map +1 -0
  42. package/dist/room-registry.js +112 -0
  43. package/dist/room-registry.js.map +1 -0
  44. package/dist/sandbox/sprites-bootstrap.d.ts.map +1 -1
  45. package/dist/sandbox/sprites-bootstrap.js +7 -1
  46. package/dist/sandbox/sprites-bootstrap.js.map +1 -1
  47. package/dist/sandbox/sprites.d.ts +5 -0
  48. package/dist/sandbox/sprites.d.ts.map +1 -1
  49. package/dist/sandbox/sprites.js +22 -2
  50. package/dist/sandbox/sprites.js.map +1 -1
  51. package/dist/server.d.ts +9 -2
  52. package/dist/server.d.ts.map +1 -1
  53. package/dist/server.js +616 -58
  54. package/dist/server.js.map +1 -1
  55. package/dist/sessions.d.ts +2 -0
  56. package/dist/sessions.d.ts.map +1 -1
  57. package/dist/sessions.js.map +1 -1
  58. package/dist/shared-sessions.d.ts +16 -0
  59. package/dist/shared-sessions.d.ts.map +1 -0
  60. package/dist/shared-sessions.js +52 -0
  61. package/dist/shared-sessions.js.map +1 -0
  62. package/dist/streams.d.ts +8 -0
  63. package/dist/streams.d.ts.map +1 -1
  64. package/dist/streams.js +22 -0
  65. package/dist/streams.js.map +1 -1
  66. package/package.json +15 -2
  67. package/dist/client/assets/index-CK__1-6e.css +0 -1
  68. 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 { addSession, cleanupStaleSessions, deleteSession, getSession, readSessionIndex, updateSessionInfo, } from "./sessions.js";
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
- // List all sessions (lazily clean up stale ones)
249
- app.get("/api/sessions", (c) => {
250
- cleanupStaleSessions(config.dataDir);
251
- const index = readSessionIndex(config.dataDir);
252
- return c.json(index);
253
- });
254
- // Get single session
286
+ // Get single session (from in-memory active sessions)
255
287
  app.get("/api/sessions/:id", (c) => {
256
- const session = getSession(config.dataDir, c.req.param("id"));
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
- addSession(config.dataDir, session);
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
- addSession(config.dataDir, session);
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
- updateSessionInfo(config.dataDir, sessionId, {});
395
+ config.sessions.update(sessionId, {});
364
396
  // SessionEnd: mark session complete and close the bridge
365
397
  if (hookEvent.type === "session_end") {
366
- updateSessionInfo(config.dataDir, sessionId, { status: "complete" });
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
- addSession(config.dataDir, session);
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
- updateSessionInfo(config.dataDir, sessionId, {
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 = config.bridgeMode === "stdio" ? undefined : getStreamEnvVars(sessionId, config.streamConfig);
524
- console.log(`[session:${sessionId}] Creating sandbox: runtime=${config.sandbox.runtime} project=${projectName} bridgeMode=${config.bridgeMode}`);
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: config.bridgeMode === "stdio",
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
- updateSessionInfo(config.dataDir, sessionId, {
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 (config.bridgeMode === "stdio") {
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 = getSession(config.dataDir, sessionId);
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
- updateSessionInfo(config.dataDir, sessionId, updates);
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
- updateSessionInfo(config.dataDir, sessionId, { status: "error" });
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 = getSession(config.dataDir, sessionId);
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
- await bridge.sendCommand({
703
- command: "git",
704
- projectDir: session.sandboxProjectDir || handle.projectDir,
705
- ...gitOp,
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
- updateSessionInfo(config.dataDir, sessionId, { status: "running" });
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({ type: "gate_resolved", gate: "ask_user_question", summary, ts: ts() });
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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
- updateSessionInfo(config.dataDir, sessionId, { status: "cancelled" });
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 = deleteSession(config.dataDir, sessionId);
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
- const session = getSession(config.dataDir, sessionId);
985
- if (!session) {
986
- console.log(`[sse] Session not found: ${sessionId}`);
987
- return c.json({ error: "Session not found" }, 404);
988
- }
989
- // Get the stream connection info
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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
- addSession(config.dataDir, session);
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({