@electric-agent/studio 1.13.1 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/active-sessions.d.ts +4 -13
- package/dist/active-sessions.d.ts.map +1 -1
- package/dist/active-sessions.js +5 -39
- package/dist/active-sessions.js.map +1 -1
- package/dist/bridge/claude-code-base.d.ts +0 -2
- package/dist/bridge/claude-code-base.d.ts.map +1 -1
- package/dist/bridge/claude-code-base.js +0 -2
- package/dist/bridge/claude-code-base.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +2 -12
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +94 -72
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/role-skills.d.ts.map +1 -1
- package/dist/bridge/role-skills.js +0 -8
- package/dist/bridge/role-skills.js.map +1 -1
- package/dist/client/assets/index-BfvQSMwH.css +1 -0
- package/dist/client/assets/index-BtX82X61.js +234 -0
- package/dist/client/index.html +2 -2
- package/dist/room-router.d.ts.map +1 -1
- package/dist/room-router.js +5 -20
- package/dist/room-router.js.map +1 -1
- package/dist/sandbox/docker.d.ts +0 -10
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +1 -115
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/sprites.d.ts +0 -1
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +0 -51
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +0 -5
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +0 -12
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +187 -1097
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +0 -3
- package/dist/session-auth.d.ts.map +1 -1
- package/dist/session-auth.js +0 -10
- package/dist/session-auth.js.map +1 -1
- 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/api-schemas.d.ts +0 -244
- package/dist/api-schemas.d.ts.map +0 -1
- package/dist/api-schemas.js +0 -103
- package/dist/api-schemas.js.map +0 -1
- package/dist/bridge/codex-docker.d.ts +0 -70
- package/dist/bridge/codex-docker.d.ts.map +0 -1
- package/dist/bridge/codex-docker.js +0 -234
- package/dist/bridge/codex-docker.js.map +0 -1
- package/dist/bridge/codex-json-parser.d.ts +0 -31
- package/dist/bridge/codex-json-parser.d.ts.map +0 -1
- package/dist/bridge/codex-json-parser.js +0 -267
- package/dist/bridge/codex-json-parser.js.map +0 -1
- package/dist/bridge/codex-md-generator.d.ts +0 -14
- package/dist/bridge/codex-md-generator.d.ts.map +0 -1
- package/dist/bridge/codex-md-generator.js +0 -55
- package/dist/bridge/codex-md-generator.js.map +0 -1
- package/dist/bridge/codex-sprites.d.ts +0 -64
- package/dist/bridge/codex-sprites.d.ts.map +0 -1
- package/dist/bridge/codex-sprites.js +0 -227
- package/dist/bridge/codex-sprites.js.map +0 -1
- package/dist/bridge/daytona.d.ts +0 -35
- package/dist/bridge/daytona.d.ts.map +0 -1
- package/dist/bridge/daytona.js +0 -141
- package/dist/bridge/daytona.js.map +0 -1
- package/dist/bridge/docker-stdio.d.ts +0 -30
- package/dist/bridge/docker-stdio.d.ts.map +0 -1
- package/dist/bridge/docker-stdio.js +0 -135
- package/dist/bridge/docker-stdio.js.map +0 -1
- package/dist/bridge/sprites.d.ts +0 -32
- package/dist/bridge/sprites.d.ts.map +0 -1
- package/dist/bridge/sprites.js +0 -133
- package/dist/bridge/sprites.js.map +0 -1
- package/dist/client/assets/index-BXdgNRgB.js +0 -235
- package/dist/client/assets/index-IvCtVUfs.css +0 -1
- package/dist/github-app.d.ts +0 -14
- package/dist/github-app.d.ts.map +0 -1
- package/dist/github-app.js +0 -62
- package/dist/github-app.js.map +0 -1
- package/dist/github-app.test.d.ts +0 -2
- package/dist/github-app.test.d.ts.map +0 -1
- package/dist/github-app.test.js +0 -62
- package/dist/github-app.test.js.map +0 -1
- package/dist/validate.d.ts +0 -10
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -24
- package/dist/validate.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -7,9 +7,8 @@ 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";
|
|
10
11
|
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";
|
|
13
12
|
import { ClaudeCodeDockerBridge } from "./bridge/claude-code-docker.js";
|
|
14
13
|
import { ClaudeCodeSpritesBridge, } from "./bridge/claude-code-sprites.js";
|
|
15
14
|
import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
|
|
@@ -17,14 +16,11 @@ import { HostedStreamBridge } from "./bridge/hosted.js";
|
|
|
17
16
|
import { DEFAULT_ELECTRIC_URL, getClaimUrl, provisionElectricResources } from "./electric-api.js";
|
|
18
17
|
import { createGate, rejectAllGates, resolveGate } from "./gate.js";
|
|
19
18
|
import { ghListAccounts, ghListBranches, ghListRepos, isGhAuthenticated } from "./git.js";
|
|
20
|
-
import { createOrgRepo, getInstallationToken } from "./github-app.js";
|
|
21
19
|
import { generateInviteCode } from "./invite-code.js";
|
|
22
20
|
import { resolveProjectDir } from "./project-utils.js";
|
|
23
|
-
import { Registry } from "./registry.js";
|
|
24
21
|
import { RoomRouter } from "./room-router.js";
|
|
25
|
-
import { deriveGlobalHookSecret, deriveHookToken,
|
|
22
|
+
import { deriveGlobalHookSecret, deriveHookToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateSessionToken, } from "./session-auth.js";
|
|
26
23
|
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
27
|
-
import { isResponse, validateBody } from "./validate.js";
|
|
28
24
|
/** Active session bridges — one per running session */
|
|
29
25
|
const bridges = new Map();
|
|
30
26
|
/** Active room routers — one per room with agent-to-agent messaging */
|
|
@@ -71,62 +67,9 @@ function resolveStudioUrl(port) {
|
|
|
71
67
|
// Fallback — won't work from sprites VMs, but at least logs a useful URL
|
|
72
68
|
return `http://localhost:${port}`;
|
|
73
69
|
}
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Rate limiting — in-memory sliding window per IP
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
78
|
-
const MAX_TOTAL_SESSIONS = Number(process.env.MAX_TOTAL_SESSIONS || 50);
|
|
79
|
-
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
80
|
-
const sessionCreationsByIp = new Map();
|
|
81
|
-
// GitHub App config (prod mode — repo creation in electric-apps org)
|
|
82
|
-
const GITHUB_APP_ID = process.env.GITHUB_APP_ID;
|
|
83
|
-
const GITHUB_INSTALLATION_ID = process.env.GITHUB_INSTALLATION_ID;
|
|
84
|
-
const GITHUB_PRIVATE_KEY = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n");
|
|
85
|
-
const GITHUB_ORG = "electric-apps";
|
|
86
|
-
// Rate limiting for GitHub token endpoint
|
|
87
|
-
const githubTokenRequestsBySession = new Map();
|
|
88
|
-
const MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR = 10;
|
|
89
|
-
function extractClientIp(c) {
|
|
90
|
-
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
91
|
-
c.req.header("cf-connecting-ip") ||
|
|
92
|
-
"unknown");
|
|
93
|
-
}
|
|
94
|
-
function checkSessionRateLimit(ip) {
|
|
95
|
-
const now = Date.now();
|
|
96
|
-
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
97
|
-
let timestamps = sessionCreationsByIp.get(ip) ?? [];
|
|
98
|
-
// Prune stale entries
|
|
99
|
-
timestamps = timestamps.filter((t) => t > cutoff);
|
|
100
|
-
if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
|
101
|
-
sessionCreationsByIp.set(ip, timestamps);
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
timestamps.push(now);
|
|
105
|
-
sessionCreationsByIp.set(ip, timestamps);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
function checkGlobalSessionCap(sessions) {
|
|
109
|
-
return sessions.size() >= MAX_TOTAL_SESSIONS;
|
|
110
|
-
}
|
|
111
|
-
function checkGithubTokenRateLimit(sessionId) {
|
|
112
|
-
const now = Date.now();
|
|
113
|
-
const requests = githubTokenRequestsBySession.get(sessionId) ?? [];
|
|
114
|
-
const recent = requests.filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
115
|
-
if (recent.length >= MAX_GITHUB_TOKENS_PER_SESSION_PER_HOUR) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
recent.push(now);
|
|
119
|
-
githubTokenRequestsBySession.set(sessionId, recent);
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Per-session cost budget
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
|
|
126
70
|
/**
|
|
127
71
|
* Accumulate cost and turn metrics from a session_end event into the session's totals.
|
|
128
72
|
* Called each time a Claude Code run finishes (initial + iterate runs).
|
|
129
|
-
* In production mode, enforces a per-session cost budget.
|
|
130
73
|
*/
|
|
131
74
|
function accumulateSessionCost(config, sessionId, event) {
|
|
132
75
|
if (event.type !== "session_end")
|
|
@@ -147,39 +90,12 @@ function accumulateSessionCost(config, sessionId, event) {
|
|
|
147
90
|
}
|
|
148
91
|
config.sessions.update(sessionId, updates);
|
|
149
92
|
console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
|
|
150
|
-
// Enforce budget in production mode
|
|
151
|
-
if (!config.devMode &&
|
|
152
|
-
updates.totalCostUsd != null &&
|
|
153
|
-
updates.totalCostUsd > MAX_SESSION_COST_USD) {
|
|
154
|
-
console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
|
|
155
|
-
const bridge = bridges.get(sessionId);
|
|
156
|
-
if (bridge) {
|
|
157
|
-
bridge
|
|
158
|
-
.emit({
|
|
159
|
-
type: "budget_exceeded",
|
|
160
|
-
budget_usd: MAX_SESSION_COST_USD,
|
|
161
|
-
spent_usd: updates.totalCostUsd,
|
|
162
|
-
ts: ts(),
|
|
163
|
-
})
|
|
164
|
-
.catch(() => { });
|
|
165
|
-
}
|
|
166
|
-
config.sessions.update(sessionId, { status: "error" });
|
|
167
|
-
closeBridge(sessionId);
|
|
168
|
-
}
|
|
169
93
|
}
|
|
170
94
|
/**
|
|
171
95
|
* Create a Claude Code bridge for a session.
|
|
172
96
|
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
173
|
-
* In production mode, enforces tool restrictions and hardcodes the model.
|
|
174
97
|
*/
|
|
175
98
|
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
176
|
-
// Production mode: restrict tools and hardcode model
|
|
177
|
-
if (!config.devMode) {
|
|
178
|
-
if (!claudeConfig.allowedTools) {
|
|
179
|
-
claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
|
|
180
|
-
}
|
|
181
|
-
claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
|
|
182
|
-
}
|
|
183
99
|
const conn = sessionStream(config, sessionId);
|
|
184
100
|
let bridge;
|
|
185
101
|
if (config.sandbox.runtime === "sprites") {
|
|
@@ -211,6 +127,31 @@ function closeBridge(sessionId) {
|
|
|
211
127
|
bridges.delete(sessionId);
|
|
212
128
|
}
|
|
213
129
|
}
|
|
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
|
+
}
|
|
214
155
|
/**
|
|
215
156
|
* Map a Claude Code hook event JSON payload to an EngineEvent.
|
|
216
157
|
*
|
|
@@ -324,6 +265,8 @@ function mapHookToEngineEvent(body) {
|
|
|
324
265
|
}
|
|
325
266
|
export function createApp(config) {
|
|
326
267
|
const app = new Hono();
|
|
268
|
+
// CORS for local development
|
|
269
|
+
app.use("*", cors({ origin: "*" }));
|
|
327
270
|
// --- API Routes ---
|
|
328
271
|
// Health check
|
|
329
272
|
app.get("/api/health", (c) => {
|
|
@@ -341,13 +284,6 @@ export function createApp(config) {
|
|
|
341
284
|
checks.sandbox = config.sandbox.runtime;
|
|
342
285
|
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
343
286
|
});
|
|
344
|
-
// Public config — exposes non-sensitive flags to the client
|
|
345
|
-
app.get("/api/config", (c) => {
|
|
346
|
-
return c.json({
|
|
347
|
-
devMode: config.devMode,
|
|
348
|
-
maxSessionCostUsd: config.devMode ? undefined : MAX_SESSION_COST_USD,
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
287
|
// Provision Electric Cloud resources via the Claim API
|
|
352
288
|
app.post("/api/provision-electric", async (c) => {
|
|
353
289
|
try {
|
|
@@ -545,7 +481,6 @@ export function createApp(config) {
|
|
|
545
481
|
if (hookEvent.type === "ask_user_question") {
|
|
546
482
|
const toolUseId = hookEvent.tool_use_id;
|
|
547
483
|
console.log(`[hook-event] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
548
|
-
config.sessions.update(sessionId, { needsInput: true });
|
|
549
484
|
try {
|
|
550
485
|
const gateTimeout = 5 * 60 * 1000; // 5 minutes
|
|
551
486
|
const result = await Promise.race([
|
|
@@ -553,7 +488,6 @@ export function createApp(config) {
|
|
|
553
488
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
554
489
|
]);
|
|
555
490
|
console.log(`[hook-event] ask_user_question gate resolved: ${toolUseId}`);
|
|
556
|
-
config.sessions.update(sessionId, { needsInput: false });
|
|
557
491
|
return c.json({
|
|
558
492
|
hookSpecificOutput: {
|
|
559
493
|
hookEventName: "PreToolUse",
|
|
@@ -567,7 +501,6 @@ export function createApp(config) {
|
|
|
567
501
|
}
|
|
568
502
|
catch (err) {
|
|
569
503
|
console.error(`[hook-event] ask_user_question gate error:`, err);
|
|
570
|
-
config.sessions.update(sessionId, { needsInput: false });
|
|
571
504
|
return c.json({ ok: true }); // Don't block Claude Code on timeout
|
|
572
505
|
}
|
|
573
506
|
}
|
|
@@ -689,7 +622,6 @@ export function createApp(config) {
|
|
|
689
622
|
if (hookEvent.type === "ask_user_question") {
|
|
690
623
|
const toolUseId = hookEvent.tool_use_id;
|
|
691
624
|
console.log(`[hook] Blocking for ask_user_question gate: ${toolUseId}`);
|
|
692
|
-
config.sessions.update(sessionId, { needsInput: true });
|
|
693
625
|
try {
|
|
694
626
|
const gateTimeout = 5 * 60 * 1000;
|
|
695
627
|
const result = await Promise.race([
|
|
@@ -697,7 +629,6 @@ export function createApp(config) {
|
|
|
697
629
|
new Promise((_, reject) => setTimeout(() => reject(new Error("AskUserQuestion gate timed out")), gateTimeout)),
|
|
698
630
|
]);
|
|
699
631
|
console.log(`[hook] ask_user_question gate resolved: ${toolUseId}`);
|
|
700
|
-
config.sessions.update(sessionId, { needsInput: false });
|
|
701
632
|
return c.json({
|
|
702
633
|
sessionId,
|
|
703
634
|
hookSpecificOutput: {
|
|
@@ -712,7 +643,6 @@ export function createApp(config) {
|
|
|
712
643
|
}
|
|
713
644
|
catch (err) {
|
|
714
645
|
console.error(`[hook] ask_user_question gate error:`, err);
|
|
715
|
-
config.sessions.update(sessionId, { needsInput: false });
|
|
716
646
|
return c.json({ ok: true, sessionId });
|
|
717
647
|
}
|
|
718
648
|
}
|
|
@@ -808,40 +738,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
808
738
|
});
|
|
809
739
|
// Start new project
|
|
810
740
|
app.post("/api/sessions", async (c) => {
|
|
811
|
-
const body = await
|
|
812
|
-
if (
|
|
813
|
-
return
|
|
814
|
-
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
815
|
-
const apiKey = config.devMode
|
|
816
|
-
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
817
|
-
: process.env.ANTHROPIC_API_KEY;
|
|
818
|
-
const oauthToken = config.devMode
|
|
819
|
-
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
820
|
-
: undefined;
|
|
821
|
-
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
822
|
-
// Block freeform sessions in production mode
|
|
823
|
-
if (body.freeform && !config.devMode) {
|
|
824
|
-
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
825
|
-
}
|
|
826
|
-
// Rate-limit session creation in production mode
|
|
827
|
-
if (!config.devMode) {
|
|
828
|
-
const ip = extractClientIp(c);
|
|
829
|
-
if (!checkSessionRateLimit(ip)) {
|
|
830
|
-
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
831
|
-
}
|
|
832
|
-
if (checkGlobalSessionCap(config.sessions)) {
|
|
833
|
-
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
834
|
-
}
|
|
741
|
+
const body = (await c.req.json());
|
|
742
|
+
if (!body.description) {
|
|
743
|
+
return c.json({ error: "description is required" }, 400);
|
|
835
744
|
}
|
|
836
745
|
const sessionId = crypto.randomUUID();
|
|
837
|
-
const inferredName =
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
.toLowerCase()
|
|
844
|
-
: `electric-${sessionId.slice(0, 8)}`;
|
|
746
|
+
const inferredName = body.name ||
|
|
747
|
+
body.description
|
|
748
|
+
.slice(0, 40)
|
|
749
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
750
|
+
.replace(/^-|-$/g, "")
|
|
751
|
+
.toLowerCase();
|
|
845
752
|
const baseDir = body.baseDir || process.cwd();
|
|
846
753
|
const { projectName } = resolveProjectDir(baseDir, inferredName);
|
|
847
754
|
console.log(`[session] Creating new session: id=${sessionId} project=${projectName}`);
|
|
@@ -878,10 +785,11 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
878
785
|
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
879
786
|
let ghAccounts = [];
|
|
880
787
|
if (!body.freeform) {
|
|
881
|
-
// Gather GitHub accounts for the merged setup gate
|
|
882
|
-
if
|
|
788
|
+
// Gather GitHub accounts for the merged setup gate
|
|
789
|
+
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
790
|
+
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
883
791
|
try {
|
|
884
|
-
ghAccounts = ghListAccounts(ghToken);
|
|
792
|
+
ghAccounts = ghListAccounts(body.ghToken);
|
|
885
793
|
}
|
|
886
794
|
catch {
|
|
887
795
|
// gh not available — no repo setup
|
|
@@ -964,15 +872,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
964
872
|
const handle = await config.sandbox.create(sessionId, {
|
|
965
873
|
projectName,
|
|
966
874
|
infra,
|
|
967
|
-
apiKey,
|
|
968
|
-
oauthToken,
|
|
969
|
-
ghToken,
|
|
970
|
-
...((!config.devMode || GITHUB_APP_ID) && {
|
|
971
|
-
prodMode: {
|
|
972
|
-
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
973
|
-
studioUrl: resolveStudioUrl(config.port),
|
|
974
|
-
},
|
|
975
|
-
}),
|
|
875
|
+
apiKey: body.apiKey,
|
|
876
|
+
oauthToken: body.oauthToken,
|
|
877
|
+
ghToken: body.ghToken,
|
|
976
878
|
});
|
|
977
879
|
console.log(`[session:${sessionId}] Sandbox created: projectDir=${handle.projectDir} port=${handle.port} previewUrl=${handle.previewUrl ?? "none"}`);
|
|
978
880
|
await bridge.emit({
|
|
@@ -1038,54 +940,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1038
940
|
ts: ts(),
|
|
1039
941
|
});
|
|
1040
942
|
}
|
|
1041
|
-
// Create GitHub repo via GitHub App when credentials are available
|
|
1042
|
-
let prodGitConfig;
|
|
1043
|
-
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
1044
|
-
try {
|
|
1045
|
-
// Repo name matches the project name (already has random slug)
|
|
1046
|
-
const repoSlug = projectName;
|
|
1047
|
-
await bridge.emit({
|
|
1048
|
-
type: "log",
|
|
1049
|
-
level: "build",
|
|
1050
|
-
message: "Creating GitHub repository...",
|
|
1051
|
-
ts: ts(),
|
|
1052
|
-
});
|
|
1053
|
-
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1054
|
-
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
1055
|
-
if (repo) {
|
|
1056
|
-
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
1057
|
-
// Initialize git and set remote in the sandbox
|
|
1058
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
1059
|
-
prodGitConfig = {
|
|
1060
|
-
mode: "pre-created",
|
|
1061
|
-
repoName: actualRepoName,
|
|
1062
|
-
repoUrl: repo.htmlUrl,
|
|
1063
|
-
};
|
|
1064
|
-
config.sessions.update(sessionId, {
|
|
1065
|
-
git: {
|
|
1066
|
-
branch: "main",
|
|
1067
|
-
remoteUrl: repo.htmlUrl,
|
|
1068
|
-
repoName: actualRepoName,
|
|
1069
|
-
lastCommitHash: null,
|
|
1070
|
-
lastCommitMessage: null,
|
|
1071
|
-
lastCheckpointAt: null,
|
|
1072
|
-
},
|
|
1073
|
-
});
|
|
1074
|
-
await bridge.emit({
|
|
1075
|
-
type: "log",
|
|
1076
|
-
level: "done",
|
|
1077
|
-
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
1078
|
-
ts: ts(),
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
else {
|
|
1082
|
-
console.warn(`[session:${sessionId}] Failed to create GitHub repo`);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
catch (err) {
|
|
1086
|
-
console.error(`[session:${sessionId}] GitHub repo creation error:`, err);
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
943
|
// Write CLAUDE.md to the sandbox workspace.
|
|
1090
944
|
// Our generator includes hardcoded playbook paths and reading order
|
|
1091
945
|
// so we don't depend on @tanstack/intent generating a skill block.
|
|
@@ -1094,18 +948,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1094
948
|
projectName,
|
|
1095
949
|
projectDir: handle.projectDir,
|
|
1096
950
|
runtime: config.sandbox.runtime,
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
},
|
|
1107
|
-
}
|
|
1108
|
-
: {}),
|
|
951
|
+
...(repoConfig
|
|
952
|
+
? {
|
|
953
|
+
git: {
|
|
954
|
+
mode: "create",
|
|
955
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
956
|
+
visibility: repoConfig.visibility,
|
|
957
|
+
},
|
|
958
|
+
}
|
|
959
|
+
: {}),
|
|
1109
960
|
});
|
|
1110
961
|
try {
|
|
1111
962
|
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
@@ -1267,54 +1118,79 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1267
1118
|
const session = config.sessions.get(sessionId);
|
|
1268
1119
|
if (!session)
|
|
1269
1120
|
return c.json({ error: "Session not found" }, 404);
|
|
1270
|
-
const body = await
|
|
1271
|
-
if (
|
|
1272
|
-
return
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1121
|
+
const body = (await c.req.json());
|
|
1122
|
+
if (!body.request) {
|
|
1123
|
+
return c.json({ error: "request is required" }, 400);
|
|
1124
|
+
}
|
|
1125
|
+
// Intercept operational commands (start/stop/restart the app/server)
|
|
1126
|
+
const normalised = body.request
|
|
1127
|
+
.toLowerCase()
|
|
1128
|
+
.replace(/[^a-z ]/g, "")
|
|
1129
|
+
.trim();
|
|
1130
|
+
const appOrServer = /\b(app|server|dev server|dev|vite)\b/;
|
|
1131
|
+
const isStartCmd = /^(start|run|launch|boot)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1132
|
+
const isStopCmd = /^(stop|kill|shutdown|shut down)\b/.test(normalised) && appOrServer.test(normalised);
|
|
1133
|
+
const isRestartCmd = /^restart\b/.test(normalised) && appOrServer.test(normalised);
|
|
1134
|
+
if (isStartCmd || isStopCmd || isRestartCmd) {
|
|
1135
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1136
|
+
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1137
|
+
try {
|
|
1138
|
+
const handle = config.sandbox.get(sessionId);
|
|
1139
|
+
if (isStopCmd) {
|
|
1140
|
+
if (handle && config.sandbox.isAlive(handle))
|
|
1141
|
+
await config.sandbox.stopApp(handle);
|
|
1142
|
+
await bridge.emit({ type: "log", level: "done", message: "App stopped", ts: ts() });
|
|
1290
1143
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
studioPort: config.port,
|
|
1295
|
-
hookToken,
|
|
1296
|
-
};
|
|
1297
|
-
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1298
|
-
// Re-register basic event tracking callbacks
|
|
1299
|
-
bridge.onAgentEvent((event) => {
|
|
1300
|
-
if (event.type === "session_start") {
|
|
1301
|
-
const ccSessionId = event.session_id;
|
|
1302
|
-
if (ccSessionId) {
|
|
1303
|
-
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1144
|
+
else {
|
|
1145
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1146
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
1304
1147
|
}
|
|
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
|
+
});
|
|
1305
1164
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1165
|
+
}
|
|
1166
|
+
catch (err) {
|
|
1167
|
+
const msg = err instanceof Error ? err.message : "Operation failed";
|
|
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,
|
|
1314
1185
|
});
|
|
1315
|
-
|
|
1186
|
+
return c.json({ ok: true });
|
|
1187
|
+
}
|
|
1188
|
+
const handle = config.sandbox.get(sessionId);
|
|
1189
|
+
if (!handle || !config.sandbox.isAlive(handle)) {
|
|
1190
|
+
return c.json({ error: "Container is not running" }, 400);
|
|
1316
1191
|
}
|
|
1317
1192
|
// Write user prompt to the stream
|
|
1193
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1318
1194
|
await bridge.emit({ type: "user_prompt", message: body.request, ts: ts() });
|
|
1319
1195
|
config.sessions.update(sessionId, { status: "running" });
|
|
1320
1196
|
await bridge.sendCommand({
|
|
@@ -1325,28 +1201,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1325
1201
|
});
|
|
1326
1202
|
return c.json({ ok: true });
|
|
1327
1203
|
});
|
|
1328
|
-
// Generate a GitHub installation token for the sandbox (prod mode only)
|
|
1329
|
-
app.post("/api/sessions/:id/github-token", async (c) => {
|
|
1330
|
-
const sessionId = c.req.param("id");
|
|
1331
|
-
if (config.devMode) {
|
|
1332
|
-
return c.json({ error: "Not available in dev mode" }, 403);
|
|
1333
|
-
}
|
|
1334
|
-
if (!GITHUB_APP_ID || !GITHUB_INSTALLATION_ID || !GITHUB_PRIVATE_KEY) {
|
|
1335
|
-
return c.json({ error: "GitHub App not configured" }, 500);
|
|
1336
|
-
}
|
|
1337
|
-
if (!checkGithubTokenRateLimit(sessionId)) {
|
|
1338
|
-
return c.json({ error: "Too many token requests" }, 429);
|
|
1339
|
-
}
|
|
1340
|
-
try {
|
|
1341
|
-
const result = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
1342
|
-
return c.json(result);
|
|
1343
|
-
}
|
|
1344
|
-
catch (err) {
|
|
1345
|
-
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1346
|
-
console.error(`GitHub token error for session ${sessionId}:`, message);
|
|
1347
|
-
return c.json({ error: "Failed to generate GitHub token" }, 500);
|
|
1348
|
-
}
|
|
1349
|
-
});
|
|
1350
1204
|
// Respond to a gate (approval, clarification, continue, revision)
|
|
1351
1205
|
app.post("/api/sessions/:id/respond", async (c) => {
|
|
1352
1206
|
const sessionId = c.req.param("id");
|
|
@@ -1637,9 +1491,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1637
1491
|
});
|
|
1638
1492
|
// Create a standalone sandbox (not tied to session creation flow)
|
|
1639
1493
|
app.post("/api/sandboxes", async (c) => {
|
|
1640
|
-
const body = await
|
|
1641
|
-
if (isResponse(body))
|
|
1642
|
-
return body;
|
|
1494
|
+
const body = (await c.req.json());
|
|
1643
1495
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1644
1496
|
try {
|
|
1645
1497
|
const handle = await config.sandbox.create(sessionId, {
|
|
@@ -1677,715 +1529,30 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1677
1529
|
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1678
1530
|
}
|
|
1679
1531
|
// Protect room-scoped routes via X-Room-Token header
|
|
1680
|
-
// "create-app" is a creation endpoint — no room token exists yet
|
|
1681
|
-
const roomAuthExemptIds = new Set(["create-app"]);
|
|
1682
1532
|
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1683
1533
|
const id = c.req.param("id");
|
|
1684
|
-
if (roomAuthExemptIds.has(id))
|
|
1685
|
-
return next();
|
|
1686
1534
|
const token = extractRoomToken(c);
|
|
1687
|
-
if (!token || !
|
|
1535
|
+
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1688
1536
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1689
1537
|
}
|
|
1690
1538
|
return next();
|
|
1691
1539
|
});
|
|
1692
1540
|
app.use("/api/rooms/:id", async (c, next) => {
|
|
1693
|
-
const id = c.req.param("id");
|
|
1694
|
-
if (roomAuthExemptIds.has(id))
|
|
1695
|
-
return next();
|
|
1696
1541
|
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1697
1542
|
return next();
|
|
1543
|
+
const id = c.req.param("id");
|
|
1698
1544
|
const token = extractRoomToken(c);
|
|
1699
|
-
if (!token || !
|
|
1545
|
+
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1700
1546
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1701
1547
|
}
|
|
1702
1548
|
return next();
|
|
1703
1549
|
});
|
|
1704
|
-
// Create a room with 3 agents for multi-agent app creation
|
|
1705
|
-
app.post("/api/rooms/create-app", async (c) => {
|
|
1706
|
-
const body = await validateBody(c, createAppRoomSchema);
|
|
1707
|
-
if (isResponse(body))
|
|
1708
|
-
return body;
|
|
1709
|
-
// In prod mode, use server-side API key; ignore user-provided credentials
|
|
1710
|
-
const apiKey = config.devMode
|
|
1711
|
-
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
1712
|
-
: process.env.ANTHROPIC_API_KEY;
|
|
1713
|
-
const oauthToken = config.devMode
|
|
1714
|
-
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
1715
|
-
: undefined;
|
|
1716
|
-
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1717
|
-
// Rate-limit session creation in production mode
|
|
1718
|
-
if (!config.devMode) {
|
|
1719
|
-
const ip = extractClientIp(c);
|
|
1720
|
-
if (!checkSessionRateLimit(ip)) {
|
|
1721
|
-
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
1722
|
-
}
|
|
1723
|
-
if (checkGlobalSessionCap(config.sessions)) {
|
|
1724
|
-
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
const roomId = crypto.randomUUID();
|
|
1728
|
-
const roomName = body.name || `app-${roomId.slice(0, 8)}`;
|
|
1729
|
-
// Create the room's durable stream
|
|
1730
|
-
const roomConn = roomStream(config, roomId);
|
|
1731
|
-
try {
|
|
1732
|
-
await DurableStream.create({
|
|
1733
|
-
url: roomConn.url,
|
|
1734
|
-
headers: roomConn.headers,
|
|
1735
|
-
contentType: "application/json",
|
|
1736
|
-
});
|
|
1737
|
-
}
|
|
1738
|
-
catch (err) {
|
|
1739
|
-
console.error(`[room:create-app] Failed to create room stream:`, err);
|
|
1740
|
-
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1741
|
-
}
|
|
1742
|
-
// Create and start the router
|
|
1743
|
-
const router = new RoomRouter(roomId, roomName, config.streamConfig);
|
|
1744
|
-
await router.start();
|
|
1745
|
-
roomRouters.set(roomId, router);
|
|
1746
|
-
// Save to room registry
|
|
1747
|
-
const code = generateInviteCode();
|
|
1748
|
-
await config.rooms.addRoom({
|
|
1749
|
-
id: roomId,
|
|
1750
|
-
code,
|
|
1751
|
-
name: roomName,
|
|
1752
|
-
createdAt: new Date().toISOString(),
|
|
1753
|
-
revoked: false,
|
|
1754
|
-
});
|
|
1755
|
-
// Define the 3 agents with randomized display names
|
|
1756
|
-
const agentSuffixes = [
|
|
1757
|
-
"fox",
|
|
1758
|
-
"owl",
|
|
1759
|
-
"lynx",
|
|
1760
|
-
"wolf",
|
|
1761
|
-
"bear",
|
|
1762
|
-
"hawk",
|
|
1763
|
-
"pine",
|
|
1764
|
-
"oak",
|
|
1765
|
-
"elm",
|
|
1766
|
-
"ivy",
|
|
1767
|
-
"ray",
|
|
1768
|
-
"arc",
|
|
1769
|
-
"reef",
|
|
1770
|
-
"dusk",
|
|
1771
|
-
"ash",
|
|
1772
|
-
"sage",
|
|
1773
|
-
];
|
|
1774
|
-
const pick = () => agentSuffixes[Math.floor(Math.random() * agentSuffixes.length)];
|
|
1775
|
-
const usedSuffixes = new Set();
|
|
1776
|
-
const uniquePick = () => {
|
|
1777
|
-
let s = pick();
|
|
1778
|
-
while (usedSuffixes.has(s))
|
|
1779
|
-
s = pick();
|
|
1780
|
-
usedSuffixes.add(s);
|
|
1781
|
-
return s;
|
|
1782
|
-
};
|
|
1783
|
-
const agentDefs = [
|
|
1784
|
-
{ name: `coder-${uniquePick()}`, role: "coder" },
|
|
1785
|
-
{ name: `reviewer-${uniquePick()}`, role: "reviewer" },
|
|
1786
|
-
{ name: `designer-${uniquePick()}`, role: "ui-designer" },
|
|
1787
|
-
];
|
|
1788
|
-
// Create session IDs and streams upfront for all 3 agents
|
|
1789
|
-
const sessions = [];
|
|
1790
|
-
for (const agentDef of agentDefs) {
|
|
1791
|
-
const sessionId = crypto.randomUUID();
|
|
1792
|
-
const conn = sessionStream(config, sessionId);
|
|
1793
|
-
try {
|
|
1794
|
-
await DurableStream.create({
|
|
1795
|
-
url: conn.url,
|
|
1796
|
-
headers: conn.headers,
|
|
1797
|
-
contentType: "application/json",
|
|
1798
|
-
});
|
|
1799
|
-
}
|
|
1800
|
-
catch (err) {
|
|
1801
|
-
console.error(`[room:create-app] Failed to create stream for ${agentDef.name}:`, err);
|
|
1802
|
-
return c.json({ error: `Failed to create stream for ${agentDef.name}` }, 500);
|
|
1803
|
-
}
|
|
1804
|
-
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1805
|
-
sessions.push({ name: agentDef.name, role: agentDef.role, sessionId, sessionToken });
|
|
1806
|
-
}
|
|
1807
|
-
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1808
|
-
console.log(`[room:create-app] Created room ${roomId} with agents: ${sessions.map((s) => s.name).join(", ")}`);
|
|
1809
|
-
// Return immediately so the client can show the room + sessions
|
|
1810
|
-
// The async flow handles sandbox creation, skill injection, and agent startup
|
|
1811
|
-
// Sessions are created in agentDefs order: [coder, reviewer, ui-designer]
|
|
1812
|
-
const coderSession = sessions[0];
|
|
1813
|
-
const reviewerSession = sessions[1];
|
|
1814
|
-
const uiDesignerSession = sessions[2];
|
|
1815
|
-
const coderBridge = getOrCreateBridge(config, coderSession.sessionId);
|
|
1816
|
-
// Record all sessions
|
|
1817
|
-
for (const s of sessions) {
|
|
1818
|
-
const projectName = s.role === "coder" && config.devMode
|
|
1819
|
-
? body.name ||
|
|
1820
|
-
body.description
|
|
1821
|
-
.slice(0, 40)
|
|
1822
|
-
.replace(/[^a-z0-9]+/gi, "-")
|
|
1823
|
-
.replace(/^-|-$/g, "")
|
|
1824
|
-
.toLowerCase()
|
|
1825
|
-
: `room-${s.name}-${s.sessionId.slice(0, 8)}`;
|
|
1826
|
-
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1827
|
-
const session = {
|
|
1828
|
-
id: s.sessionId,
|
|
1829
|
-
projectName,
|
|
1830
|
-
sandboxProjectDir,
|
|
1831
|
-
description: s.role === "coder" ? body.description : `Room agent: ${s.name} (${s.role})`,
|
|
1832
|
-
createdAt: new Date().toISOString(),
|
|
1833
|
-
lastActiveAt: new Date().toISOString(),
|
|
1834
|
-
status: "running",
|
|
1835
|
-
};
|
|
1836
|
-
config.sessions.add(session);
|
|
1837
|
-
}
|
|
1838
|
-
// Write user prompt to coder's stream
|
|
1839
|
-
await coderBridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
1840
|
-
// Gather GitHub accounts for the infra config gate (dev mode only)
|
|
1841
|
-
let ghAccounts = [];
|
|
1842
|
-
if (config.devMode && ghToken && isGhAuthenticated(ghToken)) {
|
|
1843
|
-
try {
|
|
1844
|
-
ghAccounts = ghListAccounts(ghToken);
|
|
1845
|
-
}
|
|
1846
|
-
catch {
|
|
1847
|
-
// gh not available
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
// Emit infra config gate on coder's stream
|
|
1851
|
-
const coderProjectName = config.sessions.get(coderSession.sessionId)?.projectName ?? coderSession.name;
|
|
1852
|
-
await coderBridge.emit({
|
|
1853
|
-
type: "infra_config_prompt",
|
|
1854
|
-
projectName: coderProjectName,
|
|
1855
|
-
ghAccounts,
|
|
1856
|
-
runtime: config.sandbox.runtime,
|
|
1857
|
-
ts: ts(),
|
|
1858
|
-
});
|
|
1859
|
-
// Async flow: wait for gate, create sandboxes, start agents
|
|
1860
|
-
const asyncFlow = async () => {
|
|
1861
|
-
// 1. Wait for infra config gate on coder's session
|
|
1862
|
-
await router.sendMessage("system", `Waiting for setup — open ${coderSession.name}'s session to confirm infrastructure.`);
|
|
1863
|
-
console.log(`[room:create-app:${roomId}] Waiting for infra_config gate...`);
|
|
1864
|
-
let infra;
|
|
1865
|
-
let repoConfig = null;
|
|
1866
|
-
let claimId;
|
|
1867
|
-
try {
|
|
1868
|
-
const gateValue = await createGate(coderSession.sessionId, "infra_config");
|
|
1869
|
-
console.log(`[room:create-app:${roomId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
1870
|
-
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
1871
|
-
infra = {
|
|
1872
|
-
mode: "cloud",
|
|
1873
|
-
databaseUrl: gateValue.databaseUrl,
|
|
1874
|
-
electricUrl: gateValue.electricUrl,
|
|
1875
|
-
sourceId: gateValue.sourceId,
|
|
1876
|
-
secret: gateValue.secret,
|
|
1877
|
-
};
|
|
1878
|
-
if (gateValue.mode === "claim") {
|
|
1879
|
-
claimId = gateValue.claimId;
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
else {
|
|
1883
|
-
infra = { mode: "local" };
|
|
1884
|
-
}
|
|
1885
|
-
// Extract repo config if provided
|
|
1886
|
-
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
1887
|
-
repoConfig = {
|
|
1888
|
-
account: gateValue.repoAccount,
|
|
1889
|
-
repoName: gateValue.repoName,
|
|
1890
|
-
visibility: gateValue.repoVisibility ?? "private",
|
|
1891
|
-
};
|
|
1892
|
-
config.sessions.update(coderSession.sessionId, {
|
|
1893
|
-
git: {
|
|
1894
|
-
branch: "main",
|
|
1895
|
-
remoteUrl: null,
|
|
1896
|
-
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1897
|
-
repoVisibility: repoConfig.visibility,
|
|
1898
|
-
lastCommitHash: null,
|
|
1899
|
-
lastCommitMessage: null,
|
|
1900
|
-
lastCheckpointAt: null,
|
|
1901
|
-
},
|
|
1902
|
-
});
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
catch (err) {
|
|
1906
|
-
console.log(`[room:create-app:${roomId}] Infra gate error (defaulting to local):`, err);
|
|
1907
|
-
infra = { mode: "local" };
|
|
1908
|
-
}
|
|
1909
|
-
// 2. Create sandboxes in parallel
|
|
1910
|
-
// Coder gets full scaffold, reviewer/ui-designer get minimal
|
|
1911
|
-
await router.sendMessage("system", "Creating sandboxes");
|
|
1912
|
-
await coderBridge.emit({
|
|
1913
|
-
type: "log",
|
|
1914
|
-
level: "build",
|
|
1915
|
-
message: "Creating sandboxes for all agents...",
|
|
1916
|
-
ts: ts(),
|
|
1917
|
-
});
|
|
1918
|
-
const sandboxOpts = (sid) => ({
|
|
1919
|
-
...((!config.devMode || GITHUB_APP_ID) && {
|
|
1920
|
-
prodMode: {
|
|
1921
|
-
sessionToken: deriveSessionToken(config.streamConfig.secret, sid),
|
|
1922
|
-
studioUrl: resolveStudioUrl(config.port),
|
|
1923
|
-
},
|
|
1924
|
-
}),
|
|
1925
|
-
});
|
|
1926
|
-
const coderInfo = config.sessions.get(coderSession.sessionId);
|
|
1927
|
-
if (!coderInfo)
|
|
1928
|
-
throw new Error("Coder session not found in registry");
|
|
1929
|
-
const reviewerInfo = config.sessions.get(reviewerSession.sessionId);
|
|
1930
|
-
if (!reviewerInfo)
|
|
1931
|
-
throw new Error("Reviewer session not found in registry");
|
|
1932
|
-
const uiDesignerInfo = config.sessions.get(uiDesignerSession.sessionId);
|
|
1933
|
-
if (!uiDesignerInfo)
|
|
1934
|
-
throw new Error("UI designer session not found in registry");
|
|
1935
|
-
const [coderHandle, reviewerHandle, uiDesignerHandle] = await Promise.all([
|
|
1936
|
-
config.sandbox.create(coderSession.sessionId, {
|
|
1937
|
-
projectName: coderInfo.projectName,
|
|
1938
|
-
infra,
|
|
1939
|
-
apiKey,
|
|
1940
|
-
oauthToken,
|
|
1941
|
-
ghToken,
|
|
1942
|
-
...sandboxOpts(coderSession.sessionId),
|
|
1943
|
-
}),
|
|
1944
|
-
config.sandbox.create(reviewerSession.sessionId, {
|
|
1945
|
-
projectName: reviewerInfo.projectName,
|
|
1946
|
-
infra: { mode: "none" },
|
|
1947
|
-
apiKey,
|
|
1948
|
-
oauthToken,
|
|
1949
|
-
ghToken,
|
|
1950
|
-
...sandboxOpts(reviewerSession.sessionId),
|
|
1951
|
-
}),
|
|
1952
|
-
config.sandbox.create(uiDesignerSession.sessionId, {
|
|
1953
|
-
projectName: uiDesignerInfo.projectName,
|
|
1954
|
-
infra: { mode: "none" },
|
|
1955
|
-
apiKey,
|
|
1956
|
-
oauthToken,
|
|
1957
|
-
ghToken,
|
|
1958
|
-
...sandboxOpts(uiDesignerSession.sessionId),
|
|
1959
|
-
}),
|
|
1960
|
-
]);
|
|
1961
|
-
const handles = [
|
|
1962
|
-
{ session: coderSession, handle: coderHandle },
|
|
1963
|
-
{ session: reviewerSession, handle: reviewerHandle },
|
|
1964
|
-
{ session: uiDesignerSession, handle: uiDesignerHandle },
|
|
1965
|
-
];
|
|
1966
|
-
// Update session info with sandbox details
|
|
1967
|
-
for (const { session: s, handle } of handles) {
|
|
1968
|
-
config.sessions.update(s.sessionId, {
|
|
1969
|
-
appPort: handle.port,
|
|
1970
|
-
sandboxProjectDir: handle.projectDir,
|
|
1971
|
-
previewUrl: handle.previewUrl,
|
|
1972
|
-
...(s.role === "coder" && claimId ? { claimId } : {}),
|
|
1973
|
-
});
|
|
1974
|
-
}
|
|
1975
|
-
await coderBridge.emit({
|
|
1976
|
-
type: "log",
|
|
1977
|
-
level: "done",
|
|
1978
|
-
message: "All sandboxes ready",
|
|
1979
|
-
ts: ts(),
|
|
1980
|
-
});
|
|
1981
|
-
// 3. Set up coder sandbox (full scaffold + CLAUDE.md + skills + GitHub repo)
|
|
1982
|
-
{
|
|
1983
|
-
const handle = coderHandle;
|
|
1984
|
-
// Copy scaffold
|
|
1985
|
-
await coderBridge.emit({
|
|
1986
|
-
type: "log",
|
|
1987
|
-
level: "build",
|
|
1988
|
-
message: "Setting up project...",
|
|
1989
|
-
ts: ts(),
|
|
1990
|
-
});
|
|
1991
|
-
try {
|
|
1992
|
-
if (config.sandbox.runtime === "docker") {
|
|
1993
|
-
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
1994
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${coderInfo.projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
1995
|
-
}
|
|
1996
|
-
else {
|
|
1997
|
-
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`);
|
|
1998
|
-
}
|
|
1999
|
-
await coderBridge.emit({
|
|
2000
|
-
type: "log",
|
|
2001
|
-
level: "done",
|
|
2002
|
-
message: "Project ready",
|
|
2003
|
-
ts: ts(),
|
|
2004
|
-
});
|
|
2005
|
-
}
|
|
2006
|
-
catch (err) {
|
|
2007
|
-
console.error(`[room:create-app:${roomId}] Project setup failed:`, err);
|
|
2008
|
-
await coderBridge.emit({
|
|
2009
|
-
type: "log",
|
|
2010
|
-
level: "error",
|
|
2011
|
-
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
2012
|
-
ts: ts(),
|
|
2013
|
-
});
|
|
2014
|
-
}
|
|
2015
|
-
// GitHub repo creation (uses GitHub App when credentials are available)
|
|
2016
|
-
let repoUrl = null;
|
|
2017
|
-
let prodGitConfig;
|
|
2018
|
-
if (GITHUB_APP_ID && GITHUB_INSTALLATION_ID && GITHUB_PRIVATE_KEY) {
|
|
2019
|
-
try {
|
|
2020
|
-
const repoSlug = coderInfo.projectName;
|
|
2021
|
-
await coderBridge.emit({
|
|
2022
|
-
type: "log",
|
|
2023
|
-
level: "build",
|
|
2024
|
-
message: "Creating GitHub repository...",
|
|
2025
|
-
ts: ts(),
|
|
2026
|
-
});
|
|
2027
|
-
const { token } = await getInstallationToken(GITHUB_APP_ID, GITHUB_INSTALLATION_ID, GITHUB_PRIVATE_KEY);
|
|
2028
|
-
const repo = await createOrgRepo(GITHUB_ORG, repoSlug, token);
|
|
2029
|
-
if (repo) {
|
|
2030
|
-
const actualRepoName = `${GITHUB_ORG}/${repo.htmlUrl.split("/").pop()}`;
|
|
2031
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && git init -b main && git remote add origin '${repo.cloneUrl}'`);
|
|
2032
|
-
prodGitConfig = {
|
|
2033
|
-
mode: "pre-created",
|
|
2034
|
-
repoName: actualRepoName,
|
|
2035
|
-
repoUrl: repo.htmlUrl,
|
|
2036
|
-
};
|
|
2037
|
-
repoUrl = repo.htmlUrl;
|
|
2038
|
-
config.sessions.update(coderSession.sessionId, {
|
|
2039
|
-
git: {
|
|
2040
|
-
branch: "main",
|
|
2041
|
-
remoteUrl: repo.htmlUrl,
|
|
2042
|
-
repoName: actualRepoName,
|
|
2043
|
-
lastCommitHash: null,
|
|
2044
|
-
lastCommitMessage: null,
|
|
2045
|
-
lastCheckpointAt: null,
|
|
2046
|
-
},
|
|
2047
|
-
});
|
|
2048
|
-
await coderBridge.emit({
|
|
2049
|
-
type: "log",
|
|
2050
|
-
level: "done",
|
|
2051
|
-
message: `GitHub repo created: ${repo.htmlUrl}`,
|
|
2052
|
-
ts: ts(),
|
|
2053
|
-
});
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
catch (err) {
|
|
2057
|
-
console.error(`[room:create-app:${roomId}] GitHub repo creation error:`, err);
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
else if (repoConfig) {
|
|
2061
|
-
repoUrl = `https://github.com/${repoConfig.account}/${repoConfig.repoName}`;
|
|
2062
|
-
}
|
|
2063
|
-
// Write CLAUDE.md to coder sandbox
|
|
2064
|
-
const claudeMd = generateClaudeMd({
|
|
2065
|
-
description: body.description,
|
|
2066
|
-
projectName: coderInfo.projectName,
|
|
2067
|
-
projectDir: handle.projectDir,
|
|
2068
|
-
runtime: config.sandbox.runtime,
|
|
2069
|
-
production: !config.devMode,
|
|
2070
|
-
...(prodGitConfig
|
|
2071
|
-
? { git: prodGitConfig }
|
|
2072
|
-
: repoConfig
|
|
2073
|
-
? {
|
|
2074
|
-
git: {
|
|
2075
|
-
mode: "create",
|
|
2076
|
-
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
2077
|
-
visibility: repoConfig.visibility,
|
|
2078
|
-
},
|
|
2079
|
-
}
|
|
2080
|
-
: {}),
|
|
2081
|
-
});
|
|
2082
|
-
try {
|
|
2083
|
-
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
2084
|
-
}
|
|
2085
|
-
catch (err) {
|
|
2086
|
-
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md:`, err);
|
|
2087
|
-
}
|
|
2088
|
-
// Write create-app skill to coder sandbox
|
|
2089
|
-
if (createAppSkillContent) {
|
|
2090
|
-
try {
|
|
2091
|
-
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
2092
|
-
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
2093
|
-
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2094
|
-
}
|
|
2095
|
-
catch (err) {
|
|
2096
|
-
console.error(`[room:create-app:${roomId}] Failed to write create-app skill:`, err);
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
// Write room-messaging skill to coder sandbox
|
|
2100
|
-
if (roomMessagingSkillContent) {
|
|
2101
|
-
try {
|
|
2102
|
-
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2103
|
-
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2104
|
-
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2105
|
-
}
|
|
2106
|
-
catch (err) {
|
|
2107
|
-
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill to coder:`, err);
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
// 4. Create Claude Code bridge for coder
|
|
2111
|
-
const coderPrompt = `/create-app ${body.description}`;
|
|
2112
|
-
const coderHookToken = deriveHookToken(config.streamConfig.secret, coderSession.sessionId);
|
|
2113
|
-
const coderClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2114
|
-
? {
|
|
2115
|
-
prompt: coderPrompt,
|
|
2116
|
-
cwd: handle.projectDir,
|
|
2117
|
-
studioUrl: resolveStudioUrl(config.port),
|
|
2118
|
-
hookToken: coderHookToken,
|
|
2119
|
-
agentName: coderSession.name,
|
|
2120
|
-
}
|
|
2121
|
-
: {
|
|
2122
|
-
prompt: coderPrompt,
|
|
2123
|
-
cwd: handle.projectDir,
|
|
2124
|
-
studioPort: config.port,
|
|
2125
|
-
hookToken: coderHookToken,
|
|
2126
|
-
agentName: coderSession.name,
|
|
2127
|
-
};
|
|
2128
|
-
const coderCcBridge = createClaudeCodeBridge(config, coderSession.sessionId, coderClaudeConfig);
|
|
2129
|
-
// Track coder events
|
|
2130
|
-
coderCcBridge.onAgentEvent((event) => {
|
|
2131
|
-
if (event.type === "session_start") {
|
|
2132
|
-
const ccSessionId = event.session_id;
|
|
2133
|
-
if (ccSessionId) {
|
|
2134
|
-
config.sessions.update(coderSession.sessionId, {
|
|
2135
|
-
lastCoderSessionId: ccSessionId,
|
|
2136
|
-
});
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
if (event.type === "session_end") {
|
|
2140
|
-
accumulateSessionCost(config, coderSession.sessionId, event);
|
|
2141
|
-
}
|
|
2142
|
-
// Route assistant_message output to the room router
|
|
2143
|
-
if (event.type === "assistant_message" && "text" in event) {
|
|
2144
|
-
router
|
|
2145
|
-
.handleAgentOutput(coderSession.sessionId, event.text)
|
|
2146
|
-
.catch((err) => {
|
|
2147
|
-
console.error(`[room:create-app:${roomId}] handleAgentOutput error (coder):`, err);
|
|
2148
|
-
});
|
|
2149
|
-
}
|
|
2150
|
-
// Notify room when coder is waiting for user input
|
|
2151
|
-
if (event.type === "ask_user_question") {
|
|
2152
|
-
config.sessions.update(coderSession.sessionId, { needsInput: true });
|
|
2153
|
-
router
|
|
2154
|
-
.sendMessage("system", `${coderSession.name} needs input — open their session to respond.`)
|
|
2155
|
-
.catch((err) => {
|
|
2156
|
-
console.error(`[room:create-app:${roomId}] Failed to send gate notification:`, err);
|
|
2157
|
-
});
|
|
2158
|
-
}
|
|
2159
|
-
if (event.type === "gate_resolved") {
|
|
2160
|
-
config.sessions.update(coderSession.sessionId, { needsInput: false });
|
|
2161
|
-
router
|
|
2162
|
-
.sendMessage("system", `${coderSession.name} received input — resuming.`)
|
|
2163
|
-
.catch(() => { });
|
|
2164
|
-
}
|
|
2165
|
-
});
|
|
2166
|
-
// Coder completion handler: notify room on success or failure
|
|
2167
|
-
coderCcBridge.onComplete(async (success) => {
|
|
2168
|
-
const updates = {
|
|
2169
|
-
status: success ? "complete" : "error",
|
|
2170
|
-
};
|
|
2171
|
-
let repoInfo = "";
|
|
2172
|
-
try {
|
|
2173
|
-
const gs = await config.sandbox.gitStatus(handle, handle.projectDir);
|
|
2174
|
-
if (gs.initialized) {
|
|
2175
|
-
const existing = config.sessions.get(coderSession.sessionId);
|
|
2176
|
-
updates.git = {
|
|
2177
|
-
branch: gs.branch ?? "main",
|
|
2178
|
-
remoteUrl: existing?.git?.remoteUrl ?? null,
|
|
2179
|
-
repoName: existing?.git?.repoName ?? null,
|
|
2180
|
-
repoVisibility: existing?.git?.repoVisibility,
|
|
2181
|
-
lastCommitHash: gs.lastCommitHash ?? null,
|
|
2182
|
-
lastCommitMessage: gs.lastCommitMessage ?? null,
|
|
2183
|
-
lastCheckpointAt: existing?.git?.lastCheckpointAt ?? null,
|
|
2184
|
-
};
|
|
2185
|
-
if (existing?.git?.repoName) {
|
|
2186
|
-
repoInfo = ` Repo: https://github.com/${existing.git.repoName}`;
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
|
-
catch {
|
|
2191
|
-
// Sandbox may be stopped
|
|
2192
|
-
}
|
|
2193
|
-
config.sessions.update(coderSession.sessionId, updates);
|
|
2194
|
-
const msg = success
|
|
2195
|
-
? `@room DONE: App is ready.${repoInfo}`
|
|
2196
|
-
: "@room Coder session ended unexpectedly.";
|
|
2197
|
-
router.handleAgentOutput(coderSession.sessionId, msg).catch((err) => {
|
|
2198
|
-
console.error(`[room:create-app:${roomId}] Failed to send coder completion message:`, err);
|
|
2199
|
-
});
|
|
2200
|
-
});
|
|
2201
|
-
await coderBridge.emit({
|
|
2202
|
-
type: "log",
|
|
2203
|
-
level: "build",
|
|
2204
|
-
message: `Running: claude "/create-app ${body.description}"`,
|
|
2205
|
-
ts: ts(),
|
|
2206
|
-
});
|
|
2207
|
-
await coderCcBridge.start();
|
|
2208
|
-
// Add coder as room participant
|
|
2209
|
-
const coderParticipant = {
|
|
2210
|
-
sessionId: coderSession.sessionId,
|
|
2211
|
-
name: coderSession.name,
|
|
2212
|
-
role: "coder",
|
|
2213
|
-
bridge: coderCcBridge,
|
|
2214
|
-
};
|
|
2215
|
-
await router.addParticipant(coderParticipant, false);
|
|
2216
|
-
// Send the initial command to the coder
|
|
2217
|
-
await coderCcBridge.sendCommand({
|
|
2218
|
-
command: "new",
|
|
2219
|
-
description: body.description,
|
|
2220
|
-
projectName: coderInfo.projectName,
|
|
2221
|
-
baseDir: "/home/agent/workspace",
|
|
2222
|
-
});
|
|
2223
|
-
// Store the repoUrl for reviewer/ui-designer prompts
|
|
2224
|
-
// (we continue setting up those agents now)
|
|
2225
|
-
const finalRepoUrl = repoUrl;
|
|
2226
|
-
// 5. Set up reviewer and ui-designer sandboxes
|
|
2227
|
-
const supportAgents = [
|
|
2228
|
-
{ session: reviewerSession, handle: reviewerHandle },
|
|
2229
|
-
{ session: uiDesignerSession, handle: uiDesignerHandle },
|
|
2230
|
-
];
|
|
2231
|
-
for (const { session: agentSession, handle: agentHandle } of supportAgents) {
|
|
2232
|
-
const agentBridge = getOrCreateBridge(config, agentSession.sessionId);
|
|
2233
|
-
// Write a minimal CLAUDE.md
|
|
2234
|
-
const minimalClaudeMd = "Room agent workspace";
|
|
2235
|
-
try {
|
|
2236
|
-
await config.sandbox.exec(agentHandle, `mkdir -p '${agentHandle.projectDir}' && cat > '${agentHandle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${minimalClaudeMd}\nCLAUDEMD_EOF`);
|
|
2237
|
-
}
|
|
2238
|
-
catch (err) {
|
|
2239
|
-
console.error(`[room:create-app:${roomId}] Failed to write CLAUDE.md for ${agentSession.name}:`, err);
|
|
2240
|
-
}
|
|
2241
|
-
// Write room-messaging skill
|
|
2242
|
-
if (roomMessagingSkillContent) {
|
|
2243
|
-
try {
|
|
2244
|
-
const skillDir = `${agentHandle.projectDir}/.claude/skills/room-messaging`;
|
|
2245
|
-
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2246
|
-
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2247
|
-
}
|
|
2248
|
-
catch (err) {
|
|
2249
|
-
console.error(`[room:create-app:${roomId}] Failed to write room-messaging skill for ${agentSession.name}:`, err);
|
|
2250
|
-
}
|
|
2251
|
-
}
|
|
2252
|
-
// Resolve and inject role skill
|
|
2253
|
-
const roleSkill = resolveRoleSkill(agentSession.role);
|
|
2254
|
-
if (roleSkill) {
|
|
2255
|
-
try {
|
|
2256
|
-
const skillDir = `${agentHandle.projectDir}/.claude/skills/role`;
|
|
2257
|
-
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
2258
|
-
await config.sandbox.exec(agentHandle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2259
|
-
}
|
|
2260
|
-
catch (err) {
|
|
2261
|
-
console.error(`[room:create-app:${roomId}] Failed to write role skill for ${agentSession.name}:`, err);
|
|
2262
|
-
}
|
|
2263
|
-
}
|
|
2264
|
-
// Build prompt
|
|
2265
|
-
const repoRef = finalRepoUrl ? ` The GitHub repo is: ${finalRepoUrl}.` : "";
|
|
2266
|
-
const agentPrompt = agentSession.role === "reviewer"
|
|
2267
|
-
? `You are "reviewer", a code review agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`
|
|
2268
|
-
: `You are "ui-designer", a UI design agent in a multi-agent room. Read .claude/skills/role/SKILL.md for your role guidelines.${repoRef} Wait for the coder to send a @room DONE: message before starting any work.`;
|
|
2269
|
-
// Create Claude Code bridge
|
|
2270
|
-
const agentHookToken = deriveHookToken(config.streamConfig.secret, agentSession.sessionId);
|
|
2271
|
-
const agentClaudeConfig = config.sandbox.runtime === "sprites"
|
|
2272
|
-
? {
|
|
2273
|
-
prompt: agentPrompt,
|
|
2274
|
-
cwd: agentHandle.projectDir,
|
|
2275
|
-
studioUrl: resolveStudioUrl(config.port),
|
|
2276
|
-
hookToken: agentHookToken,
|
|
2277
|
-
agentName: agentSession.name,
|
|
2278
|
-
...(roleSkill?.allowedTools && {
|
|
2279
|
-
allowedTools: roleSkill.allowedTools,
|
|
2280
|
-
}),
|
|
2281
|
-
}
|
|
2282
|
-
: {
|
|
2283
|
-
prompt: agentPrompt,
|
|
2284
|
-
cwd: agentHandle.projectDir,
|
|
2285
|
-
studioPort: config.port,
|
|
2286
|
-
hookToken: agentHookToken,
|
|
2287
|
-
agentName: agentSession.name,
|
|
2288
|
-
...(roleSkill?.allowedTools && {
|
|
2289
|
-
allowedTools: roleSkill.allowedTools,
|
|
2290
|
-
}),
|
|
2291
|
-
};
|
|
2292
|
-
const ccBridge = createClaudeCodeBridge(config, agentSession.sessionId, agentClaudeConfig);
|
|
2293
|
-
// Track events
|
|
2294
|
-
ccBridge.onAgentEvent((event) => {
|
|
2295
|
-
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)}` : ""}`);
|
|
2296
|
-
if (event.type === "session_start") {
|
|
2297
|
-
const ccSessionId = event.session_id;
|
|
2298
|
-
if (ccSessionId) {
|
|
2299
|
-
config.sessions.update(agentSession.sessionId, {
|
|
2300
|
-
lastCoderSessionId: ccSessionId,
|
|
2301
|
-
});
|
|
2302
|
-
}
|
|
2303
|
-
}
|
|
2304
|
-
if (event.type === "session_end") {
|
|
2305
|
-
accumulateSessionCost(config, agentSession.sessionId, event);
|
|
2306
|
-
}
|
|
2307
|
-
if (event.type === "assistant_message" && "text" in event) {
|
|
2308
|
-
const text = event.text;
|
|
2309
|
-
console.log(`[room:create-app:${roomId}] ${agentSession.name} assistant_message -> calling handleAgentOutput (sessionId=${agentSession.sessionId})`);
|
|
2310
|
-
router.handleAgentOutput(agentSession.sessionId, text).catch((err) => {
|
|
2311
|
-
console.error(`[room:create-app:${roomId}] handleAgentOutput error (${agentSession.name}):`, err);
|
|
2312
|
-
});
|
|
2313
|
-
}
|
|
2314
|
-
if (event.type === "ask_user_question") {
|
|
2315
|
-
config.sessions.update(agentSession.sessionId, { needsInput: true });
|
|
2316
|
-
router
|
|
2317
|
-
.sendMessage("system", `${agentSession.name} needs input — open their session to respond.`)
|
|
2318
|
-
.catch((err) => {
|
|
2319
|
-
console.error(`[room:create-app:${roomId}] Failed to send gate notification (${agentSession.name}):`, err);
|
|
2320
|
-
});
|
|
2321
|
-
}
|
|
2322
|
-
if (event.type === "gate_resolved") {
|
|
2323
|
-
config.sessions.update(agentSession.sessionId, { needsInput: false });
|
|
2324
|
-
router
|
|
2325
|
-
.sendMessage("system", `${agentSession.name} received input — resuming.`)
|
|
2326
|
-
.catch(() => { });
|
|
2327
|
-
}
|
|
2328
|
-
});
|
|
2329
|
-
ccBridge.onComplete(async (success) => {
|
|
2330
|
-
config.sessions.update(agentSession.sessionId, {
|
|
2331
|
-
status: success ? "complete" : "error",
|
|
2332
|
-
});
|
|
2333
|
-
});
|
|
2334
|
-
await agentBridge.emit({
|
|
2335
|
-
type: "log",
|
|
2336
|
-
level: "done",
|
|
2337
|
-
message: `Sandbox ready for "${agentSession.name}"`,
|
|
2338
|
-
ts: ts(),
|
|
2339
|
-
});
|
|
2340
|
-
await ccBridge.start();
|
|
2341
|
-
// Add as room participant (not gated — messages flow freely)
|
|
2342
|
-
const participant = {
|
|
2343
|
-
sessionId: agentSession.sessionId,
|
|
2344
|
-
name: agentSession.name,
|
|
2345
|
-
role: agentSession.role,
|
|
2346
|
-
bridge: ccBridge,
|
|
2347
|
-
};
|
|
2348
|
-
await router.addParticipant(participant, false);
|
|
2349
|
-
}
|
|
2350
|
-
console.log(`[room:create-app:${roomId}] All 3 agents started and added to room`);
|
|
2351
|
-
await router.sendMessage("system", `All agents ready — ${coderSession.name} is building, ${reviewerSession.name} and ${uiDesignerSession.name} waiting.`);
|
|
2352
|
-
}
|
|
2353
|
-
};
|
|
2354
|
-
asyncFlow().catch(async (err) => {
|
|
2355
|
-
console.error(`[room:create-app:${roomId}] Flow failed:`, err);
|
|
2356
|
-
for (const s of sessions) {
|
|
2357
|
-
config.sessions.update(s.sessionId, { status: "error" });
|
|
2358
|
-
}
|
|
2359
|
-
try {
|
|
2360
|
-
await coderBridge.emit({
|
|
2361
|
-
type: "log",
|
|
2362
|
-
level: "error",
|
|
2363
|
-
message: `Room creation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2364
|
-
ts: ts(),
|
|
2365
|
-
});
|
|
2366
|
-
}
|
|
2367
|
-
catch {
|
|
2368
|
-
// Bridge may not be usable
|
|
2369
|
-
}
|
|
2370
|
-
});
|
|
2371
|
-
return c.json({
|
|
2372
|
-
roomId,
|
|
2373
|
-
code,
|
|
2374
|
-
name: roomName,
|
|
2375
|
-
roomToken,
|
|
2376
|
-
sessions: sessions.map((s) => ({
|
|
2377
|
-
sessionId: s.sessionId,
|
|
2378
|
-
name: s.name,
|
|
2379
|
-
role: s.role,
|
|
2380
|
-
sessionToken: s.sessionToken,
|
|
2381
|
-
})),
|
|
2382
|
-
}, 201);
|
|
2383
|
-
});
|
|
2384
1550
|
// Create a room
|
|
2385
1551
|
app.post("/api/rooms", async (c) => {
|
|
2386
|
-
const body = await
|
|
2387
|
-
if (
|
|
2388
|
-
return
|
|
1552
|
+
const body = (await c.req.json());
|
|
1553
|
+
if (!body.name) {
|
|
1554
|
+
return c.json({ error: "name is required" }, 400);
|
|
1555
|
+
}
|
|
2389
1556
|
const roomId = crypto.randomUUID();
|
|
2390
1557
|
// Create the room's durable stream
|
|
2391
1558
|
const conn = roomStream(config, roomId);
|
|
@@ -2415,12 +1582,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2415
1582
|
createdAt: new Date().toISOString(),
|
|
2416
1583
|
revoked: false,
|
|
2417
1584
|
});
|
|
2418
|
-
const roomToken =
|
|
1585
|
+
const roomToken = deriveSessionToken(config.streamConfig.secret, roomId);
|
|
2419
1586
|
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
2420
1587
|
return c.json({ roomId, code, roomToken }, 201);
|
|
2421
1588
|
});
|
|
2422
|
-
// Join an agent room by id + invite code
|
|
2423
|
-
app.get("/api/join
|
|
1589
|
+
// Join an agent room by id + invite code
|
|
1590
|
+
app.get("/api/rooms/join/:id/:code", (c) => {
|
|
2424
1591
|
const id = c.req.param("id");
|
|
2425
1592
|
const code = c.req.param("code");
|
|
2426
1593
|
const room = config.rooms.getRoom(id);
|
|
@@ -2428,38 +1595,25 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2428
1595
|
return c.json({ error: "Room not found" }, 404);
|
|
2429
1596
|
if (room.revoked)
|
|
2430
1597
|
return c.json({ error: "Room has been revoked" }, 410);
|
|
2431
|
-
const roomToken =
|
|
1598
|
+
const roomToken = deriveSessionToken(config.streamConfig.secret, room.id);
|
|
2432
1599
|
return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
|
|
2433
1600
|
});
|
|
2434
1601
|
// Get room state
|
|
2435
1602
|
app.get("/api/rooms/:id", (c) => {
|
|
2436
1603
|
const roomId = c.req.param("id");
|
|
2437
1604
|
const router = roomRouters.get(roomId);
|
|
2438
|
-
if (router)
|
|
2439
|
-
return c.json({
|
|
2440
|
-
roomId,
|
|
2441
|
-
state: router.state,
|
|
2442
|
-
roundCount: router.roundCount,
|
|
2443
|
-
participants: router.participants.map((p) => ({
|
|
2444
|
-
sessionId: p.sessionId,
|
|
2445
|
-
name: p.name,
|
|
2446
|
-
role: p.role,
|
|
2447
|
-
running: p.bridge.isRunning(),
|
|
2448
|
-
needsInput: config.sessions.get(p.sessionId)?.needsInput ?? false,
|
|
2449
|
-
})),
|
|
2450
|
-
});
|
|
2451
|
-
}
|
|
2452
|
-
// No active router — check if room exists in the registry (e.g. after server restart)
|
|
2453
|
-
const roomEntry = config.rooms.getRoom(roomId);
|
|
2454
|
-
if (!roomEntry)
|
|
1605
|
+
if (!router)
|
|
2455
1606
|
return c.json({ error: "Room not found" }, 404);
|
|
2456
|
-
// Return basic room state without live participants
|
|
2457
|
-
// Sessions are still readable via their individual SSE streams
|
|
2458
1607
|
return c.json({
|
|
2459
1608
|
roomId,
|
|
2460
|
-
state:
|
|
2461
|
-
roundCount:
|
|
2462
|
-
participants:
|
|
1609
|
+
state: router.state,
|
|
1610
|
+
roundCount: router.roundCount,
|
|
1611
|
+
participants: router.participants.map((p) => ({
|
|
1612
|
+
sessionId: p.sessionId,
|
|
1613
|
+
name: p.name,
|
|
1614
|
+
role: p.role,
|
|
1615
|
+
running: p.bridge.isRunning(),
|
|
1616
|
+
})),
|
|
2463
1617
|
});
|
|
2464
1618
|
});
|
|
2465
1619
|
// Add an agent to a room
|
|
@@ -2468,26 +1622,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2468
1622
|
const router = roomRouters.get(roomId);
|
|
2469
1623
|
if (!router)
|
|
2470
1624
|
return c.json({ error: "Room not found" }, 404);
|
|
2471
|
-
const body = await
|
|
2472
|
-
if (isResponse(body))
|
|
2473
|
-
return body;
|
|
2474
|
-
// Rate-limit and gate credentials in production mode
|
|
2475
|
-
if (!config.devMode) {
|
|
2476
|
-
const ip = extractClientIp(c);
|
|
2477
|
-
if (!checkSessionRateLimit(ip)) {
|
|
2478
|
-
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2479
|
-
}
|
|
2480
|
-
if (checkGlobalSessionCap(config.sessions)) {
|
|
2481
|
-
return c.json({ error: "Service at capacity, please try again later" }, 503);
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
const apiKey = config.devMode
|
|
2485
|
-
? body.apiKey || process.env.ANTHROPIC_API_KEY
|
|
2486
|
-
: process.env.ANTHROPIC_API_KEY;
|
|
2487
|
-
const oauthToken = config.devMode
|
|
2488
|
-
? body.oauthToken || process.env.CLAUDE_OAUTH_TOKEN
|
|
2489
|
-
: undefined;
|
|
2490
|
-
const ghToken = config.devMode ? body.ghToken : undefined;
|
|
1625
|
+
const body = (await c.req.json());
|
|
2491
1626
|
const sessionId = crypto.randomUUID();
|
|
2492
1627
|
const randomSuffix = sessionId.slice(0, 6);
|
|
2493
1628
|
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
@@ -2536,15 +1671,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2536
1671
|
const handle = await config.sandbox.create(sessionId, {
|
|
2537
1672
|
projectName,
|
|
2538
1673
|
infra: { mode: "local" },
|
|
2539
|
-
apiKey,
|
|
2540
|
-
oauthToken,
|
|
2541
|
-
ghToken,
|
|
2542
|
-
...((!config.devMode || GITHUB_APP_ID) && {
|
|
2543
|
-
prodMode: {
|
|
2544
|
-
sessionToken: deriveSessionToken(config.streamConfig.secret, sessionId),
|
|
2545
|
-
studioUrl: resolveStudioUrl(config.port),
|
|
2546
|
-
},
|
|
2547
|
-
}),
|
|
1674
|
+
apiKey: body.apiKey,
|
|
1675
|
+
oauthToken: body.oauthToken,
|
|
1676
|
+
ghToken: body.ghToken,
|
|
2548
1677
|
});
|
|
2549
1678
|
config.sessions.update(sessionId, {
|
|
2550
1679
|
appPort: handle.port,
|
|
@@ -2658,9 +1787,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2658
1787
|
const router = roomRouters.get(roomId);
|
|
2659
1788
|
if (!router)
|
|
2660
1789
|
return c.json({ error: "Room not found" }, 404);
|
|
2661
|
-
const body = await
|
|
2662
|
-
if (
|
|
2663
|
-
return
|
|
1790
|
+
const body = (await c.req.json());
|
|
1791
|
+
if (!body.sessionId || !body.name) {
|
|
1792
|
+
return c.json({ error: "sessionId and name are required" }, 400);
|
|
1793
|
+
}
|
|
2664
1794
|
const { sessionId } = body;
|
|
2665
1795
|
// Require a valid session token — caller must already own this session.
|
|
2666
1796
|
// Room auth is handled by middleware via X-Room-Token; Authorization
|
|
@@ -2740,9 +1870,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2740
1870
|
const participant = router.participants.find((p) => p.sessionId === sessionId);
|
|
2741
1871
|
if (!participant)
|
|
2742
1872
|
return c.json({ error: "Session not found in this room" }, 404);
|
|
2743
|
-
const body = await
|
|
2744
|
-
if (
|
|
2745
|
-
return
|
|
1873
|
+
const body = (await c.req.json());
|
|
1874
|
+
if (!body.request) {
|
|
1875
|
+
return c.json({ error: "request is required" }, 400);
|
|
1876
|
+
}
|
|
2746
1877
|
await participant.bridge.sendCommand({
|
|
2747
1878
|
command: "iterate",
|
|
2748
1879
|
request: body.request,
|
|
@@ -2755,19 +1886,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2755
1886
|
const router = roomRouters.get(roomId);
|
|
2756
1887
|
if (!router)
|
|
2757
1888
|
return c.json({ error: "Room not found" }, 404);
|
|
2758
|
-
const body = await
|
|
2759
|
-
if (
|
|
2760
|
-
return body;
|
|
1889
|
+
const body = (await c.req.json());
|
|
1890
|
+
if (!body.from || !body.body) {
|
|
1891
|
+
return c.json({ error: "from and body are required" }, 400);
|
|
1892
|
+
}
|
|
2761
1893
|
await router.sendMessage(body.from, body.body, body.to);
|
|
2762
1894
|
return c.json({ ok: true });
|
|
2763
1895
|
});
|
|
2764
|
-
// SSE proxy for room events
|
|
1896
|
+
// SSE proxy for room events
|
|
2765
1897
|
app.get("/api/rooms/:id/events", async (c) => {
|
|
2766
1898
|
const roomId = c.req.param("id");
|
|
2767
|
-
|
|
2768
|
-
if (!
|
|
1899
|
+
const router = roomRouters.get(roomId);
|
|
1900
|
+
if (!router)
|
|
2769
1901
|
return c.json({ error: "Room not found" }, 404);
|
|
2770
|
-
}
|
|
2771
1902
|
const connection = roomStream(config, roomId);
|
|
2772
1903
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
2773
1904
|
const reader = new DurableStream({
|
|
@@ -2984,9 +2115,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2984
2115
|
if (!handle || !sandboxDir) {
|
|
2985
2116
|
return c.json({ error: "Container not available" }, 404);
|
|
2986
2117
|
}
|
|
2987
|
-
|
|
2988
|
-
const resolvedDir = path.resolve(sandboxDir) + path.sep;
|
|
2989
|
-
if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
|
|
2118
|
+
if (!filePath.startsWith(sandboxDir)) {
|
|
2990
2119
|
return c.json({ error: "Path outside project directory" }, 403);
|
|
2991
2120
|
}
|
|
2992
2121
|
const content = await config.sandbox.readFile(handle, filePath);
|
|
@@ -2997,8 +2126,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2997
2126
|
});
|
|
2998
2127
|
// List GitHub accounts (personal + orgs) — requires client-provided token
|
|
2999
2128
|
app.get("/api/github/accounts", (c) => {
|
|
3000
|
-
if (!config.devMode)
|
|
3001
|
-
return c.json({ error: "Not available" }, 403);
|
|
3002
2129
|
const token = c.req.header("X-GH-Token");
|
|
3003
2130
|
if (!token)
|
|
3004
2131
|
return c.json({ accounts: [] });
|
|
@@ -3010,10 +2137,8 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
3010
2137
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list accounts" }, 500);
|
|
3011
2138
|
}
|
|
3012
2139
|
});
|
|
3013
|
-
// List GitHub repos for the authenticated user — requires client-provided token
|
|
2140
|
+
// List GitHub repos for the authenticated user — requires client-provided token
|
|
3014
2141
|
app.get("/api/github/repos", (c) => {
|
|
3015
|
-
if (!config.devMode)
|
|
3016
|
-
return c.json({ error: "Not available" }, 403);
|
|
3017
2142
|
const token = c.req.header("X-GH-Token");
|
|
3018
2143
|
if (!token)
|
|
3019
2144
|
return c.json({ repos: [] });
|
|
@@ -3026,8 +2151,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
3026
2151
|
}
|
|
3027
2152
|
});
|
|
3028
2153
|
app.get("/api/github/repos/:owner/:repo/branches", (c) => {
|
|
3029
|
-
if (!config.devMode)
|
|
3030
|
-
return c.json({ error: "Not available" }, 403);
|
|
3031
2154
|
const owner = c.req.param("owner");
|
|
3032
2155
|
const repo = c.req.param("repo");
|
|
3033
2156
|
const token = c.req.header("X-GH-Token");
|
|
@@ -3041,38 +2164,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
3041
2164
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
3042
2165
|
}
|
|
3043
2166
|
});
|
|
3044
|
-
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
}
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
if (token) {
|
|
3056
|
-
console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
|
|
3057
|
-
}
|
|
3058
|
-
else {
|
|
3059
|
-
console.log("[dev] No OAuth token found in keychain");
|
|
3060
|
-
}
|
|
3061
|
-
return c.json({ oauthToken: token });
|
|
2167
|
+
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
2168
|
+
app.get("/api/credentials/keychain", (c) => {
|
|
2169
|
+
if (process.platform !== "darwin") {
|
|
2170
|
+
return c.json({ apiKey: null });
|
|
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})`);
|
|
3062
2178
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
2179
|
+
else {
|
|
2180
|
+
console.log("[dev] No OAuth token found in keychain");
|
|
3065
2181
|
}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
2182
|
+
return c.json({ oauthToken: token });
|
|
2183
|
+
}
|
|
2184
|
+
catch {
|
|
2185
|
+
return c.json({ oauthToken: null });
|
|
2186
|
+
}
|
|
2187
|
+
});
|
|
2188
|
+
// Resume a project from a GitHub repo
|
|
3069
2189
|
app.post("/api/sessions/resume", async (c) => {
|
|
3070
|
-
|
|
3071
|
-
|
|
2190
|
+
const body = (await c.req.json());
|
|
2191
|
+
if (!body.repoUrl) {
|
|
2192
|
+
return c.json({ error: "repoUrl is required" }, 400);
|
|
3072
2193
|
}
|
|
3073
|
-
const body = await validateBody(c, resumeSessionSchema);
|
|
3074
|
-
if (isResponse(body))
|
|
3075
|
-
return body;
|
|
3076
2194
|
const sessionId = crypto.randomUUID();
|
|
3077
2195
|
const repoName = body.repoUrl
|
|
3078
2196
|
.split("/")
|
|
@@ -3152,7 +2270,6 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
3152
2270
|
projectName: repoName,
|
|
3153
2271
|
projectDir: handle.projectDir,
|
|
3154
2272
|
runtime: config.sandbox.runtime,
|
|
3155
|
-
production: !config.devMode,
|
|
3156
2273
|
git: {
|
|
3157
2274
|
mode: "existing",
|
|
3158
2275
|
repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
|
|
@@ -3317,42 +2434,15 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
3317
2434
|
return app;
|
|
3318
2435
|
}
|
|
3319
2436
|
export async function startWebServer(opts) {
|
|
3320
|
-
const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
|
|
3321
|
-
if (devMode) {
|
|
3322
|
-
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
3323
|
-
}
|
|
3324
|
-
// Hydrate session registry from durable stream (survives restarts)
|
|
3325
|
-
const registry = await Registry.create(opts.streamConfig);
|
|
3326
2437
|
const config = {
|
|
3327
2438
|
port: opts.port ?? 4400,
|
|
3328
2439
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
3329
|
-
sessions: ActiveSessions
|
|
2440
|
+
sessions: new ActiveSessions(),
|
|
3330
2441
|
rooms: opts.rooms,
|
|
3331
2442
|
sandbox: opts.sandbox,
|
|
3332
2443
|
streamConfig: opts.streamConfig,
|
|
3333
2444
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
3334
|
-
devMode,
|
|
3335
2445
|
};
|
|
3336
|
-
// Reconnect to surviving sandbox containers (Docker only)
|
|
3337
|
-
if (config.sandbox.runtime === "docker") {
|
|
3338
|
-
const dockerProvider = config.sandbox;
|
|
3339
|
-
const allSessions = registry.listSessions();
|
|
3340
|
-
dockerProvider.reconnect(allSessions);
|
|
3341
|
-
// Mark sessions with live containers as "complete" (not stale),
|
|
3342
|
-
// and sessions without containers as "error"
|
|
3343
|
-
for (const session of allSessions) {
|
|
3344
|
-
if (session.status === "running") {
|
|
3345
|
-
const handle = dockerProvider.get(session.id);
|
|
3346
|
-
config.sessions.update(session.id, {
|
|
3347
|
-
status: handle ? "complete" : "error",
|
|
3348
|
-
});
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
}
|
|
3352
|
-
else {
|
|
3353
|
-
// Non-Docker: mark all running sessions as stale
|
|
3354
|
-
registry.cleanupStaleSessions(0);
|
|
3355
|
-
}
|
|
3356
2446
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
3357
2447
|
const app = createApp(config);
|
|
3358
2448
|
const hostname = process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1";
|