@electric-agent/studio 1.1.1 → 1.3.4

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 (79) hide show
  1. package/dist/bridge/claude-code-docker.d.ts +14 -0
  2. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  3. package/dist/bridge/claude-code-docker.js +121 -22
  4. package/dist/bridge/claude-code-docker.js.map +1 -1
  5. package/dist/bridge/claude-code-sprites.d.ts +13 -1
  6. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  7. package/dist/bridge/claude-code-sprites.js +122 -26
  8. package/dist/bridge/claude-code-sprites.js.map +1 -1
  9. package/dist/bridge/claude-md-generator.d.ts +1 -0
  10. package/dist/bridge/claude-md-generator.d.ts.map +1 -1
  11. package/dist/bridge/claude-md-generator.js +67 -12
  12. package/dist/bridge/claude-md-generator.js.map +1 -1
  13. package/dist/bridge/codex-docker.d.ts +65 -0
  14. package/dist/bridge/codex-docker.d.ts.map +1 -0
  15. package/dist/bridge/codex-docker.js +242 -0
  16. package/dist/bridge/codex-docker.js.map +1 -0
  17. package/dist/bridge/codex-json-parser.d.ts +31 -0
  18. package/dist/bridge/codex-json-parser.d.ts.map +1 -0
  19. package/dist/bridge/codex-json-parser.js +274 -0
  20. package/dist/bridge/codex-json-parser.js.map +1 -0
  21. package/dist/bridge/codex-md-generator.d.ts +14 -0
  22. package/dist/bridge/codex-md-generator.d.ts.map +1 -0
  23. package/dist/bridge/codex-md-generator.js +45 -0
  24. package/dist/bridge/codex-md-generator.js.map +1 -0
  25. package/dist/bridge/codex-sprites.d.ts +59 -0
  26. package/dist/bridge/codex-sprites.d.ts.map +1 -0
  27. package/dist/bridge/codex-sprites.js +237 -0
  28. package/dist/bridge/codex-sprites.js.map +1 -0
  29. package/dist/bridge/create-app-skill.d.ts +11 -0
  30. package/dist/bridge/create-app-skill.d.ts.map +1 -0
  31. package/dist/bridge/create-app-skill.js +39 -0
  32. package/dist/bridge/create-app-skill.js.map +1 -0
  33. package/dist/bridge/hosted.d.ts +1 -0
  34. package/dist/bridge/hosted.d.ts.map +1 -1
  35. package/dist/bridge/hosted.js +4 -0
  36. package/dist/bridge/hosted.js.map +1 -1
  37. package/dist/bridge/index.d.ts +0 -3
  38. package/dist/bridge/index.d.ts.map +1 -1
  39. package/dist/bridge/index.js +0 -3
  40. package/dist/bridge/index.js.map +1 -1
  41. package/dist/bridge/stream-json-parser.d.ts +0 -2
  42. package/dist/bridge/stream-json-parser.d.ts.map +1 -1
  43. package/dist/bridge/stream-json-parser.js +0 -18
  44. package/dist/bridge/stream-json-parser.js.map +1 -1
  45. package/dist/bridge/types.d.ts +6 -0
  46. package/dist/bridge/types.d.ts.map +1 -1
  47. package/dist/client/assets/index-B6arNdVE.css +1 -0
  48. package/dist/client/assets/index-CxBu-PUg.js +234 -0
  49. package/dist/client/index.html +2 -2
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/sandbox/daytona.js +4 -4
  55. package/dist/sandbox/daytona.js.map +1 -1
  56. package/dist/sandbox/docker.d.ts +0 -1
  57. package/dist/sandbox/docker.d.ts.map +1 -1
  58. package/dist/sandbox/docker.js +10 -42
  59. package/dist/sandbox/docker.js.map +1 -1
  60. package/dist/sandbox/sprites.d.ts +0 -6
  61. package/dist/sandbox/sprites.d.ts.map +1 -1
  62. package/dist/sandbox/sprites.js +4 -36
  63. package/dist/sandbox/sprites.js.map +1 -1
  64. package/dist/sandbox/types.d.ts +0 -8
  65. package/dist/sandbox/types.d.ts.map +1 -1
  66. package/dist/server.d.ts +2 -5
  67. package/dist/server.d.ts.map +1 -1
  68. package/dist/server.js +212 -161
  69. package/dist/server.js.map +1 -1
  70. package/dist/session-auth.d.ts +3 -0
  71. package/dist/session-auth.d.ts.map +1 -0
  72. package/dist/session-auth.js +11 -0
  73. package/dist/session-auth.js.map +1 -0
  74. package/dist/sessions.d.ts +0 -2
  75. package/dist/sessions.d.ts.map +1 -1
  76. package/dist/sessions.js.map +1 -1
  77. package/package.json +2 -2
  78. package/dist/client/assets/index-BeZ6CTGd.css +0 -1
  79. package/dist/client/assets/index-DRLXdDNp.js +0 -241
package/dist/server.js CHANGED
@@ -11,17 +11,15 @@ import { cors } from "hono/cors";
11
11
  import { ActiveSessions } from "./active-sessions.js";
12
12
  import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
13
13
  import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
14
- import { generateClaudeMd } from "./bridge/claude-md-generator.js";
15
- import { DaytonaSessionBridge } from "./bridge/daytona.js";
16
- import { DockerStdioBridge } from "./bridge/docker-stdio.js";
14
+ import { createAppSkillContent, generateClaudeMd } from "./bridge/claude-md-generator.js";
17
15
  import { HostedStreamBridge } from "./bridge/hosted.js";
18
- import { SpritesStdioBridge } from "./bridge/sprites.js";
19
16
  import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
20
17
  import { createGate, rejectAllGates, resolveGate } from "./gate.js";
21
18
  import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
22
19
  import { resolveProjectDir } from "./project-utils.js";
20
+ import { deriveSessionToken, validateSessionToken } from "./session-auth.js";
23
21
  import { generateInviteCode } from "./shared-sessions.js";
24
- import { getSharedStreamConnectionInfo, getStreamConnectionInfo, getStreamEnvVars, } from "./streams.js";
22
+ import { getSharedStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
25
23
  /** Active session bridges — one per running session */
26
24
  const bridges = new Map();
27
25
  /** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
@@ -51,40 +49,20 @@ function getOrCreateBridge(config, sessionId) {
51
49
  return bridge;
52
50
  }
53
51
  /**
54
- * Create a stdio-based bridge for a session after the sandbox has been created.
55
- * Replaces any existing hosted bridge for the session.
52
+ * Resolve the studio server URL for remote sandboxes (Sprites).
53
+ * On Fly.io: uses the app's public HTTPS URL.
54
+ * Locally: falls back to ngrok/tailscale URL from STUDIO_URL env, or localhost (won't work from sprites).
56
55
  */
57
- function createStdioBridge(config, sessionId) {
58
- const conn = sessionStream(config, sessionId);
59
- let bridge;
60
- if (config.sandbox.runtime === "daytona") {
61
- const daytonaProvider = config.sandbox;
62
- const sandbox = daytonaProvider.getSandboxObject(sessionId);
63
- if (!sandbox) {
64
- throw new Error(`No Daytona sandbox object for session ${sessionId}`);
65
- }
66
- bridge = new DaytonaSessionBridge(sessionId, conn, sandbox);
67
- }
68
- else if (config.sandbox.runtime === "sprites") {
69
- const spritesProvider = config.sandbox;
70
- const sprite = spritesProvider.getSpriteObject(sessionId);
71
- if (!sprite) {
72
- throw new Error(`No Sprites sandbox object for session ${sessionId}`);
73
- }
74
- bridge = new SpritesStdioBridge(sessionId, conn, sprite);
75
- }
76
- else {
77
- const dockerProvider = config.sandbox;
78
- const containerId = dockerProvider.getContainerId(sessionId);
79
- if (!containerId) {
80
- throw new Error(`No Docker container found for session ${sessionId}`);
81
- }
82
- bridge = new DockerStdioBridge(sessionId, conn, containerId);
83
- }
84
- // Replace any existing bridge
85
- closeBridge(sessionId);
86
- bridges.set(sessionId, bridge);
87
- return bridge;
56
+ function resolveStudioUrl(port) {
57
+ // Explicit override (e.g. ngrok tunnel for local dev with sprites)
58
+ if (process.env.STUDIO_URL)
59
+ return process.env.STUDIO_URL;
60
+ // Fly.io FLY_APP_NAME is set automatically
61
+ const flyApp = process.env.FLY_APP_NAME;
62
+ if (flyApp)
63
+ return `https://${flyApp}.fly.dev`;
64
+ // Fallback — won't work from sprites VMs, but at least logs a useful URL
65
+ return `http://localhost:${port}`;
88
66
  }
89
67
  /**
90
68
  * Create a Claude Code bridge for a session.
@@ -283,6 +261,45 @@ export function createApp(config) {
283
261
  return c.json({ error: message }, 500);
284
262
  }
285
263
  });
264
+ // --- Session Token Auth Middleware ---
265
+ // Protects session-scoped endpoints. Hook endpoints and creation routes are exempt.
266
+ // Hono's wildcard middleware matches creation routes like /api/sessions/local as
267
+ // :id="local", so we must explicitly skip those.
268
+ const authExemptIds = new Set(["local", "auto", "resume"]);
269
+ const hookExemptSuffixes = new Set(["/hook-event"]);
270
+ /** Extract session token from Authorization header or query param. */
271
+ function extractToken(c) {
272
+ const authHeader = c.req.header("Authorization");
273
+ if (authHeader?.startsWith("Bearer "))
274
+ return authHeader.slice(7);
275
+ return c.req.query("token") ?? undefined;
276
+ }
277
+ // Protect /api/sessions/:id/* and /api/sessions/:id
278
+ app.use("/api/sessions/:id/*", async (c, next) => {
279
+ const id = c.req.param("id");
280
+ if (authExemptIds.has(id))
281
+ return next();
282
+ const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
283
+ if (hookExemptSuffixes.has(subPath))
284
+ return next();
285
+ const token = extractToken(c);
286
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
287
+ return c.json({ error: "Invalid or missing session token" }, 401);
288
+ }
289
+ return next();
290
+ });
291
+ app.use("/api/sessions/:id", async (c, next) => {
292
+ const id = c.req.param("id");
293
+ if (authExemptIds.has(id))
294
+ return next();
295
+ if (c.req.method !== "GET" && c.req.method !== "DELETE")
296
+ return next();
297
+ const token = extractToken(c);
298
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
299
+ return c.json({ error: "Invalid or missing session token" }, 401);
300
+ }
301
+ return next();
302
+ });
286
303
  // Get single session (from in-memory active sessions)
287
304
  app.get("/api/sessions/:id", (c) => {
288
305
  const session = config.sessions.get(c.req.param("id"));
@@ -323,8 +340,9 @@ export function createApp(config) {
323
340
  config.sessions.add(session);
324
341
  // Pre-create a bridge so hook-event can emit to it immediately
325
342
  getOrCreateBridge(config, sessionId);
343
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
326
344
  console.log(`[local-session] Created session: ${sessionId}`);
327
- return c.json({ sessionId }, 201);
345
+ return c.json({ sessionId, sessionToken }, 201);
328
346
  });
329
347
  // Auto-register a local session on first hook event (SessionStart).
330
348
  // Eliminates the manual `curl POST /api/sessions/local` step.
@@ -369,8 +387,9 @@ export function createApp(config) {
369
387
  if (hookEvent) {
370
388
  await bridge.emit(hookEvent);
371
389
  }
390
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
372
391
  console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
373
- return c.json({ sessionId }, 201);
392
+ return c.json({ sessionId, sessionToken }, 201);
374
393
  });
375
394
  // Receive a hook event from Claude Code (via forward.sh) and write it
376
395
  // to the session's durable stream as an EngineEvent.
@@ -384,19 +403,27 @@ export function createApp(config) {
384
403
  if (!hookEvent) {
385
404
  return c.json({ ok: true }); // Unknown hook type — silently skip
386
405
  }
387
- try {
388
- await bridge.emit(hookEvent);
389
- }
390
- catch (err) {
391
- console.error(`[hook-event] Failed to emit:`, err);
392
- return c.json({ error: "Failed to write event" }, 500);
406
+ // For Docker/Sprites bridge sessions, the stream-json parser already emits
407
+ // events to the durable stream. Only emit from hooks for hosted (local) bridges
408
+ // to avoid duplicate events.
409
+ const isClaudeCodeBridge = bridge instanceof ClaudeCodeDockerBridge || bridge instanceof ClaudeCodeSpritesBridge;
410
+ if (!isClaudeCodeBridge) {
411
+ try {
412
+ await bridge.emit(hookEvent);
413
+ }
414
+ catch (err) {
415
+ console.error(`[hook-event] Failed to emit:`, err);
416
+ return c.json({ error: "Failed to write event" }, 500);
417
+ }
393
418
  }
394
419
  // Bump lastActiveAt on every hook event
395
420
  config.sessions.update(sessionId, {});
396
421
  // SessionEnd: mark session complete and close the bridge
397
422
  if (hookEvent.type === "session_end") {
398
- config.sessions.update(sessionId, { status: "complete" });
399
- closeBridge(sessionId);
423
+ if (!isClaudeCodeBridge) {
424
+ config.sessions.update(sessionId, { status: "complete" });
425
+ closeBridge(sessionId);
426
+ }
400
427
  return c.json({ ok: true });
401
428
  }
402
429
  // AskUserQuestion: block until the user answers via the web UI
@@ -621,8 +648,8 @@ const events = ['PreToolUse','PostToolUse','PostToolUseFailure','Stop','SessionS
621
648
  for (const ev of events) {
622
649
  if (!settings.hooks[ev]) settings.hooks[ev] = [];
623
650
  const arr = settings.hooks[ev];
624
- if (!arr.some(h => h.command === hook)) {
625
- arr.push({ type: 'command', command: hook });
651
+ if (!arr.some(g => g.hooks && g.hooks.some(h => h.command === hook))) {
652
+ arr.push({ hooks: [{ type: 'command', command: hook }] });
626
653
  }
627
654
  }
628
655
  fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\\\\n');
@@ -649,17 +676,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
649
676
  if (!body.description) {
650
677
  return c.json({ error: "description is required" }, 400);
651
678
  }
652
- // Per-session bridge mode: "claude-code" if explicitly requested, else server default
653
- const sessionBridgeMode = body.agentMode === "claude-code" ? "claude-code" : config.bridgeMode;
654
679
  const sessionId = crypto.randomUUID();
655
680
  const inferredName = body.name ||
656
- (config.inferProjectName
657
- ? await config.inferProjectName(body.description)
658
- : body.description
659
- .slice(0, 40)
660
- .replace(/[^a-z0-9]+/gi, "-")
661
- .replace(/^-|-$/g, "")
662
- .toLowerCase());
681
+ body.description
682
+ .slice(0, 40)
683
+ .replace(/[^a-z0-9]+/gi, "-")
684
+ .replace(/^-|-$/g, "")
685
+ .toLowerCase();
663
686
  const baseDir = body.baseDir || process.cwd();
664
687
  const { projectName } = resolveProjectDir(baseDir, inferredName);
665
688
  console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
@@ -689,7 +712,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
689
712
  createdAt: new Date().toISOString(),
690
713
  lastActiveAt: new Date().toISOString(),
691
714
  status: "running",
692
- agentMode: sessionBridgeMode === "claude-code" ? "claude-code" : "electric-agent",
693
715
  };
694
716
  config.sessions.add(session);
695
717
  // Write user prompt to the stream so it shows in the UI
@@ -769,16 +791,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
769
791
  message: `Creating ${config.sandbox.runtime} sandbox...`,
770
792
  ts: ts(),
771
793
  });
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}`);
794
+ console.log(`[session:${sessionId}] Creating sandbox: runtime=${config.sandbox.runtime} project=${projectName}`);
777
795
  const handle = await config.sandbox.create(sessionId, {
778
796
  projectName,
779
797
  infra,
780
- streamEnv,
781
- deferAgentStart: sessionBridgeMode === "stdio" || sessionBridgeMode === "claude-code",
782
798
  apiKey: body.apiKey,
783
799
  oauthToken: body.oauthToken,
784
800
  ghToken: body.ghToken,
@@ -796,12 +812,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
796
812
  previewUrl: handle.previewUrl,
797
813
  ...(claimId ? { claimId } : {}),
798
814
  });
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.
802
- // If stream bridge mode with Sprites, launch the agent process in the sprite
803
- // (it connects directly to the hosted Durable Stream via DS_URL env vars).
804
- if (sessionBridgeMode === "claude-code") {
815
+ // 3. Write CLAUDE.md and create a ClaudeCode bridge.
816
+ {
805
817
  console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
806
818
  // Copy pre-scaffolded project from the image and customize per-session
807
819
  await bridge.emit({
@@ -820,8 +832,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
820
832
  // Sprites/Daytona: run scaffold from globally installed electric-agent
821
833
  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
834
  }
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
835
  console.log(`[session:${sessionId}] Project setup complete`);
826
836
  await bridge.emit({
827
837
  type: "log",
@@ -852,46 +862,31 @@ echo "Start claude in this project — the session will appear in the studio UI.
852
862
  catch (err) {
853
863
  console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
854
864
  }
855
- const claudeConfig = {
856
- prompt: body.description,
857
- cwd: handle.projectDir,
858
- };
859
- bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
860
- }
861
- else if (sessionBridgeMode === "stdio") {
862
- console.log(`[session:${sessionId}] Creating stdio bridge...`);
863
- bridge = createStdioBridge(config, sessionId);
864
- }
865
- else if (config.sandbox.runtime === "sprites") {
866
- await bridge.emit({
867
- type: "log",
868
- level: "build",
869
- message: "Starting agent in sandbox...",
870
- ts: ts(),
871
- });
872
- console.log(`[session:${sessionId}] Starting agent process in sprite...`);
873
- try {
874
- const spritesProvider = config.sandbox;
875
- await spritesProvider.startAgent(handle);
876
- // Give the agent time to start and connect to the stream
877
- await new Promise((r) => setTimeout(r, 3000));
878
- console.log(`[session:${sessionId}] Agent process launched in sprite`);
879
- await bridge.emit({
880
- type: "log",
881
- level: "done",
882
- message: "Agent started",
883
- ts: ts(),
884
- });
885
- }
886
- catch (err) {
887
- console.error(`[session:${sessionId}] Failed to start agent in sprite:`, err);
888
- await bridge.emit({
889
- type: "log",
890
- level: "error",
891
- message: `Failed to start agent: ${err instanceof Error ? err.message : "unknown error"}`,
892
- ts: ts(),
893
- });
865
+ // Ensure the create-app skill is present in the project.
866
+ // The npm-installed electric-agent may be an older version that
867
+ // doesn't include .claude/skills/ in its template directory.
868
+ if (createAppSkillContent) {
869
+ try {
870
+ const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
871
+ const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
872
+ await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
873
+ }
874
+ catch (err) {
875
+ console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
876
+ }
894
877
  }
878
+ const claudeConfig = config.sandbox.runtime === "sprites"
879
+ ? {
880
+ prompt: `/create-app ${body.description}`,
881
+ cwd: handle.projectDir,
882
+ studioUrl: resolveStudioUrl(config.port),
883
+ }
884
+ : {
885
+ prompt: `/create-app ${body.description}`,
886
+ cwd: handle.projectDir,
887
+ studioPort: config.port,
888
+ };
889
+ bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
895
890
  }
896
891
  // 4. Log repo config
897
892
  if (repoConfig) {
@@ -905,8 +900,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
905
900
  // 5. Start listening for agent events via the bridge
906
901
  // Track Claude Code session ID for --resume on iterate
907
902
  bridge.onAgentEvent((event) => {
908
- if (event.type === "session_start" && "session_id" in event) {
903
+ if (event.type === "session_start") {
909
904
  const ccSessionId = event.session_id;
905
+ console.log(`[session:${sessionId}] Captured Claude Code session ID: ${ccSessionId}`);
910
906
  if (ccSessionId) {
911
907
  config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
912
908
  }
@@ -935,9 +931,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
935
931
  // Container may already be stopped
936
932
  }
937
933
  config.sessions.update(sessionId, updates);
938
- // For Claude Code mode: check if the app is running after completion
934
+ // Check if the app is running after completion
939
935
  // and emit app_ready so the UI shows the preview link
940
- if (sessionBridgeMode === "claude-code" && success) {
936
+ if (success) {
941
937
  try {
942
938
  const appRunning = await config.sandbox.isAppRunning(handle);
943
939
  if (appRunning) {
@@ -953,6 +949,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
953
949
  }
954
950
  }
955
951
  });
952
+ // Show the command being sent to Claude Code
953
+ await bridge.emit({
954
+ type: "log",
955
+ level: "build",
956
+ message: `Running: claude "/create-app ${body.description}"`,
957
+ ts: ts(),
958
+ });
956
959
  console.log(`[session:${sessionId}] Starting bridge listener...`);
957
960
  await bridge.start();
958
961
  console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
@@ -974,7 +977,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
974
977
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
975
978
  config.sessions.update(sessionId, { status: "error" });
976
979
  });
977
- return c.json({ sessionId, session }, 201);
980
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
981
+ return c.json({ sessionId, session, sessionToken }, 201);
978
982
  });
979
983
  // Send iteration request
980
984
  app.post("/api/sessions/:id/iterate", async (c) => {
@@ -1036,20 +1040,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1036
1040
  if (!handle || !config.sandbox.isAlive(handle)) {
1037
1041
  return c.json({ error: "Container is not running" }, 400);
1038
1042
  }
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
- }
1043
+ // Send git requests as user messages via Claude Code bridge
1044
+ await bridge.sendCommand({
1045
+ command: "iterate",
1046
+ request: body.request,
1047
+ });
1053
1048
  return c.json({ ok: true });
1054
1049
  }
1055
1050
  const handle = config.sandbox.get(sessionId);
@@ -1086,7 +1081,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1086
1081
  const resolvedBy = participantId && participantName
1087
1082
  ? { id: participantId, displayName: participantName }
1088
1083
  : undefined;
1089
- // AskUserQuestion gates: resolve the blocking hook-event and emit gate_resolved
1084
+ // AskUserQuestion gates: try to resolve the blocking hook-event first.
1085
+ // If no gate is pending (Docker/Sprites bridge sessions create no gate),
1086
+ // fall through to the generic bridge.sendGateResponse() path below.
1090
1087
  if (gate === "ask_user_question") {
1091
1088
  const toolUseId = body.toolUseId;
1092
1089
  if (!toolUseId) {
@@ -1094,24 +1091,24 @@ echo "Start claude in this project — the session will appear in the studio UI.
1094
1091
  }
1095
1092
  const answer = body.answer || "";
1096
1093
  const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
1097
- if (!resolved) {
1098
- return c.json({ error: "No pending ask_user_question gate found" }, 404);
1099
- }
1100
- // Emit gate_resolved for replay
1101
- try {
1102
- const bridge = getOrCreateBridge(config, sessionId);
1103
- await bridge.emit({
1104
- type: "gate_resolved",
1105
- gate: "ask_user_question",
1106
- summary,
1107
- resolvedBy,
1108
- ts: ts(),
1109
- });
1110
- }
1111
- catch {
1112
- // Non-critical
1094
+ if (resolved) {
1095
+ // Hook session gate was blocking, now resolved
1096
+ try {
1097
+ const bridge = getOrCreateBridge(config, sessionId);
1098
+ await bridge.emit({
1099
+ type: "gate_resolved",
1100
+ gate: "ask_user_question",
1101
+ summary,
1102
+ resolvedBy,
1103
+ ts: ts(),
1104
+ });
1105
+ }
1106
+ catch {
1107
+ // Non-critical
1108
+ }
1109
+ return c.json({ ok: true });
1113
1110
  }
1114
- return c.json({ ok: true });
1111
+ // No pending gate — fall through to bridge.sendGateResponse()
1115
1112
  }
1116
1113
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1117
1114
  const serverGates = new Set(["infra_config"]);
@@ -1244,9 +1241,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
1244
1241
  }
1245
1242
  return c.json({ success: true });
1246
1243
  });
1244
+ // Interrupt the running Claude Code process without destroying the session.
1245
+ // The sandbox stays alive and the bridge remains open for follow-up messages.
1246
+ app.post("/api/sessions/:id/interrupt", async (c) => {
1247
+ const sessionId = c.req.param("id");
1248
+ const bridge = bridges.get(sessionId);
1249
+ if (bridge) {
1250
+ bridge.interrupt();
1251
+ // Emit session_end so the UI knows the process stopped
1252
+ await bridge.emit({
1253
+ type: "session_end",
1254
+ success: false,
1255
+ ts: ts(),
1256
+ });
1257
+ }
1258
+ rejectAllGates(sessionId);
1259
+ config.sessions.update(sessionId, { status: "complete" });
1260
+ return c.json({ ok: true });
1261
+ });
1247
1262
  // Cancel a running session
1248
1263
  app.post("/api/sessions/:id/cancel", async (c) => {
1249
1264
  const sessionId = c.req.param("id");
1265
+ // Write session_end to the stream so SSE clients see the cancellation
1266
+ const conn = sessionStream(config, sessionId);
1267
+ try {
1268
+ const stream = new DurableStream({
1269
+ url: conn.url,
1270
+ headers: conn.headers,
1271
+ contentType: "application/json",
1272
+ });
1273
+ const endEvent = {
1274
+ source: "server",
1275
+ type: "session_end",
1276
+ success: false,
1277
+ ts: ts(),
1278
+ };
1279
+ await stream.append(JSON.stringify(endEvent));
1280
+ }
1281
+ catch {
1282
+ // Best effort — stream may not exist yet
1283
+ }
1250
1284
  closeBridge(sessionId);
1251
1285
  const handle = config.sandbox.get(sessionId);
1252
1286
  if (handle)
@@ -1303,12 +1337,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1303
1337
  app.post("/api/sandboxes", async (c) => {
1304
1338
  const body = (await c.req.json());
1305
1339
  const sessionId = body.sessionId ?? crypto.randomUUID();
1306
- const streamEnv = getStreamEnvVars(sessionId, config.streamConfig);
1307
1340
  try {
1308
1341
  const handle = await config.sandbox.create(sessionId, {
1309
1342
  projectName: body.projectName,
1310
1343
  infra: body.infra,
1311
- streamEnv,
1312
1344
  });
1313
1345
  return c.json({
1314
1346
  sessionId: handle.sessionId,
@@ -1334,6 +1366,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
1334
1366
  return c.json({ ok: true });
1335
1367
  });
1336
1368
  // --- Shared Sessions ---
1369
+ // Protect /api/shared-sessions/:id/* (all sub-routes)
1370
+ // Exempt: "join" (Hono matches join/:code as :id/*)
1371
+ const sharedSessionExemptIds = new Set(["join"]);
1372
+ app.use("/api/shared-sessions/:id/*", async (c, next) => {
1373
+ const id = c.req.param("id");
1374
+ if (sharedSessionExemptIds.has(id))
1375
+ return next();
1376
+ const token = extractToken(c);
1377
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1378
+ return c.json({ error: "Invalid or missing room token" }, 401);
1379
+ }
1380
+ return next();
1381
+ });
1337
1382
  // Create a shared session
1338
1383
  app.post("/api/shared-sessions", async (c) => {
1339
1384
  const body = (await c.req.json());
@@ -1384,16 +1429,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
1384
1429
  createdAt: new Date().toISOString(),
1385
1430
  revoked: false,
1386
1431
  });
1432
+ const roomToken = deriveSessionToken(config.streamConfig.secret, id);
1387
1433
  console.log(`[shared-session] Created: id=${id} code=${code}`);
1388
- return c.json({ id, code }, 201);
1434
+ return c.json({ id, code, roomToken }, 201);
1389
1435
  });
1390
- // Resolve invite code → shared session ID
1436
+ // Resolve invite code → shared session ID + room token
1391
1437
  app.get("/api/shared-sessions/join/:code", (c) => {
1392
1438
  const code = c.req.param("code");
1393
1439
  const entry = config.rooms.getRoomByCode(code);
1394
1440
  if (!entry)
1395
1441
  return c.json({ error: "Shared session not found" }, 404);
1396
- return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked });
1442
+ const roomToken = deriveSessionToken(config.streamConfig.secret, entry.id);
1443
+ return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked, roomToken });
1397
1444
  });
1398
1445
  // Join a shared session as participant
1399
1446
  app.post("/api/shared-sessions/:id/join", async (c) => {
@@ -1492,7 +1539,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1492
1539
  if (!entry)
1493
1540
  return c.json({ error: "Shared session not found" }, 404);
1494
1541
  const connection = sharedSessionStream(config, id);
1495
- const lastEventId = c.req.header("Last-Event-ID") || "-1";
1542
+ const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1496
1543
  const reader = new DurableStream({
1497
1544
  url: connection.url,
1498
1545
  headers: connection.headers,
@@ -1558,8 +1605,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1558
1605
  // Get the stream connection info (no session lookup needed —
1559
1606
  // the DS stream may exist from a previous server lifetime)
1560
1607
  const connection = sessionStream(config, sessionId);
1561
- // Last-Event-ID allows reconnection from where the client left off
1562
- const lastEventId = c.req.header("Last-Event-ID") || "-1";
1608
+ // Last-Event-ID allows reconnection from where the client left off.
1609
+ // Also check for an explicit ?offset= query param — when the client
1610
+ // manually reconnects (e.g. after a tab switch), the new EventSource
1611
+ // won't carry the Last-Event-ID from the previous connection, so the
1612
+ // client passes it explicitly.
1613
+ const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1563
1614
  console.log(`[sse] Reading stream from offset=${lastEventId} url=${connection.url}`);
1564
1615
  const reader = new DurableStream({
1565
1616
  url: connection.url,
@@ -1782,7 +1833,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1782
1833
  message: `Resumed from ${body.repoUrl}`,
1783
1834
  ts: ts(),
1784
1835
  });
1785
- return c.json({ sessionId, session, appPort: handle.port }, 201);
1836
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1837
+ return c.json({ sessionId, session, sessionToken, appPort: handle.port }, 201);
1786
1838
  }
1787
1839
  catch (e) {
1788
1840
  const msg = e instanceof Error ? e.message : "Failed to resume from repo";
@@ -1816,8 +1868,7 @@ export async function startWebServer(opts) {
1816
1868
  rooms: opts.rooms,
1817
1869
  sandbox: opts.sandbox,
1818
1870
  streamConfig: opts.streamConfig,
1819
- bridgeMode: opts.bridgeMode ?? "stream",
1820
- inferProjectName: opts.inferProjectName,
1871
+ bridgeMode: opts.bridgeMode ?? "claude-code",
1821
1872
  };
1822
1873
  fs.mkdirSync(config.dataDir, { recursive: true });
1823
1874
  const app = createApp(config);