@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.
- package/dist/bridge/claude-code-docker.d.ts +27 -1
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +171 -43
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts +24 -0
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +177 -39
- package/dist/bridge/claude-code-sprites.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +1 -0
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +64 -7
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/codex-docker.d.ts +65 -0
- package/dist/bridge/codex-docker.d.ts.map +1 -0
- package/dist/bridge/codex-docker.js +242 -0
- package/dist/bridge/codex-docker.js.map +1 -0
- package/dist/bridge/codex-json-parser.d.ts +31 -0
- package/dist/bridge/codex-json-parser.d.ts.map +1 -0
- package/dist/bridge/codex-json-parser.js +274 -0
- package/dist/bridge/codex-json-parser.js.map +1 -0
- package/dist/bridge/codex-md-generator.d.ts +14 -0
- package/dist/bridge/codex-md-generator.d.ts.map +1 -0
- package/dist/bridge/codex-md-generator.js +45 -0
- package/dist/bridge/codex-md-generator.js.map +1 -0
- package/dist/bridge/codex-sprites.d.ts +59 -0
- package/dist/bridge/codex-sprites.d.ts.map +1 -0
- package/dist/bridge/codex-sprites.js +237 -0
- package/dist/bridge/codex-sprites.js.map +1 -0
- package/dist/bridge/create-app-skill.d.ts +11 -0
- package/dist/bridge/create-app-skill.d.ts.map +1 -0
- package/dist/bridge/create-app-skill.js +39 -0
- package/dist/bridge/create-app-skill.js.map +1 -0
- package/dist/bridge/index.d.ts +0 -3
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/index.js +0 -3
- package/dist/bridge/index.js.map +1 -1
- package/dist/bridge/stream-json-parser.d.ts +0 -2
- package/dist/bridge/stream-json-parser.d.ts.map +1 -1
- package/dist/bridge/stream-json-parser.js +0 -18
- package/dist/bridge/stream-json-parser.js.map +1 -1
- package/dist/client/assets/index-Bq9zwhHj.css +1 -0
- package/dist/client/assets/index-Dgpqg5fv.js +234 -0
- package/dist/client/index.html +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/sandbox/daytona.js +4 -4
- package/dist/sandbox/daytona.js.map +1 -1
- package/dist/sandbox/docker.d.ts +0 -1
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +10 -42
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +0 -6
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +4 -36
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +0 -8
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +2 -5
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +195 -160
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +3 -0
- package/dist/session-auth.d.ts.map +1 -0
- package/dist/session-auth.js +11 -0
- package/dist/session-auth.js.map +1 -0
- package/dist/sessions.d.ts +0 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/package.json +2 -2
- package/dist/client/assets/index-BeZ6CTGd.css +0 -1
- 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,
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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.
|
|
800
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
//
|
|
934
|
+
// Check if the app is running after completion
|
|
930
935
|
// and emit app_ready so the UI shows the preview link
|
|
931
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
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 (
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? "
|
|
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);
|