@electric-agent/studio 1.1.1 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/claude-code-docker.d.ts +14 -0
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +121 -22
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts +13 -1
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +122 -26
- 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 +67 -12
- 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/hosted.d.ts +1 -0
- package/dist/bridge/hosted.d.ts.map +1 -1
- package/dist/bridge/hosted.js +4 -0
- package/dist/bridge/hosted.js.map +1 -1
- 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/bridge/types.d.ts +6 -0
- package/dist/bridge/types.d.ts.map +1 -1
- package/dist/client/assets/index-B6arNdVE.css +1 -0
- package/dist/client/assets/index-CxBu-PUg.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 +212 -161
- 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) {
|
|
@@ -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"
|
|
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
|
-
//
|
|
934
|
+
// Check if the app is running after completion
|
|
939
935
|
// and emit app_ready so the UI shows the preview link
|
|
940
|
-
if (
|
|
936
|
+
if (success) {
|
|
941
937
|
try {
|
|
942
938
|
const appRunning = await config.sandbox.isAppRunning(handle);
|
|
943
939
|
if (appRunning) {
|
|
@@ -953,6 +949,13 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
953
949
|
}
|
|
954
950
|
}
|
|
955
951
|
});
|
|
952
|
+
// Show the command being sent to Claude Code
|
|
953
|
+
await bridge.emit({
|
|
954
|
+
type: "log",
|
|
955
|
+
level: "build",
|
|
956
|
+
message: `Running: claude "/create-app ${body.description}"`,
|
|
957
|
+
ts: ts(),
|
|
958
|
+
});
|
|
956
959
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
957
960
|
await bridge.start();
|
|
958
961
|
console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
|
|
@@ -974,7 +977,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
974
977
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
975
978
|
config.sessions.update(sessionId, { status: "error" });
|
|
976
979
|
});
|
|
977
|
-
|
|
980
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
981
|
+
return c.json({ sessionId, session, sessionToken }, 201);
|
|
978
982
|
});
|
|
979
983
|
// Send iteration request
|
|
980
984
|
app.post("/api/sessions/:id/iterate", async (c) => {
|
|
@@ -1036,20 +1040,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1036
1040
|
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1037
1041
|
return c.json({ error: "Container is not running" }, 400);
|
|
1038
1042
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
else {
|
|
1047
|
-
await bridge.sendCommand({
|
|
1048
|
-
command: "git",
|
|
1049
|
-
projectDir: session.sandboxProjectDir || handle.projectDir,
|
|
1050
|
-
...gitOp,
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1043
|
+
// Send git requests as user messages via Claude Code bridge
|
|
1044
|
+
await bridge.sendCommand({
|
|
1045
|
+
command: "iterate",
|
|
1046
|
+
request: body.request,
|
|
1047
|
+
});
|
|
1053
1048
|
return c.json({ ok: true });
|
|
1054
1049
|
}
|
|
1055
1050
|
const handle = config.sandbox.get(sessionId);
|
|
@@ -1086,7 +1081,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1086
1081
|
const resolvedBy = participantId && participantName
|
|
1087
1082
|
? { id: participantId, displayName: participantName }
|
|
1088
1083
|
: undefined;
|
|
1089
|
-
// AskUserQuestion gates: resolve the blocking hook-event
|
|
1084
|
+
// AskUserQuestion gates: try to resolve the blocking hook-event first.
|
|
1085
|
+
// If no gate is pending (Docker/Sprites bridge sessions create no gate),
|
|
1086
|
+
// fall through to the generic bridge.sendGateResponse() path below.
|
|
1090
1087
|
if (gate === "ask_user_question") {
|
|
1091
1088
|
const toolUseId = body.toolUseId;
|
|
1092
1089
|
if (!toolUseId) {
|
|
@@ -1094,24 +1091,24 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1094
1091
|
}
|
|
1095
1092
|
const answer = body.answer || "";
|
|
1096
1093
|
const resolved = resolveGate(sessionId, `ask_user_question:${toolUseId}`, { answer });
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1094
|
+
if (resolved) {
|
|
1095
|
+
// Hook session — gate was blocking, now resolved
|
|
1096
|
+
try {
|
|
1097
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1098
|
+
await bridge.emit({
|
|
1099
|
+
type: "gate_resolved",
|
|
1100
|
+
gate: "ask_user_question",
|
|
1101
|
+
summary,
|
|
1102
|
+
resolvedBy,
|
|
1103
|
+
ts: ts(),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
// Non-critical
|
|
1108
|
+
}
|
|
1109
|
+
return c.json({ ok: true });
|
|
1113
1110
|
}
|
|
1114
|
-
|
|
1111
|
+
// No pending gate — fall through to bridge.sendGateResponse()
|
|
1115
1112
|
}
|
|
1116
1113
|
// Server-side gates are resolved in-process (they run on the server, not inside the container)
|
|
1117
1114
|
const serverGates = new Set(["infra_config"]);
|
|
@@ -1244,9 +1241,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1244
1241
|
}
|
|
1245
1242
|
return c.json({ success: true });
|
|
1246
1243
|
});
|
|
1244
|
+
// Interrupt the running Claude Code process without destroying the session.
|
|
1245
|
+
// The sandbox stays alive and the bridge remains open for follow-up messages.
|
|
1246
|
+
app.post("/api/sessions/:id/interrupt", async (c) => {
|
|
1247
|
+
const sessionId = c.req.param("id");
|
|
1248
|
+
const bridge = bridges.get(sessionId);
|
|
1249
|
+
if (bridge) {
|
|
1250
|
+
bridge.interrupt();
|
|
1251
|
+
// Emit session_end so the UI knows the process stopped
|
|
1252
|
+
await bridge.emit({
|
|
1253
|
+
type: "session_end",
|
|
1254
|
+
success: false,
|
|
1255
|
+
ts: ts(),
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
rejectAllGates(sessionId);
|
|
1259
|
+
config.sessions.update(sessionId, { status: "complete" });
|
|
1260
|
+
return c.json({ ok: true });
|
|
1261
|
+
});
|
|
1247
1262
|
// Cancel a running session
|
|
1248
1263
|
app.post("/api/sessions/:id/cancel", async (c) => {
|
|
1249
1264
|
const sessionId = c.req.param("id");
|
|
1265
|
+
// Write session_end to the stream so SSE clients see the cancellation
|
|
1266
|
+
const conn = sessionStream(config, sessionId);
|
|
1267
|
+
try {
|
|
1268
|
+
const stream = new DurableStream({
|
|
1269
|
+
url: conn.url,
|
|
1270
|
+
headers: conn.headers,
|
|
1271
|
+
contentType: "application/json",
|
|
1272
|
+
});
|
|
1273
|
+
const endEvent = {
|
|
1274
|
+
source: "server",
|
|
1275
|
+
type: "session_end",
|
|
1276
|
+
success: false,
|
|
1277
|
+
ts: ts(),
|
|
1278
|
+
};
|
|
1279
|
+
await stream.append(JSON.stringify(endEvent));
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
// Best effort — stream may not exist yet
|
|
1283
|
+
}
|
|
1250
1284
|
closeBridge(sessionId);
|
|
1251
1285
|
const handle = config.sandbox.get(sessionId);
|
|
1252
1286
|
if (handle)
|
|
@@ -1303,12 +1337,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1303
1337
|
app.post("/api/sandboxes", async (c) => {
|
|
1304
1338
|
const body = (await c.req.json());
|
|
1305
1339
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1306
|
-
const streamEnv = getStreamEnvVars(sessionId, config.streamConfig);
|
|
1307
1340
|
try {
|
|
1308
1341
|
const handle = await config.sandbox.create(sessionId, {
|
|
1309
1342
|
projectName: body.projectName,
|
|
1310
1343
|
infra: body.infra,
|
|
1311
|
-
streamEnv,
|
|
1312
1344
|
});
|
|
1313
1345
|
return c.json({
|
|
1314
1346
|
sessionId: handle.sessionId,
|
|
@@ -1334,6 +1366,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1334
1366
|
return c.json({ ok: true });
|
|
1335
1367
|
});
|
|
1336
1368
|
// --- Shared Sessions ---
|
|
1369
|
+
// Protect /api/shared-sessions/:id/* (all sub-routes)
|
|
1370
|
+
// Exempt: "join" (Hono matches join/:code as :id/*)
|
|
1371
|
+
const sharedSessionExemptIds = new Set(["join"]);
|
|
1372
|
+
app.use("/api/shared-sessions/:id/*", async (c, next) => {
|
|
1373
|
+
const id = c.req.param("id");
|
|
1374
|
+
if (sharedSessionExemptIds.has(id))
|
|
1375
|
+
return next();
|
|
1376
|
+
const token = extractToken(c);
|
|
1377
|
+
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1378
|
+
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1379
|
+
}
|
|
1380
|
+
return next();
|
|
1381
|
+
});
|
|
1337
1382
|
// Create a shared session
|
|
1338
1383
|
app.post("/api/shared-sessions", async (c) => {
|
|
1339
1384
|
const body = (await c.req.json());
|
|
@@ -1384,16 +1429,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1384
1429
|
createdAt: new Date().toISOString(),
|
|
1385
1430
|
revoked: false,
|
|
1386
1431
|
});
|
|
1432
|
+
const roomToken = deriveSessionToken(config.streamConfig.secret, id);
|
|
1387
1433
|
console.log(`[shared-session] Created: id=${id} code=${code}`);
|
|
1388
|
-
return c.json({ id, code }, 201);
|
|
1434
|
+
return c.json({ id, code, roomToken }, 201);
|
|
1389
1435
|
});
|
|
1390
|
-
// Resolve invite code → shared session ID
|
|
1436
|
+
// Resolve invite code → shared session ID + room token
|
|
1391
1437
|
app.get("/api/shared-sessions/join/:code", (c) => {
|
|
1392
1438
|
const code = c.req.param("code");
|
|
1393
1439
|
const entry = config.rooms.getRoomByCode(code);
|
|
1394
1440
|
if (!entry)
|
|
1395
1441
|
return c.json({ error: "Shared session not found" }, 404);
|
|
1396
|
-
|
|
1442
|
+
const roomToken = deriveSessionToken(config.streamConfig.secret, entry.id);
|
|
1443
|
+
return c.json({ id: entry.id, code: entry.code, revoked: entry.revoked, roomToken });
|
|
1397
1444
|
});
|
|
1398
1445
|
// Join a shared session as participant
|
|
1399
1446
|
app.post("/api/shared-sessions/:id/join", async (c) => {
|
|
@@ -1492,7 +1539,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1492
1539
|
if (!entry)
|
|
1493
1540
|
return c.json({ error: "Shared session not found" }, 404);
|
|
1494
1541
|
const connection = sharedSessionStream(config, id);
|
|
1495
|
-
const lastEventId = c.req.header("Last-Event-ID") || "-1";
|
|
1542
|
+
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1496
1543
|
const reader = new DurableStream({
|
|
1497
1544
|
url: connection.url,
|
|
1498
1545
|
headers: connection.headers,
|
|
@@ -1558,8 +1605,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1558
1605
|
// Get the stream connection info (no session lookup needed —
|
|
1559
1606
|
// the DS stream may exist from a previous server lifetime)
|
|
1560
1607
|
const connection = sessionStream(config, sessionId);
|
|
1561
|
-
// Last-Event-ID allows reconnection from where the client left off
|
|
1562
|
-
|
|
1608
|
+
// Last-Event-ID allows reconnection from where the client left off.
|
|
1609
|
+
// Also check for an explicit ?offset= query param — when the client
|
|
1610
|
+
// manually reconnects (e.g. after a tab switch), the new EventSource
|
|
1611
|
+
// won't carry the Last-Event-ID from the previous connection, so the
|
|
1612
|
+
// client passes it explicitly.
|
|
1613
|
+
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1563
1614
|
console.log(`[sse] Reading stream from offset=${lastEventId} url=${connection.url}`);
|
|
1564
1615
|
const reader = new DurableStream({
|
|
1565
1616
|
url: connection.url,
|
|
@@ -1782,7 +1833,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1782
1833
|
message: `Resumed from ${body.repoUrl}`,
|
|
1783
1834
|
ts: ts(),
|
|
1784
1835
|
});
|
|
1785
|
-
|
|
1836
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1837
|
+
return c.json({ sessionId, session, sessionToken, appPort: handle.port }, 201);
|
|
1786
1838
|
}
|
|
1787
1839
|
catch (e) {
|
|
1788
1840
|
const msg = e instanceof Error ? e.message : "Failed to resume from repo";
|
|
@@ -1816,8 +1868,7 @@ export async function startWebServer(opts) {
|
|
|
1816
1868
|
rooms: opts.rooms,
|
|
1817
1869
|
sandbox: opts.sandbox,
|
|
1818
1870
|
streamConfig: opts.streamConfig,
|
|
1819
|
-
bridgeMode: opts.bridgeMode ?? "
|
|
1820
|
-
inferProjectName: opts.inferProjectName,
|
|
1871
|
+
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
1821
1872
|
};
|
|
1822
1873
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
1823
1874
|
const app = createApp(config);
|