@electric-agent/studio 1.1.1 → 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 +11 -0
  2. package/dist/bridge/claude-code-docker.d.ts.map +1 -1
  3. package/dist/bridge/claude-code-docker.js +103 -22
  4. package/dist/bridge/claude-code-docker.js.map +1 -1
  5. package/dist/bridge/claude-code-sprites.d.ts +10 -1
  6. package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
  7. package/dist/bridge/claude-code-sprites.js +120 -44
  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 -11
  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 +187 -161
  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) {
@@ -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) {
@@ -974,7 +970,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
974
970
  console.error(`[session:${sessionId}] Session creation flow failed:`, err);
975
971
  config.sessions.update(sessionId, { status: "error" });
976
972
  });
977
- return c.json({ sessionId, session }, 201);
973
+ const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
974
+ return c.json({ sessionId, session, sessionToken }, 201);
978
975
  });
979
976
  // Send iteration request
980
977
  app.post("/api/sessions/:id/iterate", async (c) => {
@@ -1036,20 +1033,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
1036
1033
  if (!handle || !config.sandbox.isAlive(handle)) {
1037
1034
  return c.json({ error: "Container is not running" }, 400);
1038
1035
  }
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
- }
1036
+ // Send git requests as user messages via Claude Code bridge
1037
+ await bridge.sendCommand({
1038
+ command: "iterate",
1039
+ request: body.request,
1040
+ });
1053
1041
  return c.json({ ok: true });
1054
1042
  }
1055
1043
  const handle = config.sandbox.get(sessionId);
@@ -1086,7 +1074,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
1086
1074
  const resolvedBy = participantId && participantName
1087
1075
  ? { id: participantId, displayName: participantName }
1088
1076
  : undefined;
1089
- // 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.
1090
1080
  if (gate === "ask_user_question") {
1091
1081
  const toolUseId = body.toolUseId;
1092
1082
  if (!toolUseId) {
@@ -1094,24 +1084,24 @@ echo "Start claude in this project — the session will appear in the studio UI.
1094
1084
  }
1095
1085
  const answer = body.answer || "";
1096
1086
  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
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 });
1113
1103
  }
1114
- return c.json({ ok: true });
1104
+ // No pending gate — fall through to bridge.sendGateResponse()
1115
1105
  }
1116
1106
  // Server-side gates are resolved in-process (they run on the server, not inside the container)
1117
1107
  const serverGates = new Set(["infra_config"]);
@@ -1247,6 +1237,25 @@ echo "Start claude in this project — the session will appear in the studio UI.
1247
1237
  // Cancel a running session
1248
1238
  app.post("/api/sessions/:id/cancel", async (c) => {
1249
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
+ }
1250
1259
  closeBridge(sessionId);
1251
1260
  const handle = config.sandbox.get(sessionId);
1252
1261
  if (handle)
@@ -1303,12 +1312,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
1303
1312
  app.post("/api/sandboxes", async (c) => {
1304
1313
  const body = (await c.req.json());
1305
1314
  const sessionId = body.sessionId ?? crypto.randomUUID();
1306
- const streamEnv = getStreamEnvVars(sessionId, config.streamConfig);
1307
1315
  try {
1308
1316
  const handle = await config.sandbox.create(sessionId, {
1309
1317
  projectName: body.projectName,
1310
1318
  infra: body.infra,
1311
- streamEnv,
1312
1319
  });
1313
1320
  return c.json({
1314
1321
  sessionId: handle.sessionId,
@@ -1334,6 +1341,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
1334
1341
  return c.json({ ok: true });
1335
1342
  });
1336
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
+ });
1337
1357
  // Create a shared session
1338
1358
  app.post("/api/shared-sessions", async (c) => {
1339
1359
  const body = (await c.req.json());
@@ -1384,16 +1404,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
1384
1404
  createdAt: new Date().toISOString(),
1385
1405
  revoked: false,
1386
1406
  });
1407
+ const roomToken = deriveSessionToken(config.streamConfig.secret, id);
1387
1408
  console.log(`[shared-session] Created: id=${id} code=${code}`);
1388
- return c.json({ id, code }, 201);
1409
+ return c.json({ id, code, roomToken }, 201);
1389
1410
  });
1390
- // Resolve invite code → shared session ID
1411
+ // Resolve invite code → shared session ID + room token
1391
1412
  app.get("/api/shared-sessions/join/:code", (c) => {
1392
1413
  const code = c.req.param("code");
1393
1414
  const entry = config.rooms.getRoomByCode(code);
1394
1415
  if (!entry)
1395
1416
  return c.json({ error: "Shared session not found" }, 404);
1396
- 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 });
1397
1419
  });
1398
1420
  // Join a shared session as participant
1399
1421
  app.post("/api/shared-sessions/:id/join", async (c) => {
@@ -1492,7 +1514,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
1492
1514
  if (!entry)
1493
1515
  return c.json({ error: "Shared session not found" }, 404);
1494
1516
  const connection = sharedSessionStream(config, id);
1495
- const lastEventId = c.req.header("Last-Event-ID") || "-1";
1517
+ const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
1496
1518
  const reader = new DurableStream({
1497
1519
  url: connection.url,
1498
1520
  headers: connection.headers,
@@ -1558,8 +1580,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
1558
1580
  // Get the stream connection info (no session lookup needed —
1559
1581
  // the DS stream may exist from a previous server lifetime)
1560
1582
  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";
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";
1563
1589
  console.log(`[sse] Reading stream from offset=${lastEventId} url=${connection.url}`);
1564
1590
  const reader = new DurableStream({
1565
1591
  url: connection.url,
@@ -1782,7 +1808,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
1782
1808
  message: `Resumed from ${body.repoUrl}`,
1783
1809
  ts: ts(),
1784
1810
  });
1785
- 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);
1786
1813
  }
1787
1814
  catch (e) {
1788
1815
  const msg = e instanceof Error ? e.message : "Failed to resume from repo";
@@ -1816,8 +1843,7 @@ export async function startWebServer(opts) {
1816
1843
  rooms: opts.rooms,
1817
1844
  sandbox: opts.sandbox,
1818
1845
  streamConfig: opts.streamConfig,
1819
- bridgeMode: opts.bridgeMode ?? "stream",
1820
- inferProjectName: opts.inferProjectName,
1846
+ bridgeMode: opts.bridgeMode ?? "claude-code",
1821
1847
  };
1822
1848
  fs.mkdirSync(config.dataDir, { recursive: true });
1823
1849
  const app = createApp(config);