@electric-agent/studio 1.5.0 → 1.12.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/api-schemas.d.ts +225 -0
- package/dist/api-schemas.d.ts.map +1 -0
- package/dist/api-schemas.js +95 -0
- package/dist/api-schemas.js.map +1 -0
- package/dist/bridge/claude-code-base.d.ts +121 -0
- package/dist/bridge/claude-code-base.d.ts.map +1 -0
- package/dist/bridge/claude-code-base.js +263 -0
- package/dist/bridge/claude-code-base.js.map +1 -0
- package/dist/bridge/claude-code-docker.d.ts +13 -73
- package/dist/bridge/claude-code-docker.d.ts.map +1 -1
- package/dist/bridge/claude-code-docker.js +91 -302
- package/dist/bridge/claude-code-docker.js.map +1 -1
- package/dist/bridge/claude-code-sprites.d.ts +12 -59
- package/dist/bridge/claude-code-sprites.d.ts.map +1 -1
- package/dist/bridge/claude-code-sprites.js +88 -281
- package/dist/bridge/claude-code-sprites.js.map +1 -1
- package/dist/bridge/claude-md-generator.d.ts +22 -5
- package/dist/bridge/claude-md-generator.d.ts.map +1 -1
- package/dist/bridge/claude-md-generator.js +81 -213
- package/dist/bridge/claude-md-generator.js.map +1 -1
- package/dist/bridge/codex-docker.d.ts +56 -51
- package/dist/bridge/codex-docker.js +222 -230
- package/dist/bridge/codex-json-parser.d.ts +11 -11
- package/dist/bridge/codex-json-parser.js +231 -238
- package/dist/bridge/codex-md-generator.d.ts +3 -3
- package/dist/bridge/codex-md-generator.js +42 -32
- package/dist/bridge/codex-sprites.d.ts +50 -45
- package/dist/bridge/codex-sprites.js +212 -222
- package/dist/bridge/daytona.d.ts +25 -25
- package/dist/bridge/daytona.js +131 -136
- package/dist/bridge/docker-stdio.d.ts +21 -21
- package/dist/bridge/docker-stdio.js +126 -132
- package/dist/bridge/hosted.d.ts +3 -2
- package/dist/bridge/hosted.d.ts.map +1 -1
- package/dist/bridge/hosted.js +4 -0
- package/dist/bridge/hosted.js.map +1 -1
- package/dist/bridge/message-parser.d.ts +24 -0
- package/dist/bridge/message-parser.d.ts.map +1 -0
- package/dist/bridge/message-parser.js +39 -0
- package/dist/bridge/message-parser.js.map +1 -0
- package/dist/bridge/role-skills.d.ts +25 -0
- package/dist/bridge/role-skills.d.ts.map +1 -0
- package/dist/bridge/role-skills.js +120 -0
- package/dist/bridge/role-skills.js.map +1 -0
- package/dist/bridge/room-messaging-skill.d.ts +11 -0
- package/dist/bridge/room-messaging-skill.d.ts.map +1 -0
- package/dist/bridge/room-messaging-skill.js +41 -0
- package/dist/bridge/room-messaging-skill.js.map +1 -0
- package/dist/bridge/sprites.d.ts +22 -22
- package/dist/bridge/sprites.js +123 -128
- package/dist/bridge/stream-json-parser.js +12 -5
- package/dist/bridge/stream-json-parser.js.map +1 -1
- package/dist/bridge/types.d.ts +4 -10
- package/dist/bridge/types.d.ts.map +1 -1
- package/dist/client/assets/index-BfvQSMwH.css +1 -0
- package/dist/client/assets/index-CiwD5LkP.js +235 -0
- package/dist/client/index.html +2 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/invite-code.d.ts +5 -0
- package/dist/invite-code.d.ts.map +1 -0
- package/dist/invite-code.js +14 -0
- package/dist/invite-code.js.map +1 -0
- package/dist/project-utils.d.ts.map +1 -1
- package/dist/project-utils.js.map +1 -1
- package/dist/registry.d.ts +11 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/room-router.d.ts +73 -0
- package/dist/room-router.d.ts.map +1 -0
- package/dist/room-router.js +345 -0
- package/dist/room-router.js.map +1 -0
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +5 -6
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/index.d.ts +0 -1
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +0 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/sprites.d.ts.map +1 -1
- package/dist/sandbox/sprites.js +40 -10
- package/dist/sandbox/sprites.js.map +1 -1
- package/dist/sandbox/types.d.ts +4 -2
- package/dist/sandbox/types.d.ts.map +1 -1
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +824 -309
- package/dist/server.js.map +1 -1
- package/dist/session-auth.d.ts +9 -0
- package/dist/session-auth.d.ts.map +1 -1
- package/dist/session-auth.js +30 -0
- package/dist/session-auth.js.map +1 -1
- package/dist/sessions.d.ts +7 -1
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js.map +1 -1
- package/dist/streams.d.ts +2 -6
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +6 -17
- package/dist/streams.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 +6 -9
- package/dist/client/assets/index-DDzmxYub.js +0 -234
- package/dist/client/assets/index-DcP7prsZ.css +0 -1
package/dist/server.js
CHANGED
|
@@ -7,21 +7,26 @@ 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, 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
|
-
import { createAppSkillContent, generateClaudeMd } from "./bridge/claude-md-generator.js";
|
|
15
|
+
import { createAppSkillContent, generateClaudeMd, resolveRoleSkill, roomMessagingSkillContent, } from "./bridge/claude-md-generator.js";
|
|
15
16
|
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 { generateInviteCode } from "./invite-code.js";
|
|
19
21
|
import { resolveProjectDir } from "./project-utils.js";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
22
|
+
import { RoomRouter } from "./room-router.js";
|
|
23
|
+
import { deriveGlobalHookSecret, deriveHookToken, deriveRoomToken, deriveSessionToken, validateGlobalHookSecret, validateHookToken, validateRoomToken, validateSessionToken, } from "./session-auth.js";
|
|
24
|
+
import { getRoomStreamConnectionInfo, getStreamConnectionInfo, } from "./streams.js";
|
|
25
|
+
import { isResponse, validateBody } from "./validate.js";
|
|
23
26
|
/** Active session bridges — one per running session */
|
|
24
27
|
const bridges = new Map();
|
|
28
|
+
/** Active room routers — one per room with agent-to-agent messaging */
|
|
29
|
+
const roomRouters = new Map();
|
|
25
30
|
/** Inflight hook session creations — prevents duplicate sessions from concurrent hooks */
|
|
26
31
|
const inflightHookCreations = new Map();
|
|
27
32
|
function parseRepoNameFromUrl(url) {
|
|
@@ -34,9 +39,9 @@ function parseRepoNameFromUrl(url) {
|
|
|
34
39
|
function sessionStream(config, sessionId) {
|
|
35
40
|
return getStreamConnectionInfo(sessionId, config.streamConfig);
|
|
36
41
|
}
|
|
37
|
-
/** Get stream connection info for a
|
|
38
|
-
function
|
|
39
|
-
return
|
|
42
|
+
/** Get stream connection info for a room */
|
|
43
|
+
function roomStream(config, roomId) {
|
|
44
|
+
return getRoomStreamConnectionInfo(roomId, config.streamConfig);
|
|
40
45
|
}
|
|
41
46
|
/** Create or retrieve the SessionBridge for a session */
|
|
42
47
|
function getOrCreateBridge(config, sessionId) {
|
|
@@ -64,11 +69,92 @@ function resolveStudioUrl(port) {
|
|
|
64
69
|
// Fallback — won't work from sprites VMs, but at least logs a useful URL
|
|
65
70
|
return `http://localhost:${port}`;
|
|
66
71
|
}
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Rate limiting — in-memory sliding window per IP
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
const MAX_SESSIONS_PER_IP_PER_HOUR = Number(process.env.MAX_SESSIONS_PER_IP_PER_HOUR) || 5;
|
|
76
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
77
|
+
const sessionCreationsByIp = new Map();
|
|
78
|
+
function extractClientIp(c) {
|
|
79
|
+
return (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
80
|
+
c.req.header("cf-connecting-ip") ||
|
|
81
|
+
"unknown");
|
|
82
|
+
}
|
|
83
|
+
function checkSessionRateLimit(ip) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
const cutoff = now - RATE_LIMIT_WINDOW_MS;
|
|
86
|
+
let timestamps = sessionCreationsByIp.get(ip) ?? [];
|
|
87
|
+
// Prune stale entries
|
|
88
|
+
timestamps = timestamps.filter((t) => t > cutoff);
|
|
89
|
+
if (timestamps.length >= MAX_SESSIONS_PER_IP_PER_HOUR) {
|
|
90
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
timestamps.push(now);
|
|
94
|
+
sessionCreationsByIp.set(ip, timestamps);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Per-session cost budget
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
const MAX_SESSION_COST_USD = Number(process.env.MAX_SESSION_COST_USD) || 5;
|
|
101
|
+
/**
|
|
102
|
+
* Accumulate cost and turn metrics from a session_end event into the session's totals.
|
|
103
|
+
* Called each time a Claude Code run finishes (initial + iterate runs).
|
|
104
|
+
* In production mode, enforces a per-session cost budget.
|
|
105
|
+
*/
|
|
106
|
+
function accumulateSessionCost(config, sessionId, event) {
|
|
107
|
+
if (event.type !== "session_end")
|
|
108
|
+
return;
|
|
109
|
+
const { cost_usd, num_turns, duration_ms } = event;
|
|
110
|
+
if (cost_usd == null && num_turns == null && duration_ms == null)
|
|
111
|
+
return;
|
|
112
|
+
const existing = config.sessions.get(sessionId);
|
|
113
|
+
const updates = {};
|
|
114
|
+
if (cost_usd != null) {
|
|
115
|
+
updates.totalCostUsd = (existing?.totalCostUsd ?? 0) + cost_usd;
|
|
116
|
+
}
|
|
117
|
+
if (num_turns != null) {
|
|
118
|
+
updates.totalTurns = (existing?.totalTurns ?? 0) + num_turns;
|
|
119
|
+
}
|
|
120
|
+
if (duration_ms != null) {
|
|
121
|
+
updates.totalDurationMs = (existing?.totalDurationMs ?? 0) + duration_ms;
|
|
122
|
+
}
|
|
123
|
+
config.sessions.update(sessionId, updates);
|
|
124
|
+
console.log(`[session:${sessionId}] Cost: $${updates.totalCostUsd?.toFixed(4) ?? "?"} (${updates.totalTurns ?? "?"} turns)`);
|
|
125
|
+
// Enforce budget in production mode
|
|
126
|
+
if (!config.devMode &&
|
|
127
|
+
updates.totalCostUsd != null &&
|
|
128
|
+
updates.totalCostUsd > MAX_SESSION_COST_USD) {
|
|
129
|
+
console.log(`[session:${sessionId}] Budget exceeded: $${updates.totalCostUsd.toFixed(2)} > $${MAX_SESSION_COST_USD}`);
|
|
130
|
+
const bridge = bridges.get(sessionId);
|
|
131
|
+
if (bridge) {
|
|
132
|
+
bridge
|
|
133
|
+
.emit({
|
|
134
|
+
type: "budget_exceeded",
|
|
135
|
+
budget_usd: MAX_SESSION_COST_USD,
|
|
136
|
+
spent_usd: updates.totalCostUsd,
|
|
137
|
+
ts: ts(),
|
|
138
|
+
})
|
|
139
|
+
.catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
config.sessions.update(sessionId, { status: "error" });
|
|
142
|
+
closeBridge(sessionId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
67
145
|
/**
|
|
68
146
|
* Create a Claude Code bridge for a session.
|
|
69
147
|
* Spawns `claude` CLI with stream-json I/O inside the sandbox.
|
|
148
|
+
* In production mode, enforces tool restrictions and hardcodes the model.
|
|
70
149
|
*/
|
|
71
150
|
function createClaudeCodeBridge(config, sessionId, claudeConfig) {
|
|
151
|
+
// Production mode: restrict tools and hardcode model
|
|
152
|
+
if (!config.devMode) {
|
|
153
|
+
if (!claudeConfig.allowedTools) {
|
|
154
|
+
claudeConfig.allowedTools = PRODUCTION_ALLOWED_TOOLS;
|
|
155
|
+
}
|
|
156
|
+
claudeConfig.model = undefined; // force default (claude-sonnet-4-6)
|
|
157
|
+
}
|
|
72
158
|
const conn = sessionStream(config, sessionId);
|
|
73
159
|
let bridge;
|
|
74
160
|
if (config.sandbox.runtime === "sprites") {
|
|
@@ -198,12 +284,26 @@ function mapHookToEngineEvent(body) {
|
|
|
198
284
|
text: body.last_assistant_message || "",
|
|
199
285
|
ts: now,
|
|
200
286
|
};
|
|
201
|
-
case "SessionEnd":
|
|
202
|
-
|
|
287
|
+
case "SessionEnd": {
|
|
288
|
+
const endEvent = {
|
|
203
289
|
type: "session_end",
|
|
204
290
|
success: true,
|
|
205
291
|
ts: now,
|
|
206
292
|
};
|
|
293
|
+
// Claude Code SessionEnd hook may include session stats
|
|
294
|
+
const session = body.session;
|
|
295
|
+
if (session) {
|
|
296
|
+
if (typeof session.cost_usd === "number")
|
|
297
|
+
endEvent.cost_usd = session.cost_usd;
|
|
298
|
+
if (typeof session.num_turns === "number")
|
|
299
|
+
endEvent.num_turns = session.num_turns;
|
|
300
|
+
if (typeof session.duration_ms === "number")
|
|
301
|
+
endEvent.duration_ms = session.duration_ms;
|
|
302
|
+
if (typeof session.duration_api_ms === "number")
|
|
303
|
+
endEvent.duration_api_ms = session.duration_api_ms;
|
|
304
|
+
}
|
|
305
|
+
return endEvent;
|
|
306
|
+
}
|
|
207
307
|
case "UserPromptSubmit":
|
|
208
308
|
return {
|
|
209
309
|
type: "user_prompt",
|
|
@@ -224,8 +324,6 @@ function mapHookToEngineEvent(body) {
|
|
|
224
324
|
}
|
|
225
325
|
export function createApp(config) {
|
|
226
326
|
const app = new Hono();
|
|
227
|
-
// CORS for local development
|
|
228
|
-
app.use("*", cors({ origin: "*" }));
|
|
229
327
|
// --- API Routes ---
|
|
230
328
|
// Health check
|
|
231
329
|
app.get("/api/health", (c) => {
|
|
@@ -243,6 +341,10 @@ export function createApp(config) {
|
|
|
243
341
|
checks.sandbox = config.sandbox.runtime;
|
|
244
342
|
return c.json({ healthy, checks }, healthy ? 200 : 503);
|
|
245
343
|
});
|
|
344
|
+
// Public config — exposes non-sensitive flags to the client
|
|
345
|
+
app.get("/api/config", (c) => {
|
|
346
|
+
return c.json({ devMode: config.devMode });
|
|
347
|
+
});
|
|
246
348
|
// Provision Electric Cloud resources via the Claim API
|
|
247
349
|
app.post("/api/provision-electric", async (c) => {
|
|
248
350
|
try {
|
|
@@ -267,7 +369,7 @@ export function createApp(config) {
|
|
|
267
369
|
// Hono's wildcard middleware matches creation routes like /api/sessions/local as
|
|
268
370
|
// :id="local", so we must explicitly skip those.
|
|
269
371
|
const authExemptIds = new Set(["local", "auto", "resume"]);
|
|
270
|
-
|
|
372
|
+
// Hook-event auth is handled in the endpoint handler via validateHookToken
|
|
271
373
|
/** Extract session token from Authorization header or query param. */
|
|
272
374
|
function extractToken(c) {
|
|
273
375
|
const authHeader = c.req.header("Authorization");
|
|
@@ -281,7 +383,8 @@ export function createApp(config) {
|
|
|
281
383
|
if (authExemptIds.has(id))
|
|
282
384
|
return next();
|
|
283
385
|
const subPath = c.req.path.replace(/^\/api\/sessions\/[^/]+/, "");
|
|
284
|
-
|
|
386
|
+
// Hook-event uses a purpose-scoped hook token (validated in the handler)
|
|
387
|
+
if (subPath === "/hook-event")
|
|
285
388
|
return next();
|
|
286
389
|
const token = extractToken(c);
|
|
287
390
|
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
@@ -342,8 +445,9 @@ export function createApp(config) {
|
|
|
342
445
|
// Pre-create a bridge so hook-event can emit to it immediately
|
|
343
446
|
getOrCreateBridge(config, sessionId);
|
|
344
447
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
448
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
345
449
|
console.log(`[local-session] Created session: ${sessionId}`);
|
|
346
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
450
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
347
451
|
});
|
|
348
452
|
// Auto-register a local session on first hook event (SessionStart).
|
|
349
453
|
// Eliminates the manual `curl POST /api/sessions/local` step.
|
|
@@ -389,14 +493,20 @@ export function createApp(config) {
|
|
|
389
493
|
await bridge.emit(hookEvent);
|
|
390
494
|
}
|
|
391
495
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
496
|
+
const hookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
392
497
|
console.log(`[auto-session] Created session: ${sessionId} (project: ${projectName})`);
|
|
393
|
-
return c.json({ sessionId, sessionToken }, 201);
|
|
498
|
+
return c.json({ sessionId, sessionToken, hookToken }, 201);
|
|
394
499
|
});
|
|
395
500
|
// Receive a hook event from Claude Code (via forward.sh) and write it
|
|
396
501
|
// to the session's durable stream as an EngineEvent.
|
|
397
502
|
// For AskUserQuestion, this blocks until the user answers in the web UI.
|
|
398
503
|
app.post("/api/sessions/:id/hook-event", async (c) => {
|
|
399
504
|
const sessionId = c.req.param("id");
|
|
505
|
+
// Validate hook token (scoped per-session, separate from session token)
|
|
506
|
+
const token = extractToken(c);
|
|
507
|
+
if (!token || !validateHookToken(config.streamConfig.secret, sessionId, token)) {
|
|
508
|
+
return c.json({ error: "Invalid or missing hook token" }, 401);
|
|
509
|
+
}
|
|
400
510
|
const body = (await c.req.json());
|
|
401
511
|
const bridge = getOrCreateBridge(config, sessionId);
|
|
402
512
|
// Map Claude Code hook JSON → EngineEvent
|
|
@@ -421,6 +531,7 @@ export function createApp(config) {
|
|
|
421
531
|
config.sessions.update(sessionId, {});
|
|
422
532
|
// SessionEnd: mark session complete and close the bridge
|
|
423
533
|
if (hookEvent.type === "session_end") {
|
|
534
|
+
accumulateSessionCost(config, sessionId, hookEvent);
|
|
424
535
|
if (!isClaudeCodeBridge) {
|
|
425
536
|
config.sessions.update(sessionId, { status: "complete" });
|
|
426
537
|
closeBridge(sessionId);
|
|
@@ -457,6 +568,16 @@ export function createApp(config) {
|
|
|
457
568
|
return c.json({ ok: true });
|
|
458
569
|
});
|
|
459
570
|
// --- Unified Hook Endpoint (transcript_path correlation) ---
|
|
571
|
+
// Protect the unified hook endpoint with a global hook secret derived from
|
|
572
|
+
// the DS secret. The hook setup script embeds this secret in the forwarder
|
|
573
|
+
// so that only local Claude Code instances can post events.
|
|
574
|
+
app.use("/api/hook", async (c, next) => {
|
|
575
|
+
const token = extractToken(c);
|
|
576
|
+
if (!token || !validateGlobalHookSecret(config.streamConfig.secret, token)) {
|
|
577
|
+
return c.json({ error: "Invalid or missing hook secret" }, 401);
|
|
578
|
+
}
|
|
579
|
+
return next();
|
|
580
|
+
});
|
|
460
581
|
// Single endpoint for all Claude Code hook events. Uses transcript_path
|
|
461
582
|
// from the hook JSON as the correlation key — stable across resume/compact,
|
|
462
583
|
// changes on /clear. Replaces the need for client-side session tracking.
|
|
@@ -553,6 +674,7 @@ export function createApp(config) {
|
|
|
553
674
|
config.sessions.update(sessionId, {});
|
|
554
675
|
// SessionEnd: mark complete and close bridge (keep mapping for potential re-open)
|
|
555
676
|
if (hookEvent.type === "session_end") {
|
|
677
|
+
accumulateSessionCost(config, sessionId, hookEvent);
|
|
556
678
|
config.sessions.update(sessionId, { status: "complete" });
|
|
557
679
|
closeBridge(sessionId);
|
|
558
680
|
return c.json({ ok: true, sessionId });
|
|
@@ -593,6 +715,7 @@ export function createApp(config) {
|
|
|
593
715
|
// Usage: cd <project> && curl -s http://localhost:4400/api/hooks/setup | bash
|
|
594
716
|
app.get("/api/hooks/setup", (c) => {
|
|
595
717
|
const port = config.port;
|
|
718
|
+
const hookSecret = deriveGlobalHookSecret(config.streamConfig.secret);
|
|
596
719
|
const script = `#!/bin/bash
|
|
597
720
|
# Electric Agent — Claude Code hook installer (project-scoped)
|
|
598
721
|
# Installs the hook forwarder into the current project's .claude/ directory.
|
|
@@ -613,10 +736,12 @@ cat > "\${FORWARD_SH}" << 'HOOKEOF'
|
|
|
613
736
|
# Installed by: curl -s http://localhost:EA_PORT/api/hooks/setup | bash
|
|
614
737
|
|
|
615
738
|
EA_PORT="\${EA_PORT:-EA_PORT_PLACEHOLDER}"
|
|
739
|
+
EA_HOOK_SECRET="\${EA_HOOK_SECRET:-EA_HOOK_SECRET_PLACEHOLDER}"
|
|
616
740
|
BODY="$(cat)"
|
|
617
741
|
|
|
618
742
|
RESPONSE=$(curl -s -X POST "http://localhost:\${EA_PORT}/api/hook" \\
|
|
619
743
|
-H "Content-Type: application/json" \\
|
|
744
|
+
-H "Authorization: Bearer \${EA_HOOK_SECRET}" \\
|
|
620
745
|
-d "\${BODY}" \\
|
|
621
746
|
--max-time 360 \\
|
|
622
747
|
--connect-timeout 2 \\
|
|
@@ -630,8 +755,9 @@ fi
|
|
|
630
755
|
exit 0
|
|
631
756
|
HOOKEOF
|
|
632
757
|
|
|
633
|
-
# Replace
|
|
758
|
+
# Replace placeholders with actual values
|
|
634
759
|
sed -i.bak "s/EA_PORT_PLACEHOLDER/${port}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
760
|
+
sed -i.bak "s/EA_HOOK_SECRET_PLACEHOLDER/${hookSecret}/" "\${FORWARD_SH}" && rm -f "\${FORWARD_SH}.bak"
|
|
635
761
|
chmod +x "\${FORWARD_SH}"
|
|
636
762
|
|
|
637
763
|
# Merge hook config into project-level settings.local.json
|
|
@@ -673,9 +799,19 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
673
799
|
});
|
|
674
800
|
// Start new project
|
|
675
801
|
app.post("/api/sessions", async (c) => {
|
|
676
|
-
const body =
|
|
677
|
-
if (
|
|
678
|
-
return
|
|
802
|
+
const body = await validateBody(c, createSessionSchema);
|
|
803
|
+
if (isResponse(body))
|
|
804
|
+
return body;
|
|
805
|
+
// Block freeform sessions in production mode
|
|
806
|
+
if (body.freeform && !config.devMode) {
|
|
807
|
+
return c.json({ error: "Freeform sessions are not available" }, 403);
|
|
808
|
+
}
|
|
809
|
+
// Rate-limit session creation in production mode
|
|
810
|
+
if (!config.devMode) {
|
|
811
|
+
const ip = extractClientIp(c);
|
|
812
|
+
if (!checkSessionRateLimit(ip)) {
|
|
813
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
814
|
+
}
|
|
679
815
|
}
|
|
680
816
|
const sessionId = crypto.randomUUID();
|
|
681
817
|
const inferredName = body.name ||
|
|
@@ -717,74 +853,84 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
717
853
|
config.sessions.add(session);
|
|
718
854
|
// Write user prompt to the stream so it shows in the UI
|
|
719
855
|
await bridge.emit({ type: "user_prompt", message: body.description, ts: ts() });
|
|
720
|
-
//
|
|
721
|
-
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
856
|
+
// Freeform sessions skip the infra config gate — no Electric/DB setup needed
|
|
722
857
|
let ghAccounts = [];
|
|
723
|
-
if (body.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
858
|
+
if (!body.freeform) {
|
|
859
|
+
// Gather GitHub accounts for the merged setup gate
|
|
860
|
+
// Only check if the client provided a token — never fall back to server-side GH_TOKEN
|
|
861
|
+
if (body.ghToken && isGhAuthenticated(body.ghToken)) {
|
|
862
|
+
try {
|
|
863
|
+
ghAccounts = ghListAccounts(body.ghToken);
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
// gh not available — no repo setup
|
|
867
|
+
}
|
|
729
868
|
}
|
|
869
|
+
// Emit combined infra + repo setup gate
|
|
870
|
+
await bridge.emit({
|
|
871
|
+
type: "infra_config_prompt",
|
|
872
|
+
projectName,
|
|
873
|
+
ghAccounts,
|
|
874
|
+
runtime: config.sandbox.runtime,
|
|
875
|
+
ts: ts(),
|
|
876
|
+
});
|
|
730
877
|
}
|
|
731
|
-
// Emit combined infra + repo setup gate
|
|
732
|
-
await bridge.emit({
|
|
733
|
-
type: "infra_config_prompt",
|
|
734
|
-
projectName,
|
|
735
|
-
ghAccounts,
|
|
736
|
-
runtime: config.sandbox.runtime,
|
|
737
|
-
ts: ts(),
|
|
738
|
-
});
|
|
739
878
|
// Launch async flow: wait for setup gate → create sandbox → start agent
|
|
740
879
|
const asyncFlow = async () => {
|
|
741
|
-
// 1. Wait for combined infra + repo config
|
|
880
|
+
// 1. Wait for combined infra + repo config (skip for freeform)
|
|
742
881
|
let infra;
|
|
743
882
|
let repoConfig = null;
|
|
744
|
-
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
745
883
|
let claimId;
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
884
|
+
if (body.freeform) {
|
|
885
|
+
// Freeform sessions don't need Electric infrastructure
|
|
886
|
+
infra = { mode: "none" };
|
|
887
|
+
console.log(`[session:${sessionId}] Freeform session — skipping infra gate`);
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
console.log(`[session:${sessionId}] Waiting for infra_config gate...`);
|
|
891
|
+
try {
|
|
892
|
+
const gateValue = await createGate(sessionId, "infra_config");
|
|
893
|
+
console.log(`[session:${sessionId}] Infra gate resolved: mode=${gateValue.mode}`);
|
|
894
|
+
if (gateValue.mode === "cloud" || gateValue.mode === "claim") {
|
|
895
|
+
// Normalize claim → cloud for the sandbox layer (same env vars)
|
|
896
|
+
infra = {
|
|
897
|
+
mode: "cloud",
|
|
898
|
+
databaseUrl: gateValue.databaseUrl,
|
|
899
|
+
electricUrl: gateValue.electricUrl,
|
|
900
|
+
sourceId: gateValue.sourceId,
|
|
901
|
+
secret: gateValue.secret,
|
|
902
|
+
};
|
|
903
|
+
if (gateValue.mode === "claim") {
|
|
904
|
+
claimId = gateValue.claimId;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
infra = { mode: "local" };
|
|
909
|
+
}
|
|
910
|
+
// Extract repo config if provided
|
|
911
|
+
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
912
|
+
repoConfig = {
|
|
913
|
+
account: gateValue.repoAccount,
|
|
914
|
+
repoName: gateValue.repoName,
|
|
915
|
+
visibility: gateValue.repoVisibility ?? "private",
|
|
916
|
+
};
|
|
917
|
+
config.sessions.update(sessionId, {
|
|
918
|
+
git: {
|
|
919
|
+
branch: "main",
|
|
920
|
+
remoteUrl: null,
|
|
921
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
922
|
+
repoVisibility: repoConfig.visibility,
|
|
923
|
+
lastCommitHash: null,
|
|
924
|
+
lastCommitMessage: null,
|
|
925
|
+
lastCheckpointAt: null,
|
|
926
|
+
},
|
|
927
|
+
});
|
|
760
928
|
}
|
|
761
929
|
}
|
|
762
|
-
|
|
930
|
+
catch (err) {
|
|
931
|
+
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
763
932
|
infra = { mode: "local" };
|
|
764
933
|
}
|
|
765
|
-
// Extract repo config if provided
|
|
766
|
-
if (gateValue.repoAccount && gateValue.repoName?.trim()) {
|
|
767
|
-
repoConfig = {
|
|
768
|
-
account: gateValue.repoAccount,
|
|
769
|
-
repoName: gateValue.repoName,
|
|
770
|
-
visibility: gateValue.repoVisibility ?? "private",
|
|
771
|
-
};
|
|
772
|
-
config.sessions.update(sessionId, {
|
|
773
|
-
git: {
|
|
774
|
-
branch: "main",
|
|
775
|
-
remoteUrl: null,
|
|
776
|
-
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
777
|
-
repoVisibility: repoConfig.visibility,
|
|
778
|
-
lastCommitHash: null,
|
|
779
|
-
lastCommitMessage: null,
|
|
780
|
-
lastCheckpointAt: null,
|
|
781
|
-
},
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
catch (err) {
|
|
786
|
-
console.log(`[session:${sessionId}] Infra gate error (defaulting to local):`, err);
|
|
787
|
-
infra = { mode: "local" };
|
|
788
934
|
}
|
|
789
935
|
// 2. Create sandbox — emit progress events so the UI shows feedback
|
|
790
936
|
await bridge.emit({
|
|
@@ -817,76 +963,119 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
817
963
|
// 3. Write CLAUDE.md and create a ClaudeCode bridge.
|
|
818
964
|
{
|
|
819
965
|
console.log(`[session:${sessionId}] Setting up Claude Code bridge...`);
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
type: "log",
|
|
823
|
-
level: "build",
|
|
824
|
-
message: "Setting up project...",
|
|
825
|
-
ts: ts(),
|
|
826
|
-
});
|
|
827
|
-
try {
|
|
828
|
-
if (config.sandbox.runtime === "docker") {
|
|
829
|
-
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
830
|
-
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
831
|
-
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName}"/' package.json`);
|
|
832
|
-
}
|
|
833
|
-
else {
|
|
834
|
-
// Sprites/Daytona: run scaffold from globally installed electric-agent
|
|
835
|
-
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
836
|
-
}
|
|
837
|
-
console.log(`[session:${sessionId}] Project setup complete`);
|
|
966
|
+
if (!body.freeform) {
|
|
967
|
+
// Copy pre-scaffolded project from the image and customize per-session
|
|
838
968
|
await bridge.emit({
|
|
839
969
|
type: "log",
|
|
840
|
-
level: "
|
|
841
|
-
message: "
|
|
970
|
+
level: "build",
|
|
971
|
+
message: "Setting up project...",
|
|
842
972
|
ts: ts(),
|
|
843
973
|
});
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
974
|
+
try {
|
|
975
|
+
if (config.sandbox.runtime === "docker") {
|
|
976
|
+
// Docker: copy the pre-built scaffold base (baked into the image)
|
|
977
|
+
await config.sandbox.exec(handle, `cp -r /opt/scaffold-base '${handle.projectDir}'`);
|
|
978
|
+
await config.sandbox.exec(handle, `cd '${handle.projectDir}' && sed -i 's/"name": "scaffold-base"/"name": "${projectName.replace(/[^a-z0-9_-]/gi, "-")}"/' package.json`);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
// Sprites: run scaffold from globally installed electric-agent
|
|
982
|
+
await config.sandbox.exec(handle, `source /etc/profile.d/npm-global.sh 2>/dev/null; electric-agent scaffold '${handle.projectDir}' --name '${projectName}' --skip-git`);
|
|
983
|
+
}
|
|
984
|
+
console.log(`[session:${sessionId}] Project setup complete`);
|
|
985
|
+
await bridge.emit({
|
|
986
|
+
type: "log",
|
|
987
|
+
level: "done",
|
|
988
|
+
message: "Project ready",
|
|
989
|
+
ts: ts(),
|
|
990
|
+
});
|
|
991
|
+
// Log the agent package version installed in the sandbox
|
|
992
|
+
try {
|
|
993
|
+
const agentVersion = (await config.sandbox.exec(handle, "electric-agent --version 2>/dev/null | tail -1")).trim();
|
|
994
|
+
await bridge.emit({
|
|
995
|
+
type: "log",
|
|
996
|
+
level: "verbose",
|
|
997
|
+
message: `electric-agent@${agentVersion}`,
|
|
998
|
+
ts: ts(),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
// Non-critical — don't block session creation
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
console.error(`[session:${sessionId}] Project setup failed:`, err);
|
|
1007
|
+
await bridge.emit({
|
|
1008
|
+
type: "log",
|
|
1009
|
+
level: "error",
|
|
1010
|
+
message: `Project setup failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
1011
|
+
ts: ts(),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
// Write CLAUDE.md to the sandbox workspace.
|
|
1015
|
+
// Our generator includes hardcoded playbook paths and reading order
|
|
1016
|
+
// so we don't depend on @tanstack/intent generating a skill block.
|
|
1017
|
+
const claudeMd = generateClaudeMd({
|
|
1018
|
+
description: body.description,
|
|
1019
|
+
projectName,
|
|
1020
|
+
projectDir: handle.projectDir,
|
|
1021
|
+
runtime: config.sandbox.runtime,
|
|
1022
|
+
production: !config.devMode,
|
|
1023
|
+
...(repoConfig
|
|
1024
|
+
? {
|
|
1025
|
+
git: {
|
|
1026
|
+
mode: "create",
|
|
1027
|
+
repoName: `${repoConfig.account}/${repoConfig.repoName}`,
|
|
1028
|
+
visibility: repoConfig.visibility,
|
|
1029
|
+
},
|
|
1030
|
+
}
|
|
1031
|
+
: {}),
|
|
852
1032
|
});
|
|
1033
|
+
try {
|
|
1034
|
+
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
1038
|
+
}
|
|
1039
|
+
// Ensure the create-app skill is present in the project.
|
|
1040
|
+
// The npm-installed electric-agent may be an older version that
|
|
1041
|
+
// doesn't include .claude/skills/ in its template directory.
|
|
1042
|
+
if (createAppSkillContent) {
|
|
1043
|
+
try {
|
|
1044
|
+
const skillDir = `${handle.projectDir}/.claude/skills/create-app`;
|
|
1045
|
+
const skillB64 = Buffer.from(createAppSkillContent).toString("base64");
|
|
1046
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
853
1052
|
}
|
|
854
|
-
//
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
projectName,
|
|
858
|
-
projectDir: handle.projectDir,
|
|
859
|
-
runtime: config.sandbox.runtime,
|
|
860
|
-
});
|
|
861
|
-
try {
|
|
862
|
-
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
863
|
-
}
|
|
864
|
-
catch (err) {
|
|
865
|
-
console.error(`[session:${sessionId}] Failed to write CLAUDE.md:`, err);
|
|
866
|
-
}
|
|
867
|
-
// Ensure the create-app skill is present in the project.
|
|
868
|
-
// The npm-installed electric-agent may be an older version that
|
|
869
|
-
// doesn't include .claude/skills/ in its template directory.
|
|
870
|
-
if (createAppSkillContent) {
|
|
1053
|
+
// Ensure the room-messaging skill is present so agents have
|
|
1054
|
+
// persistent access to the multi-agent protocol reference.
|
|
1055
|
+
if (roomMessagingSkillContent) {
|
|
871
1056
|
try {
|
|
872
|
-
const skillDir = `${handle.projectDir}/.claude/skills/
|
|
873
|
-
const skillB64 = Buffer.from(
|
|
1057
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1058
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
874
1059
|
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
875
1060
|
}
|
|
876
1061
|
catch (err) {
|
|
877
|
-
console.error(`[session:${sessionId}] Failed to write
|
|
1062
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
878
1063
|
}
|
|
879
1064
|
}
|
|
1065
|
+
const sessionPrompt = body.freeform ? body.description : `/create-app ${body.description}`;
|
|
1066
|
+
const sessionHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
880
1067
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
881
1068
|
? {
|
|
882
|
-
prompt:
|
|
1069
|
+
prompt: sessionPrompt,
|
|
883
1070
|
cwd: handle.projectDir,
|
|
884
1071
|
studioUrl: resolveStudioUrl(config.port),
|
|
1072
|
+
hookToken: sessionHookToken,
|
|
885
1073
|
}
|
|
886
1074
|
: {
|
|
887
|
-
prompt:
|
|
1075
|
+
prompt: sessionPrompt,
|
|
888
1076
|
cwd: handle.projectDir,
|
|
889
1077
|
studioPort: config.port,
|
|
1078
|
+
hookToken: sessionHookToken,
|
|
890
1079
|
};
|
|
891
1080
|
bridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
892
1081
|
}
|
|
@@ -900,7 +1089,7 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
900
1089
|
});
|
|
901
1090
|
}
|
|
902
1091
|
// 5. Start listening for agent events via the bridge
|
|
903
|
-
// Track Claude Code session ID
|
|
1092
|
+
// Track Claude Code session ID and cost from agent events
|
|
904
1093
|
bridge.onAgentEvent((event) => {
|
|
905
1094
|
if (event.type === "session_start") {
|
|
906
1095
|
const ccSessionId = event.session_id;
|
|
@@ -909,6 +1098,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
909
1098
|
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
910
1099
|
}
|
|
911
1100
|
}
|
|
1101
|
+
if (event.type === "session_end") {
|
|
1102
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1103
|
+
}
|
|
912
1104
|
});
|
|
913
1105
|
bridge.onComplete(async (success) => {
|
|
914
1106
|
const updates = {
|
|
@@ -957,29 +1149,37 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
957
1149
|
await bridge.emit({
|
|
958
1150
|
type: "log",
|
|
959
1151
|
level: "build",
|
|
960
|
-
message:
|
|
1152
|
+
message: body.freeform
|
|
1153
|
+
? `Running: claude "${body.description}"`
|
|
1154
|
+
: `Running: claude "/create-app ${body.description}"`,
|
|
961
1155
|
ts: ts(),
|
|
962
1156
|
});
|
|
963
1157
|
console.log(`[session:${sessionId}] Starting bridge listener...`);
|
|
964
1158
|
await bridge.start();
|
|
965
1159
|
console.log(`[session:${sessionId}] Bridge started, sending 'new' command...`);
|
|
966
1160
|
// 5. Send the new command via the bridge
|
|
967
|
-
|
|
1161
|
+
await bridge.sendCommand({
|
|
968
1162
|
command: "new",
|
|
969
1163
|
description: body.description,
|
|
970
1164
|
projectName,
|
|
971
1165
|
baseDir: "/home/agent/workspace",
|
|
972
|
-
};
|
|
973
|
-
if (repoConfig) {
|
|
974
|
-
newCmd.gitRepoName = `${repoConfig.account}/${repoConfig.repoName}`;
|
|
975
|
-
newCmd.gitRepoVisibility = repoConfig.visibility;
|
|
976
|
-
}
|
|
977
|
-
await bridge.sendCommand(newCmd);
|
|
1166
|
+
});
|
|
978
1167
|
console.log(`[session:${sessionId}] Command sent, waiting for agent...`);
|
|
979
1168
|
};
|
|
980
1169
|
asyncFlow().catch(async (err) => {
|
|
981
1170
|
console.error(`[session:${sessionId}] Session creation flow failed:`, err);
|
|
982
1171
|
config.sessions.update(sessionId, { status: "error" });
|
|
1172
|
+
try {
|
|
1173
|
+
await bridge.emit({
|
|
1174
|
+
type: "log",
|
|
1175
|
+
level: "error",
|
|
1176
|
+
message: `Session failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1177
|
+
ts: ts(),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
catch {
|
|
1181
|
+
// Bridge may not be usable if the failure happened early
|
|
1182
|
+
}
|
|
983
1183
|
});
|
|
984
1184
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
985
1185
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -990,10 +1190,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
990
1190
|
const session = config.sessions.get(sessionId);
|
|
991
1191
|
if (!session)
|
|
992
1192
|
return c.json({ error: "Session not found" }, 404);
|
|
993
|
-
const body =
|
|
994
|
-
if (
|
|
995
|
-
return
|
|
996
|
-
}
|
|
1193
|
+
const body = await validateBody(c, iterateSessionSchema);
|
|
1194
|
+
if (isResponse(body))
|
|
1195
|
+
return body;
|
|
997
1196
|
// Intercept operational commands (start/stop/restart the app/server)
|
|
998
1197
|
const normalised = body.request
|
|
999
1198
|
.toLowerCase()
|
|
@@ -1122,6 +1321,22 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1122
1321
|
}
|
|
1123
1322
|
// No pending gate — fall through to bridge.sendGateResponse()
|
|
1124
1323
|
}
|
|
1324
|
+
// Outbound message gates (room agent → room stream): resolved in-process
|
|
1325
|
+
if (gate === "outbound_message_gate") {
|
|
1326
|
+
const gateId = body.gateId;
|
|
1327
|
+
const action = body.action;
|
|
1328
|
+
if (!gateId || !action) {
|
|
1329
|
+
return c.json({ error: "gateId and action are required for outbound_message_gate" }, 400);
|
|
1330
|
+
}
|
|
1331
|
+
const resolved = resolveGate(sessionId, gateId, {
|
|
1332
|
+
action,
|
|
1333
|
+
editedBody: body.editedBody,
|
|
1334
|
+
});
|
|
1335
|
+
if (resolved) {
|
|
1336
|
+
return c.json({ ok: true });
|
|
1337
|
+
}
|
|
1338
|
+
return c.json({ error: "No pending gate found" }, 404);
|
|
1339
|
+
}
|
|
1125
1340
|
// Server-side gates are resolved in-process (they run on the server, not inside the container)
|
|
1126
1341
|
const serverGates = new Set(["infra_config"]);
|
|
1127
1342
|
// Forward agent gate responses via the bridge
|
|
@@ -1347,7 +1562,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1347
1562
|
});
|
|
1348
1563
|
// Create a standalone sandbox (not tied to session creation flow)
|
|
1349
1564
|
app.post("/api/sandboxes", async (c) => {
|
|
1350
|
-
const body =
|
|
1565
|
+
const body = await validateBody(c, createSandboxSchema);
|
|
1566
|
+
if (isResponse(body))
|
|
1567
|
+
return body;
|
|
1351
1568
|
const sessionId = body.sessionId ?? crypto.randomUUID();
|
|
1352
1569
|
try {
|
|
1353
1570
|
const handle = await config.sandbox.create(sessionId, {
|
|
@@ -1377,30 +1594,40 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1377
1594
|
await config.sandbox.destroy(handle);
|
|
1378
1595
|
return c.json({ ok: true });
|
|
1379
1596
|
});
|
|
1380
|
-
// ---
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1597
|
+
// --- Room Routes (agent-to-agent messaging) ---
|
|
1598
|
+
// Extract room token from X-Room-Token header or ?token= query param.
|
|
1599
|
+
// This is separate from extractToken() (which reads Authorization) so that
|
|
1600
|
+
// Authorization remains available for session tokens on endpoints that need both.
|
|
1601
|
+
function extractRoomToken(c) {
|
|
1602
|
+
return c.req.header("X-Room-Token") ?? c.req.query("token") ?? undefined;
|
|
1603
|
+
}
|
|
1604
|
+
// Protect room-scoped routes via X-Room-Token header
|
|
1605
|
+
app.use("/api/rooms/:id/*", async (c, next) => {
|
|
1385
1606
|
const id = c.req.param("id");
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const token = extractToken(c);
|
|
1389
|
-
if (!token || !validateSessionToken(config.streamConfig.secret, id, token)) {
|
|
1607
|
+
const token = extractRoomToken(c);
|
|
1608
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1390
1609
|
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1391
1610
|
}
|
|
1392
1611
|
return next();
|
|
1393
1612
|
});
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1613
|
+
app.use("/api/rooms/:id", async (c, next) => {
|
|
1614
|
+
if (c.req.method !== "GET" && c.req.method !== "DELETE")
|
|
1615
|
+
return next();
|
|
1616
|
+
const id = c.req.param("id");
|
|
1617
|
+
const token = extractRoomToken(c);
|
|
1618
|
+
if (!token || !validateRoomToken(config.streamConfig.secret, id, token)) {
|
|
1619
|
+
return c.json({ error: "Invalid or missing room token" }, 401);
|
|
1399
1620
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1621
|
+
return next();
|
|
1622
|
+
});
|
|
1623
|
+
// Create a room
|
|
1624
|
+
app.post("/api/rooms", async (c) => {
|
|
1625
|
+
const body = await validateBody(c, createRoomSchema);
|
|
1626
|
+
if (isResponse(body))
|
|
1627
|
+
return body;
|
|
1628
|
+
const roomId = crypto.randomUUID();
|
|
1629
|
+
// Create the room's durable stream
|
|
1630
|
+
const conn = roomStream(config, roomId);
|
|
1404
1631
|
try {
|
|
1405
1632
|
await DurableStream.create({
|
|
1406
1633
|
url: conn.url,
|
|
@@ -1409,148 +1636,341 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1409
1636
|
});
|
|
1410
1637
|
}
|
|
1411
1638
|
catch (err) {
|
|
1412
|
-
console.error(`[
|
|
1413
|
-
return c.json({ error: "Failed to create
|
|
1639
|
+
console.error(`[room] Failed to create durable stream:`, err);
|
|
1640
|
+
return c.json({ error: "Failed to create room stream" }, 500);
|
|
1414
1641
|
}
|
|
1415
|
-
//
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
headers: conn.headers,
|
|
1419
|
-
contentType: "application/json",
|
|
1642
|
+
// Create and start the router
|
|
1643
|
+
const router = new RoomRouter(roomId, body.name, config.streamConfig, {
|
|
1644
|
+
maxRounds: body.maxRounds,
|
|
1420
1645
|
});
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
createdBy: body.participant,
|
|
1426
|
-
ts: ts(),
|
|
1427
|
-
};
|
|
1428
|
-
await stream.append(JSON.stringify(createdEvent));
|
|
1429
|
-
// Write participant_joined for the creator
|
|
1430
|
-
const joinedEvent = {
|
|
1431
|
-
type: "participant_joined",
|
|
1432
|
-
participant: body.participant,
|
|
1433
|
-
ts: ts(),
|
|
1434
|
-
};
|
|
1435
|
-
await stream.append(JSON.stringify(joinedEvent));
|
|
1436
|
-
// Save to room registry
|
|
1646
|
+
await router.start();
|
|
1647
|
+
roomRouters.set(roomId, router);
|
|
1648
|
+
// Save to room registry for persistence
|
|
1649
|
+
const code = generateInviteCode();
|
|
1437
1650
|
await config.rooms.addRoom({
|
|
1438
|
-
id,
|
|
1651
|
+
id: roomId,
|
|
1439
1652
|
code,
|
|
1440
1653
|
name: body.name,
|
|
1441
1654
|
createdAt: new Date().toISOString(),
|
|
1442
1655
|
revoked: false,
|
|
1443
1656
|
});
|
|
1444
|
-
const roomToken =
|
|
1445
|
-
console.log(`[
|
|
1446
|
-
return c.json({
|
|
1657
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, roomId);
|
|
1658
|
+
console.log(`[room] Created: id=${roomId} name=${body.name} code=${code}`);
|
|
1659
|
+
return c.json({ roomId, code, roomToken }, 201);
|
|
1447
1660
|
});
|
|
1448
|
-
//
|
|
1449
|
-
app.get("/api/
|
|
1661
|
+
// Join an agent room by id + invite code
|
|
1662
|
+
app.get("/api/rooms/join/:id/:code", (c) => {
|
|
1663
|
+
const id = c.req.param("id");
|
|
1450
1664
|
const code = c.req.param("code");
|
|
1451
|
-
const
|
|
1452
|
-
if (!
|
|
1453
|
-
return c.json({ error: "
|
|
1454
|
-
|
|
1455
|
-
|
|
1665
|
+
const room = config.rooms.getRoom(id);
|
|
1666
|
+
if (!room || room.code !== code)
|
|
1667
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1668
|
+
if (room.revoked)
|
|
1669
|
+
return c.json({ error: "Room has been revoked" }, 410);
|
|
1670
|
+
const roomToken = deriveRoomToken(config.streamConfig.secret, room.id);
|
|
1671
|
+
return c.json({ id: room.id, code: room.code, name: room.name, roomToken });
|
|
1456
1672
|
});
|
|
1457
|
-
//
|
|
1458
|
-
app.
|
|
1459
|
-
const
|
|
1460
|
-
const
|
|
1461
|
-
if (!
|
|
1462
|
-
return c.json({ error: "
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
contentType: "application/json",
|
|
1673
|
+
// Get room state
|
|
1674
|
+
app.get("/api/rooms/:id", (c) => {
|
|
1675
|
+
const roomId = c.req.param("id");
|
|
1676
|
+
const router = roomRouters.get(roomId);
|
|
1677
|
+
if (!router)
|
|
1678
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1679
|
+
return c.json({
|
|
1680
|
+
roomId,
|
|
1681
|
+
state: router.state,
|
|
1682
|
+
roundCount: router.roundCount,
|
|
1683
|
+
participants: router.participants.map((p) => ({
|
|
1684
|
+
sessionId: p.sessionId,
|
|
1685
|
+
name: p.name,
|
|
1686
|
+
role: p.role,
|
|
1687
|
+
running: p.bridge.isRunning(),
|
|
1688
|
+
})),
|
|
1474
1689
|
});
|
|
1475
|
-
const event = {
|
|
1476
|
-
type: "participant_joined",
|
|
1477
|
-
participant: body.participant,
|
|
1478
|
-
ts: ts(),
|
|
1479
|
-
};
|
|
1480
|
-
await stream.append(JSON.stringify(event));
|
|
1481
|
-
return c.json({ ok: true });
|
|
1482
1690
|
});
|
|
1483
|
-
//
|
|
1484
|
-
app.post("/api/
|
|
1485
|
-
const
|
|
1486
|
-
const
|
|
1487
|
-
if (!
|
|
1488
|
-
return c.json({ error: "
|
|
1691
|
+
// Add an agent to a room
|
|
1692
|
+
app.post("/api/rooms/:id/agents", async (c) => {
|
|
1693
|
+
const roomId = c.req.param("id");
|
|
1694
|
+
const router = roomRouters.get(roomId);
|
|
1695
|
+
if (!router)
|
|
1696
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1697
|
+
const body = await validateBody(c, addAgentSchema);
|
|
1698
|
+
if (isResponse(body))
|
|
1699
|
+
return body;
|
|
1700
|
+
const sessionId = crypto.randomUUID();
|
|
1701
|
+
const randomSuffix = sessionId.slice(0, 6);
|
|
1702
|
+
const agentName = body.name?.trim() || `agent-${randomSuffix}`;
|
|
1703
|
+
const projectName = `room-${agentName}-${sessionId.slice(0, 8)}`;
|
|
1704
|
+
console.log(`[room:${roomId}] Adding agent: name=${agentName} session=${sessionId}`);
|
|
1705
|
+
// Create the session's durable stream
|
|
1706
|
+
const conn = sessionStream(config, sessionId);
|
|
1707
|
+
try {
|
|
1708
|
+
await DurableStream.create({
|
|
1709
|
+
url: conn.url,
|
|
1710
|
+
headers: conn.headers,
|
|
1711
|
+
contentType: "application/json",
|
|
1712
|
+
});
|
|
1489
1713
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
console.error(`[room:${roomId}] Failed to create session stream:`, err);
|
|
1716
|
+
return c.json({ error: "Failed to create session stream" }, 500);
|
|
1717
|
+
}
|
|
1718
|
+
// Create bridge
|
|
1719
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1720
|
+
// Record session
|
|
1721
|
+
const sandboxProjectDir = `/home/agent/workspace/${projectName}`;
|
|
1722
|
+
const session = {
|
|
1723
|
+
id: sessionId,
|
|
1724
|
+
projectName,
|
|
1725
|
+
sandboxProjectDir,
|
|
1726
|
+
description: `Room agent: ${agentName} (${body.role ?? "participant"})`,
|
|
1727
|
+
createdAt: new Date().toISOString(),
|
|
1728
|
+
lastActiveAt: new Date().toISOString(),
|
|
1729
|
+
status: "running",
|
|
1500
1730
|
};
|
|
1501
|
-
|
|
1502
|
-
|
|
1731
|
+
config.sessions.add(session);
|
|
1732
|
+
// Return early so the client can store the session token and show the
|
|
1733
|
+
// session in the sidebar immediately. The sandbox setup continues in
|
|
1734
|
+
// the background — events stream to the session's durable stream so
|
|
1735
|
+
// the UI stays up to date.
|
|
1736
|
+
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1737
|
+
(async () => {
|
|
1738
|
+
await bridge.emit({
|
|
1739
|
+
type: "log",
|
|
1740
|
+
level: "build",
|
|
1741
|
+
message: `Creating sandbox for room agent "${agentName}"...`,
|
|
1742
|
+
ts: ts(),
|
|
1743
|
+
});
|
|
1744
|
+
try {
|
|
1745
|
+
const handle = await config.sandbox.create(sessionId, {
|
|
1746
|
+
projectName,
|
|
1747
|
+
infra: { mode: "local" },
|
|
1748
|
+
apiKey: body.apiKey,
|
|
1749
|
+
oauthToken: body.oauthToken,
|
|
1750
|
+
ghToken: body.ghToken,
|
|
1751
|
+
});
|
|
1752
|
+
config.sessions.update(sessionId, {
|
|
1753
|
+
appPort: handle.port,
|
|
1754
|
+
sandboxProjectDir: handle.projectDir,
|
|
1755
|
+
previewUrl: handle.previewUrl,
|
|
1756
|
+
});
|
|
1757
|
+
// Inject room-messaging skill so agents know the @room protocol
|
|
1758
|
+
if (roomMessagingSkillContent) {
|
|
1759
|
+
try {
|
|
1760
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1761
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1762
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1763
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1764
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1765
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1766
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1767
|
+
}
|
|
1768
|
+
catch (err) {
|
|
1769
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
// Resolve role skill (behavioral guidelines + tool permissions)
|
|
1773
|
+
const roleSkill = resolveRoleSkill(body.role);
|
|
1774
|
+
// Inject role skill file into sandbox
|
|
1775
|
+
if (roleSkill) {
|
|
1776
|
+
try {
|
|
1777
|
+
const skillDir = `${handle.projectDir}/.claude/skills/role`;
|
|
1778
|
+
const skillB64 = Buffer.from(roleSkill.skillContent).toString("base64");
|
|
1779
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1780
|
+
}
|
|
1781
|
+
catch (err) {
|
|
1782
|
+
console.error(`[session:${sessionId}] Failed to write role skill:`, err);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
// Build prompt — reference the role skill if available
|
|
1786
|
+
const rolePromptSuffix = roleSkill
|
|
1787
|
+
? `\nRead .claude/skills/role/SKILL.md for your role guidelines before proceeding.`
|
|
1788
|
+
: "";
|
|
1789
|
+
const agentPrompt = `You are "${agentName}"${body.role ? `, role: ${body.role}` : ""}. You are joining a multi-agent room.${rolePromptSuffix}`;
|
|
1790
|
+
// Create Claude Code bridge (with role-specific tool permissions)
|
|
1791
|
+
const agentHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1792
|
+
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1793
|
+
? {
|
|
1794
|
+
prompt: agentPrompt,
|
|
1795
|
+
cwd: handle.projectDir,
|
|
1796
|
+
studioUrl: resolveStudioUrl(config.port),
|
|
1797
|
+
hookToken: agentHookToken,
|
|
1798
|
+
agentName: agentName,
|
|
1799
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1800
|
+
}
|
|
1801
|
+
: {
|
|
1802
|
+
prompt: agentPrompt,
|
|
1803
|
+
cwd: handle.projectDir,
|
|
1804
|
+
studioPort: config.port,
|
|
1805
|
+
hookToken: agentHookToken,
|
|
1806
|
+
agentName: agentName,
|
|
1807
|
+
...(roleSkill?.allowedTools && { allowedTools: roleSkill.allowedTools }),
|
|
1808
|
+
};
|
|
1809
|
+
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1810
|
+
// Track Claude Code session ID and cost
|
|
1811
|
+
ccBridge.onAgentEvent((event) => {
|
|
1812
|
+
if (event.type === "session_start") {
|
|
1813
|
+
const ccSessionId = event.session_id;
|
|
1814
|
+
if (ccSessionId) {
|
|
1815
|
+
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (event.type === "session_end") {
|
|
1819
|
+
accumulateSessionCost(config, sessionId, event);
|
|
1820
|
+
}
|
|
1821
|
+
// Route assistant_message output to the room router
|
|
1822
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1823
|
+
router
|
|
1824
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1825
|
+
.catch((err) => {
|
|
1826
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
await bridge.emit({
|
|
1831
|
+
type: "log",
|
|
1832
|
+
level: "done",
|
|
1833
|
+
message: `Sandbox ready for "${agentName}"`,
|
|
1834
|
+
ts: ts(),
|
|
1835
|
+
});
|
|
1836
|
+
await ccBridge.start();
|
|
1837
|
+
// Add participant to room router
|
|
1838
|
+
const participant = {
|
|
1839
|
+
sessionId,
|
|
1840
|
+
name: agentName,
|
|
1841
|
+
role: body.role,
|
|
1842
|
+
bridge: ccBridge,
|
|
1843
|
+
};
|
|
1844
|
+
await router.addParticipant(participant, body.gated ?? false);
|
|
1845
|
+
// If there's an initial prompt, send it directly to this agent only (not broadcast)
|
|
1846
|
+
if (body.initialPrompt) {
|
|
1847
|
+
await ccBridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
const msg = err instanceof Error ? err.message : "Failed to create agent sandbox";
|
|
1852
|
+
console.error(`[room:${roomId}] Agent creation failed:`, err);
|
|
1853
|
+
await bridge.emit({ type: "log", level: "error", message: msg, ts: ts() });
|
|
1854
|
+
}
|
|
1855
|
+
})();
|
|
1856
|
+
return c.json({ sessionId, participantName: agentName, sessionToken }, 201);
|
|
1503
1857
|
});
|
|
1504
|
-
//
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1858
|
+
// Add an existing running session to a room
|
|
1859
|
+
app.post("/api/rooms/:id/sessions", async (c) => {
|
|
1860
|
+
const roomId = c.req.param("id");
|
|
1861
|
+
const router = roomRouters.get(roomId);
|
|
1862
|
+
if (!router)
|
|
1863
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1864
|
+
const body = await validateBody(c, addSessionToRoomSchema);
|
|
1865
|
+
if (isResponse(body))
|
|
1866
|
+
return body;
|
|
1867
|
+
const { sessionId } = body;
|
|
1868
|
+
// Require a valid session token — caller must already own this session.
|
|
1869
|
+
// Room auth is handled by middleware via X-Room-Token; Authorization
|
|
1870
|
+
// carries the session ownership proof here.
|
|
1871
|
+
const authHeader = c.req.header("Authorization");
|
|
1872
|
+
const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
|
|
1873
|
+
if (!sessionToken ||
|
|
1874
|
+
!validateSessionToken(config.streamConfig.secret, sessionId, sessionToken)) {
|
|
1875
|
+
return c.json({ error: "Invalid or missing session token" }, 401);
|
|
1511
1876
|
}
|
|
1512
|
-
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1877
|
+
// Verify the session exists
|
|
1878
|
+
const sessionInfo = config.sessions.get(sessionId);
|
|
1879
|
+
if (!sessionInfo) {
|
|
1880
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1881
|
+
}
|
|
1882
|
+
// Get the sandbox handle — must be running
|
|
1883
|
+
const handle = config.sandbox.get(sessionId);
|
|
1884
|
+
if (!handle) {
|
|
1885
|
+
return c.json({ error: "Session sandbox not found or not running" }, 400);
|
|
1886
|
+
}
|
|
1887
|
+
// Get or create bridge (it should already exist for a running session)
|
|
1888
|
+
const bridge = getOrCreateBridge(config, sessionId);
|
|
1889
|
+
console.log(`[room:${roomId}] Adding existing session: name=${body.name} session=${sessionId}`);
|
|
1890
|
+
try {
|
|
1891
|
+
// Inject room-messaging skill
|
|
1892
|
+
if (roomMessagingSkillContent) {
|
|
1893
|
+
try {
|
|
1894
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
1895
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
1896
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
1897
|
+
// Append room-messaging reference to CLAUDE.md so the agent knows to read it
|
|
1898
|
+
const roomRef = `\n\n## Room Messaging (CRITICAL)\nYou are a participant in a multi-agent room. Read .claude/skills/room-messaging/SKILL.md for the messaging protocol.\nAll communication with other agents MUST use @room or @<name> messages as described in that skill.\n`;
|
|
1899
|
+
const refB64 = Buffer.from(roomRef).toString("base64");
|
|
1900
|
+
await config.sandbox.exec(handle, `echo '${refB64}' | base64 -d >> '${handle.projectDir}/CLAUDE.md'`);
|
|
1901
|
+
}
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
// The existing bridge is already a Claude Code bridge — wire up room output handling
|
|
1907
|
+
bridge.onAgentEvent((event) => {
|
|
1908
|
+
if (event.type === "assistant_message" && "text" in event) {
|
|
1909
|
+
router
|
|
1910
|
+
.handleAgentOutput(sessionId, event.text)
|
|
1911
|
+
.catch((err) => {
|
|
1912
|
+
console.error(`[room:${roomId}] handleAgentOutput error:`, err);
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
// Add participant to room router
|
|
1917
|
+
const participant = {
|
|
1918
|
+
sessionId,
|
|
1919
|
+
name: body.name,
|
|
1920
|
+
bridge,
|
|
1921
|
+
};
|
|
1922
|
+
await router.addParticipant(participant, false);
|
|
1923
|
+
// If there's an initial prompt, send it directly to this agent
|
|
1924
|
+
if (body.initialPrompt) {
|
|
1925
|
+
await bridge.sendCommand({ command: "iterate", request: body.initialPrompt });
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
catch (err) {
|
|
1929
|
+
const msg = err instanceof Error ? err.message : "Failed to add session to room";
|
|
1930
|
+
console.error(`[room:${roomId}] Add session failed:`, err);
|
|
1931
|
+
return c.json({ error: msg }, 500);
|
|
1932
|
+
}
|
|
1933
|
+
// No need to return sessionToken — caller already proved they have it
|
|
1934
|
+
return c.json({ sessionId, participantName: body.name }, 201);
|
|
1528
1935
|
});
|
|
1529
|
-
//
|
|
1530
|
-
app.
|
|
1531
|
-
const
|
|
1936
|
+
// Send a message directly to a specific session in a room (bypasses room stream)
|
|
1937
|
+
app.post("/api/rooms/:id/sessions/:sessionId/iterate", async (c) => {
|
|
1938
|
+
const roomId = c.req.param("id");
|
|
1532
1939
|
const sessionId = c.req.param("sessionId");
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1940
|
+
const router = roomRouters.get(roomId);
|
|
1941
|
+
if (!router)
|
|
1942
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1943
|
+
const participant = router.participants.find((p) => p.sessionId === sessionId);
|
|
1944
|
+
if (!participant)
|
|
1945
|
+
return c.json({ error: "Session not found in this room" }, 404);
|
|
1946
|
+
const body = await validateBody(c, iterateRoomSessionSchema);
|
|
1947
|
+
if (isResponse(body))
|
|
1948
|
+
return body;
|
|
1949
|
+
await participant.bridge.sendCommand({
|
|
1950
|
+
command: "iterate",
|
|
1951
|
+
request: body.request,
|
|
1538
1952
|
});
|
|
1539
|
-
const event = {
|
|
1540
|
-
type: "session_unlinked",
|
|
1541
|
-
sessionId,
|
|
1542
|
-
ts: ts(),
|
|
1543
|
-
};
|
|
1544
|
-
await stream.append(JSON.stringify(event));
|
|
1545
1953
|
return c.json({ ok: true });
|
|
1546
1954
|
});
|
|
1547
|
-
//
|
|
1548
|
-
app.
|
|
1549
|
-
const
|
|
1550
|
-
const
|
|
1551
|
-
if (!
|
|
1552
|
-
return c.json({ error: "
|
|
1553
|
-
const
|
|
1955
|
+
// Send a message to a room (from human or API)
|
|
1956
|
+
app.post("/api/rooms/:id/messages", async (c) => {
|
|
1957
|
+
const roomId = c.req.param("id");
|
|
1958
|
+
const router = roomRouters.get(roomId);
|
|
1959
|
+
if (!router)
|
|
1960
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1961
|
+
const body = await validateBody(c, sendRoomMessageSchema);
|
|
1962
|
+
if (isResponse(body))
|
|
1963
|
+
return body;
|
|
1964
|
+
await router.sendMessage(body.from, body.body, body.to);
|
|
1965
|
+
return c.json({ ok: true });
|
|
1966
|
+
});
|
|
1967
|
+
// SSE proxy for room events
|
|
1968
|
+
app.get("/api/rooms/:id/events", async (c) => {
|
|
1969
|
+
const roomId = c.req.param("id");
|
|
1970
|
+
const router = roomRouters.get(roomId);
|
|
1971
|
+
if (!router)
|
|
1972
|
+
return c.json({ error: "Room not found" }, 404);
|
|
1973
|
+
const connection = roomStream(config, roomId);
|
|
1554
1974
|
const lastEventId = c.req.header("Last-Event-ID") || c.req.query("offset") || "-1";
|
|
1555
1975
|
const reader = new DurableStream({
|
|
1556
1976
|
url: connection.url,
|
|
@@ -1589,23 +2009,27 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1589
2009
|
},
|
|
1590
2010
|
});
|
|
1591
2011
|
});
|
|
1592
|
-
//
|
|
1593
|
-
app.post("/api/
|
|
1594
|
-
const
|
|
1595
|
-
const
|
|
1596
|
-
if (!
|
|
1597
|
-
return c.json({ error: "
|
|
1598
|
-
|
|
2012
|
+
// Close a room
|
|
2013
|
+
app.post("/api/rooms/:id/close", async (c) => {
|
|
2014
|
+
const roomId = c.req.param("id");
|
|
2015
|
+
const router = roomRouters.get(roomId);
|
|
2016
|
+
if (!router)
|
|
2017
|
+
return c.json({ error: "Room not found" }, 404);
|
|
2018
|
+
// Emit room_closed event
|
|
2019
|
+
const conn = roomStream(config, roomId);
|
|
1599
2020
|
const stream = new DurableStream({
|
|
1600
2021
|
url: conn.url,
|
|
1601
2022
|
headers: conn.headers,
|
|
1602
2023
|
contentType: "application/json",
|
|
1603
2024
|
});
|
|
1604
2025
|
const event = {
|
|
1605
|
-
type: "
|
|
2026
|
+
type: "room_closed",
|
|
2027
|
+
closedBy: "human",
|
|
2028
|
+
summary: "Room closed by user",
|
|
1606
2029
|
ts: ts(),
|
|
1607
2030
|
};
|
|
1608
2031
|
await stream.append(JSON.stringify(event));
|
|
2032
|
+
router.close();
|
|
1609
2033
|
return c.json({ ok: true });
|
|
1610
2034
|
});
|
|
1611
2035
|
// --- SSE Proxy ---
|
|
@@ -1675,6 +2099,46 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1675
2099
|
},
|
|
1676
2100
|
});
|
|
1677
2101
|
});
|
|
2102
|
+
// --- Stream Append Proxy ---
|
|
2103
|
+
// Proxy endpoint for writing events to a session's durable stream.
|
|
2104
|
+
// Authenticates via session token so the caller never needs DS_SECRET.
|
|
2105
|
+
// Used by sandbox agents to write events back to the session stream.
|
|
2106
|
+
app.post("/api/sessions/:id/stream/append", async (c) => {
|
|
2107
|
+
const sessionId = c.req.param("id");
|
|
2108
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
2109
|
+
if (!contentType.includes("application/json")) {
|
|
2110
|
+
return c.json({ error: "Content-Type must be application/json" }, 415);
|
|
2111
|
+
}
|
|
2112
|
+
const body = await c.req.text();
|
|
2113
|
+
if (!body) {
|
|
2114
|
+
return c.json({ error: "Request body is required" }, 400);
|
|
2115
|
+
}
|
|
2116
|
+
// Guard against oversized payloads (64 KB limit)
|
|
2117
|
+
if (body.length > 65_536) {
|
|
2118
|
+
return c.json({ error: "Payload too large" }, 413);
|
|
2119
|
+
}
|
|
2120
|
+
// Validate JSON before forwarding to the stream
|
|
2121
|
+
try {
|
|
2122
|
+
JSON.parse(body);
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
2126
|
+
}
|
|
2127
|
+
const connection = sessionStream(config, sessionId);
|
|
2128
|
+
try {
|
|
2129
|
+
const writer = new DurableStream({
|
|
2130
|
+
url: connection.url,
|
|
2131
|
+
headers: connection.headers,
|
|
2132
|
+
contentType: "application/json",
|
|
2133
|
+
});
|
|
2134
|
+
await writer.append(body);
|
|
2135
|
+
return c.json({ ok: true });
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
console.error(`[stream-proxy] Append failed: session=${sessionId}`, err);
|
|
2139
|
+
return c.json({ error: "Failed to append to stream" }, 500);
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
1678
2142
|
// --- Git/GitHub Routes ---
|
|
1679
2143
|
// Get git status for a session
|
|
1680
2144
|
app.get("/api/sessions/:id/git-status", async (c) => {
|
|
@@ -1722,7 +2186,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1722
2186
|
if (!handle || !sandboxDir) {
|
|
1723
2187
|
return c.json({ error: "Container not available" }, 404);
|
|
1724
2188
|
}
|
|
1725
|
-
|
|
2189
|
+
const resolvedPath = path.resolve(filePath);
|
|
2190
|
+
const resolvedDir = path.resolve(sandboxDir) + path.sep;
|
|
2191
|
+
if (!resolvedPath.startsWith(resolvedDir) && resolvedPath !== path.resolve(sandboxDir)) {
|
|
1726
2192
|
return c.json({ error: "Path outside project directory" }, 403);
|
|
1727
2193
|
}
|
|
1728
2194
|
const content = await config.sandbox.readFile(handle, filePath);
|
|
@@ -1771,32 +2237,41 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1771
2237
|
return c.json({ error: e instanceof Error ? e.message : "Failed to list branches" }, 500);
|
|
1772
2238
|
}
|
|
1773
2239
|
});
|
|
1774
|
-
// Read Claude credentials from macOS Keychain (dev convenience)
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1781
|
-
const parsed = JSON.parse(raw);
|
|
1782
|
-
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
1783
|
-
if (token) {
|
|
1784
|
-
console.log(`[dev] Loaded OAuth token from keychain: ${token.slice(0, 20)}...${token.slice(-10)}`);
|
|
2240
|
+
// Read Claude credentials from macOS Keychain (dev convenience).
|
|
2241
|
+
// Disabled by default — enable via devMode: true or STUDIO_DEV_MODE=1.
|
|
2242
|
+
if (config.devMode) {
|
|
2243
|
+
app.get("/api/credentials/keychain", (c) => {
|
|
2244
|
+
if (process.platform !== "darwin") {
|
|
2245
|
+
return c.json({ apiKey: null });
|
|
1785
2246
|
}
|
|
1786
|
-
|
|
1787
|
-
|
|
2247
|
+
try {
|
|
2248
|
+
const raw = execFileSync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], { encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2249
|
+
const parsed = JSON.parse(raw);
|
|
2250
|
+
const token = parsed.claudeAiOauth?.accessToken ?? null;
|
|
2251
|
+
if (token) {
|
|
2252
|
+
console.log(`[dev] Loaded OAuth token from keychain (length: ${token.length})`);
|
|
2253
|
+
}
|
|
2254
|
+
else {
|
|
2255
|
+
console.log("[dev] No OAuth token found in keychain");
|
|
2256
|
+
}
|
|
2257
|
+
return c.json({ oauthToken: token });
|
|
1788
2258
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
});
|
|
2259
|
+
catch {
|
|
2260
|
+
return c.json({ oauthToken: null });
|
|
2261
|
+
}
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
1795
2264
|
// Resume a project from a GitHub repo
|
|
1796
2265
|
app.post("/api/sessions/resume", async (c) => {
|
|
1797
|
-
const body =
|
|
1798
|
-
if (
|
|
1799
|
-
return
|
|
2266
|
+
const body = await validateBody(c, resumeSessionSchema);
|
|
2267
|
+
if (isResponse(body))
|
|
2268
|
+
return body;
|
|
2269
|
+
// Rate-limit session creation in production mode
|
|
2270
|
+
if (!config.devMode) {
|
|
2271
|
+
const ip = extractClientIp(c);
|
|
2272
|
+
if (!checkSessionRateLimit(ip)) {
|
|
2273
|
+
return c.json({ error: "Too many sessions. Please try again later." }, 429);
|
|
2274
|
+
}
|
|
1800
2275
|
}
|
|
1801
2276
|
const sessionId = crypto.randomUUID();
|
|
1802
2277
|
const repoName = body.repoUrl
|
|
@@ -1877,6 +2352,12 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1877
2352
|
projectName: repoName,
|
|
1878
2353
|
projectDir: handle.projectDir,
|
|
1879
2354
|
runtime: config.sandbox.runtime,
|
|
2355
|
+
production: !config.devMode,
|
|
2356
|
+
git: {
|
|
2357
|
+
mode: "existing",
|
|
2358
|
+
repoName: parseRepoNameFromUrl(body.repoUrl) ?? repoName,
|
|
2359
|
+
branch: gs.branch ?? body.branch ?? "main",
|
|
2360
|
+
},
|
|
1880
2361
|
});
|
|
1881
2362
|
try {
|
|
1882
2363
|
await config.sandbox.exec(handle, `cat > '${handle.projectDir}/CLAUDE.md' << 'CLAUDEMD_EOF'\n${claudeMd}\nCLAUDEMD_EOF`);
|
|
@@ -1895,18 +2376,33 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1895
2376
|
console.error(`[session:${sessionId}] Failed to write create-app skill:`, err);
|
|
1896
2377
|
}
|
|
1897
2378
|
}
|
|
2379
|
+
// Ensure the room-messaging skill is present so agents have
|
|
2380
|
+
// persistent access to the multi-agent protocol reference.
|
|
2381
|
+
if (roomMessagingSkillContent) {
|
|
2382
|
+
try {
|
|
2383
|
+
const skillDir = `${handle.projectDir}/.claude/skills/room-messaging`;
|
|
2384
|
+
const skillB64 = Buffer.from(roomMessagingSkillContent).toString("base64");
|
|
2385
|
+
await config.sandbox.exec(handle, `mkdir -p '${skillDir}' && echo '${skillB64}' | base64 -d > '${skillDir}/SKILL.md'`);
|
|
2386
|
+
}
|
|
2387
|
+
catch (err) {
|
|
2388
|
+
console.error(`[session:${sessionId}] Failed to write room-messaging skill:`, err);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
1898
2391
|
// 3. Create Claude Code bridge with a resume prompt
|
|
1899
2392
|
const resumePrompt = "You are resuming work on an existing project. Explore the codebase to understand its structure, then wait for instructions from the user.";
|
|
2393
|
+
const resumeHookToken = deriveHookToken(config.streamConfig.secret, sessionId);
|
|
1900
2394
|
const claudeConfig = config.sandbox.runtime === "sprites"
|
|
1901
2395
|
? {
|
|
1902
2396
|
prompt: resumePrompt,
|
|
1903
2397
|
cwd: handle.projectDir,
|
|
1904
2398
|
studioUrl: resolveStudioUrl(config.port),
|
|
2399
|
+
hookToken: resumeHookToken,
|
|
1905
2400
|
}
|
|
1906
2401
|
: {
|
|
1907
2402
|
prompt: resumePrompt,
|
|
1908
2403
|
cwd: handle.projectDir,
|
|
1909
2404
|
studioPort: config.port,
|
|
2405
|
+
hookToken: resumeHookToken,
|
|
1910
2406
|
};
|
|
1911
2407
|
const ccBridge = createClaudeCodeBridge(config, sessionId, claudeConfig);
|
|
1912
2408
|
// 4. Register event listeners (reuse pattern from normal flow)
|
|
@@ -1918,6 +2414,9 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1918
2414
|
config.sessions.update(sessionId, { lastCoderSessionId: ccSessionId });
|
|
1919
2415
|
}
|
|
1920
2416
|
}
|
|
2417
|
+
if (event.type === "session_end") {
|
|
2418
|
+
accumulateSessionCost(config, sessionId, event);
|
|
2419
|
+
}
|
|
1921
2420
|
});
|
|
1922
2421
|
ccBridge.onComplete(async (success) => {
|
|
1923
2422
|
const updates = {
|
|
@@ -1983,6 +2482,17 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
1983
2482
|
asyncFlow().catch(async (err) => {
|
|
1984
2483
|
console.error(`[session:${sessionId}] Resume flow failed:`, err);
|
|
1985
2484
|
config.sessions.update(sessionId, { status: "error" });
|
|
2485
|
+
try {
|
|
2486
|
+
await bridge.emit({
|
|
2487
|
+
type: "log",
|
|
2488
|
+
level: "error",
|
|
2489
|
+
message: `Resume failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2490
|
+
ts: ts(),
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
catch {
|
|
2494
|
+
// Bridge may not be usable if the failure happened early
|
|
2495
|
+
}
|
|
1986
2496
|
});
|
|
1987
2497
|
const sessionToken = deriveSessionToken(config.streamConfig.secret, sessionId);
|
|
1988
2498
|
return c.json({ sessionId, session, sessionToken }, 201);
|
|
@@ -2007,6 +2517,10 @@ echo "Start claude in this project — the session will appear in the studio UI.
|
|
|
2007
2517
|
return app;
|
|
2008
2518
|
}
|
|
2009
2519
|
export async function startWebServer(opts) {
|
|
2520
|
+
const devMode = opts.devMode ?? process.env.STUDIO_DEV_MODE === "1";
|
|
2521
|
+
if (devMode) {
|
|
2522
|
+
console.log("[studio] Dev mode enabled — keychain endpoint active");
|
|
2523
|
+
}
|
|
2010
2524
|
const config = {
|
|
2011
2525
|
port: opts.port ?? 4400,
|
|
2012
2526
|
dataDir: opts.dataDir ?? path.resolve(process.cwd(), ".electric-agent"),
|
|
@@ -2015,6 +2529,7 @@ export async function startWebServer(opts) {
|
|
|
2015
2529
|
sandbox: opts.sandbox,
|
|
2016
2530
|
streamConfig: opts.streamConfig,
|
|
2017
2531
|
bridgeMode: opts.bridgeMode ?? "claude-code",
|
|
2532
|
+
devMode,
|
|
2018
2533
|
};
|
|
2019
2534
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
|
2020
2535
|
const app = createApp(config);
|