@electric-agent/studio 1.14.0 → 1.14.2
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/active-sessions.d.ts +13 -4
- package/dist/active-sessions.d.ts.map +1 -1
- package/dist/active-sessions.js +39 -5
- package/dist/active-sessions.js.map +1 -1
- package/dist/api-schemas.d.ts +244 -0
- package/dist/api-schemas.d.ts.map +1 -0
- package/dist/api-schemas.js +103 -0
- package/dist/api-schemas.js.map +1 -0
- package/dist/bridge/claude-code-base.d.ts +2 -0
- package/dist/bridge/claude-code-base.d.ts.map +1 -1
- package/dist/bridge/claude-code-base.js +2 -0
- package/dist/bridge/claude-code-base.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +12 -2
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +72 -94
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/message-parser.d.ts +3 -3
- package/dist/bridge/message-parser.d.ts.map +1 -1
- package/dist/bridge/message-parser.js +3 -3
- package/dist/bridge/message-parser.js.map +1 -1
- package/dist/bridge/role-skills.d.ts.map +1 -1
- package/dist/bridge/role-skills.js +8 -0
- package/dist/bridge/role-skills.js.map +1 -1
- package/dist/client/assets/index-BSGS-yya.css +1 -0
- package/dist/client/assets/index-qUqEqKXn.js +235 -0
- package/dist/client/index.html +2 -2
- package/dist/github-app.d.ts +14 -0
- package/dist/github-app.d.ts.map +1 -0
- package/dist/github-app.js +62 -0
- package/dist/github-app.js.map +1 -0
- package/dist/room-router.d.ts +13 -0
- package/dist/room-router.d.ts.map +1 -1
- package/dist/room-router.js +48 -5
- package/dist/room-router.js.map +1 -1
- package/dist/sandbox/docker.d.ts +10 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +115 -1
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +1 -0
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +51 -0
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +5 -0
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1088 -186
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +3 -0
- package/dist/session-auth.d.ts.map +1 -1
- package/dist/session-auth.js +10 -0
- package/dist/session-auth.js.map +1 -1
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +24 -0
- package/dist/validate.js.map +1 -0
- package/package.json +7 -2
- package/dist/client/assets/index-BfvQSMwH.css +0 -1
- package/dist/client/assets/index-BtX82X61.js +0 -234
- package/dist/sandbox/daytona-push.d.ts +0 -3
- package/dist/sandbox/daytona-push.d.ts.map +0 -1
- package/dist/sandbox/daytona-push.js +0 -56
- package/dist/sandbox/daytona-push.js.map +0 -1
- package/dist/sandbox/daytona-registry.d.ts +0 -41
- package/dist/sandbox/daytona-registry.d.ts.map +0 -1
- package/dist/sandbox/daytona-registry.js +0 -127
- package/dist/sandbox/daytona-registry.js.map +0 -1
- package/dist/sandbox/daytona.d.ts +0 -41
- package/dist/sandbox/daytona.d.ts.map +0 -1
- package/dist/sandbox/daytona.js +0 -282
- package/dist/sandbox/daytona.js.map +0 -1
- package/dist/shared-sessions.d.ts +0 -16
- package/dist/shared-sessions.d.ts.map +0 -1
- package/dist/shared-sessions.js +0 -52
- package/dist/shared-sessions.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -7,8 +7,9 @@ import { ts } from "@electric-agent/protocol";
|
|
|
7
7
|
import { serve } from "@hono/node-server";
|
|
8
8
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
|
-
import { cors } from "hono/cors";
|
|
11
10
|
import { ActiveSessions } from "./active-sessions.js";
|
|
11
|
+
import { addAgentSchema, addSessionToRoomSchema, createAppRoomSchema, createRoomSchema, createSandboxSchema, createSessionSchema, iterateRoomSessionSchema, iterateSessionSchema, resumeSessionSchema, sendRoomMessageSchema, } from "./api-schemas.js";
|
|
12
|
+
import { PRODUCTION_ALLOWED_TOOLS } from "./bridge/claude-code-base.js";
|
|
12
13
|
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
13
14
|
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
14
15
|
import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
|
|
@@ -16,11 +17,27 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
|
16
17
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
17
18
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
18
19
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
+
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
19
21
|
import { generateInviteCode } from "./invite-code.js";
|
|
20
22
|
import { resolveProjectDir } from "./project-utils.js";
|
|
23
|
+
import { Registry } from "./registry.js";
|
|
21
24
|
import { RoomRouter } from "./room-router.js";
|
|
22
|
-
import { deriveGlobalHookSecret, deriveHookToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateSessionToken, } from "./session-auth.js";
|
|
25
|
+
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
23
26
|
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
27
|
+
import { isResponse, validateBody } from "./validate.js";
|
|
28
|
+
/** Read OAuth token from macOS Keychain (Claude Code credentials). */
|
|
29
|
+
function readKeychainOAuthToken() {
|
|
30
|
+
if (process.platform !== "darwin")
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
return parsed.claudeAiOauth?.accessToken ?? null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
24
41
|
/** Active session bridges — one per running session */
|
|
25
42
|
const bridges = new Map();
|
|
26
43
|
/** Active room routers — one per room with agent-to-agent messaging */
|
|
@@ -67,9 +84,62 @@ function resolveStudioUrl(port) {
|
|
|
67
84
|
// Fallback — won't work from sprites VMs, but at least logs a useful URL
|
|
68
85
|
return `http://localhost:${port}`;
|
|
69
86
|
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Rate limiting — in-memory sliding window per IP
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
91
|
+
const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
|
|
92
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
93
|
+
const sessionCreationsByIp = new Map();
|
|
94
|
+
// GitHub App config (prod mode — repo creation in electric-apps org)
|
|
95
|
+
const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
|
|
96
|
+
const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
|
|
97
|
+
const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
98
|
+
const GITHUB_ORG = "electric-apps";
|
|
99
|
+
// Rate limiting for GitHub token endpoint
|
|
100
|
+
const githubTokenRequestsBySession = new Map();
|
|
101
|
+
const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
|
|
102
|
+
function extractClientIp(c) {
|
|
103
|
+
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
104
|
+
c.req.header("cf-connecting-ip") ||
|
|
105
|
+
"unknown");
|
|
106
|
+
}
|
|
107
|
+
function checkSessionRateLimit(ip) {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
110
|
+
let timestamps = sessionCreationsByIp.get(ip) ?? [];
|
|
111
|
+
// Prune stale entries
|
|
112
|
+
timestamps = timestamps.filter((t) => t > cutoff);
|
|
113
|
+
if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
|
114
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
timestamps.push(now);
|
|
118
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
function checkGlobalSessionCap(sessions) {
|
|
122
|
+
return sessions.size() >= MAX_TOTAL_SESSIONS;
|
|
123
|
+
}
|
|
124
|
+
function checkGithubTokenRateLimit(sessionId) {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
|
|
127
|
+
const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
128
|
+
if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
recent.push(now);
|
|
132
|
+
githubTokenRequestsBySession.set(sessionId, recent);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Per-session cost budget
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
|
|
70
139
|
/**
|
|
71
140
|
* Accumulate cost and turn metrics from a session_end event into the session's totals.
|
|
72
141
|
* Called each time a Claude Code run finishes (initial + iterate runs).
|
|
142
|
+
* In production mode, enforces a per-session cost budget.
|
|
73
143
|
*/
|
|
74
144
|
function accumulateSessionCost(config, sessionId, event) {
|
|
75
145
|
if (event.type !== "session_end")
|
|
@@ -90,12 +160,39 @@ function accumulateSessionCost(config, sessionId, event) {
|
|
|
90
160
|
}
|
|
91
161
|
config.sessions.update(sessionId, updates);
|
|
92
162
|
console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
|
|
163
|
+
// Enforce budget in production mode
|
|
164
|
+
if (!config.devMode &&
|
|
165
|
+
updates.totalCostUsd != null &&
|
|
166
|
+
updates.totalCostUsd > MAX_SESSION_COST_USD) {
|
|
167
|
+
console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
|
|
168
|
+
const bridge = bridges.get(sessionId);
|
|
169
|
+
if (bridge) {
|
|
170
|
+
bridge
|
|
171
|
+
.emit({
|
|
172
|
+
type: "budget_exceeded",
|
|
173
|
+
budget_usd: MAX_SESSION_COST_USD,
|
|
174
|
+
spent_usd: updates.totalCostUsd,
|
|
175
|
+
ts: ts(),
|
|
176
|
+
})
|
|
177
|
+
.catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
180
|
+
closeBridge(sessionId);
|
|
181
|
+
}
|
|
93
182
|
}
|
|
94
183
|
/**
|
|
95
184
|
* Create a Claude Code bridge for a session.
|
|
96
185
|
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
186
|
+
* In production mode, enforces tool restrictions and hardcodes the model.
|
|
97
187
|
*/
|
|
98
188
|
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
189
|
+
// Production mode: restrict tools and hardcode model
|
|
190
|
+
if (!config.devMode) {
|
|
191
|
+
if (!claudeConfig.allowedTools) {
|
|
192
|
+
claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
|
|
193
|
+
}
|
|
194
|
+
claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
|
|
195
|
+
}
|
|
99
196
|
const conn = sessionStream(config, sessionId);
|
|
100
197
|
let bridge;
|
|
101
198
|
if (config.sandbox.runtime === "sprites") {
|
|
@@ -127,31 +224,6 @@ function closeBridge(sessionId) {
|
|
|
127
224
|
bridges.delete(sessionId);
|
|
128
225
|
}
|
|
129
226
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Detect git operations from natural language prompts.
|
|
132
|
-
* Returns structured gitOp fields if matched, null otherwise.
|
|
133
|
-
*/
|
|
134
|
-
function detectGitOp(request) {
|
|
135
|
-
const lower = request.toLowerCase().trim();
|
|
136
|
-
// Commit: "commit", "commit the code", "commit changes", "commit with message ..."
|
|
137
|
-
if (/^(git\s+)?commit\b/.test(lower) || /^save\s+(my\s+)?(changes|progress|work)\b/.test(lower)) {
|
|
138
|
-
// Extract commit message after "commit" keyword, or after "message:" / "msg:"
|
|
139
|
-
const msgMatch = request.match(/(?:commit\s+(?:with\s+(?:message\s+)?)?|message:\s*|msg:\s*)["']?(.+?)["']?\s*$/i);
|
|
140
|
-
const message = msgMatch?.[1]?.replace(/^(the\s+)?(code|changes)\s*/i, "").trim();
|
|
141
|
-
return { gitOp: "commit", gitMessage: message || undefined };
|
|
142
|
-
}
|
|
143
|
-
// Push: "push", "push to github", "push to remote", "git push"
|
|
144
|
-
if (/^(git\s+)?push\b/.test(lower)) {
|
|
145
|
-
return { gitOp: "push" };
|
|
146
|
-
}
|
|
147
|
-
// Create PR: "create pr", "open pr", "make pr", "create pull request"
|
|
148
|
-
if (/^(create|open|make)\s+(a\s+)?(pr|pull\s*request)\b/.test(lower)) {
|
|
149
|
-
// Try to extract title after the PR keyword
|
|
150
|
-
const titleMatch = request.match(/(?:pr|pull\s*request)\s+(?:(?:titled?|called|named)\s+)?["']?(.+?)["']?\s*$/i);
|
|
151
|
-
return { gitOp: "create-pr", gitPrTitle: titleMatch?.[1] || undefined };
|
|
152
|
-
}
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
227
|
/**
|
|
156
228
|
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
157
229
|
*
|
|
@@ -265,8 +337,6 @@ function mapHookToEngineEvent(body) {
|
|
|
265
337
|
}
|
|
266
338
|
export function createApp(config) {
|
|
267
339
|
const app = new Hono();
|
|
268
|
-
// CORS for local development
|
|
269
|
-
app.use("*", cors({ origin: "*" }));
|
|
270
340
|
// --- API Routes ---
|
|
271
341
|
// Health check
|
|
272
342
|
app.get("/api/health", (c) => {
|
|
@@ -284,6 +354,13 @@ export function createApp(config) {
|
|
|
284
354
|
checks.sandbox = config.sandbox.runtime;
|
|
285
355
|
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
286
356
|
});
|
|
357
|
+
// Public config — exposes non-sensitive flags to the client
|
|
358
|
+
app.get("/api/config", (c) => {
|
|
359
|
+
return c.json({
|
|
360
|
+
devMode: config.devMode,
|
|
361
|
+
maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
|
|
362
|
+
});
|
|
363
|
+
});
|
|
287
364
|
// Provision Electric Cloud resources via the Claim API
|
|
288
365
|
app.post("/api/provision-electric", async (c) => {
|
|
289
366
|
try {
|
|
@@ -481,6 +558,7 @@ export function createApp(config) {
|
|
|
481
558
|
if (hookEvent.type === "ask_user_question") {
|
|
482
559
|
const toolUseId = hookEvent.tool_use_id;
|
|
483
560
|
console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
561
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
484
562
|
try {
|
|
485
563
|
const gateTimeout = 5 * 60 * 1000; // 5 minutes
|
|
486
564
|
const result = await Promise.race([
|
|
@@ -488,6 +566,7 @@ export function createApp(config) {
|
|
|
488
566
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
489
567
|
]);
|
|
490
568
|
console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
|
|
569
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
491
570
|
return c.json({
|
|
492
571
|
hookSpecificOutput: {
|
|
493
572
|
hookEventName: "PreToolUse",
|
|
@@ -501,6 +580,7 @@ export function createApp(config) {
|
|
|
501
580
|
}
|
|
502
581
|
catch (err) {
|
|
503
582
|
console.error(`[hook-event] ask_user_question gate error:`, err);
|
|
583
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
504
584
|
return c.json({ ok: true }); // Don't block Claude Code on timeout
|
|
505
585
|
}
|
|
506
586
|
}
|
|
@@ -622,6 +702,7 @@ export function createApp(config) {
|
|
|
622
702
|
if (hookEvent.type === "ask_user_question") {
|
|
623
703
|
const toolUseId = hookEvent.tool_use_id;
|
|
624
704
|
console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
705
|
+
config.sessions.update(sessionId, { needsInput: true });
|
|
625
706
|
try {
|
|
626
707
|
const gateTimeout = 5 * 60 * 1000;
|
|
627
708
|
const result = await Promise.race([
|
|
@@ -629,6 +710,7 @@ export function createApp(config) {
|
|
|
629
710
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
630
711
|
]);
|
|
631
712
|
console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
|
|
713
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
632
714
|
return c.json({
|
|
633
715
|
sessionId,
|
|
634
716
|
hookSpecificOutput: {
|
|
@@ -643,6 +725,7 @@ export function createApp(config) {
|
|
|
643
725
|
}
|
|
644
726
|
catch (err) {
|
|
645
727
|
console.error(`[hook] ask_user_question gate error:`, err);
|
|
728
|
+
config.sessions.update(sessionId, { needsInput: false });
|
|
646
729
|
return c.json({ ok: true, sessionId });
|
|
647
730
|
}
|
|
648
731
|
}
|
|
@@ -738,17 +821,42 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
738
821
|
});
|
|
739
822
|
// Start new project
|
|
740
823
|
app.post("/api/sessions", async (c) => {
|
|
741
|
-
const body =
|
|
742
|
-
if (
|
|
743
|
-
return
|
|
824
|
+
const body = await validateBody(c, createSessionSchema);
|
|
825
|
+
if (isResponse(body))
|
|
826
|
+
return body;
|
|
827
|
+
// Resolve Claude credentials — try OAuth from keychain first, then API key
|
|
828
|
+
const apiKey = config.devMode
|
|
829
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
830
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
831
|
+
const oauthToken = config.devMode
|
|
832
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
833
|
+
: (readKeychainOAuthToken() ?? undefined);
|
|
834
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
835
|
+
const authType = oauthToken ? "oauth-keychain" : apiKey ? "api-key" : "none";
|
|
836
|
+
console.log(`[auth] Using ${authType} for Claude credentials`);
|
|
837
|
+
// Block freeform sessions in production mode
|
|
838
|
+
if (body.freeform && !config.devMode) {
|
|
839
|
+
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
840
|
+
}
|
|
841
|
+
// Rate-limit session creation in production mode
|
|
842
|
+
if (!config.devMode) {
|
|
843
|
+
const ip = extractClientIp(c);
|
|
844
|
+
if (!checkSessionRateLimit(ip)) {
|
|
845
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
846
|
+
}
|
|
847
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
848
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
849
|
+
}
|
|
744
850
|
}
|
|
745
851
|
const sessionId = crypto.randomUUID();
|
|
746
|
-
const inferredName =
|
|
747
|
-
body.
|
|
748
|
-
.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
852
|
+
const inferredName = config.devMode
|
|
853
|
+
? body.name ||
|
|
854
|
+
body.description
|
|
855
|
+
.slice(0, 40)
|
|
856
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
857
|
+
.replace(/^-|-$/g, "")
|
|
858
|
+
.toLowerCase()
|
|
859
|
+
: `electric-${sessionId.slice(0, 8)}`;
|
|
752
860
|
const baseDir = body.baseDir || process.cwd();
|
|
753
861
|
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
754
862
|
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
@@ -785,11 +893,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
785
893
|
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
786
894
|
let ghAccounts = [];
|
|
787
895
|
if (!body.freeform) {
|
|
788
|
-
// Gather GitHub accounts for the merged setup gate
|
|
789
|
-
|
|
790
|
-
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
896
|
+
// Gather GitHub accounts for the merged setup gate (dev mode only)
|
|
897
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
791
898
|
try {
|
|
792
|
-
ghAccounts = ghListAccounts(
|
|
899
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
793
900
|
}
|
|
794
901
|
catch {
|
|
795
902
|
// gh not available — no repo setup
|
|
@@ -872,9 +979,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
872
979
|
const handle = await config.sandbox.create(sessionId, {
|
|
873
980
|
projectName,
|
|
874
981
|
infra,
|
|
875
|
-
apiKey
|
|
876
|
-
oauthToken
|
|
877
|
-
ghToken
|
|
982
|
+
apiKey,
|
|
983
|
+
oauthToken,
|
|
984
|
+
ghToken,
|
|
985
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
986
|
+
prodMode: {
|
|
987
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
988
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
989
|
+
},
|
|
990
|
+
}),
|
|
878
991
|
});
|
|
879
992
|
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
880
993
|
await bridge.emit({
|
|
@@ -940,6 +1053,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
940
1053
|
ts: ts(),
|
|
941
1054
|
});
|
|
942
1055
|
}
|
|
1056
|
+
// Create GitHub repo via GitHub App when credentials are available
|
|
1057
|
+
let prodGitConfig;
|
|
1058
|
+
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
1059
|
+
try {
|
|
1060
|
+
// Repo name matches the project name (already has random slug)
|
|
1061
|
+
const repoSlug = projectName;
|
|
1062
|
+
await bridge.emit({
|
|
1063
|
+
type: "log",
|
|
1064
|
+
level: "build",
|
|
1065
|
+
message: "Creating GitHub repository...",
|
|
1066
|
+
ts: ts(),
|
|
1067
|
+
});
|
|
1068
|
+
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1069
|
+
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
1070
|
+
if (repo) {
|
|
1071
|
+
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
1072
|
+
// Initialize git and set remote in the sandbox
|
|
1073
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
1074
|
+
prodGitConfig = {
|
|
1075
|
+
mode: "pre-created",
|
|
1076
|
+
repoName: actualRepoName,
|
|
1077
|
+
repoUrl: repo.htmlUrl,
|
|
1078
|
+
};
|
|
1079
|
+
config.sessions.update(sessionId, {
|
|
1080
|
+
git: {
|
|
1081
|
+
branch: "main",
|
|
1082
|
+
remoteUrl: repo.htmlUrl,
|
|
1083
|
+
repoName: actualRepoName,
|
|
1084
|
+
lastCommitHash: null,
|
|
1085
|
+
lastCommitMessage: null,
|
|
1086
|
+
lastCheckpointAt: null,
|
|
1087
|
+
},
|
|
1088
|
+
});
|
|
1089
|
+
await bridge.emit({
|
|
1090
|
+
type: "log",
|
|
1091
|
+
level: "done",
|
|
1092
|
+
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
1093
|
+
ts: ts(),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
943
1104
|
// Write CLAUDE.md to the sandbox workspace.
|
|
944
1105
|
// Our generator includes hardcoded playbook paths and reading order
|
|
945
1106
|
// so we don't depend on @tanstack/intent generating a skill block.
|
|
@@ -948,15 +1109,18 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
948
1109
|
projectName,
|
|
949
1110
|
projectDir: handle.projectDir,
|
|
950
1111
|
runtime: config.sandbox.runtime,
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1112
|
+
production: !config.devMode,
|
|
1113
|
+
...(prodGitConfig
|
|
1114
|
+
? { git: prodGitConfig }
|
|
1115
|
+
: repoConfig
|
|
1116
|
+
? {
|
|
1117
|
+
git: {
|
|
1118
|
+
mode: "create",
|
|
1119
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1120
|
+
visibility: repoConfig.visibility,
|
|
1121
|
+
},
|
|
1122
|
+
}
|
|
1123
|
+
: {}),
|
|
960
1124
|
});
|
|
961
1125
|
try {
|
|
962
1126
|
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
@@ -1118,79 +1282,54 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1118
1282
|
const session = config.sessions.get(sessionId);
|
|
1119
1283
|
if (!session)
|
|
1120
1284
|
return c.json({ error: "Session not found" }, 404);
|
|
1121
|
-
const body =
|
|
1122
|
-
if (
|
|
1123
|
-
return
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
await config.sandbox.stopApp(handle);
|
|
1142
|
-
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
1285
|
+
const body = await validateBody(c, iterateSessionSchema);
|
|
1286
|
+
if (isResponse(body))
|
|
1287
|
+
return body;
|
|
1288
|
+
const handle = config.sandbox.get(sessionId);
|
|
1289
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1290
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
1291
|
+
}
|
|
1292
|
+
// Ensure we have a CC bridge (not just a stream writer).
|
|
1293
|
+
// After server restart, bridges are lost — getOrCreateBridge would create
|
|
1294
|
+
// a HostedStreamBridge that can only write to the stream but can't spawn
|
|
1295
|
+
// Claude Code processes. We need a ClaudeCodeDockerBridge to restart the agent.
|
|
1296
|
+
let bridge = bridges.get(sessionId);
|
|
1297
|
+
if (!bridge) {
|
|
1298
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1299
|
+
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1300
|
+
? {
|
|
1301
|
+
prompt: body.request,
|
|
1302
|
+
cwd: session.sandboxProjectDir || handle.projectDir,
|
|
1303
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1304
|
+
hookToken,
|
|
1143
1305
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1306
|
+
: {
|
|
1307
|
+
prompt: body.request,
|
|
1308
|
+
cwd: session.sandboxProjectDir || handle.projectDir,
|
|
1309
|
+
studioPort: config.port,
|
|
1310
|
+
hookToken,
|
|
1311
|
+
};
|
|
1312
|
+
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1313
|
+
// Re-register basic event tracking callbacks
|
|
1314
|
+
bridge.onAgentEvent((event) => {
|
|
1315
|
+
if (event.type === "session_start") {
|
|
1316
|
+
const ccSessionId = event.session_id;
|
|
1317
|
+
if (ccSessionId) {
|
|
1318
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1147
1319
|
}
|
|
1148
|
-
if (isRestartCmd)
|
|
1149
|
-
await config.sandbox.stopApp(handle);
|
|
1150
|
-
await config.sandbox.startApp(handle);
|
|
1151
|
-
await bridge.emit({
|
|
1152
|
-
type: "log",
|
|
1153
|
-
level: "done",
|
|
1154
|
-
message: "App started",
|
|
1155
|
-
ts: ts(),
|
|
1156
|
-
});
|
|
1157
|
-
await bridge.emit({
|
|
1158
|
-
type: "app_status",
|
|
1159
|
-
status: "running",
|
|
1160
|
-
port: session.appPort,
|
|
1161
|
-
previewUrl: session.previewUrl,
|
|
1162
|
-
ts: ts(),
|
|
1163
|
-
});
|
|
1164
1320
|
}
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1169
|
-
}
|
|
1170
|
-
return c.json({ ok: true });
|
|
1171
|
-
}
|
|
1172
|
-
// Intercept git commands (commit, push, create PR)
|
|
1173
|
-
const gitOp = detectGitOp(body.request);
|
|
1174
|
-
if (gitOp) {
|
|
1175
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1176
|
-
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1177
|
-
const handle = config.sandbox.get(sessionId);
|
|
1178
|
-
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1179
|
-
return c.json({ error: "Container is not running" }, 400);
|
|
1180
|
-
}
|
|
1181
|
-
// Send git requests as user messages via Claude Code bridge
|
|
1182
|
-
await bridge.sendCommand({
|
|
1183
|
-
command: "iterate",
|
|
1184
|
-
request: body.request,
|
|
1321
|
+
if (event.type === "session_end") {
|
|
1322
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1323
|
+
}
|
|
1185
1324
|
});
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1325
|
+
bridge.onComplete(async (success) => {
|
|
1326
|
+
config.sessions.update(sessionId, {
|
|
1327
|
+
status: success ? "complete" : "error",
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
console.log(`[iterate] Recreated CC bridge for session ${sessionId} after restart`);
|
|
1191
1331
|
}
|
|
1192
1332
|
// Write user prompt to the stream
|
|
1193
|
-
const bridge = getOrCreateBridge(config, sessionId);
|
|
1194
1333
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1195
1334
|
config.sessions.update(sessionId, { status: "running" });
|
|
1196
1335
|
await bridge.sendCommand({
|
|
@@ -1201,6 +1340,28 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1201
1340
|
});
|
|
1202
1341
|
return c.json({ ok: true });
|
|
1203
1342
|
});
|
|
1343
|
+
// Generate a GitHub installation token for the sandbox (prod mode only)
|
|
1344
|
+
app.post("/api/sessions/:id/github-token", async (c) => {
|
|
1345
|
+
const sessionId = c.req.param("id");
|
|
1346
|
+
if (config.devMode) {
|
|
1347
|
+
return c.json({ error: "Not available in dev mode" }, 403);
|
|
1348
|
+
}
|
|
1349
|
+
if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
|
|
1350
|
+
return c.json({ error: "GitHub App not configured" }, 500);
|
|
1351
|
+
}
|
|
1352
|
+
if (!checkGithubTokenRateLimit(sessionId)) {
|
|
1353
|
+
return c.json({ error: "Too many token requests" }, 429);
|
|
1354
|
+
}
|
|
1355
|
+
try {
|
|
1356
|
+
const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1357
|
+
return c.json(result);
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1361
|
+
console.error(`GitHub token error for session ${sessionId}:`, message);
|
|
1362
|
+
return c.json({ error: "Failed to generate GitHub token" }, 500);
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1204
1365
|
// Respond to a gate (approval, clarification, continue, revision)
|
|
1205
1366
|
app.post("/api/sessions/:id/respond", async (c) => {
|
|
1206
1367
|
const sessionId = c.req.param("id");
|
|
@@ -1491,7 +1652,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1491
1652
|
});
|
|
1492
1653
|
// Create a standalone sandbox (not tied to session creation flow)
|
|
1493
1654
|
app.post("/api/sandboxes", async (c) => {
|
|
1494
|
-
const body =
|
|
1655
|
+
const body = await validateBody(c, createSandboxSchema);
|
|
1656
|
+
if (isResponse(body))
|
|
1657
|
+
return body;
|
|
1495
1658
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1496
1659
|
try {
|
|
1497
1660
|
const handle = await config.sandbox.create(sessionId, {
|
|
@@ -1529,30 +1692,695 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1529
1692
|
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1530
1693
|
}
|
|
1531
1694
|
// Protect room-scoped routes via X-Room-Token header
|
|
1695
|
+
// "create-app" is a creation endpoint — no room token exists yet
|
|
1696
|
+
const roomAuthExemptIds = new Set(["create-app"]);
|
|
1532
1697
|
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1533
1698
|
const id = c.req.param("id");
|
|
1699
|
+
if (roomAuthExemptIds.has(id))
|
|
1700
|
+
return next();
|
|
1534
1701
|
const token = extractRoomToken(c);
|
|
1535
|
-
if (!token || !
|
|
1702
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1536
1703
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1537
1704
|
}
|
|
1538
1705
|
return next();
|
|
1539
1706
|
});
|
|
1540
1707
|
app.use("/api/rooms/:id", async (c, next) => {
|
|
1708
|
+
const id = c.req.param("id");
|
|
1709
|
+
if (roomAuthExemptIds.has(id))
|
|
1710
|
+
return next();
|
|
1541
1711
|
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1542
1712
|
return next();
|
|
1543
|
-
const id = c.req.param("id");
|
|
1544
1713
|
const token = extractRoomToken(c);
|
|
1545
|
-
if (!token || !
|
|
1714
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1546
1715
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1547
1716
|
}
|
|
1548
1717
|
return next();
|
|
1549
1718
|
});
|
|
1719
|
+
// Create a room with 3 agents for multi-agent app creation
|
|
1720
|
+
app.post("/api/rooms/create-app", async (c) => {
|
|
1721
|
+
const body = await validateBody(c, createAppRoomSchema);
|
|
1722
|
+
if (isResponse(body))
|
|
1723
|
+
return body;
|
|
1724
|
+
// Resolve Claude credentials — try OAuth from keychain first, then API key
|
|
1725
|
+
const apiKey = config.devMode
|
|
1726
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
1727
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
1728
|
+
const oauthToken = config.devMode
|
|
1729
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
1730
|
+
: (readKeychainOAuthToken() ?? undefined);
|
|
1731
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1732
|
+
const authType = oauthToken ? "oauth-keychain" : apiKey ? "api-key" : "none";
|
|
1733
|
+
console.log(`[auth] Using ${authType} for Claude credentials`);
|
|
1734
|
+
// Rate-limit session creation in production mode
|
|
1735
|
+
if (!config.devMode) {
|
|
1736
|
+
const ip = extractClientIp(c);
|
|
1737
|
+
if (!checkSessionRateLimit(ip)) {
|
|
1738
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
1739
|
+
}
|
|
1740
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
1741
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const roomId = crypto.randomUUID();
|
|
1745
|
+
const roomName = body.name || `app-${roomId.slice(0, 8)}`;
|
|
1746
|
+
// Create the room's durable stream
|
|
1747
|
+
const roomConn = roomStream(config, roomId);
|
|
1748
|
+
try {
|
|
1749
|
+
await DurableStream.create({
|
|
1750
|
+
url: roomConn.url,
|
|
1751
|
+
headers: roomConn.headers,
|
|
1752
|
+
contentType: "application/json",
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
catch (err) {
|
|
1756
|
+
console.error(`[room:create-app] Failed to create room stream:`, err);
|
|
1757
|
+
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1758
|
+
}
|
|
1759
|
+
// Create and start the router
|
|
1760
|
+
const router = new RoomRouter(roomId, roomName, config.streamConfig);
|
|
1761
|
+
await router.start();
|
|
1762
|
+
roomRouters.set(roomId, router);
|
|
1763
|
+
// Save to room registry
|
|
1764
|
+
const code = generateInviteCode();
|
|
1765
|
+
await config.rooms.addRoom({
|
|
1766
|
+
id: roomId,
|
|
1767
|
+
code,
|
|
1768
|
+
name: roomName,
|
|
1769
|
+
createdAt: new Date().toISOString(),
|
|
1770
|
+
revoked: false,
|
|
1771
|
+
});
|
|
1772
|
+
// Define the 3 agents with randomized display names
|
|
1773
|
+
const agentSuffixes = [
|
|
1774
|
+
"fox",
|
|
1775
|
+
"owl",
|
|
1776
|
+
"lynx",
|
|
1777
|
+
"wolf",
|
|
1778
|
+
"bear",
|
|
1779
|
+
"hawk",
|
|
1780
|
+
"pine",
|
|
1781
|
+
"oak",
|
|
1782
|
+
"elm",
|
|
1783
|
+
"ivy",
|
|
1784
|
+
"ray",
|
|
1785
|
+
"arc",
|
|
1786
|
+
"reef",
|
|
1787
|
+
"dusk",
|
|
1788
|
+
"ash",
|
|
1789
|
+
"sage",
|
|
1790
|
+
];
|
|
1791
|
+
const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
|
|
1792
|
+
const usedSuffixes = new Set();
|
|
1793
|
+
const uniquePick = () => {
|
|
1794
|
+
let s = pick();
|
|
1795
|
+
while (usedSuffixes.has(s))
|
|
1796
|
+
s = pick();
|
|
1797
|
+
usedSuffixes.add(s);
|
|
1798
|
+
return s;
|
|
1799
|
+
};
|
|
1800
|
+
const agentDefs = [
|
|
1801
|
+
{ name: `coder-${uniquePick()}`, role: "coder" },
|
|
1802
|
+
{ name: `reviewer-${uniquePick()}`, role: "reviewer" },
|
|
1803
|
+
];
|
|
1804
|
+
// Create session IDs and streams upfront for all 3 agents
|
|
1805
|
+
const sessions = [];
|
|
1806
|
+
for (const agentDef of agentDefs) {
|
|
1807
|
+
const sessionId = crypto.randomUUID();
|
|
1808
|
+
const conn = sessionStream(config, sessionId);
|
|
1809
|
+
try {
|
|
1810
|
+
await DurableStream.create({
|
|
1811
|
+
url: conn.url,
|
|
1812
|
+
headers: conn.headers,
|
|
1813
|
+
contentType: "application/json",
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
catch (err) {
|
|
1817
|
+
console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
|
|
1818
|
+
return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
|
|
1819
|
+
}
|
|
1820
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1821
|
+
sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
|
|
1822
|
+
}
|
|
1823
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1824
|
+
console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
|
|
1825
|
+
// Return immediately so the client can show the room + sessions
|
|
1826
|
+
// The async flow handles sandbox creation, skill injection, and agent startup
|
|
1827
|
+
// Sessions are created in agentDefs order: [coder, reviewer]
|
|
1828
|
+
const coderSession = sessions[0];
|
|
1829
|
+
const reviewerSession = sessions[1];
|
|
1830
|
+
const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
|
|
1831
|
+
// Record all sessions
|
|
1832
|
+
for (const s of sessions) {
|
|
1833
|
+
const projectName = s.role === "coder" && config.devMode
|
|
1834
|
+
? body.name ||
|
|
1835
|
+
body.description
|
|
1836
|
+
.slice(0, 40)
|
|
1837
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
1838
|
+
.replace(/^-|-$/g, "")
|
|
1839
|
+
.toLowerCase()
|
|
1840
|
+
: `room-${s.name}-${s.sessionId.slice(0, 8)}`;
|
|
1841
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1842
|
+
const session = {
|
|
1843
|
+
id: s.sessionId,
|
|
1844
|
+
projectName,
|
|
1845
|
+
sandboxProjectDir,
|
|
1846
|
+
description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
|
|
1847
|
+
createdAt: new Date().toISOString(),
|
|
1848
|
+
lastActiveAt: new Date().toISOString(),
|
|
1849
|
+
status: "running",
|
|
1850
|
+
};
|
|
1851
|
+
config.sessions.add(session);
|
|
1852
|
+
}
|
|
1853
|
+
// Write user prompt to coder's stream
|
|
1854
|
+
await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
1855
|
+
// Gather GitHub accounts for the infra config gate (dev mode only)
|
|
1856
|
+
let ghAccounts = [];
|
|
1857
|
+
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
1858
|
+
try {
|
|
1859
|
+
ghAccounts = ghListAccounts(ghToken);
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
// gh not available
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
// Emit infra config gate on coder's stream
|
|
1866
|
+
const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
|
|
1867
|
+
await coderBridge.emit({
|
|
1868
|
+
type: "infra_config_prompt",
|
|
1869
|
+
projectName: coderProjectName,
|
|
1870
|
+
ghAccounts,
|
|
1871
|
+
runtime: config.sandbox.runtime,
|
|
1872
|
+
ts: ts(),
|
|
1873
|
+
});
|
|
1874
|
+
// Async flow: wait for gate, create sandboxes, start agents
|
|
1875
|
+
const asyncFlow = async () => {
|
|
1876
|
+
// 1. Wait for infra config gate on coder's session
|
|
1877
|
+
await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
|
|
1878
|
+
console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
|
|
1879
|
+
let infra;
|
|
1880
|
+
let repoConfig = null;
|
|
1881
|
+
let claimId;
|
|
1882
|
+
try {
|
|
1883
|
+
const gateValue = await createGate(coderSession.sessionId, "infra_config");
|
|
1884
|
+
console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
1885
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
1886
|
+
infra = {
|
|
1887
|
+
mode: "cloud",
|
|
1888
|
+
databaseUrl: gateValue.databaseUrl,
|
|
1889
|
+
electricUrl: gateValue.electricUrl,
|
|
1890
|
+
sourceId: gateValue.sourceId,
|
|
1891
|
+
secret: gateValue.secret,
|
|
1892
|
+
};
|
|
1893
|
+
if (gateValue.mode === "claim") {
|
|
1894
|
+
claimId = gateValue.claimId;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
else {
|
|
1898
|
+
infra = { mode: "local" };
|
|
1899
|
+
}
|
|
1900
|
+
// Extract repo config if provided
|
|
1901
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
1902
|
+
repoConfig = {
|
|
1903
|
+
account: gateValue.repoAccount,
|
|
1904
|
+
repoName: gateValue.repoName,
|
|
1905
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
1906
|
+
};
|
|
1907
|
+
config.sessions.update(coderSession.sessionId, {
|
|
1908
|
+
git: {
|
|
1909
|
+
branch: "main",
|
|
1910
|
+
remoteUrl: null,
|
|
1911
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1912
|
+
repoVisibility: repoConfig.visibility,
|
|
1913
|
+
lastCommitHash: null,
|
|
1914
|
+
lastCommitMessage: null,
|
|
1915
|
+
lastCheckpointAt: null,
|
|
1916
|
+
},
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
catch (err) {
|
|
1921
|
+
console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
|
|
1922
|
+
infra = { mode: "local" };
|
|
1923
|
+
}
|
|
1924
|
+
// 2. Create sandboxes in parallel
|
|
1925
|
+
// Coder gets full scaffold, reviewer/ui-designer get minimal
|
|
1926
|
+
await router.sendMessage("system", "Creating sandboxes");
|
|
1927
|
+
await coderBridge.emit({
|
|
1928
|
+
type: "log",
|
|
1929
|
+
level: "build",
|
|
1930
|
+
message: "Creating sandboxes for all agents...",
|
|
1931
|
+
ts: ts(),
|
|
1932
|
+
});
|
|
1933
|
+
const sandboxOpts = (sid) => ({
|
|
1934
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
1935
|
+
prodMode: {
|
|
1936
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
|
|
1937
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1938
|
+
},
|
|
1939
|
+
}),
|
|
1940
|
+
});
|
|
1941
|
+
const coderInfo = config.sessions.get(coderSession.sessionId);
|
|
1942
|
+
if (!coderInfo)
|
|
1943
|
+
throw new Error("Coder session not found in registry");
|
|
1944
|
+
const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
|
|
1945
|
+
if (!reviewerInfo)
|
|
1946
|
+
throw new Error("Reviewer session not found in registry");
|
|
1947
|
+
const [coderHandle, reviewerHandle] = await Promise.all([
|
|
1948
|
+
config.sandbox.create(coderSession.sessionId, {
|
|
1949
|
+
projectName: coderInfo.projectName,
|
|
1950
|
+
infra,
|
|
1951
|
+
apiKey,
|
|
1952
|
+
oauthToken,
|
|
1953
|
+
ghToken,
|
|
1954
|
+
...sandboxOpts(coderSession.sessionId),
|
|
1955
|
+
}),
|
|
1956
|
+
config.sandbox.create(reviewerSession.sessionId, {
|
|
1957
|
+
projectName: reviewerInfo.projectName,
|
|
1958
|
+
infra: { mode: "none" },
|
|
1959
|
+
apiKey,
|
|
1960
|
+
oauthToken,
|
|
1961
|
+
ghToken,
|
|
1962
|
+
...sandboxOpts(reviewerSession.sessionId),
|
|
1963
|
+
}),
|
|
1964
|
+
]);
|
|
1965
|
+
const handles = [
|
|
1966
|
+
{ session: coderSession, handle: coderHandle },
|
|
1967
|
+
{ session: reviewerSession, handle: reviewerHandle },
|
|
1968
|
+
];
|
|
1969
|
+
// Update session info with sandbox details
|
|
1970
|
+
for (const { session: s, handle } of handles) {
|
|
1971
|
+
config.sessions.update(s.sessionId, {
|
|
1972
|
+
appPort: handle.port,
|
|
1973
|
+
sandboxProjectDir: handle.projectDir,
|
|
1974
|
+
previewUrl: handle.previewUrl,
|
|
1975
|
+
...(s.role === "coder" && claimId ? { claimId } : {}),
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
await coderBridge.emit({
|
|
1979
|
+
type: "log",
|
|
1980
|
+
level: "done",
|
|
1981
|
+
message: "All sandboxes ready",
|
|
1982
|
+
ts: ts(),
|
|
1983
|
+
});
|
|
1984
|
+
// 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
|
|
1985
|
+
{
|
|
1986
|
+
const handle = coderHandle;
|
|
1987
|
+
// Copy scaffold
|
|
1988
|
+
await coderBridge.emit({
|
|
1989
|
+
type: "log",
|
|
1990
|
+
level: "build",
|
|
1991
|
+
message: "Setting up project...",
|
|
1992
|
+
ts: ts(),
|
|
1993
|
+
});
|
|
1994
|
+
try {
|
|
1995
|
+
if (config.sandbox.runtime === "docker") {
|
|
1996
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
1997
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
1998
|
+
}
|
|
1999
|
+
else {
|
|
2000
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${coderInfo.projectName}' --skip-git`);
|
|
2001
|
+
}
|
|
2002
|
+
await coderBridge.emit({
|
|
2003
|
+
type: "log",
|
|
2004
|
+
level: "done",
|
|
2005
|
+
message: "Project ready",
|
|
2006
|
+
ts: ts(),
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
catch (err) {
|
|
2010
|
+
console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
|
|
2011
|
+
await coderBridge.emit({
|
|
2012
|
+
type: "log",
|
|
2013
|
+
level: "error",
|
|
2014
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
2015
|
+
ts: ts(),
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
// GitHub repo creation (uses GitHub App when credentials are available)
|
|
2019
|
+
let repoUrl = null;
|
|
2020
|
+
let prodGitConfig;
|
|
2021
|
+
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
2022
|
+
try {
|
|
2023
|
+
const repoSlug = coderInfo.projectName;
|
|
2024
|
+
await coderBridge.emit({
|
|
2025
|
+
type: "log",
|
|
2026
|
+
level: "build",
|
|
2027
|
+
message: "Creating GitHub repository...",
|
|
2028
|
+
ts: ts(),
|
|
2029
|
+
});
|
|
2030
|
+
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
2031
|
+
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
2032
|
+
if (repo) {
|
|
2033
|
+
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
2034
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
2035
|
+
prodGitConfig = {
|
|
2036
|
+
mode: "pre-created",
|
|
2037
|
+
repoName: actualRepoName,
|
|
2038
|
+
repoUrl: repo.htmlUrl,
|
|
2039
|
+
};
|
|
2040
|
+
repoUrl = repo.htmlUrl;
|
|
2041
|
+
config.sessions.update(coderSession.sessionId, {
|
|
2042
|
+
git: {
|
|
2043
|
+
branch: "main",
|
|
2044
|
+
remoteUrl: repo.htmlUrl,
|
|
2045
|
+
repoName: actualRepoName,
|
|
2046
|
+
lastCommitHash: null,
|
|
2047
|
+
lastCommitMessage: null,
|
|
2048
|
+
lastCheckpointAt: null,
|
|
2049
|
+
},
|
|
2050
|
+
});
|
|
2051
|
+
await coderBridge.emit({
|
|
2052
|
+
type: "log",
|
|
2053
|
+
level: "done",
|
|
2054
|
+
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
2055
|
+
ts: ts(),
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
catch (err) {
|
|
2060
|
+
console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
else if (repoConfig) {
|
|
2064
|
+
repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
|
|
2065
|
+
}
|
|
2066
|
+
// Write CLAUDE.md to coder sandbox
|
|
2067
|
+
const claudeMd = generateClaudeMd({
|
|
2068
|
+
description: body.description,
|
|
2069
|
+
projectName: coderInfo.projectName,
|
|
2070
|
+
projectDir: handle.projectDir,
|
|
2071
|
+
runtime: config.sandbox.runtime,
|
|
2072
|
+
production: !config.devMode,
|
|
2073
|
+
...(prodGitConfig
|
|
2074
|
+
? { git: prodGitConfig }
|
|
2075
|
+
: repoConfig
|
|
2076
|
+
? {
|
|
2077
|
+
git: {
|
|
2078
|
+
mode: "create",
|
|
2079
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
2080
|
+
visibility: repoConfig.visibility,
|
|
2081
|
+
},
|
|
2082
|
+
}
|
|
2083
|
+
: {}),
|
|
2084
|
+
});
|
|
2085
|
+
try {
|
|
2086
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
2087
|
+
}
|
|
2088
|
+
catch (err) {
|
|
2089
|
+
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
|
|
2090
|
+
}
|
|
2091
|
+
// Write create-app skill to coder sandbox
|
|
2092
|
+
if (createAppSkillContent) {
|
|
2093
|
+
try {
|
|
2094
|
+
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
2095
|
+
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
2096
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2097
|
+
}
|
|
2098
|
+
catch (err) {
|
|
2099
|
+
console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
// Write room-messaging skill to coder sandbox
|
|
2103
|
+
if (roomMessagingSkillContent) {
|
|
2104
|
+
try {
|
|
2105
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2106
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2107
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2108
|
+
}
|
|
2109
|
+
catch (err) {
|
|
2110
|
+
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
// 4. Create Claude Code bridge for coder
|
|
2114
|
+
const coderPrompt = `/create-app ${body.description}`;
|
|
2115
|
+
const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
|
|
2116
|
+
const coderClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2117
|
+
? {
|
|
2118
|
+
prompt: coderPrompt,
|
|
2119
|
+
cwd: handle.projectDir,
|
|
2120
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2121
|
+
hookToken: coderHookToken,
|
|
2122
|
+
agentName: coderSession.name,
|
|
2123
|
+
}
|
|
2124
|
+
: {
|
|
2125
|
+
prompt: coderPrompt,
|
|
2126
|
+
cwd: handle.projectDir,
|
|
2127
|
+
studioPort: config.port,
|
|
2128
|
+
hookToken: coderHookToken,
|
|
2129
|
+
agentName: coderSession.name,
|
|
2130
|
+
};
|
|
2131
|
+
const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
|
|
2132
|
+
// Track coder events
|
|
2133
|
+
coderCcBridge.onAgentEvent((event) => {
|
|
2134
|
+
if (event.type === "session_start") {
|
|
2135
|
+
const ccSessionId = event.session_id;
|
|
2136
|
+
if (ccSessionId) {
|
|
2137
|
+
config.sessions.update(coderSession.sessionId, {
|
|
2138
|
+
lastCoderSessionId: ccSessionId,
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
if (event.type === "session_end") {
|
|
2143
|
+
accumulateSessionCost(config, coderSession.sessionId, event);
|
|
2144
|
+
}
|
|
2145
|
+
// Route assistant_message output to the room router
|
|
2146
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
2147
|
+
const text = event.text;
|
|
2148
|
+
router.handleAgentOutput(coderSession.sessionId, text).catch((err) => {
|
|
2149
|
+
console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
// Notify room when coder is waiting for user input
|
|
2153
|
+
if (event.type === "ask_user_question") {
|
|
2154
|
+
config.sessions.update(coderSession.sessionId, { needsInput: true });
|
|
2155
|
+
router
|
|
2156
|
+
.sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
|
|
2157
|
+
.catch((err) => {
|
|
2158
|
+
console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
if (event.type === "gate_resolved") {
|
|
2162
|
+
config.sessions.update(coderSession.sessionId, { needsInput: false });
|
|
2163
|
+
router
|
|
2164
|
+
.sendMessage("system", `${coderSession.name} received input — resuming.`)
|
|
2165
|
+
.catch(() => { });
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
// Coder completion handler: notify room on success or failure
|
|
2169
|
+
coderCcBridge.onComplete(async (success) => {
|
|
2170
|
+
const updates = {
|
|
2171
|
+
status: success ? "complete" : "error",
|
|
2172
|
+
};
|
|
2173
|
+
try {
|
|
2174
|
+
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
2175
|
+
if (gs.initialized) {
|
|
2176
|
+
const existing = config.sessions.get(coderSession.sessionId);
|
|
2177
|
+
updates.git = {
|
|
2178
|
+
branch: gs.branch ?? "main",
|
|
2179
|
+
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
2180
|
+
repoName: existing?.git?.repoName ?? null,
|
|
2181
|
+
repoVisibility: existing?.git?.repoVisibility,
|
|
2182
|
+
lastCommitHash: gs.lastCommitHash ?? null,
|
|
2183
|
+
lastCommitMessage: gs.lastCommitMessage ?? null,
|
|
2184
|
+
lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
catch {
|
|
2189
|
+
// Sandbox may be stopped
|
|
2190
|
+
}
|
|
2191
|
+
config.sessions.update(coderSession.sessionId, updates);
|
|
2192
|
+
const status = success ? "completed" : "ended with errors";
|
|
2193
|
+
console.log(`[room:create-app:${roomId}] Coder session ${status}`);
|
|
2194
|
+
});
|
|
2195
|
+
await coderBridge.emit({
|
|
2196
|
+
type: "log",
|
|
2197
|
+
level: "build",
|
|
2198
|
+
message: `Running: claude "/create-app ${body.description}"`,
|
|
2199
|
+
ts: ts(),
|
|
2200
|
+
});
|
|
2201
|
+
await coderCcBridge.start();
|
|
2202
|
+
// Add coder as room participant
|
|
2203
|
+
const coderParticipant = {
|
|
2204
|
+
sessionId: coderSession.sessionId,
|
|
2205
|
+
name: coderSession.name,
|
|
2206
|
+
role: "coder",
|
|
2207
|
+
bridge: coderCcBridge,
|
|
2208
|
+
};
|
|
2209
|
+
await router.addParticipant(coderParticipant, false);
|
|
2210
|
+
// Send the initial command to the coder
|
|
2211
|
+
await coderCcBridge.sendCommand({
|
|
2212
|
+
command: "new",
|
|
2213
|
+
description: body.description,
|
|
2214
|
+
projectName: coderInfo.projectName,
|
|
2215
|
+
baseDir: "/home/agent/workspace",
|
|
2216
|
+
});
|
|
2217
|
+
// Store the repoUrl for reviewer/ui-designer prompts
|
|
2218
|
+
// (we continue setting up those agents now)
|
|
2219
|
+
const finalRepoUrl = repoUrl;
|
|
2220
|
+
// Share repo info with all agents via the room router's discovery prompt
|
|
2221
|
+
router.setRepoInfo({
|
|
2222
|
+
url: finalRepoUrl,
|
|
2223
|
+
branch: "main",
|
|
2224
|
+
});
|
|
2225
|
+
// 5. Set up reviewer and ui-designer sandboxes
|
|
2226
|
+
const supportAgents = [{ session: reviewerSession, handle: reviewerHandle }];
|
|
2227
|
+
for (const { session: agentSession, handle: agentHandle } of supportAgents) {
|
|
2228
|
+
const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
|
|
2229
|
+
// Write a minimal CLAUDE.md
|
|
2230
|
+
const minimalClaudeMd = "Room agent workspace";
|
|
2231
|
+
try {
|
|
2232
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
|
|
2233
|
+
}
|
|
2234
|
+
catch (err) {
|
|
2235
|
+
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
|
|
2236
|
+
}
|
|
2237
|
+
// Write room-messaging skill
|
|
2238
|
+
if (roomMessagingSkillContent) {
|
|
2239
|
+
try {
|
|
2240
|
+
const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
|
|
2241
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2242
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2243
|
+
}
|
|
2244
|
+
catch (err) {
|
|
2245
|
+
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
// Resolve and inject role skill
|
|
2249
|
+
const roleSkill = resolveRoleSkill(agentSession.role);
|
|
2250
|
+
if (roleSkill) {
|
|
2251
|
+
try {
|
|
2252
|
+
const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
|
|
2253
|
+
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
2254
|
+
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2255
|
+
}
|
|
2256
|
+
catch (err) {
|
|
2257
|
+
console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
// Build prompt (repo info is now passed via the room router's discovery prompt)
|
|
2261
|
+
const agentPrompt = agentSession.role === "reviewer"
|
|
2262
|
+
? `You are "reviewer", a read-only code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines. CRITICAL: You must NEVER modify code — only read and review. Wait for the coder to send a @room REVIEW_REQUEST: message before starting any work.`
|
|
2263
|
+
: `You are "${agentSession.role}", an agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.`;
|
|
2264
|
+
// Create Claude Code bridge
|
|
2265
|
+
const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
|
|
2266
|
+
const agentClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2267
|
+
? {
|
|
2268
|
+
prompt: agentPrompt,
|
|
2269
|
+
cwd: agentHandle.projectDir,
|
|
2270
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2271
|
+
hookToken: agentHookToken,
|
|
2272
|
+
agentName: agentSession.name,
|
|
2273
|
+
...(roleSkill?.allowedTools && {
|
|
2274
|
+
allowedTools: roleSkill.allowedTools,
|
|
2275
|
+
}),
|
|
2276
|
+
}
|
|
2277
|
+
: {
|
|
2278
|
+
prompt: agentPrompt,
|
|
2279
|
+
cwd: agentHandle.projectDir,
|
|
2280
|
+
studioPort: config.port,
|
|
2281
|
+
hookToken: agentHookToken,
|
|
2282
|
+
agentName: agentSession.name,
|
|
2283
|
+
...(roleSkill?.allowedTools && {
|
|
2284
|
+
allowedTools: roleSkill.allowedTools,
|
|
2285
|
+
}),
|
|
2286
|
+
};
|
|
2287
|
+
const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
|
|
2288
|
+
// Track events
|
|
2289
|
+
ccBridge.onAgentEvent((event) => {
|
|
2290
|
+
console.log(`[room:create-app:${roomId}] ${agentSession.name} event: type=${event.type}${event.type === "assistant_message" && "text" in event ? ` text=${event.text.slice(0, 120)}` : ""}`);
|
|
2291
|
+
if (event.type === "session_start") {
|
|
2292
|
+
const ccSessionId = event.session_id;
|
|
2293
|
+
if (ccSessionId) {
|
|
2294
|
+
config.sessions.update(agentSession.sessionId, {
|
|
2295
|
+
lastCoderSessionId: ccSessionId,
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
if (event.type === "session_end") {
|
|
2300
|
+
accumulateSessionCost(config, agentSession.sessionId, event);
|
|
2301
|
+
}
|
|
2302
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
2303
|
+
const text = event.text;
|
|
2304
|
+
console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
|
|
2305
|
+
router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
|
|
2306
|
+
console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
if (event.type === "ask_user_question") {
|
|
2310
|
+
config.sessions.update(agentSession.sessionId, { needsInput: true });
|
|
2311
|
+
router
|
|
2312
|
+
.sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
|
|
2313
|
+
.catch((err) => {
|
|
2314
|
+
console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
if (event.type === "gate_resolved") {
|
|
2318
|
+
config.sessions.update(agentSession.sessionId, { needsInput: false });
|
|
2319
|
+
router
|
|
2320
|
+
.sendMessage("system", `${agentSession.name} received input — resuming.`)
|
|
2321
|
+
.catch(() => { });
|
|
2322
|
+
}
|
|
2323
|
+
});
|
|
2324
|
+
ccBridge.onComplete(async (success) => {
|
|
2325
|
+
config.sessions.update(agentSession.sessionId, {
|
|
2326
|
+
status: success ? "complete" : "error",
|
|
2327
|
+
});
|
|
2328
|
+
});
|
|
2329
|
+
await agentBridge.emit({
|
|
2330
|
+
type: "log",
|
|
2331
|
+
level: "done",
|
|
2332
|
+
message: `Sandbox ready for "${agentSession.name}"`,
|
|
2333
|
+
ts: ts(),
|
|
2334
|
+
});
|
|
2335
|
+
await ccBridge.start();
|
|
2336
|
+
// Add as room participant (not gated — messages flow freely)
|
|
2337
|
+
const participant = {
|
|
2338
|
+
sessionId: agentSession.sessionId,
|
|
2339
|
+
name: agentSession.name,
|
|
2340
|
+
role: agentSession.role,
|
|
2341
|
+
bridge: ccBridge,
|
|
2342
|
+
};
|
|
2343
|
+
await router.addParticipant(participant, false);
|
|
2344
|
+
}
|
|
2345
|
+
console.log(`[room:create-app:${roomId}] All agents started and added to room`);
|
|
2346
|
+
await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} waiting for review request. UI designer can be added later via "Add Agent".`);
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
asyncFlow().catch(async (err) => {
|
|
2350
|
+
console.error(`[room:create-app:${roomId}] Flow failed:`, err);
|
|
2351
|
+
for (const s of sessions) {
|
|
2352
|
+
config.sessions.update(s.sessionId, { status: "error" });
|
|
2353
|
+
}
|
|
2354
|
+
try {
|
|
2355
|
+
await coderBridge.emit({
|
|
2356
|
+
type: "log",
|
|
2357
|
+
level: "error",
|
|
2358
|
+
message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2359
|
+
ts: ts(),
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
catch {
|
|
2363
|
+
// Bridge may not be usable
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2366
|
+
return c.json({
|
|
2367
|
+
roomId,
|
|
2368
|
+
code,
|
|
2369
|
+
name: roomName,
|
|
2370
|
+
roomToken,
|
|
2371
|
+
sessions: sessions.map((s) => ({
|
|
2372
|
+
sessionId: s.sessionId,
|
|
2373
|
+
name: s.name,
|
|
2374
|
+
role: s.role,
|
|
2375
|
+
sessionToken: s.sessionToken,
|
|
2376
|
+
})),
|
|
2377
|
+
}, 201);
|
|
2378
|
+
});
|
|
1550
2379
|
// Create a room
|
|
1551
2380
|
app.post("/api/rooms", async (c) => {
|
|
1552
|
-
const body =
|
|
1553
|
-
if (
|
|
1554
|
-
return
|
|
1555
|
-
}
|
|
2381
|
+
const body = await validateBody(c, createRoomSchema);
|
|
2382
|
+
if (isResponse(body))
|
|
2383
|
+
return body;
|
|
1556
2384
|
const roomId = crypto.randomUUID();
|
|
1557
2385
|
// Create the room's durable stream
|
|
1558
2386
|
const conn = roomStream(config, roomId);
|
|
@@ -1582,12 +2410,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1582
2410
|
createdAt: new Date().toISOString(),
|
|
1583
2411
|
revoked: false,
|
|
1584
2412
|
});
|
|
1585
|
-
const roomToken =
|
|
2413
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1586
2414
|
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1587
2415
|
return c.json({ roomId, code, roomToken }, 201);
|
|
1588
2416
|
});
|
|
1589
|
-
// Join an agent room by id + invite code
|
|
1590
|
-
app.get("/api/
|
|
2417
|
+
// Join an agent room by id + invite code (outside /api/rooms/:id to avoid auth middleware)
|
|
2418
|
+
app.get("/api/join-room/:id/:code", (c) => {
|
|
1591
2419
|
const id = c.req.param("id");
|
|
1592
2420
|
const code = c.req.param("code");
|
|
1593
2421
|
const room = config.rooms.getRoom(id);
|
|
@@ -1595,25 +2423,51 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1595
2423
|
return c.json({ error: "Room not found" }, 404);
|
|
1596
2424
|
if (room.revoked)
|
|
1597
2425
|
return c.json({ error: "Room has been revoked" }, 410);
|
|
1598
|
-
const roomToken =
|
|
2426
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
|
|
1599
2427
|
return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
|
|
1600
2428
|
});
|
|
1601
2429
|
// Get room state
|
|
1602
2430
|
app.get("/api/rooms/:id", (c) => {
|
|
1603
2431
|
const roomId = c.req.param("id");
|
|
1604
2432
|
const router = roomRouters.get(roomId);
|
|
1605
|
-
if (
|
|
2433
|
+
if (router) {
|
|
2434
|
+
// Find preview URL / port from any participant's session (prefer coder role)
|
|
2435
|
+
const coderParticipant = router.participants.find((p) => p.role === "coder");
|
|
2436
|
+
const previewParticipant = coderParticipant ?? router.participants[0];
|
|
2437
|
+
let previewUrl;
|
|
2438
|
+
let appPort;
|
|
2439
|
+
if (previewParticipant) {
|
|
2440
|
+
const handle = config.sandbox.get(previewParticipant.sessionId);
|
|
2441
|
+
const session = config.sessions.get(previewParticipant.sessionId);
|
|
2442
|
+
previewUrl = handle?.previewUrl ?? session?.previewUrl;
|
|
2443
|
+
appPort = handle?.port ?? session?.appPort;
|
|
2444
|
+
}
|
|
2445
|
+
return c.json({
|
|
2446
|
+
roomId,
|
|
2447
|
+
state: router.state,
|
|
2448
|
+
roundCount: router.roundCount,
|
|
2449
|
+
previewUrl,
|
|
2450
|
+
appPort,
|
|
2451
|
+
participants: router.participants.map((p) => ({
|
|
2452
|
+
sessionId: p.sessionId,
|
|
2453
|
+
name: p.name,
|
|
2454
|
+
role: p.role,
|
|
2455
|
+
running: p.bridge.isRunning(),
|
|
2456
|
+
needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
|
|
2457
|
+
})),
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
// No active router — check if room exists in the registry (e.g. after server restart)
|
|
2461
|
+
const roomEntry = config.rooms.getRoom(roomId);
|
|
2462
|
+
if (!roomEntry)
|
|
1606
2463
|
return c.json({ error: "Room not found" }, 404);
|
|
2464
|
+
// Return basic room state without live participants
|
|
2465
|
+
// Sessions are still readable via their individual SSE streams
|
|
1607
2466
|
return c.json({
|
|
1608
2467
|
roomId,
|
|
1609
|
-
state:
|
|
1610
|
-
roundCount:
|
|
1611
|
-
participants:
|
|
1612
|
-
sessionId: p.sessionId,
|
|
1613
|
-
name: p.name,
|
|
1614
|
-
role: p.role,
|
|
1615
|
-
running: p.bridge.isRunning(),
|
|
1616
|
-
})),
|
|
2468
|
+
state: "closed",
|
|
2469
|
+
roundCount: 0,
|
|
2470
|
+
participants: [],
|
|
1617
2471
|
});
|
|
1618
2472
|
});
|
|
1619
2473
|
// Add an agent to a room
|
|
@@ -1622,7 +2476,26 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1622
2476
|
const router = roomRouters.get(roomId);
|
|
1623
2477
|
if (!router)
|
|
1624
2478
|
return c.json({ error: "Room not found" }, 404);
|
|
1625
|
-
const body =
|
|
2479
|
+
const body = await validateBody(c, addAgentSchema);
|
|
2480
|
+
if (isResponse(body))
|
|
2481
|
+
return body;
|
|
2482
|
+
// Rate-limit and gate credentials in production mode
|
|
2483
|
+
if (!config.devMode) {
|
|
2484
|
+
const ip = extractClientIp(c);
|
|
2485
|
+
if (!checkSessionRateLimit(ip)) {
|
|
2486
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2487
|
+
}
|
|
2488
|
+
if (checkGlobalSessionCap(config.sessions)) {
|
|
2489
|
+
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
const apiKey = config.devMode
|
|
2493
|
+
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
2494
|
+
: process.env.ANTHROPIC_API_KEY;
|
|
2495
|
+
const oauthToken = config.devMode
|
|
2496
|
+
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
2497
|
+
: undefined;
|
|
2498
|
+
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1626
2499
|
const sessionId = crypto.randomUUID();
|
|
1627
2500
|
const randomSuffix = sessionId.slice(0, 6);
|
|
1628
2501
|
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
@@ -1671,9 +2544,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1671
2544
|
const handle = await config.sandbox.create(sessionId, {
|
|
1672
2545
|
projectName,
|
|
1673
2546
|
infra: { mode: "local" },
|
|
1674
|
-
apiKey
|
|
1675
|
-
oauthToken
|
|
1676
|
-
ghToken
|
|
2547
|
+
apiKey,
|
|
2548
|
+
oauthToken,
|
|
2549
|
+
ghToken,
|
|
2550
|
+
...((!config.devMode || GITHUB_APP_ID) && {
|
|
2551
|
+
prodMode: {
|
|
2552
|
+
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
2553
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
2554
|
+
},
|
|
2555
|
+
}),
|
|
1677
2556
|
});
|
|
1678
2557
|
config.sessions.update(sessionId, {
|
|
1679
2558
|
appPort: handle.port,
|
|
@@ -1787,10 +2666,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1787
2666
|
const router = roomRouters.get(roomId);
|
|
1788
2667
|
if (!router)
|
|
1789
2668
|
return c.json({ error: "Room not found" }, 404);
|
|
1790
|
-
const body =
|
|
1791
|
-
if (
|
|
1792
|
-
return
|
|
1793
|
-
}
|
|
2669
|
+
const body = await validateBody(c, addSessionToRoomSchema);
|
|
2670
|
+
if (isResponse(body))
|
|
2671
|
+
return body;
|
|
1794
2672
|
const { sessionId } = body;
|
|
1795
2673
|
// Require a valid session token — caller must already own this session.
|
|
1796
2674
|
// Room auth is handled by middleware via X-Room-Token; Authorization
|
|
@@ -1870,10 +2748,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1870
2748
|
const participant = router.participants.find((p) => p.sessionId === sessionId);
|
|
1871
2749
|
if (!participant)
|
|
1872
2750
|
return c.json({ error: "Session not found in this room" }, 404);
|
|
1873
|
-
const body =
|
|
1874
|
-
if (
|
|
1875
|
-
return
|
|
1876
|
-
}
|
|
2751
|
+
const body = await validateBody(c, iterateRoomSessionSchema);
|
|
2752
|
+
if (isResponse(body))
|
|
2753
|
+
return body;
|
|
1877
2754
|
await participant.bridge.sendCommand({
|
|
1878
2755
|
command: "iterate",
|
|
1879
2756
|
request: body.request,
|
|
@@ -1886,19 +2763,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1886
2763
|
const router = roomRouters.get(roomId);
|
|
1887
2764
|
if (!router)
|
|
1888
2765
|
return c.json({ error: "Room not found" }, 404);
|
|
1889
|
-
const body =
|
|
1890
|
-
if (
|
|
1891
|
-
return
|
|
1892
|
-
}
|
|
2766
|
+
const body = await validateBody(c, sendRoomMessageSchema);
|
|
2767
|
+
if (isResponse(body))
|
|
2768
|
+
return body;
|
|
1893
2769
|
await router.sendMessage(body.from, body.body, body.to);
|
|
1894
2770
|
return c.json({ ok: true });
|
|
1895
2771
|
});
|
|
1896
|
-
// SSE proxy for room events
|
|
2772
|
+
// SSE proxy for room events (works even after server restart — reads from durable stream)
|
|
1897
2773
|
app.get("/api/rooms/:id/events", async (c) => {
|
|
1898
2774
|
const roomId = c.req.param("id");
|
|
1899
|
-
|
|
1900
|
-
if (!
|
|
2775
|
+
// Verify room exists in registry or has active router
|
|
2776
|
+
if (!roomRouters.has(roomId) && !config.rooms.getRoom(roomId)) {
|
|
1901
2777
|
return c.json({ error: "Room not found" }, 404);
|
|
2778
|
+
}
|
|
1902
2779
|
const connection = roomStream(config, roomId);
|
|
1903
2780
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1904
2781
|
const reader = new DurableStream({
|
|
@@ -2115,7 +2992,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2115
2992
|
if (!handle || !sandboxDir) {
|
|
2116
2993
|
return c.json({ error: "Container not available" }, 404);
|
|
2117
2994
|
}
|
|
2118
|
-
|
|
2995
|
+
const resolvedPath = path.resolve(filePath);
|
|
2996
|
+
const resolvedDir = path.resolve(sandboxDir) + path.sep;
|
|
2997
|
+
if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
|
|
2119
2998
|
return c.json({ error: "Path outside project directory" }, 403);
|
|
2120
2999
|
}
|
|
2121
3000
|
const content = await config.sandbox.readFile(handle, filePath);
|
|
@@ -2126,6 +3005,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2126
3005
|
});
|
|
2127
3006
|
// List GitHub accounts (personal + orgs) — requires client-provided token
|
|
2128
3007
|
app.get("/api/github/accounts", (c) => {
|
|
3008
|
+
if (!config.devMode)
|
|
3009
|
+
return c.json({ error: "Not available" }, 403);
|
|
2129
3010
|
const token = c.req.header("X-GH-Token");
|
|
2130
3011
|
if (!token)
|
|
2131
3012
|
return c.json({ accounts: [] });
|
|
@@ -2137,8 +3018,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2137
3018
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
2138
3019
|
}
|
|
2139
3020
|
});
|
|
2140
|
-
// List GitHub repos for the authenticated user — requires client-provided token
|
|
3021
|
+
// List GitHub repos for the authenticated user — requires client-provided token (dev mode only)
|
|
2141
3022
|
app.get("/api/github/repos", (c) => {
|
|
3023
|
+
if (!config.devMode)
|
|
3024
|
+
return c.json({ error: "Not available" }, 403);
|
|
2142
3025
|
const token = c.req.header("X-GH-Token");
|
|
2143
3026
|
if (!token)
|
|
2144
3027
|
return c.json({ repos: [] });
|
|
@@ -2151,6 +3034,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2151
3034
|
}
|
|
2152
3035
|
});
|
|
2153
3036
|
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
3037
|
+
if (!config.devMode)
|
|
3038
|
+
return c.json({ error: "Not available" }, 403);
|
|
2154
3039
|
const owner = c.req.param("owner");
|
|
2155
3040
|
const repo = c.req.param("repo");
|
|
2156
3041
|
const token = c.req.header("X-GH-Token");
|
|
@@ -2164,33 +3049,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2164
3049
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
2165
3050
|
}
|
|
2166
3051
|
});
|
|
2167
|
-
// Read Claude credentials from macOS Keychain
|
|
3052
|
+
// Read Claude credentials from macOS Keychain.
|
|
2168
3053
|
app.get("/api/credentials/keychain", (c) => {
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
try {
|
|
2173
|
-
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2174
|
-
const parsed = JSON.parse(raw);
|
|
2175
|
-
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
2176
|
-
if (token) {
|
|
2177
|
-
console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
|
|
2178
|
-
}
|
|
2179
|
-
else {
|
|
2180
|
-
console.log("[dev] No OAuth token found in keychain");
|
|
2181
|
-
}
|
|
2182
|
-
return c.json({ oauthToken: token });
|
|
2183
|
-
}
|
|
2184
|
-
catch {
|
|
2185
|
-
return c.json({ oauthToken: null });
|
|
3054
|
+
const token = readKeychainOAuthToken();
|
|
3055
|
+
if (token) {
|
|
3056
|
+
console.log(`[keychain] Loaded OAuth token (length: ${token.length})`);
|
|
2186
3057
|
}
|
|
3058
|
+
return c.json({ oauthToken: token });
|
|
2187
3059
|
});
|
|
2188
|
-
// Resume a project from a GitHub repo
|
|
3060
|
+
// Resume a project from a GitHub repo (dev mode only)
|
|
2189
3061
|
app.post("/api/sessions/resume", async (c) => {
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
return c.json({ error: "repoUrl is required" }, 400);
|
|
3062
|
+
if (!config.devMode) {
|
|
3063
|
+
return c.json({ error: "Resume from repo not available" }, 403);
|
|
2193
3064
|
}
|
|
3065
|
+
const body = await validateBody(c, resumeSessionSchema);
|
|
3066
|
+
if (isResponse(body))
|
|
3067
|
+
return body;
|
|
2194
3068
|
const sessionId = crypto.randomUUID();
|
|
2195
3069
|
const repoName = body.repoUrl
|
|
2196
3070
|
.split("/")
|
|
@@ -2270,6 +3144,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2270
3144
|
projectName: repoName,
|
|
2271
3145
|
projectDir: handle.projectDir,
|
|
2272
3146
|
runtime: config.sandbox.runtime,
|
|
3147
|
+
production: !config.devMode,
|
|
2273
3148
|
git: {
|
|
2274
3149
|
mode: "existing",
|
|
2275
3150
|
repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
|
|
@@ -2434,15 +3309,42 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2434
3309
|
return app;
|
|
2435
3310
|
}
|
|
2436
3311
|
export async function startWebServer(opts) {
|
|
3312
|
+
const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
|
|
3313
|
+
if (devMode) {
|
|
3314
|
+
console.log("[studio] Dev mode enabled");
|
|
3315
|
+
}
|
|
3316
|
+
// Hydrate session registry from durable stream (survives restarts)
|
|
3317
|
+
const registry = await Registry.create(opts.streamConfig);
|
|
2437
3318
|
const config = {
|
|
2438
3319
|
port: opts.port ?? 4400,
|
|
2439
3320
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
2440
|
-
sessions:
|
|
3321
|
+
sessions: ActiveSessions.fromRegistry(registry),
|
|
2441
3322
|
rooms: opts.rooms,
|
|
2442
3323
|
sandbox: opts.sandbox,
|
|
2443
3324
|
streamConfig: opts.streamConfig,
|
|
2444
3325
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
3326
|
+
devMode,
|
|
2445
3327
|
};
|
|
3328
|
+
// Reconnect to surviving sandbox containers (Docker only)
|
|
3329
|
+
if (config.sandbox.runtime === "docker") {
|
|
3330
|
+
const dockerProvider = config.sandbox;
|
|
3331
|
+
const allSessions = registry.listSessions();
|
|
3332
|
+
dockerProvider.reconnect(allSessions);
|
|
3333
|
+
// Mark sessions with live containers as "complete" (not stale),
|
|
3334
|
+
// and sessions without containers as "error"
|
|
3335
|
+
for (const session of allSessions) {
|
|
3336
|
+
if (session.status === "running") {
|
|
3337
|
+
const handle = dockerProvider.get(session.id);
|
|
3338
|
+
config.sessions.update(session.id, {
|
|
3339
|
+
status: handle ? "complete" : "error",
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
else {
|
|
3345
|
+
// Non-Docker: mark all running sessions as stale
|
|
3346
|
+
registry.cleanupStaleSessions(0);
|
|
3347
|
+
}
|
|
2446
3348
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2447
3349
|
const app = createApp(config);
|
|
2448
3350
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|