@electric-agent/studio 1.1.0 → 1.3.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 (73) hide show
  1. package/dist/bridge/claude-code-docker.d.ts +27 -1
  2. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  3. package/dist/bridge/claude-code-docker.js +171 -43
  4. package/dist/bridge/claude-code-docker.js.map +1 -1
  5. package/dist/bridge/claude-code-sprites.d.ts +24 -0
  6. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  7. package/dist/bridge/claude-code-sprites.js +177 -39
  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 +64 -7
  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/index.d.ts +0 -3
  34. package/dist/bridge/index.d.ts.map +1 -1
  35. package/dist/bridge/index.js +0 -3
  36. package/dist/bridge/index.js.map +1 -1
  37. package/dist/bridge/stream-json-parser.d.ts +0 -2
  38. package/dist/bridge/stream-json-parser.d.ts.map +1 -1
  39. package/dist/bridge/stream-json-parser.js +0 -18
  40. package/dist/bridge/stream-json-parser.js.map +1 -1
  41. package/dist/client/assets/index-Bq9zwhHj.css +1 -0
  42. package/dist/client/assets/index-Dgpqg5fv.js +234 -0
  43. package/dist/client/index.html +2 -2
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/sandbox/daytona.js +4 -4
  49. package/dist/sandbox/daytona.js.map +1 -1
  50. package/dist/sandbox/docker.d.ts +0 -1
  51. package/dist/sandbox/docker.d.ts.map +1 -1
  52. package/dist/sandbox/docker.js +10 -42
  53. package/dist/sandbox/docker.js.map +1 -1
  54. package/dist/sandbox/sprites.d.ts +0 -6
  55. package/dist/sandbox/sprites.d.ts.map +1 -1
  56. package/dist/sandbox/sprites.js +4 -36
  57. package/dist/sandbox/sprites.js.map +1 -1
  58. package/dist/sandbox/types.d.ts +0 -8
  59. package/dist/sandbox/types.d.ts.map +1 -1
  60. package/dist/server.d.ts +2 -5
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +195 -160
  63. package/dist/server.js.map +1 -1
  64. package/dist/session-auth.d.ts +3 -0
  65. package/dist/session-auth.d.ts.map +1 -0
  66. package/dist/session-auth.js +11 -0
  67. package/dist/session-auth.js.map +1 -0
  68. package/dist/sessions.d.ts +0 -2
  69. package/dist/sessions.d.ts.map +1 -1
  70. package/dist/sessions.js.map +1 -1
  71. package/package.json +2 -2
  72. package/dist/client/assets/index-BeZ6CTGd.css +0 -1
  73. 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) {
@@ -903,6 +898,16 @@ echo "Start claude in this project — the session will appear in the studio UI.
903
898
  });
904
899
  }
905
900
  // 5. Start listening for agent events via the bridge
901
+ // Track Claude Code session ID for --resume on iterate
902
+ bridge.onAgentEvent((event) => {
903
+ if (event.type === "session_start") {
904
+ const ccSessionId = event.session_id;
905
+ console.log(`[session:${sessionId}] Captured Claude Code session ID: ${ccSessionId}`);
906
+ if (ccSessionId) {
907
+ config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
908
+ }
909
+ }
910
+ });
906
911
  bridge.onComplete(async (success) => {
907
912
  const updates = {
908
913
  status: success ? "complete" : "error",
@@ -926,9 +931,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
926
931
  // Container may already be stopped
927
932
  }
928
933
  config.sessions.update(sessionId, updates);
929
- // For Claude Code mode: check if the app is running after completion
934
+ // Check if the app is running after completion
930
935
  // and emit app_ready so the UI shows the preview link
931
- if (sessionBridgeMode === "claude-code" && success) {
936
+ if (success) {
932
937
  try {
933
938
  const appRunning = await config.sandbox.isAppRunning(handle);
934
939
  if (appRunning) {
@@ -965,7 +970,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
965
970
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
966
971
  config.sessions.update(sessionId, { status: "error" });
967
972
  });
968
- return c.json({ sessionId, session }, 201);
973
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
974
+ return c.json({ sessionId, session, sessionToken }, 201);
969
975
  });
970
976
  // Send iteration request
971
977
  app.post("/api/sessions/:id/iterate", async (c) => {
@@ -1027,20 +1033,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1027
1033
  if (!handle || !config.sandbox.isAlive(handle)) {
1028
1034
  return c.json({ error: "Container is not running" }, 400);
1029
1035
  }
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
- }
1036
+ // Send git requests as user messages via Claude Code bridge
1037
+ await bridge.sendCommand({
1038
+ command: "iterate",
1039
+ request: body.request,
1040
+ });
1044
1041
  return c.json({ ok: true });
1045
1042
  }
1046
1043
  const handle = config.sandbox.get(sessionId);
@@ -1077,7 +1074,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1077
1074
  const resolvedBy = participantId && participantName
1078
1075
  ? { id: participantId, displayName: participantName }
1079
1076
  : undefined;
1080
- // AskUserQuestion gates: resolve the blocking hook-event and emit gate_resolved
1077
+ // AskUserQuestion gates: try to resolve the blocking hook-event first.
1078
+ // If no gate is pending (Docker/Sprites bridge sessions create no gate),
1079
+ // fall through to the generic bridge.sendGateResponse() path below.
1081
1080
  if (gate === "ask_user_question") {
1082
1081
  const toolUseId = body.toolUseId;
1083
1082
  if (!toolUseId) {
@@ -1085,24 +1084,24 @@ echo "Start claude in this project — the session will appear in the studio UI.
1085
1084
  }
1086
1085
  const answer = body.answer || "";
1087
1086
  const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
1088
- if (!resolved) {
1089
- return c.json({ error: "No pending ask_user_question gate found" }, 404);
1090
- }
1091
- // Emit gate_resolved for replay
1092
- try {
1093
- const bridge = getOrCreateBridge(config, sessionId);
1094
- await bridge.emit({
1095
- type: "gate_resolved",
1096
- gate: "ask_user_question",
1097
- summary,
1098
- resolvedBy,
1099
- ts: ts(),
1100
- });
1101
- }
1102
- catch {
1103
- // Non-critical
1087
+ if (resolved) {
1088
+ // Hook session gate was blocking, now resolved
1089
+ try {
1090
+ const bridge = getOrCreateBridge(config, sessionId);
1091
+ await bridge.emit({
1092
+ type: "gate_resolved",
1093
+ gate: "ask_user_question",
1094
+ summary,
1095
+ resolvedBy,
1096
+ ts: ts(),
1097
+ });
1098
+ }
1099
+ catch {
1100
+ // Non-critical
1101
+ }
1102
+ return c.json({ ok: true });
1104
1103
  }
1105
- return c.json({ ok: true });
1104
+ // No pending gate — fall through to bridge.sendGateResponse()
1106
1105
  }
1107
1106
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1108
1107
  const serverGates = new Set(["infra_config"]);
@@ -1238,6 +1237,25 @@ echo "Start claude in this project — the session will appear in the studio UI.
1238
1237
  // Cancel a running session
1239
1238
  app.post("/api/sessions/:id/cancel", async (c) => {
1240
1239
  const sessionId = c.req.param("id");
1240
+ // Write session_end to the stream so SSE clients see the cancellation
1241
+ const conn = sessionStream(config, sessionId);
1242
+ try {
1243
+ const stream = new DurableStream({
1244
+ url: conn.url,
1245
+ headers: conn.headers,
1246
+ contentType: "application/json",
1247
+ });
1248
+ const endEvent = {
1249
+ source: "server",
1250
+ type: "session_end",
1251
+ success: false,
1252
+ ts: ts(),
1253
+ };
1254
+ await stream.append(JSON.stringify(endEvent));
1255
+ }
1256
+ catch {
1257
+ // Best effort — stream may not exist yet
1258
+ }
1241
1259
  closeBridge(sessionId);
1242
1260
  const handle = config.sandbox.get(sessionId);
1243
1261
  if (handle)
@@ -1294,12 +1312,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1294
1312
  app.post("/api/sandboxes", async (c) => {
1295
1313
  const body = (await c.req.json());
1296
1314
  const sessionId = body.sessionId ?? crypto.randomUUID();
1297
- const streamEnv = getStreamEnvVars(sessionId, config.streamConfig);
1298
1315
  try {
1299
1316
  const handle = await config.sandbox.create(sessionId, {
1300
1317
  projectName: body.projectName,
1301
1318
  infra: body.infra,
1302
- streamEnv,
1303
1319
  });
1304
1320
  return c.json({
1305
1321
  sessionId: handle.sessionId,
@@ -1325,6 +1341,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
1325
1341
  return c.json({ ok: true });
1326
1342
  });
1327
1343
  // --- Shared Sessions ---
1344
+ // Protect /api/shared-sessions/:id/* (all sub-routes)
1345
+ // Exempt: "join" (Hono matches join/:code as :id/*)
1346
+ const sharedSessionExemptIds = new Set(["join"]);
1347
+ app.use("/api/shared-sessions/:id/*", async (c, next) => {
1348
+ const id = c.req.param("id");
1349
+ if (sharedSessionExemptIds.has(id))
1350
+ return next();
1351
+ const token = extractToken(c);
1352
+ if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
1353
+ return c.json({ error: "Invalid or missing room token" }, 401);
1354
+ }
1355
+ return next();
1356
+ });
1328
1357
  // Create a shared session
1329
1358
  app.post("/api/shared-sessions", async (c) => {
1330
1359
  const body = (await c.req.json());
@@ -1375,16 +1404,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
1375
1404
  createdAt: new Date().toISOString(),
1376
1405
  revoked: false,
1377
1406
  });
1407
+ const roomToken = deriveSessionToken(config.streamConfig.secret, id);
1378
1408
  console.log(`[shared-session] Created: id=${id} code=${code}`);
1379
- return c.json({ id, code }, 201);
1409
+ return c.json({ id, code, roomToken }, 201);
1380
1410
  });
1381
- // Resolve invite code → shared session ID
1411
+ // Resolve invite code → shared session ID + room token
1382
1412
  app.get("/api/shared-sessions/join/:code", (c) => {
1383
1413
  const code = c.req.param("code");
1384
1414
  const entry = config.rooms.getRoomByCode(code);
1385
1415
  if (!entry)
1386
1416
  return c.json({ error: "Shared session not found" }, 404);
1387
- return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked });
1417
+ const roomToken = deriveSessionToken(config.streamConfig.secret, entry.id);
1418
+ return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked, roomToken });
1388
1419
  });
1389
1420
  // Join a shared session as participant
1390
1421
  app.post("/api/shared-sessions/:id/join", async (c) => {
@@ -1483,7 +1514,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1483
1514
  if (!entry)
1484
1515
  return c.json({ error: "Shared session not found" }, 404);
1485
1516
  const connection = sharedSessionStream(config, id);
1486
- const lastEventId = c.req.header("Last-Event-ID") || "-1";
1517
+ const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1487
1518
  const reader = new DurableStream({
1488
1519
  url: connection.url,
1489
1520
  headers: connection.headers,
@@ -1549,8 +1580,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1549
1580
  // Get the stream connection info (no session lookup needed —
1550
1581
  // the DS stream may exist from a previous server lifetime)
1551
1582
  const connection = sessionStream(config, sessionId);
1552
- // Last-Event-ID allows reconnection from where the client left off
1553
- const lastEventId = c.req.header("Last-Event-ID") || "-1";
1583
+ // Last-Event-ID allows reconnection from where the client left off.
1584
+ // Also check for an explicit ?offset= query param — when the client
1585
+ // manually reconnects (e.g. after a tab switch), the new EventSource
1586
+ // won't carry the Last-Event-ID from the previous connection, so the
1587
+ // client passes it explicitly.
1588
+ const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1554
1589
  console.log(`[sse] Reading stream from offset=${lastEventId} url=${connection.url}`);
1555
1590
  const reader = new DurableStream({
1556
1591
  url: connection.url,
@@ -1773,7 +1808,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1773
1808
  message: `Resumed from ${body.repoUrl}`,
1774
1809
  ts: ts(),
1775
1810
  });
1776
- return c.json({ sessionId, session, appPort: handle.port }, 201);
1811
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
1812
+ return c.json({ sessionId, session, sessionToken, appPort: handle.port }, 201);
1777
1813
  }
1778
1814
  catch (e) {
1779
1815
  const msg = e instanceof Error ? e.message : "Failed to resume from repo";
@@ -1807,8 +1843,7 @@ export async function startWebServer(opts) {
1807
1843
  rooms: opts.rooms,
1808
1844
  sandbox: opts.sandbox,
1809
1845
  streamConfig: opts.streamConfig,
1810
- bridgeMode: opts.bridgeMode ?? "stream",
1811
- inferProjectName: opts.inferProjectName,
1846
+ bridgeMode: opts.bridgeMode ?? "claude-code",
1812
1847
  };
1813
1848
  fs.mkdirSync(config.dataDir, { recursive: true });
1814
1849
  const app = createApp(config);