@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.
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 +74 -0
  6. package/dist/bridge/claude-code-docker.d.ts.map +1 -0
  7. package/dist/bridge/claude-code-docker.js +305 -0
  8. package/dist/bridge/claude-code-docker.js.map +1 -0
  9. package/dist/bridge/claude-code-sprites.d.ts +64 -0
  10. package/dist/bridge/claude-code-sprites.d.ts.map +1 -0
  11. package/dist/bridge/claude-code-sprites.js +293 -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 +303 -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 +625 -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
  }
@@ -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 = getSession(config.dataDir, sessionId);
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
- updateSessionInfo(config.dataDir, sessionId, updates);
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
- updateSessionInfo(config.dataDir, sessionId, { status: "error" });
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 = getSession(config.dataDir, sessionId);
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
- await bridge.sendCommand({
703
- command: "git",
704
- projectDir: session.sandboxProjectDir || handle.projectDir,
705
- ...gitOp,
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
- updateSessionInfo(config.dataDir, sessionId, { status: "running" });
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({ type: "gate_resolved", gate: "ask_user_question", summary, ts: ts() });
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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
- updateSessionInfo(config.dataDir, sessionId, { status: "cancelled" });
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 = deleteSession(config.dataDir, sessionId);
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
- 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
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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 = getSession(config.dataDir, sessionId);
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
- addSession(config.dataDir, session);
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({