@lifeaitools/clauth 1.30.23 → 1.30.24
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/.clauth-skill/SKILL.md +111 -111
- package/README.md +25 -0
- package/cli/api.classify.test.js +75 -75
- package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
- package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
- package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
- package/cli/assets/watchdog.ps1 +42 -42
- package/cli/commands/agent-cron.js +396 -396
- package/cli/commands/agent-pool.js +1962 -1962
- package/cli/commands/codevelop.js +1190 -1190
- package/cli/commands/doctor.js +302 -302
- package/cli/commands/install.js +10 -10
- package/cli/commands/invite.js +175 -175
- package/cli/commands/join.js +179 -179
- package/cli/commands/npm.js +182 -182
- package/cli/commands/scrub.js +327 -327
- package/cli/commands/scrub.test.js +115 -115
- package/cli/commands/serve.js +41 -95
- package/cli/commands/watchdog.js +209 -209
- package/cli/conf-path.js +21 -21
- package/cli/enrollment-script.js +82 -82
- package/cli/fingerprint.js +143 -143
- package/cli/index.js +1053 -1053
- package/cli/lib/fs-git.js +282 -282
- package/cli/recovery.js +101 -101
- package/cli/studio-debug.js +1095 -1095
- package/cli/supervisor-registry.js +594 -589
- package/cli/supervisor-registry.test.js +397 -397
- package/cli/supervisor-ui.test.js +5 -83
- package/cli/watchdog-registry.js +209 -209
- package/cli/watchdog-registry.test.js +89 -89
- package/install.ps1 +21 -21
- package/package.json +2 -2
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/supabase/migrations/001_clauth_schema.sql +12 -12
- package/supabase/migrations/003_clauth_config.sql +13 -13
- package/supabase/migrations/003_machine_enrollments.sql +39 -39
- package/cli/served-script-syntax.test.mjs +0 -54
package/cli/studio-debug.js
CHANGED
|
@@ -1,1095 +1,1095 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import os from "os";
|
|
4
|
-
import path from "path";
|
|
5
|
-
import { spawn } from "child_process";
|
|
6
|
-
import ts from "typescript";
|
|
7
|
-
|
|
8
|
-
const DEFAULT_POLL_TIMEOUT = 600_000;
|
|
9
|
-
const MAX_POLL_TIMEOUT = 600_000;
|
|
10
|
-
const MAX_EVENT_QUEUE = 100;
|
|
11
|
-
const MAX_SNIPPET = 2_000;
|
|
12
|
-
const STYLE_WRITE_EXTENSIONS = new Set([".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx"]);
|
|
13
|
-
const TEXT_WRITE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".txt", ".css", ".scss"]);
|
|
14
|
-
|
|
15
|
-
const APP_TARGETS = {
|
|
16
|
-
studio_test: {
|
|
17
|
-
appSlug: "studio_test",
|
|
18
|
-
brandSlug: "studio_test",
|
|
19
|
-
packageName: "@regen/studio",
|
|
20
|
-
command: "pnpm --filter @regen/studio dev",
|
|
21
|
-
url: "http://localhost:3011/editor/local-test-target",
|
|
22
|
-
},
|
|
23
|
-
"studio-test": {
|
|
24
|
-
appSlug: "studio_test",
|
|
25
|
-
brandSlug: "studio_test",
|
|
26
|
-
packageName: "@regen/studio",
|
|
27
|
-
command: "pnpm --filter @regen/studio dev",
|
|
28
|
-
url: "http://localhost:3011/editor/local-test-target",
|
|
29
|
-
},
|
|
30
|
-
prt: {
|
|
31
|
-
appSlug: "prt",
|
|
32
|
-
brandSlug: "prt",
|
|
33
|
-
packageName: "@regen/prt-portal",
|
|
34
|
-
command: "pnpm --filter @regen/prt-portal dev",
|
|
35
|
-
url: "http://localhost:3006",
|
|
36
|
-
},
|
|
37
|
-
"prt-portal": {
|
|
38
|
-
appSlug: "prt",
|
|
39
|
-
brandSlug: "prt",
|
|
40
|
-
packageName: "@regen/prt-portal",
|
|
41
|
-
command: "pnpm --filter @regen/prt-portal dev",
|
|
42
|
-
url: "http://localhost:3006",
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function nowIso() {
|
|
47
|
-
return new Date().toISOString();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const EDITOR_SETTINGS_FILENAME = ".studio-editor-settings.json";
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Editable-prompts settings file — every Claude prompt built here has a default
|
|
54
|
-
* baked into code; this loads the SAME-SHAPED override from
|
|
55
|
-
* `<repoRoot>/.studio-editor-settings.json` if present (same file/shape
|
|
56
|
-
* agent-pool.js's buildFullSystemPrompt() reads, different field). Read fresh
|
|
57
|
-
* on every dispatch (no caching) so an edit takes effect on the next edit sent,
|
|
58
|
-
* no daemon restart needed. Never throws.
|
|
59
|
-
*/
|
|
60
|
-
function loadEditorSettings(repoRoot) {
|
|
61
|
-
if (!repoRoot) return {};
|
|
62
|
-
try {
|
|
63
|
-
const settingsPath = path.join(repoRoot, EDITOR_SETTINGS_FILENAME);
|
|
64
|
-
if (!fs.existsSync(settingsPath)) return {};
|
|
65
|
-
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
66
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
67
|
-
} catch (e) {
|
|
68
|
-
console.error(`[studio-debug] ${EDITOR_SETTINGS_FILENAME} malformed, using code default edit prompt: ${e.message}`);
|
|
69
|
-
return {};
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Build the per-edit dispatch prompt. Editable: set `editPromptTemplate` in
|
|
75
|
-
* `<repoRoot>/.studio-editor-settings.json` to override the whole template —
|
|
76
|
-
* tokens {targetFile} {purpose} {appSlug} {brandSlug} {repoRoot} {devUrl}
|
|
77
|
-
* {mode} {selector} {currentText} {newText} {notes} are substituted (each
|
|
78
|
-
* resolves to "" when the underlying value is absent, same as the default).
|
|
79
|
-
*/
|
|
80
|
-
function buildEditPrompt(session, event) {
|
|
81
|
-
const repoRoot = (session.repoRoot || session.cwd || "").replace(/\\/g, "/");
|
|
82
|
-
const targetFile = event.file
|
|
83
|
-
? `TARGET FILE: ${event.file}`
|
|
84
|
-
: `TARGET FILE: unknown — locate it from Selector "${event.selector || "unknown"}"${event.component ? ` (component: ${event.component})` : ""} in the repo below before editing anything.`;
|
|
85
|
-
const tokens = {
|
|
86
|
-
targetFile,
|
|
87
|
-
purpose: event.instruction || "Make the requested edit.",
|
|
88
|
-
appSlug: session.appSlug || "",
|
|
89
|
-
brandSlug: session.brandSlug || "",
|
|
90
|
-
repoRoot,
|
|
91
|
-
devUrl: session.devUrl || "unknown",
|
|
92
|
-
mode: event.mode || "direct_edit",
|
|
93
|
-
selector: event.selector || "unknown",
|
|
94
|
-
currentText: event.textSnippet || "",
|
|
95
|
-
newText: event.textEdit?.newText || "",
|
|
96
|
-
notes: event.notes || "",
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const settings = loadEditorSettings(repoRoot);
|
|
100
|
-
if (typeof settings.editPromptTemplate === "string" && settings.editPromptTemplate.trim()) {
|
|
101
|
-
return Object.entries(tokens).reduce(
|
|
102
|
-
(text, [key, value]) => text.replaceAll(`{${key}}`, value),
|
|
103
|
-
settings.editPromptTemplate,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return [
|
|
108
|
-
`You are a Studio local-debug agent. Make ONE source edit to the codebase.`,
|
|
109
|
-
``,
|
|
110
|
-
// Explicit file + purpose, stated up front and unmissable — required even when
|
|
111
|
-
// no deterministic file hint exists, so a warm session's first turn is never
|
|
112
|
-
// vague about what it's touching or why.
|
|
113
|
-
tokens.targetFile,
|
|
114
|
-
`PURPOSE: ${tokens.purpose}`,
|
|
115
|
-
``,
|
|
116
|
-
`App: ${tokens.appSlug} (brand: ${tokens.brandSlug})`,
|
|
117
|
-
`Repo: ${tokens.repoRoot}`,
|
|
118
|
-
`Dev URL: ${tokens.devUrl}`,
|
|
119
|
-
``,
|
|
120
|
-
`Edit event:`,
|
|
121
|
-
` Mode: ${tokens.mode}`,
|
|
122
|
-
` Selector: ${tokens.selector}`,
|
|
123
|
-
tokens.currentText ? ` Current text: "${tokens.currentText}"` : "",
|
|
124
|
-
tokens.newText ? ` New text: "${tokens.newText}"` : "",
|
|
125
|
-
tokens.notes ? ` Notes: ${tokens.notes}` : "",
|
|
126
|
-
``,
|
|
127
|
-
`Do not edit any file other than the one this edit targets.`,
|
|
128
|
-
`After editing, output a JSON object: {"status":"done","message":"<what you did>","filesChanged":["<path>"]}`,
|
|
129
|
-
].filter(Boolean).join("\n");
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Build the prompt for a `mode:"chat"` event — a genuine conversational turn, not a
|
|
134
|
-
* source edit. Deliberately does NOT reuse buildEditPrompt(): that template's "Make ONE
|
|
135
|
-
* source edit" instruction plus the {"status","message","filesChanged"} JSON-output
|
|
136
|
-
* contract make no sense for a question like "what does this page do?" and produced
|
|
137
|
-
* broken chat replies (the agent trying to force a conversational answer into an edit
|
|
138
|
-
* JSON shape, or refusing because there was no real target file to edit). Editable via
|
|
139
|
-
* `<repoRoot>/.studio-editor-settings.json`'s `chatPromptTemplate` field (same token set
|
|
140
|
-
* as `editPromptTemplate`, same substitution rule).
|
|
141
|
-
*/
|
|
142
|
-
function buildChatPrompt(session, event) {
|
|
143
|
-
const repoRoot = (session.repoRoot || session.cwd || "").replace(/\\/g, "/");
|
|
144
|
-
const tokens = {
|
|
145
|
-
purpose: event.instruction || "",
|
|
146
|
-
appSlug: session.appSlug || "",
|
|
147
|
-
brandSlug: session.brandSlug || "",
|
|
148
|
-
repoRoot,
|
|
149
|
-
devUrl: session.devUrl || "unknown",
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const settings = loadEditorSettings(repoRoot);
|
|
153
|
-
if (typeof settings.chatPromptTemplate === "string" && settings.chatPromptTemplate.trim()) {
|
|
154
|
-
return Object.entries(tokens).reduce(
|
|
155
|
-
(text, [key, value]) => text.replaceAll(`{${key}}`, value),
|
|
156
|
-
settings.chatPromptTemplate,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return [
|
|
161
|
-
`You are a Studio local-debug chat assistant helping someone edit the "${tokens.appSlug}" app (brand: ${tokens.brandSlug}).`,
|
|
162
|
-
`Repo: ${tokens.repoRoot}`,
|
|
163
|
-
`Dev URL: ${tokens.devUrl}`,
|
|
164
|
-
``,
|
|
165
|
-
`This is a conversational turn, not a source-edit dispatch — answer the question or`,
|
|
166
|
-
`discuss the request in plain text. Only touch files if the person explicitly asks`,
|
|
167
|
-
`you to make a change; if you do, describe what you changed in your reply.`,
|
|
168
|
-
`Reply in plain prose — do NOT wrap your answer in the edit-dispatch JSON contract.`,
|
|
169
|
-
``,
|
|
170
|
-
tokens.purpose,
|
|
171
|
-
].filter(Boolean).join("\n");
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Persist an editable-prompts settings patch to `<repoRoot>/.studio-editor-settings.json`,
|
|
176
|
-
* merging with whatever is already on disk. Only `systemPrompt`, `editPromptTemplate`, and
|
|
177
|
-
* `chatPromptTemplate` are recognized fields — the same keys loadEditorSettings()/buildEditPrompt() (this
|
|
178
|
-
* file) and buildFullSystemPrompt()/buildStudioEditorSystemPrompt() (agent-pool.js) read.
|
|
179
|
-
* Empty-string values clear that field back to the code default rather than persisting "".
|
|
180
|
-
*/
|
|
181
|
-
function saveEditorSettings(repoRoot, patch) {
|
|
182
|
-
if (!repoRoot) throw new Error("repoRoot required");
|
|
183
|
-
const settingsPath = path.join(repoRoot, EDITOR_SETTINGS_FILENAME);
|
|
184
|
-
const current = loadEditorSettings(repoRoot);
|
|
185
|
-
const next = { ...current };
|
|
186
|
-
for (const key of ["systemPrompt", "editPromptTemplate", "chatPromptTemplate"]) {
|
|
187
|
-
if (!(key in patch)) continue;
|
|
188
|
-
const value = typeof patch[key] === "string" ? patch[key].trim() : "";
|
|
189
|
-
if (value) next[key] = value;
|
|
190
|
-
else delete next[key];
|
|
191
|
-
}
|
|
192
|
-
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
193
|
-
return next;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function safeString(value, max = MAX_SNIPPET) {
|
|
197
|
-
if (typeof value !== "string") return value;
|
|
198
|
-
return value.length > max ? value.slice(0, max) : value;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function normalizeEvent(session, body) {
|
|
202
|
-
const type = body.type || body.mode || "direct_edit";
|
|
203
|
-
const id = body.id || `evt_${crypto.randomUUID()}`;
|
|
204
|
-
const target = body.target && typeof body.target === "object" ? { ...body.target } : {};
|
|
205
|
-
if (typeof target.textSnippet === "string") target.textSnippet = safeString(target.textSnippet, 500);
|
|
206
|
-
if (typeof target.outerHTMLSnippet === "string") target.outerHTMLSnippet = safeString(target.outerHTMLSnippet, MAX_SNIPPET);
|
|
207
|
-
if (typeof target.outerHTML === "string" && !target.outerHTMLSnippet) {
|
|
208
|
-
target.outerHTMLSnippet = safeString(target.outerHTML, MAX_SNIPPET);
|
|
209
|
-
delete target.outerHTML;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
type,
|
|
214
|
-
id,
|
|
215
|
-
mode: body.mode || type,
|
|
216
|
-
sessionId: session.sessionId,
|
|
217
|
-
brandSlug: session.brandSlug,
|
|
218
|
-
appSlug: session.appSlug,
|
|
219
|
-
target,
|
|
220
|
-
reference: body.reference || null,
|
|
221
|
-
instruction: safeString(body.instruction || body.prompt || "", 4_000),
|
|
222
|
-
createdAt: nowIso(),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function readBody(req, maxBytes = 128 * 1024) {
|
|
227
|
-
return new Promise((resolve, reject) => {
|
|
228
|
-
let data = "";
|
|
229
|
-
req.on("data", chunk => {
|
|
230
|
-
data += chunk;
|
|
231
|
-
if (data.length > maxBytes) reject(new Error("Body too large"));
|
|
232
|
-
});
|
|
233
|
-
req.on("end", () => {
|
|
234
|
-
if (!data.trim()) return resolve({});
|
|
235
|
-
try {
|
|
236
|
-
resolve(JSON.parse(data));
|
|
237
|
-
} catch {
|
|
238
|
-
reject(new Error("Invalid JSON"));
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
req.on("error", reject);
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function writeJson(res, status, data, cors) {
|
|
246
|
-
res.writeHead(status, { "Content-Type": "application/json", ...cors });
|
|
247
|
-
res.end(JSON.stringify(data));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function resolveSourceFile(session, file, allowedExtensions = STYLE_WRITE_EXTENSIONS) {
|
|
251
|
-
if (!file || typeof file !== "string") return null;
|
|
252
|
-
const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
|
|
253
|
-
const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
|
|
254
|
-
const rel = path.relative(root, candidate);
|
|
255
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
256
|
-
if (!allowedExtensions.has(path.extname(candidate).toLowerCase())) return null;
|
|
257
|
-
return { root, path: candidate, relativePath: rel.replace(/\\/g, "/") };
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function applyTextRangeEdit(sourceText, textEdit) {
|
|
261
|
-
const source = textEdit.source;
|
|
262
|
-
if (!source || !source.range || typeof source.range.start !== "number" || typeof source.range.end !== "number") {
|
|
263
|
-
throw new Error("text-write requires source.range.start and source.range.end.");
|
|
264
|
-
}
|
|
265
|
-
const { start, end } = source.range;
|
|
266
|
-
if (start < 0 || end < start || end > sourceText.length) {
|
|
267
|
-
throw new Error(`text-write source range [${start}, ${end}] is out of bounds (file length: ${sourceText.length}).`);
|
|
268
|
-
}
|
|
269
|
-
if (textEdit.oldText !== undefined) {
|
|
270
|
-
const oldSlice = sourceText.slice(start, end);
|
|
271
|
-
if (oldSlice !== textEdit.oldText) {
|
|
272
|
-
throw new Error(`text-write oldText mismatch at [${start}, ${end}]: expected "${String(textEdit.oldText).slice(0, 80)}", found "${oldSlice.slice(0, 80)}".`);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return `${sourceText.slice(0, start)}${textEdit.newText}${sourceText.slice(end)}`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function escapeRegExp(value) {
|
|
279
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function applyCssTextEdit(source, property, value, lineNumber = null) {
|
|
283
|
-
const propertyPattern = new RegExp(`(${escapeRegExp(property)}\\s*:\\s*)([^;\\n]+)(\\s*;?)`);
|
|
284
|
-
if (Number.isInteger(lineNumber) && lineNumber > 0) {
|
|
285
|
-
const lines = source.split(/\r?\n/);
|
|
286
|
-
const index = Math.min(lines.length - 1, lineNumber - 1);
|
|
287
|
-
const windowStart = Math.max(0, index - 8);
|
|
288
|
-
const windowEnd = Math.min(lines.length - 1, index + 8);
|
|
289
|
-
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
290
|
-
if (propertyPattern.test(lines[i])) {
|
|
291
|
-
lines[i] = lines[i].replace(propertyPattern, `$1${value}$3`);
|
|
292
|
-
return { content: lines.join("\n"), strategy: "replace-near-line" };
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
const anchor = lines[index] ?? "";
|
|
296
|
-
const indent = anchor.match(/^\s*/)?.[0] ?? "";
|
|
297
|
-
lines.splice(index + 1, 0, `${indent}${property}: ${value};`);
|
|
298
|
-
return { content: lines.join("\n"), strategy: "insert-near-line" };
|
|
299
|
-
}
|
|
300
|
-
const replaced = source.replace(propertyPattern, `$1${value}$3`);
|
|
301
|
-
if (replaced !== source) return { content: replaced, strategy: "replace-first" };
|
|
302
|
-
throw new Error(`CSS property "${property}" was not found and no source line was provided.`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function applyJsxStyleEdit(source, property, value, lineNumber = null) {
|
|
306
|
-
const lines = source.split(/\r?\n/);
|
|
307
|
-
const targetIndex = Number.isInteger(lineNumber) && lineNumber > 0 ? Math.min(lines.length - 1, lineNumber - 1) : -1;
|
|
308
|
-
const windowStart = targetIndex >= 0 ? Math.max(0, targetIndex - 8) : 0;
|
|
309
|
-
const windowEnd = targetIndex >= 0 ? Math.min(lines.length - 1, targetIndex + 8) : lines.length - 1;
|
|
310
|
-
const camel = property.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
311
|
-
const quoted = JSON.stringify(value);
|
|
312
|
-
const propPattern = new RegExp(`(${camel}\\s*:\\s*)(["'\`])([^"'\`]+)(\\2)`);
|
|
313
|
-
|
|
314
|
-
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
315
|
-
if (propPattern.test(lines[i])) {
|
|
316
|
-
lines[i] = lines[i].replace(propPattern, `$1${quoted}`);
|
|
317
|
-
return { content: lines.join("\n"), strategy: "replace-js-style-near-line" };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
322
|
-
if (lines[i].includes("style={{")) {
|
|
323
|
-
lines[i] = lines[i].replace("style={{", `style={{ ${camel}: ${quoted},`);
|
|
324
|
-
return { content: lines.join("\n"), strategy: "insert-js-style-near-line" };
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
throw new Error(`No inline style object for "${property}" was found near the selected source line.`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Syntax-check extensions we know how to parse with the TS compiler. Non-TS/JS
|
|
332
|
-
// source (e.g. plain .css, .md) is skipped — there is nothing meaningful for
|
|
333
|
-
// ts.transpileModule to validate there.
|
|
334
|
-
const SYNTAX_CHECK_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
335
|
-
|
|
336
|
-
// Same pattern already proven in
|
|
337
|
-
// packages/studio-local-debug/src/integration/editor-coverage-matrix.test.ts
|
|
338
|
-
// (syntaxDiagnosticsLikeEditor) and returned by apps/studio's accept-adapter.ts
|
|
339
|
-
// as `syntaxDiagnostics`. Kept identical here so results are directly comparable.
|
|
340
|
-
function syntaxDiagnosticsFor(source, fileName) {
|
|
341
|
-
const output = ts.transpileModule(source, {
|
|
342
|
-
fileName,
|
|
343
|
-
reportDiagnostics: true,
|
|
344
|
-
compilerOptions: {
|
|
345
|
-
jsx: ts.JsxEmit.Preserve,
|
|
346
|
-
target: ts.ScriptTarget.ESNext,
|
|
347
|
-
module: ts.ModuleKind.ESNext,
|
|
348
|
-
},
|
|
349
|
-
});
|
|
350
|
-
return output.diagnostics || [];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function formatDiagnostic(diagnostic) {
|
|
354
|
-
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
355
|
-
if (diagnostic.file && typeof diagnostic.start === "number") {
|
|
356
|
-
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
|
357
|
-
return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
|
|
358
|
-
}
|
|
359
|
-
return message;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Read each changed file (relative to the session repo root) and run a TS
|
|
364
|
-
* syntax check. Returns { ok, diagnostics } — diagnostics is a flat array of
|
|
365
|
-
* human-readable strings, empty when every checkable file is syntax-clean.
|
|
366
|
-
* Files outside SYNTAX_CHECK_EXTENSIONS, or that no longer exist on disk, are
|
|
367
|
-
* skipped (not treated as failures) — this check validates parseability, not
|
|
368
|
-
* file presence.
|
|
369
|
-
*/
|
|
370
|
-
function checkFilesSyntax(session, filesChanged) {
|
|
371
|
-
const diagnostics = [];
|
|
372
|
-
const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
|
|
373
|
-
for (const file of Array.isArray(filesChanged) ? filesChanged : []) {
|
|
374
|
-
if (!file || typeof file !== "string") continue;
|
|
375
|
-
const ext = path.extname(file).toLowerCase();
|
|
376
|
-
if (!SYNTAX_CHECK_EXTENSIONS.has(ext)) continue;
|
|
377
|
-
const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
|
|
378
|
-
let source;
|
|
379
|
-
try {
|
|
380
|
-
source = fs.readFileSync(candidate, "utf8");
|
|
381
|
-
} catch (err) {
|
|
382
|
-
// A referenced file that no longer exists is itself suspicious, but the
|
|
383
|
-
// agent may have renamed/moved it deliberately as part of the edit —
|
|
384
|
-
// treat as unable-to-verify, not a hard syntax failure.
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
const fileDiagnostics = syntaxDiagnosticsFor(source, candidate);
|
|
388
|
-
for (const d of fileDiagnostics) {
|
|
389
|
-
diagnostics.push(`${file}: ${formatDiagnostic(d)}`);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
return { ok: diagnostics.length === 0, diagnostics };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Best-effort collection of the file(s) an event referenced, used as a
|
|
397
|
-
* fallback filesChanged set when the agent's JSON reply fails to parse (or
|
|
398
|
-
* omits filesChanged) — so a malformed-but-plausible-looking agent response
|
|
399
|
-
* still gets its target file(s) syntax-checked instead of slipping through
|
|
400
|
-
* ungated.
|
|
401
|
-
*/
|
|
402
|
-
function filesReferencedByEvent(event) {
|
|
403
|
-
const files = new Set();
|
|
404
|
-
const add = (f) => {
|
|
405
|
-
if (typeof f === "string" && f.trim()) files.add(f.trim());
|
|
406
|
-
};
|
|
407
|
-
add(event?.file);
|
|
408
|
-
add(event?.target?.file);
|
|
409
|
-
add(event?.textEdit?.source?.file);
|
|
410
|
-
add(event?.styleEdit?.source?.file);
|
|
411
|
-
return [...files];
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function isUrlReachable(url) {
|
|
415
|
-
try {
|
|
416
|
-
const response = await fetch(url, {
|
|
417
|
-
method: "GET",
|
|
418
|
-
signal: AbortSignal.timeout(1_500),
|
|
419
|
-
});
|
|
420
|
-
return response.status < 500;
|
|
421
|
-
} catch {
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function resolveTarget(input = {}) {
|
|
427
|
-
const slug = String(input.appSlug || input.brandSlug || "prt").toLowerCase();
|
|
428
|
-
const configured = APP_TARGETS[slug];
|
|
429
|
-
// No registration required IF the caller supplies an explicit devUrl/devCommand —
|
|
430
|
-
// an arbitrary appSlug with an explicit target is a legitimate ad-hoc session. But
|
|
431
|
-
// an unknown slug with NO explicit override has nothing to resolve to at all.
|
|
432
|
-
if (!configured && !input.devUrl && !input.devCommand) {
|
|
433
|
-
return {
|
|
434
|
-
error: "unknown_app",
|
|
435
|
-
message: `Unknown appSlug "${slug}" — not registered in APP_TARGETS and no devUrl/devCommand override was provided.`,
|
|
436
|
-
};
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const base = configured || {
|
|
440
|
-
appSlug: slug,
|
|
441
|
-
brandSlug: input.brandSlug || slug,
|
|
442
|
-
command: input.devCommand,
|
|
443
|
-
url: input.devUrl,
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
appSlug: input.appSlug || base.appSlug,
|
|
448
|
-
brandSlug: input.brandSlug || base.brandSlug || input.appSlug || base.appSlug,
|
|
449
|
-
command: input.devCommand || base.command,
|
|
450
|
-
url: input.devUrl || base.url,
|
|
451
|
-
cwd: input.cwd || input.repoRoot || process.cwd(),
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* A reachable HTTP server is not necessarily a genuine Next.js DEV server the editor
|
|
457
|
-
* can attach HMR-based live edits to — it could be a production build (no HMR at all)
|
|
458
|
-
* or a dev server sitting behind an auth gate the editor can't get past. Probe the
|
|
459
|
-
* webpack-hmr path: a redirect (typically to a login page) or an auth-rejection status
|
|
460
|
-
* means source-edit sessions cannot establish the HMR stream this feature depends on.
|
|
461
|
-
*/
|
|
462
|
-
async function isGenuineDevServer(url) {
|
|
463
|
-
try {
|
|
464
|
-
const response = await fetch(`${url.replace(/\/$/, "")}/_next/webpack-hmr`, {
|
|
465
|
-
method: "GET",
|
|
466
|
-
redirect: "manual",
|
|
467
|
-
signal: AbortSignal.timeout(1_500),
|
|
468
|
-
});
|
|
469
|
-
if (response.status >= 300 && response.status < 400) return false;
|
|
470
|
-
if (response.status === 401 || response.status === 403) return false;
|
|
471
|
-
return true;
|
|
472
|
-
} catch {
|
|
473
|
-
// Network-level errors on a websocket-upgrade path (abrupt close, protocol error)
|
|
474
|
-
// are expected even for a genuine dev server hit with a plain GET — don't reject
|
|
475
|
-
// on that basis alone, only on an explicit auth/redirect signal above.
|
|
476
|
-
return true;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function splitCommand(command) {
|
|
481
|
-
if (!command || typeof command !== "string") return null;
|
|
482
|
-
const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
483
|
-
return parts.map(part => part.replace(/^"|"$/g, ""));
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function startDevProcess(target, logFile) {
|
|
487
|
-
const parts = splitCommand(target.command);
|
|
488
|
-
if (!parts || parts.length === 0) {
|
|
489
|
-
return { started: false, error: "missing_command" };
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const logDir = path.join(os.tmpdir(), "clauth-studio-debug");
|
|
493
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
494
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
495
|
-
const outPath = path.join(logDir, `${target.appSlug}-${stamp}.out.log`);
|
|
496
|
-
const errPath = path.join(logDir, `${target.appSlug}-${stamp}.err.log`);
|
|
497
|
-
const out = fs.openSync(outPath, "a");
|
|
498
|
-
const err = fs.openSync(errPath, "a");
|
|
499
|
-
|
|
500
|
-
const proc = spawn(parts[0], parts.slice(1), {
|
|
501
|
-
cwd: target.cwd,
|
|
502
|
-
env: process.env,
|
|
503
|
-
stdio: ["ignore", out, err],
|
|
504
|
-
shell: process.platform === "win32",
|
|
505
|
-
detached: true,
|
|
506
|
-
windowsHide: true,
|
|
507
|
-
});
|
|
508
|
-
proc.unref();
|
|
509
|
-
try {
|
|
510
|
-
fs.appendFileSync(logFile, `[${nowIso()}] studio-debug dev start pid=${proc.pid} command=${target.command}\n`);
|
|
511
|
-
} catch {}
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
started: true,
|
|
515
|
-
pid: proc.pid,
|
|
516
|
-
command: target.command,
|
|
517
|
-
url: target.url,
|
|
518
|
-
stdout: outPath,
|
|
519
|
-
stderr: errPath,
|
|
520
|
-
process: proc,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
export class StudioDebugSessionStore {
|
|
525
|
-
constructor({
|
|
526
|
-
port = 52437,
|
|
527
|
-
logFile = path.join(os.tmpdir(), "clauth-serve.log"),
|
|
528
|
-
dispatchAgent = null,
|
|
529
|
-
acquireSessionWorker = null,
|
|
530
|
-
releaseSessionWorker = null,
|
|
531
|
-
} = {}) {
|
|
532
|
-
this.port = port;
|
|
533
|
-
this.logFile = logFile;
|
|
534
|
-
this.sessions = new Map();
|
|
535
|
-
this.dispatchAgent = dispatchAgent;
|
|
536
|
-
// One warm channel per editor session, acquired eagerly at start() and reused for
|
|
537
|
-
// every dispatch (chat and direct-edit alike) via submitEvent()'s dispatchAgent call
|
|
538
|
-
// through to session end — never a fresh one-shot process per edit.
|
|
539
|
-
this.acquireSessionWorker = acquireSessionWorker;
|
|
540
|
-
this.releaseSessionWorker = releaseSessionWorker;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
async start(input = {}) {
|
|
544
|
-
const target = resolveTarget(input);
|
|
545
|
-
if (target.error) {
|
|
546
|
-
return { ok: false, error: target.error, message: target.message, status: "error" };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const shouldLaunch = input.launchDevServer !== false;
|
|
550
|
-
const reachable = await isUrlReachable(target.url);
|
|
551
|
-
if (reachable && !(await isGenuineDevServer(target.url))) {
|
|
552
|
-
return {
|
|
553
|
-
ok: false,
|
|
554
|
-
error: "non_dev_server",
|
|
555
|
-
message: `${target.url} is reachable but its Next dev HMR stream is not accessible (redirected or auth-gated) — source-edit sessions require a genuine, reachable Next.js dev server.`,
|
|
556
|
-
status: "error",
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
const launch = reachable
|
|
560
|
-
? { started: false, command: target.command, url: target.url, alreadyRunning: true }
|
|
561
|
-
: shouldLaunch
|
|
562
|
-
? startDevProcess(target, this.logFile)
|
|
563
|
-
: { started: false, command: target.command, url: target.url, skipped: true };
|
|
564
|
-
|
|
565
|
-
const sessionId = input.sessionId || `studio-${crypto.randomUUID()}`;
|
|
566
|
-
const token = crypto.randomBytes(24).toString("base64url");
|
|
567
|
-
const session = {
|
|
568
|
-
sessionId,
|
|
569
|
-
token,
|
|
570
|
-
brandSlug: target.brandSlug,
|
|
571
|
-
appSlug: target.appSlug,
|
|
572
|
-
repoRoot: input.repoRoot || target.cwd,
|
|
573
|
-
cwd: target.cwd,
|
|
574
|
-
devCommand: target.command,
|
|
575
|
-
devUrl: target.url,
|
|
576
|
-
modeDefault: input.modeDefault || "direct_edit",
|
|
577
|
-
agentModel: typeof input.agentModel === "string" && input.agentModel.trim() ? input.agentModel.trim() : null,
|
|
578
|
-
warmAgent: input.warmAgent !== false,
|
|
579
|
-
status: "waiting_for_agent",
|
|
580
|
-
createdAt: nowIso(),
|
|
581
|
-
updatedAt: nowIso(),
|
|
582
|
-
stopped: false,
|
|
583
|
-
events: [],
|
|
584
|
-
replies: [],
|
|
585
|
-
pendingPolls: [],
|
|
586
|
-
launch,
|
|
587
|
-
};
|
|
588
|
-
this.sessions.set(sessionId, session);
|
|
589
|
-
|
|
590
|
-
const relayBaseUrl = `http://127.0.0.1:${this.port}/studio/debug/${sessionId}`;
|
|
591
|
-
const claudeBaseUrl = `http://127.0.0.1:${this.port}/studio/claude/${sessionId}`;
|
|
592
|
-
const pollCommand = `node scripts/studio-debug-poll.mjs --session ${sessionId} --token ${token} --relay ${claudeBaseUrl}`;
|
|
593
|
-
|
|
594
|
-
// When dispatchAgent is wired, events dispatch directly — no polling agent needed.
|
|
595
|
-
if (this.dispatchAgent) {
|
|
596
|
-
session.status = "waiting_for_event";
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Acquire this session's warm channel now, at session start — not lazily on the
|
|
600
|
-
// first edit. Every subsequent dispatchAgent call for this sessionId (chat or
|
|
601
|
-
// direct-edit) reuses the same pinned worker. Best-effort: a warm-pool exhaustion
|
|
602
|
-
// here does not fail session start, dispatchToSession() will surface it per-turn.
|
|
603
|
-
if (session.warmAgent && this.acquireSessionWorker) {
|
|
604
|
-
try { this.acquireSessionWorker(sessionId, session.agentModel, session.repoRoot || session.cwd); } catch { /* best-effort */ }
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return {
|
|
608
|
-
ok: true,
|
|
609
|
-
sessionId,
|
|
610
|
-
token,
|
|
611
|
-
devUrl: target.url,
|
|
612
|
-
relayBaseUrl,
|
|
613
|
-
status: session.status,
|
|
614
|
-
agentMode: session.warmAgent ? "warm" : "disabled",
|
|
615
|
-
launch: {
|
|
616
|
-
started: !!launch.started,
|
|
617
|
-
alreadyRunning: !!launch.alreadyRunning,
|
|
618
|
-
command: launch.command,
|
|
619
|
-
url: launch.url,
|
|
620
|
-
pid: launch.pid || null,
|
|
621
|
-
},
|
|
622
|
-
pollCommand,
|
|
623
|
-
claudeBaseUrl,
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
get(sessionId) {
|
|
628
|
-
return this.sessions.get(sessionId) || null;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
authenticate(sessionId, token) {
|
|
632
|
-
const session = this.get(sessionId);
|
|
633
|
-
if (!session) return { error: "not_found" };
|
|
634
|
-
if (session.token !== token) return { error: "unauthorized" };
|
|
635
|
-
return { session };
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
submitEvent(sessionId, body) {
|
|
639
|
-
const auth = this.authenticate(sessionId, body.token);
|
|
640
|
-
if (auth.error) return auth;
|
|
641
|
-
const session = auth.session;
|
|
642
|
-
if (!session.warmAgent) {
|
|
643
|
-
return {
|
|
644
|
-
error: "agent_disabled",
|
|
645
|
-
message: "This direct-write session does not allow agent-dispatched events.",
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
const event = normalizeEvent(session, body);
|
|
649
|
-
|
|
650
|
-
session.updatedAt = nowIso();
|
|
651
|
-
session.status = "event_pending";
|
|
652
|
-
|
|
653
|
-
// If a dispatchAgent handler exists, spawn a full headless Claude agent.
|
|
654
|
-
if (this.dispatchAgent) {
|
|
655
|
-
// Stash images from the event body so the dispatcher can write them to tmp
|
|
656
|
-
if (Array.isArray(body.images) && body.images.length > 0) {
|
|
657
|
-
session._pendingImages = body.images;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// A chat turn is conversational, not a source-edit dispatch: use buildChatPrompt()
|
|
661
|
-
// (plain prose, no "make ONE source edit" instruction, no edit-JSON output contract)
|
|
662
|
-
// and skip the syntax-check-and-retry loop below entirely — there's no edited file
|
|
663
|
-
// to check, and the agent's raw reply text IS the message, not something to parse
|
|
664
|
-
// as JSON. Previously every chat message reused buildEditPrompt(), so the agent was
|
|
665
|
-
// told to edit a file and reply in {"status","message","filesChanged"} JSON for what
|
|
666
|
-
// was just a question — see submitEvent's mode branch below for the corresponding fix.
|
|
667
|
-
if (event.mode === "chat") {
|
|
668
|
-
const chatPrompt = buildChatPrompt(session, event);
|
|
669
|
-
this.dispatchAgent(event.id, session.token, null, session, chatPrompt)
|
|
670
|
-
.then((result) => {
|
|
671
|
-
const message = String(result?.package ?? "").trim() || "(no reply)";
|
|
672
|
-
session.replies.push({
|
|
673
|
-
eventId: event.id,
|
|
674
|
-
status: result?.ok === false ? "error" : "done",
|
|
675
|
-
message,
|
|
676
|
-
filesChanged: [],
|
|
677
|
-
diagnostics: [],
|
|
678
|
-
createdAt: nowIso(),
|
|
679
|
-
});
|
|
680
|
-
session.status = result?.ok === false ? "error" : "done";
|
|
681
|
-
session.updatedAt = nowIso();
|
|
682
|
-
})
|
|
683
|
-
.catch((err) => {
|
|
684
|
-
session.replies.push({
|
|
685
|
-
eventId: event.id,
|
|
686
|
-
status: "error",
|
|
687
|
-
message: `Chat dispatch failed: ${err?.message || "unknown error"}`,
|
|
688
|
-
filesChanged: [],
|
|
689
|
-
diagnostics: [],
|
|
690
|
-
createdAt: nowIso(),
|
|
691
|
-
});
|
|
692
|
-
session.status = "error";
|
|
693
|
-
session.updatedAt = nowIso();
|
|
694
|
-
});
|
|
695
|
-
return { ok: true, eventId: event.id, status: session.status };
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const prompt = buildEditPrompt(session, event);
|
|
699
|
-
|
|
700
|
-
const fallbackFiles = filesReferencedByEvent(event);
|
|
701
|
-
|
|
702
|
-
const parseAgentResult = (result) => {
|
|
703
|
-
// BUG FIX (2026-07-02): AgentPool.dispatchToSession()/dispatch() resolve with
|
|
704
|
-
// `result.package` (the agent's raw final-text response, see agent-pool.js:629
|
|
705
|
-
// `package: (text || "").trim()`) -- NOT `result.stdout` or `result.output`,
|
|
706
|
-
// neither of which exist on this result shape. Reading the wrong fields meant
|
|
707
|
-
// JSON.parse("") always threw, parsed silently stayed {}, and EVERY dispatch
|
|
708
|
-
// reported status:"done" / filesChanged:[] regardless of what the agent
|
|
709
|
-
// actually did -- the syntax-check-and-retry loop below was checking an empty
|
|
710
|
-
// file list every single time and never catching anything.
|
|
711
|
-
const rawText = String(result?.package ?? "").trim();
|
|
712
|
-
let parsed = {};
|
|
713
|
-
try {
|
|
714
|
-
parsed = JSON.parse(rawText);
|
|
715
|
-
} catch {
|
|
716
|
-
// Agents don't always emit pure JSON despite instructions -- a trailing/leading
|
|
717
|
-
// sentence around the JSON object is common. Try to recover the LAST {...}
|
|
718
|
-
// block in the text before giving up (never throws further).
|
|
719
|
-
const match = rawText.match(/\{[\s\S]*\}/);
|
|
720
|
-
if (match) {
|
|
721
|
-
try { parsed = JSON.parse(match[0]); } catch { /* give up, use defaults below */ }
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
const filesChanged = Array.isArray(parsed.filesChanged) && parsed.filesChanged.length > 0
|
|
725
|
-
? parsed.filesChanged
|
|
726
|
-
: fallbackFiles;
|
|
727
|
-
return {
|
|
728
|
-
status: parsed.status || "done",
|
|
729
|
-
message: parsed.message || rawText.slice(0, 500) || "Agent completed",
|
|
730
|
-
filesChanged,
|
|
731
|
-
};
|
|
732
|
-
};
|
|
733
|
-
|
|
734
|
-
// D1/D2 (studio-editor-v1-auto-escalation.md): after the dispatched agent
|
|
735
|
-
// reports back, verify the file(s) it touched still parse before trusting
|
|
736
|
-
// "status":"done". A malformed agent JSON reply (bare try/catch above)
|
|
737
|
-
// must NOT bypass this — filesChanged falls back to whatever the original
|
|
738
|
-
// event referenced so even an unparseable reply still gets checked.
|
|
739
|
-
// On a syntax failure, re-dispatch exactly once with the diagnostic
|
|
740
|
-
// appended; a second failure (or a second dispatch error) is a hard stop
|
|
741
|
-
// to status "error" with diagnostics attached — never a silent accept,
|
|
742
|
-
// never an unbounded retry loop.
|
|
743
|
-
this.dispatchAgent(event.id, session.token, null, session, prompt)
|
|
744
|
-
.then(async (result) => {
|
|
745
|
-
const attempt1 = parseAgentResult(result);
|
|
746
|
-
const check1 = checkFilesSyntax(session, attempt1.filesChanged);
|
|
747
|
-
|
|
748
|
-
if (check1.ok) {
|
|
749
|
-
session.replies.push({
|
|
750
|
-
eventId: event.id,
|
|
751
|
-
status: attempt1.status,
|
|
752
|
-
message: attempt1.message,
|
|
753
|
-
filesChanged: attempt1.filesChanged,
|
|
754
|
-
diagnostics: [],
|
|
755
|
-
createdAt: nowIso(),
|
|
756
|
-
});
|
|
757
|
-
session.status = "done";
|
|
758
|
-
session.updatedAt = nowIso();
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const retryPrompt = [
|
|
763
|
-
prompt,
|
|
764
|
-
``,
|
|
765
|
-
`Your previous edit broke the file's syntax. Diagnostic: ${check1.diagnostics.join("; ")}.`,
|
|
766
|
-
`Fix the file so it is syntactically valid, preserving your intended change.`,
|
|
767
|
-
].join("\n");
|
|
768
|
-
|
|
769
|
-
let attempt2Result;
|
|
770
|
-
try {
|
|
771
|
-
attempt2Result = await this.dispatchAgent(event.id, session.token, null, session, retryPrompt);
|
|
772
|
-
} catch {
|
|
773
|
-
session.replies.push({
|
|
774
|
-
eventId: event.id,
|
|
775
|
-
status: "error",
|
|
776
|
-
message: "Agent retry dispatch failed after syntax check failure",
|
|
777
|
-
filesChanged: attempt1.filesChanged,
|
|
778
|
-
diagnostics: check1.diagnostics,
|
|
779
|
-
createdAt: nowIso(),
|
|
780
|
-
});
|
|
781
|
-
session.status = "error";
|
|
782
|
-
session.updatedAt = nowIso();
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const attempt2 = parseAgentResult(attempt2Result);
|
|
787
|
-
const check2 = checkFilesSyntax(session, attempt2.filesChanged);
|
|
788
|
-
|
|
789
|
-
if (check2.ok) {
|
|
790
|
-
session.replies.push({
|
|
791
|
-
eventId: event.id,
|
|
792
|
-
status: attempt2.status,
|
|
793
|
-
message: attempt2.message,
|
|
794
|
-
filesChanged: attempt2.filesChanged,
|
|
795
|
-
diagnostics: [],
|
|
796
|
-
createdAt: nowIso(),
|
|
797
|
-
});
|
|
798
|
-
session.status = "done";
|
|
799
|
-
session.updatedAt = nowIso();
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
session.replies.push({
|
|
804
|
-
eventId: event.id,
|
|
805
|
-
status: "error",
|
|
806
|
-
message: `Agent edit failed syntax check after retry: ${check2.diagnostics.join("; ")}`,
|
|
807
|
-
filesChanged: attempt2.filesChanged,
|
|
808
|
-
diagnostics: check2.diagnostics,
|
|
809
|
-
createdAt: nowIso(),
|
|
810
|
-
});
|
|
811
|
-
session.status = "error";
|
|
812
|
-
session.updatedAt = nowIso();
|
|
813
|
-
})
|
|
814
|
-
.catch(() => {
|
|
815
|
-
session.replies.push({
|
|
816
|
-
eventId: event.id,
|
|
817
|
-
status: "error",
|
|
818
|
-
message: "Agent dispatch failed",
|
|
819
|
-
filesChanged: [],
|
|
820
|
-
diagnostics: [],
|
|
821
|
-
createdAt: nowIso(),
|
|
822
|
-
});
|
|
823
|
-
session.status = "error";
|
|
824
|
-
session.updatedAt = nowIso();
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
return { ok: true, eventId: event.id, status: session.status };
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Legacy path: queue for a polling agent
|
|
831
|
-
if (session.pendingPolls.length > 0) {
|
|
832
|
-
const poll = session.pendingPolls.shift();
|
|
833
|
-
poll(event);
|
|
834
|
-
} else {
|
|
835
|
-
session.events.push(event);
|
|
836
|
-
if (session.events.length > MAX_EVENT_QUEUE) session.events.shift();
|
|
837
|
-
}
|
|
838
|
-
return { ok: true, eventId: event.id, status: session.status };
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
poll(sessionId, token, timeoutMs = DEFAULT_POLL_TIMEOUT) {
|
|
842
|
-
const auth = this.authenticate(sessionId, token);
|
|
843
|
-
if (auth.error) return Promise.resolve(auth);
|
|
844
|
-
const session = auth.session;
|
|
845
|
-
if (session.events.length > 0) {
|
|
846
|
-
session.status = "agent_received";
|
|
847
|
-
session.updatedAt = nowIso();
|
|
848
|
-
return Promise.resolve(session.events.shift());
|
|
849
|
-
}
|
|
850
|
-
if (session.stopped) return Promise.resolve({ type: "stopped" });
|
|
851
|
-
|
|
852
|
-
const timeout = Math.min(Math.max(Number(timeoutMs) || DEFAULT_POLL_TIMEOUT, 1), MAX_POLL_TIMEOUT);
|
|
853
|
-
session.status = "waiting_for_event";
|
|
854
|
-
session.updatedAt = nowIso();
|
|
855
|
-
|
|
856
|
-
return new Promise(resolve => {
|
|
857
|
-
const timer = setTimeout(() => {
|
|
858
|
-
session.pendingPolls = session.pendingPolls.filter(fn => fn !== finish);
|
|
859
|
-
resolve({ type: "timeout" });
|
|
860
|
-
}, timeout);
|
|
861
|
-
const finish = event => {
|
|
862
|
-
clearTimeout(timer);
|
|
863
|
-
session.status = "agent_received";
|
|
864
|
-
session.updatedAt = nowIso();
|
|
865
|
-
resolve(event);
|
|
866
|
-
};
|
|
867
|
-
session.pendingPolls.push(finish);
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
reply(sessionId, body) {
|
|
872
|
-
const auth = this.authenticate(sessionId, body.token);
|
|
873
|
-
if (auth.error) return auth;
|
|
874
|
-
const session = auth.session;
|
|
875
|
-
const reply = {
|
|
876
|
-
eventId: body.eventId,
|
|
877
|
-
status: body.status || body.type || "done",
|
|
878
|
-
message: body.message || "",
|
|
879
|
-
filesChanged: Array.isArray(body.filesChanged)
|
|
880
|
-
? body.filesChanged
|
|
881
|
-
: body.file
|
|
882
|
-
? [body.file]
|
|
883
|
-
: [],
|
|
884
|
-
createdAt: nowIso(),
|
|
885
|
-
};
|
|
886
|
-
session.replies.push(reply);
|
|
887
|
-
session.status = reply.status === "done" ? "done" : reply.status;
|
|
888
|
-
session.updatedAt = nowIso();
|
|
889
|
-
return { ok: true, status: session.status, reply };
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
writeText(sessionId, body) {
|
|
893
|
-
const auth = this.authenticate(sessionId, body.token);
|
|
894
|
-
if (auth.error) return auth;
|
|
895
|
-
const session = auth.session;
|
|
896
|
-
const textEdit = body.textEdit && typeof body.textEdit === "object" ? body.textEdit : {};
|
|
897
|
-
const source = textEdit.source && typeof textEdit.source === "object" ? textEdit.source : {};
|
|
898
|
-
const file = source.file || textEdit.file || body.file;
|
|
899
|
-
const fileInfo = resolveSourceFile(session, file, TEXT_WRITE_EXTENSIONS);
|
|
900
|
-
if (!fileInfo) {
|
|
901
|
-
return { error: "invalid_source", message: `text-write requires a source file inside the debug session repo (got: ${file || "(none)"}).` };
|
|
902
|
-
}
|
|
903
|
-
if (typeof textEdit.newText !== "string") {
|
|
904
|
-
return { error: "invalid_body", message: "text-write requires textEdit.newText." };
|
|
905
|
-
}
|
|
906
|
-
try {
|
|
907
|
-
const original = fs.readFileSync(fileInfo.path, "utf8");
|
|
908
|
-
const updated = applyTextRangeEdit(original, { ...textEdit, source });
|
|
909
|
-
fs.writeFileSync(fileInfo.path, updated, "utf8");
|
|
910
|
-
const reply = {
|
|
911
|
-
eventId: body.id || `text_${crypto.randomUUID()}`,
|
|
912
|
-
status: "done",
|
|
913
|
-
message: `Direct text write (source_replace [${source.range?.start}, ${source.range?.end}])`,
|
|
914
|
-
filesChanged: [fileInfo.relativePath],
|
|
915
|
-
createdAt: nowIso(),
|
|
916
|
-
};
|
|
917
|
-
session.replies.push(reply);
|
|
918
|
-
session.status = "done";
|
|
919
|
-
session.updatedAt = nowIso();
|
|
920
|
-
return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged };
|
|
921
|
-
} catch (err) {
|
|
922
|
-
return { error: "text_write_failed", message: err instanceof Error ? err.message : String(err) };
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
writeStyle(sessionId, body) {
|
|
927
|
-
const auth = this.authenticate(sessionId, body.token);
|
|
928
|
-
if (auth.error) return auth;
|
|
929
|
-
const session = auth.session;
|
|
930
|
-
const styleEdit = body.styleEdit && typeof body.styleEdit === "object" ? body.styleEdit : {};
|
|
931
|
-
const deltas = Array.isArray(styleEdit.deltas) ? styleEdit.deltas : [];
|
|
932
|
-
const source = styleEdit.source && typeof styleEdit.source === "object" ? styleEdit.source : {};
|
|
933
|
-
const fileInfo = resolveSourceFile(session, source.file || body.file);
|
|
934
|
-
if (!fileInfo) return { error: "invalid_source", message: "style-write requires a source file inside the debug session repo." };
|
|
935
|
-
if (deltas.length !== 1) return { error: "invalid_delta", message: "style-write currently accepts exactly one deterministic CSS delta." };
|
|
936
|
-
|
|
937
|
-
const delta = deltas[0] || {};
|
|
938
|
-
if (typeof delta.property !== "string" || typeof delta.value !== "string") {
|
|
939
|
-
return { error: "invalid_delta", message: "style-write requires string property and value." };
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
try {
|
|
943
|
-
const original = fs.readFileSync(fileInfo.path, "utf8");
|
|
944
|
-
const ext = path.extname(fileInfo.path).toLowerCase();
|
|
945
|
-
const result = ext === ".jsx" || ext === ".tsx"
|
|
946
|
-
? applyJsxStyleEdit(original, delta.property, delta.value, source.line || body.line || null)
|
|
947
|
-
: applyCssTextEdit(original, delta.property, delta.value, source.line || body.line || null);
|
|
948
|
-
fs.writeFileSync(fileInfo.path, result.content, "utf8");
|
|
949
|
-
const reply = {
|
|
950
|
-
eventId: body.id || `style_${crypto.randomUUID()}`,
|
|
951
|
-
status: "done",
|
|
952
|
-
message: `Direct style write (${result.strategy})`,
|
|
953
|
-
filesChanged: [fileInfo.relativePath],
|
|
954
|
-
createdAt: nowIso(),
|
|
955
|
-
};
|
|
956
|
-
session.replies.push(reply);
|
|
957
|
-
session.status = "done";
|
|
958
|
-
session.updatedAt = nowIso();
|
|
959
|
-
return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged, strategy: result.strategy };
|
|
960
|
-
} catch (err) {
|
|
961
|
-
return { error: "style_write_failed", message: err instanceof Error ? err.message : String(err) };
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
status(sessionId, token) {
|
|
966
|
-
const auth = this.authenticate(sessionId, token);
|
|
967
|
-
if (auth.error) return auth;
|
|
968
|
-
const session = auth.session;
|
|
969
|
-
return {
|
|
970
|
-
ok: true,
|
|
971
|
-
sessionId: session.sessionId,
|
|
972
|
-
brandSlug: session.brandSlug,
|
|
973
|
-
appSlug: session.appSlug,
|
|
974
|
-
devUrl: session.devUrl,
|
|
975
|
-
status: session.status,
|
|
976
|
-
createdAt: session.createdAt,
|
|
977
|
-
updatedAt: session.updatedAt,
|
|
978
|
-
pendingEvents: session.events.length,
|
|
979
|
-
pendingPolls: session.pendingPolls.length,
|
|
980
|
-
replies: session.replies,
|
|
981
|
-
launch: {
|
|
982
|
-
started: !!session.launch?.started,
|
|
983
|
-
alreadyRunning: !!session.launch?.alreadyRunning,
|
|
984
|
-
command: session.launch?.command || session.devCommand,
|
|
985
|
-
url: session.launch?.url || session.devUrl,
|
|
986
|
-
pid: session.launch?.pid || null,
|
|
987
|
-
},
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
stop(sessionId, token) {
|
|
992
|
-
const auth = this.authenticate(sessionId, token);
|
|
993
|
-
if (auth.error) return auth;
|
|
994
|
-
const session = auth.session;
|
|
995
|
-
session.stopped = true;
|
|
996
|
-
session.status = "stopped";
|
|
997
|
-
session.updatedAt = nowIso();
|
|
998
|
-
if (session.launch?.process && !session.launch.process.killed) {
|
|
999
|
-
try {
|
|
1000
|
-
session.launch.process.kill();
|
|
1001
|
-
} catch {}
|
|
1002
|
-
}
|
|
1003
|
-
for (const poll of session.pendingPolls.splice(0)) poll({ type: "stopped" });
|
|
1004
|
-
this.sessions.delete(sessionId);
|
|
1005
|
-
if (this.releaseSessionWorker) {
|
|
1006
|
-
try { this.releaseSessionWorker(sessionId); } catch { /* best-effort */ }
|
|
1007
|
-
}
|
|
1008
|
-
return { ok: true, status: "stopped", sessionId };
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
export function createStudioDebugRuntime(options) {
|
|
1013
|
-
const store = new StudioDebugSessionStore(options);
|
|
1014
|
-
|
|
1015
|
-
async function handle(req, res, url, cors) {
|
|
1016
|
-
const reqPath = url.pathname;
|
|
1017
|
-
const method = req.method;
|
|
1018
|
-
|
|
1019
|
-
if (method === "POST" && reqPath === "/studio/debug/start") {
|
|
1020
|
-
try {
|
|
1021
|
-
const body = await readBody(req);
|
|
1022
|
-
const result = await store.start(body);
|
|
1023
|
-
return writeJson(res, result.ok ? 200 : 400, result, cors);
|
|
1024
|
-
} catch (err) {
|
|
1025
|
-
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Editable-prompts settings — the UI for .studio-editor-settings.json (loadEditorSettings()
|
|
1030
|
-
// above / buildFullSystemPrompt()+buildStudioEditorSystemPrompt() in agent-pool.js). GET
|
|
1031
|
-
// returns what's on disk (empty object if never set); POST merges a partial patch.
|
|
1032
|
-
if (reqPath === "/studio/editor-settings") {
|
|
1033
|
-
const repoRoot = url.searchParams.get("repoRoot");
|
|
1034
|
-
if (!repoRoot) return writeJson(res, 400, { ok: false, error: "repoRoot query param required" }, cors);
|
|
1035
|
-
if (method === "GET") {
|
|
1036
|
-
return writeJson(res, 200, { ok: true, settings: loadEditorSettings(repoRoot) }, cors);
|
|
1037
|
-
}
|
|
1038
|
-
if (method === "POST") {
|
|
1039
|
-
try {
|
|
1040
|
-
const patch = await readBody(req);
|
|
1041
|
-
const settings = saveEditorSettings(repoRoot, patch || {});
|
|
1042
|
-
return writeJson(res, 200, { ok: true, settings }, cors);
|
|
1043
|
-
} catch (err) {
|
|
1044
|
-
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
return writeJson(res, 405, { ok: false, error: "method not allowed" }, cors);
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|text-write|status|stop)$/);
|
|
1051
|
-
const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
|
|
1052
|
-
if (!debugMatch && !claudeMatch) return false;
|
|
1053
|
-
const [, sessionId, action] = debugMatch || claudeMatch;
|
|
1054
|
-
|
|
1055
|
-
try {
|
|
1056
|
-
if (method === "POST" && action === "events") {
|
|
1057
|
-
const result = store.submitEvent(sessionId, await readBody(req));
|
|
1058
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1059
|
-
}
|
|
1060
|
-
if (method === "POST" && action === "style-write") {
|
|
1061
|
-
const result = store.writeStyle(sessionId, await readBody(req));
|
|
1062
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
1063
|
-
}
|
|
1064
|
-
if (method === "POST" && action === "text-write") {
|
|
1065
|
-
const result = store.writeText(sessionId, await readBody(req));
|
|
1066
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
1067
|
-
}
|
|
1068
|
-
if (method === "GET" && action === "poll") {
|
|
1069
|
-
const result = await store.poll(sessionId, url.searchParams.get("token"), url.searchParams.get("timeout"));
|
|
1070
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1071
|
-
}
|
|
1072
|
-
if (method === "POST" && action === "reply") {
|
|
1073
|
-
const result = store.reply(sessionId, await readBody(req));
|
|
1074
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1075
|
-
}
|
|
1076
|
-
if (method === "GET" && action === "status") {
|
|
1077
|
-
const result = store.status(sessionId, url.searchParams.get("token"));
|
|
1078
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1079
|
-
}
|
|
1080
|
-
if (method === "POST" && action === "stop") {
|
|
1081
|
-
const body = await readBody(req);
|
|
1082
|
-
const result = store.stop(sessionId, body.token || url.searchParams.get("token"));
|
|
1083
|
-
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1084
|
-
}
|
|
1085
|
-
return writeJson(res, 405, { ok: false, error: "method_not_allowed" }, cors);
|
|
1086
|
-
} catch (err) {
|
|
1087
|
-
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
return { store, handle };
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
export const studioDebugTargets = APP_TARGETS;
|
|
1095
|
-
export { buildEditPrompt, buildChatPrompt, loadEditorSettings };
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_POLL_TIMEOUT = 600_000;
|
|
9
|
+
const MAX_POLL_TIMEOUT = 600_000;
|
|
10
|
+
const MAX_EVENT_QUEUE = 100;
|
|
11
|
+
const MAX_SNIPPET = 2_000;
|
|
12
|
+
const STYLE_WRITE_EXTENSIONS = new Set([".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx"]);
|
|
13
|
+
const TEXT_WRITE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".txt", ".css", ".scss"]);
|
|
14
|
+
|
|
15
|
+
const APP_TARGETS = {
|
|
16
|
+
studio_test: {
|
|
17
|
+
appSlug: "studio_test",
|
|
18
|
+
brandSlug: "studio_test",
|
|
19
|
+
packageName: "@regen/studio",
|
|
20
|
+
command: "pnpm --filter @regen/studio dev",
|
|
21
|
+
url: "http://localhost:3011/editor/local-test-target",
|
|
22
|
+
},
|
|
23
|
+
"studio-test": {
|
|
24
|
+
appSlug: "studio_test",
|
|
25
|
+
brandSlug: "studio_test",
|
|
26
|
+
packageName: "@regen/studio",
|
|
27
|
+
command: "pnpm --filter @regen/studio dev",
|
|
28
|
+
url: "http://localhost:3011/editor/local-test-target",
|
|
29
|
+
},
|
|
30
|
+
prt: {
|
|
31
|
+
appSlug: "prt",
|
|
32
|
+
brandSlug: "prt",
|
|
33
|
+
packageName: "@regen/prt-portal",
|
|
34
|
+
command: "pnpm --filter @regen/prt-portal dev",
|
|
35
|
+
url: "http://localhost:3006",
|
|
36
|
+
},
|
|
37
|
+
"prt-portal": {
|
|
38
|
+
appSlug: "prt",
|
|
39
|
+
brandSlug: "prt",
|
|
40
|
+
packageName: "@regen/prt-portal",
|
|
41
|
+
command: "pnpm --filter @regen/prt-portal dev",
|
|
42
|
+
url: "http://localhost:3006",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function nowIso() {
|
|
47
|
+
return new Date().toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const EDITOR_SETTINGS_FILENAME = ".studio-editor-settings.json";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Editable-prompts settings file — every Claude prompt built here has a default
|
|
54
|
+
* baked into code; this loads the SAME-SHAPED override from
|
|
55
|
+
* `<repoRoot>/.studio-editor-settings.json` if present (same file/shape
|
|
56
|
+
* agent-pool.js's buildFullSystemPrompt() reads, different field). Read fresh
|
|
57
|
+
* on every dispatch (no caching) so an edit takes effect on the next edit sent,
|
|
58
|
+
* no daemon restart needed. Never throws.
|
|
59
|
+
*/
|
|
60
|
+
function loadEditorSettings(repoRoot) {
|
|
61
|
+
if (!repoRoot) return {};
|
|
62
|
+
try {
|
|
63
|
+
const settingsPath = path.join(repoRoot, EDITOR_SETTINGS_FILENAME);
|
|
64
|
+
if (!fs.existsSync(settingsPath)) return {};
|
|
65
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
66
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.error(`[studio-debug] ${EDITOR_SETTINGS_FILENAME} malformed, using code default edit prompt: ${e.message}`);
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the per-edit dispatch prompt. Editable: set `editPromptTemplate` in
|
|
75
|
+
* `<repoRoot>/.studio-editor-settings.json` to override the whole template —
|
|
76
|
+
* tokens {targetFile} {purpose} {appSlug} {brandSlug} {repoRoot} {devUrl}
|
|
77
|
+
* {mode} {selector} {currentText} {newText} {notes} are substituted (each
|
|
78
|
+
* resolves to "" when the underlying value is absent, same as the default).
|
|
79
|
+
*/
|
|
80
|
+
function buildEditPrompt(session, event) {
|
|
81
|
+
const repoRoot = (session.repoRoot || session.cwd || "").replace(/\\/g, "/");
|
|
82
|
+
const targetFile = event.file
|
|
83
|
+
? `TARGET FILE: ${event.file}`
|
|
84
|
+
: `TARGET FILE: unknown — locate it from Selector "${event.selector || "unknown"}"${event.component ? ` (component: ${event.component})` : ""} in the repo below before editing anything.`;
|
|
85
|
+
const tokens = {
|
|
86
|
+
targetFile,
|
|
87
|
+
purpose: event.instruction || "Make the requested edit.",
|
|
88
|
+
appSlug: session.appSlug || "",
|
|
89
|
+
brandSlug: session.brandSlug || "",
|
|
90
|
+
repoRoot,
|
|
91
|
+
devUrl: session.devUrl || "unknown",
|
|
92
|
+
mode: event.mode || "direct_edit",
|
|
93
|
+
selector: event.selector || "unknown",
|
|
94
|
+
currentText: event.textSnippet || "",
|
|
95
|
+
newText: event.textEdit?.newText || "",
|
|
96
|
+
notes: event.notes || "",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const settings = loadEditorSettings(repoRoot);
|
|
100
|
+
if (typeof settings.editPromptTemplate === "string" && settings.editPromptTemplate.trim()) {
|
|
101
|
+
return Object.entries(tokens).reduce(
|
|
102
|
+
(text, [key, value]) => text.replaceAll(`{${key}}`, value),
|
|
103
|
+
settings.editPromptTemplate,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
`You are a Studio local-debug agent. Make ONE source edit to the codebase.`,
|
|
109
|
+
``,
|
|
110
|
+
// Explicit file + purpose, stated up front and unmissable — required even when
|
|
111
|
+
// no deterministic file hint exists, so a warm session's first turn is never
|
|
112
|
+
// vague about what it's touching or why.
|
|
113
|
+
tokens.targetFile,
|
|
114
|
+
`PURPOSE: ${tokens.purpose}`,
|
|
115
|
+
``,
|
|
116
|
+
`App: ${tokens.appSlug} (brand: ${tokens.brandSlug})`,
|
|
117
|
+
`Repo: ${tokens.repoRoot}`,
|
|
118
|
+
`Dev URL: ${tokens.devUrl}`,
|
|
119
|
+
``,
|
|
120
|
+
`Edit event:`,
|
|
121
|
+
` Mode: ${tokens.mode}`,
|
|
122
|
+
` Selector: ${tokens.selector}`,
|
|
123
|
+
tokens.currentText ? ` Current text: "${tokens.currentText}"` : "",
|
|
124
|
+
tokens.newText ? ` New text: "${tokens.newText}"` : "",
|
|
125
|
+
tokens.notes ? ` Notes: ${tokens.notes}` : "",
|
|
126
|
+
``,
|
|
127
|
+
`Do not edit any file other than the one this edit targets.`,
|
|
128
|
+
`After editing, output a JSON object: {"status":"done","message":"<what you did>","filesChanged":["<path>"]}`,
|
|
129
|
+
].filter(Boolean).join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the prompt for a `mode:"chat"` event — a genuine conversational turn, not a
|
|
134
|
+
* source edit. Deliberately does NOT reuse buildEditPrompt(): that template's "Make ONE
|
|
135
|
+
* source edit" instruction plus the {"status","message","filesChanged"} JSON-output
|
|
136
|
+
* contract make no sense for a question like "what does this page do?" and produced
|
|
137
|
+
* broken chat replies (the agent trying to force a conversational answer into an edit
|
|
138
|
+
* JSON shape, or refusing because there was no real target file to edit). Editable via
|
|
139
|
+
* `<repoRoot>/.studio-editor-settings.json`'s `chatPromptTemplate` field (same token set
|
|
140
|
+
* as `editPromptTemplate`, same substitution rule).
|
|
141
|
+
*/
|
|
142
|
+
function buildChatPrompt(session, event) {
|
|
143
|
+
const repoRoot = (session.repoRoot || session.cwd || "").replace(/\\/g, "/");
|
|
144
|
+
const tokens = {
|
|
145
|
+
purpose: event.instruction || "",
|
|
146
|
+
appSlug: session.appSlug || "",
|
|
147
|
+
brandSlug: session.brandSlug || "",
|
|
148
|
+
repoRoot,
|
|
149
|
+
devUrl: session.devUrl || "unknown",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const settings = loadEditorSettings(repoRoot);
|
|
153
|
+
if (typeof settings.chatPromptTemplate === "string" && settings.chatPromptTemplate.trim()) {
|
|
154
|
+
return Object.entries(tokens).reduce(
|
|
155
|
+
(text, [key, value]) => text.replaceAll(`{${key}}`, value),
|
|
156
|
+
settings.chatPromptTemplate,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
`You are a Studio local-debug chat assistant helping someone edit the "${tokens.appSlug}" app (brand: ${tokens.brandSlug}).`,
|
|
162
|
+
`Repo: ${tokens.repoRoot}`,
|
|
163
|
+
`Dev URL: ${tokens.devUrl}`,
|
|
164
|
+
``,
|
|
165
|
+
`This is a conversational turn, not a source-edit dispatch — answer the question or`,
|
|
166
|
+
`discuss the request in plain text. Only touch files if the person explicitly asks`,
|
|
167
|
+
`you to make a change; if you do, describe what you changed in your reply.`,
|
|
168
|
+
`Reply in plain prose — do NOT wrap your answer in the edit-dispatch JSON contract.`,
|
|
169
|
+
``,
|
|
170
|
+
tokens.purpose,
|
|
171
|
+
].filter(Boolean).join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Persist an editable-prompts settings patch to `<repoRoot>/.studio-editor-settings.json`,
|
|
176
|
+
* merging with whatever is already on disk. Only `systemPrompt`, `editPromptTemplate`, and
|
|
177
|
+
* `chatPromptTemplate` are recognized fields — the same keys loadEditorSettings()/buildEditPrompt() (this
|
|
178
|
+
* file) and buildFullSystemPrompt()/buildStudioEditorSystemPrompt() (agent-pool.js) read.
|
|
179
|
+
* Empty-string values clear that field back to the code default rather than persisting "".
|
|
180
|
+
*/
|
|
181
|
+
function saveEditorSettings(repoRoot, patch) {
|
|
182
|
+
if (!repoRoot) throw new Error("repoRoot required");
|
|
183
|
+
const settingsPath = path.join(repoRoot, EDITOR_SETTINGS_FILENAME);
|
|
184
|
+
const current = loadEditorSettings(repoRoot);
|
|
185
|
+
const next = { ...current };
|
|
186
|
+
for (const key of ["systemPrompt", "editPromptTemplate", "chatPromptTemplate"]) {
|
|
187
|
+
if (!(key in patch)) continue;
|
|
188
|
+
const value = typeof patch[key] === "string" ? patch[key].trim() : "";
|
|
189
|
+
if (value) next[key] = value;
|
|
190
|
+
else delete next[key];
|
|
191
|
+
}
|
|
192
|
+
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeString(value, max = MAX_SNIPPET) {
|
|
197
|
+
if (typeof value !== "string") return value;
|
|
198
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeEvent(session, body) {
|
|
202
|
+
const type = body.type || body.mode || "direct_edit";
|
|
203
|
+
const id = body.id || `evt_${crypto.randomUUID()}`;
|
|
204
|
+
const target = body.target && typeof body.target === "object" ? { ...body.target } : {};
|
|
205
|
+
if (typeof target.textSnippet === "string") target.textSnippet = safeString(target.textSnippet, 500);
|
|
206
|
+
if (typeof target.outerHTMLSnippet === "string") target.outerHTMLSnippet = safeString(target.outerHTMLSnippet, MAX_SNIPPET);
|
|
207
|
+
if (typeof target.outerHTML === "string" && !target.outerHTMLSnippet) {
|
|
208
|
+
target.outerHTMLSnippet = safeString(target.outerHTML, MAX_SNIPPET);
|
|
209
|
+
delete target.outerHTML;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
type,
|
|
214
|
+
id,
|
|
215
|
+
mode: body.mode || type,
|
|
216
|
+
sessionId: session.sessionId,
|
|
217
|
+
brandSlug: session.brandSlug,
|
|
218
|
+
appSlug: session.appSlug,
|
|
219
|
+
target,
|
|
220
|
+
reference: body.reference || null,
|
|
221
|
+
instruction: safeString(body.instruction || body.prompt || "", 4_000),
|
|
222
|
+
createdAt: nowIso(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readBody(req, maxBytes = 128 * 1024) {
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
let data = "";
|
|
229
|
+
req.on("data", chunk => {
|
|
230
|
+
data += chunk;
|
|
231
|
+
if (data.length > maxBytes) reject(new Error("Body too large"));
|
|
232
|
+
});
|
|
233
|
+
req.on("end", () => {
|
|
234
|
+
if (!data.trim()) return resolve({});
|
|
235
|
+
try {
|
|
236
|
+
resolve(JSON.parse(data));
|
|
237
|
+
} catch {
|
|
238
|
+
reject(new Error("Invalid JSON"));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
req.on("error", reject);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function writeJson(res, status, data, cors) {
|
|
246
|
+
res.writeHead(status, { "Content-Type": "application/json", ...cors });
|
|
247
|
+
res.end(JSON.stringify(data));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveSourceFile(session, file, allowedExtensions = STYLE_WRITE_EXTENSIONS) {
|
|
251
|
+
if (!file || typeof file !== "string") return null;
|
|
252
|
+
const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
|
|
253
|
+
const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
|
|
254
|
+
const rel = path.relative(root, candidate);
|
|
255
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
256
|
+
if (!allowedExtensions.has(path.extname(candidate).toLowerCase())) return null;
|
|
257
|
+
return { root, path: candidate, relativePath: rel.replace(/\\/g, "/") };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function applyTextRangeEdit(sourceText, textEdit) {
|
|
261
|
+
const source = textEdit.source;
|
|
262
|
+
if (!source || !source.range || typeof source.range.start !== "number" || typeof source.range.end !== "number") {
|
|
263
|
+
throw new Error("text-write requires source.range.start and source.range.end.");
|
|
264
|
+
}
|
|
265
|
+
const { start, end } = source.range;
|
|
266
|
+
if (start < 0 || end < start || end > sourceText.length) {
|
|
267
|
+
throw new Error(`text-write source range [${start}, ${end}] is out of bounds (file length: ${sourceText.length}).`);
|
|
268
|
+
}
|
|
269
|
+
if (textEdit.oldText !== undefined) {
|
|
270
|
+
const oldSlice = sourceText.slice(start, end);
|
|
271
|
+
if (oldSlice !== textEdit.oldText) {
|
|
272
|
+
throw new Error(`text-write oldText mismatch at [${start}, ${end}]: expected "${String(textEdit.oldText).slice(0, 80)}", found "${oldSlice.slice(0, 80)}".`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return `${sourceText.slice(0, start)}${textEdit.newText}${sourceText.slice(end)}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function escapeRegExp(value) {
|
|
279
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function applyCssTextEdit(source, property, value, lineNumber = null) {
|
|
283
|
+
const propertyPattern = new RegExp(`(${escapeRegExp(property)}\\s*:\\s*)([^;\\n]+)(\\s*;?)`);
|
|
284
|
+
if (Number.isInteger(lineNumber) && lineNumber > 0) {
|
|
285
|
+
const lines = source.split(/\r?\n/);
|
|
286
|
+
const index = Math.min(lines.length - 1, lineNumber - 1);
|
|
287
|
+
const windowStart = Math.max(0, index - 8);
|
|
288
|
+
const windowEnd = Math.min(lines.length - 1, index + 8);
|
|
289
|
+
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
290
|
+
if (propertyPattern.test(lines[i])) {
|
|
291
|
+
lines[i] = lines[i].replace(propertyPattern, `$1${value}$3`);
|
|
292
|
+
return { content: lines.join("\n"), strategy: "replace-near-line" };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const anchor = lines[index] ?? "";
|
|
296
|
+
const indent = anchor.match(/^\s*/)?.[0] ?? "";
|
|
297
|
+
lines.splice(index + 1, 0, `${indent}${property}: ${value};`);
|
|
298
|
+
return { content: lines.join("\n"), strategy: "insert-near-line" };
|
|
299
|
+
}
|
|
300
|
+
const replaced = source.replace(propertyPattern, `$1${value}$3`);
|
|
301
|
+
if (replaced !== source) return { content: replaced, strategy: "replace-first" };
|
|
302
|
+
throw new Error(`CSS property "${property}" was not found and no source line was provided.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function applyJsxStyleEdit(source, property, value, lineNumber = null) {
|
|
306
|
+
const lines = source.split(/\r?\n/);
|
|
307
|
+
const targetIndex = Number.isInteger(lineNumber) && lineNumber > 0 ? Math.min(lines.length - 1, lineNumber - 1) : -1;
|
|
308
|
+
const windowStart = targetIndex >= 0 ? Math.max(0, targetIndex - 8) : 0;
|
|
309
|
+
const windowEnd = targetIndex >= 0 ? Math.min(lines.length - 1, targetIndex + 8) : lines.length - 1;
|
|
310
|
+
const camel = property.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
311
|
+
const quoted = JSON.stringify(value);
|
|
312
|
+
const propPattern = new RegExp(`(${camel}\\s*:\\s*)(["'\`])([^"'\`]+)(\\2)`);
|
|
313
|
+
|
|
314
|
+
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
315
|
+
if (propPattern.test(lines[i])) {
|
|
316
|
+
lines[i] = lines[i].replace(propPattern, `$1${quoted}`);
|
|
317
|
+
return { content: lines.join("\n"), strategy: "replace-js-style-near-line" };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (let i = windowStart; i <= windowEnd; i += 1) {
|
|
322
|
+
if (lines[i].includes("style={{")) {
|
|
323
|
+
lines[i] = lines[i].replace("style={{", `style={{ ${camel}: ${quoted},`);
|
|
324
|
+
return { content: lines.join("\n"), strategy: "insert-js-style-near-line" };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw new Error(`No inline style object for "${property}" was found near the selected source line.`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Syntax-check extensions we know how to parse with the TS compiler. Non-TS/JS
|
|
332
|
+
// source (e.g. plain .css, .md) is skipped — there is nothing meaningful for
|
|
333
|
+
// ts.transpileModule to validate there.
|
|
334
|
+
const SYNTAX_CHECK_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
335
|
+
|
|
336
|
+
// Same pattern already proven in
|
|
337
|
+
// packages/studio-local-debug/src/integration/editor-coverage-matrix.test.ts
|
|
338
|
+
// (syntaxDiagnosticsLikeEditor) and returned by apps/studio's accept-adapter.ts
|
|
339
|
+
// as `syntaxDiagnostics`. Kept identical here so results are directly comparable.
|
|
340
|
+
function syntaxDiagnosticsFor(source, fileName) {
|
|
341
|
+
const output = ts.transpileModule(source, {
|
|
342
|
+
fileName,
|
|
343
|
+
reportDiagnostics: true,
|
|
344
|
+
compilerOptions: {
|
|
345
|
+
jsx: ts.JsxEmit.Preserve,
|
|
346
|
+
target: ts.ScriptTarget.ESNext,
|
|
347
|
+
module: ts.ModuleKind.ESNext,
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return output.diagnostics || [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatDiagnostic(diagnostic) {
|
|
354
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
|
355
|
+
if (diagnostic.file && typeof diagnostic.start === "number") {
|
|
356
|
+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
|
357
|
+
return `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
|
|
358
|
+
}
|
|
359
|
+
return message;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Read each changed file (relative to the session repo root) and run a TS
|
|
364
|
+
* syntax check. Returns { ok, diagnostics } — diagnostics is a flat array of
|
|
365
|
+
* human-readable strings, empty when every checkable file is syntax-clean.
|
|
366
|
+
* Files outside SYNTAX_CHECK_EXTENSIONS, or that no longer exist on disk, are
|
|
367
|
+
* skipped (not treated as failures) — this check validates parseability, not
|
|
368
|
+
* file presence.
|
|
369
|
+
*/
|
|
370
|
+
function checkFilesSyntax(session, filesChanged) {
|
|
371
|
+
const diagnostics = [];
|
|
372
|
+
const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
|
|
373
|
+
for (const file of Array.isArray(filesChanged) ? filesChanged : []) {
|
|
374
|
+
if (!file || typeof file !== "string") continue;
|
|
375
|
+
const ext = path.extname(file).toLowerCase();
|
|
376
|
+
if (!SYNTAX_CHECK_EXTENSIONS.has(ext)) continue;
|
|
377
|
+
const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
|
|
378
|
+
let source;
|
|
379
|
+
try {
|
|
380
|
+
source = fs.readFileSync(candidate, "utf8");
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// A referenced file that no longer exists is itself suspicious, but the
|
|
383
|
+
// agent may have renamed/moved it deliberately as part of the edit —
|
|
384
|
+
// treat as unable-to-verify, not a hard syntax failure.
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const fileDiagnostics = syntaxDiagnosticsFor(source, candidate);
|
|
388
|
+
for (const d of fileDiagnostics) {
|
|
389
|
+
diagnostics.push(`${file}: ${formatDiagnostic(d)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return { ok: diagnostics.length === 0, diagnostics };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Best-effort collection of the file(s) an event referenced, used as a
|
|
397
|
+
* fallback filesChanged set when the agent's JSON reply fails to parse (or
|
|
398
|
+
* omits filesChanged) — so a malformed-but-plausible-looking agent response
|
|
399
|
+
* still gets its target file(s) syntax-checked instead of slipping through
|
|
400
|
+
* ungated.
|
|
401
|
+
*/
|
|
402
|
+
function filesReferencedByEvent(event) {
|
|
403
|
+
const files = new Set();
|
|
404
|
+
const add = (f) => {
|
|
405
|
+
if (typeof f === "string" && f.trim()) files.add(f.trim());
|
|
406
|
+
};
|
|
407
|
+
add(event?.file);
|
|
408
|
+
add(event?.target?.file);
|
|
409
|
+
add(event?.textEdit?.source?.file);
|
|
410
|
+
add(event?.styleEdit?.source?.file);
|
|
411
|
+
return [...files];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function isUrlReachable(url) {
|
|
415
|
+
try {
|
|
416
|
+
const response = await fetch(url, {
|
|
417
|
+
method: "GET",
|
|
418
|
+
signal: AbortSignal.timeout(1_500),
|
|
419
|
+
});
|
|
420
|
+
return response.status < 500;
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function resolveTarget(input = {}) {
|
|
427
|
+
const slug = String(input.appSlug || input.brandSlug || "prt").toLowerCase();
|
|
428
|
+
const configured = APP_TARGETS[slug];
|
|
429
|
+
// No registration required IF the caller supplies an explicit devUrl/devCommand —
|
|
430
|
+
// an arbitrary appSlug with an explicit target is a legitimate ad-hoc session. But
|
|
431
|
+
// an unknown slug with NO explicit override has nothing to resolve to at all.
|
|
432
|
+
if (!configured && !input.devUrl && !input.devCommand) {
|
|
433
|
+
return {
|
|
434
|
+
error: "unknown_app",
|
|
435
|
+
message: `Unknown appSlug "${slug}" — not registered in APP_TARGETS and no devUrl/devCommand override was provided.`,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const base = configured || {
|
|
440
|
+
appSlug: slug,
|
|
441
|
+
brandSlug: input.brandSlug || slug,
|
|
442
|
+
command: input.devCommand,
|
|
443
|
+
url: input.devUrl,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
appSlug: input.appSlug || base.appSlug,
|
|
448
|
+
brandSlug: input.brandSlug || base.brandSlug || input.appSlug || base.appSlug,
|
|
449
|
+
command: input.devCommand || base.command,
|
|
450
|
+
url: input.devUrl || base.url,
|
|
451
|
+
cwd: input.cwd || input.repoRoot || process.cwd(),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* A reachable HTTP server is not necessarily a genuine Next.js DEV server the editor
|
|
457
|
+
* can attach HMR-based live edits to — it could be a production build (no HMR at all)
|
|
458
|
+
* or a dev server sitting behind an auth gate the editor can't get past. Probe the
|
|
459
|
+
* webpack-hmr path: a redirect (typically to a login page) or an auth-rejection status
|
|
460
|
+
* means source-edit sessions cannot establish the HMR stream this feature depends on.
|
|
461
|
+
*/
|
|
462
|
+
async function isGenuineDevServer(url) {
|
|
463
|
+
try {
|
|
464
|
+
const response = await fetch(`${url.replace(/\/$/, "")}/_next/webpack-hmr`, {
|
|
465
|
+
method: "GET",
|
|
466
|
+
redirect: "manual",
|
|
467
|
+
signal: AbortSignal.timeout(1_500),
|
|
468
|
+
});
|
|
469
|
+
if (response.status >= 300 && response.status < 400) return false;
|
|
470
|
+
if (response.status === 401 || response.status === 403) return false;
|
|
471
|
+
return true;
|
|
472
|
+
} catch {
|
|
473
|
+
// Network-level errors on a websocket-upgrade path (abrupt close, protocol error)
|
|
474
|
+
// are expected even for a genuine dev server hit with a plain GET — don't reject
|
|
475
|
+
// on that basis alone, only on an explicit auth/redirect signal above.
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function splitCommand(command) {
|
|
481
|
+
if (!command || typeof command !== "string") return null;
|
|
482
|
+
const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
483
|
+
return parts.map(part => part.replace(/^"|"$/g, ""));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function startDevProcess(target, logFile) {
|
|
487
|
+
const parts = splitCommand(target.command);
|
|
488
|
+
if (!parts || parts.length === 0) {
|
|
489
|
+
return { started: false, error: "missing_command" };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const logDir = path.join(os.tmpdir(), "clauth-studio-debug");
|
|
493
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
494
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
495
|
+
const outPath = path.join(logDir, `${target.appSlug}-${stamp}.out.log`);
|
|
496
|
+
const errPath = path.join(logDir, `${target.appSlug}-${stamp}.err.log`);
|
|
497
|
+
const out = fs.openSync(outPath, "a");
|
|
498
|
+
const err = fs.openSync(errPath, "a");
|
|
499
|
+
|
|
500
|
+
const proc = spawn(parts[0], parts.slice(1), {
|
|
501
|
+
cwd: target.cwd,
|
|
502
|
+
env: process.env,
|
|
503
|
+
stdio: ["ignore", out, err],
|
|
504
|
+
shell: process.platform === "win32",
|
|
505
|
+
detached: true,
|
|
506
|
+
windowsHide: true,
|
|
507
|
+
});
|
|
508
|
+
proc.unref();
|
|
509
|
+
try {
|
|
510
|
+
fs.appendFileSync(logFile, `[${nowIso()}] studio-debug dev start pid=${proc.pid} command=${target.command}\n`);
|
|
511
|
+
} catch {}
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
started: true,
|
|
515
|
+
pid: proc.pid,
|
|
516
|
+
command: target.command,
|
|
517
|
+
url: target.url,
|
|
518
|
+
stdout: outPath,
|
|
519
|
+
stderr: errPath,
|
|
520
|
+
process: proc,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export class StudioDebugSessionStore {
|
|
525
|
+
constructor({
|
|
526
|
+
port = 52437,
|
|
527
|
+
logFile = path.join(os.tmpdir(), "clauth-serve.log"),
|
|
528
|
+
dispatchAgent = null,
|
|
529
|
+
acquireSessionWorker = null,
|
|
530
|
+
releaseSessionWorker = null,
|
|
531
|
+
} = {}) {
|
|
532
|
+
this.port = port;
|
|
533
|
+
this.logFile = logFile;
|
|
534
|
+
this.sessions = new Map();
|
|
535
|
+
this.dispatchAgent = dispatchAgent;
|
|
536
|
+
// One warm channel per editor session, acquired eagerly at start() and reused for
|
|
537
|
+
// every dispatch (chat and direct-edit alike) via submitEvent()'s dispatchAgent call
|
|
538
|
+
// through to session end — never a fresh one-shot process per edit.
|
|
539
|
+
this.acquireSessionWorker = acquireSessionWorker;
|
|
540
|
+
this.releaseSessionWorker = releaseSessionWorker;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async start(input = {}) {
|
|
544
|
+
const target = resolveTarget(input);
|
|
545
|
+
if (target.error) {
|
|
546
|
+
return { ok: false, error: target.error, message: target.message, status: "error" };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const shouldLaunch = input.launchDevServer !== false;
|
|
550
|
+
const reachable = await isUrlReachable(target.url);
|
|
551
|
+
if (reachable && !(await isGenuineDevServer(target.url))) {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
error: "non_dev_server",
|
|
555
|
+
message: `${target.url} is reachable but its Next dev HMR stream is not accessible (redirected or auth-gated) — source-edit sessions require a genuine, reachable Next.js dev server.`,
|
|
556
|
+
status: "error",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const launch = reachable
|
|
560
|
+
? { started: false, command: target.command, url: target.url, alreadyRunning: true }
|
|
561
|
+
: shouldLaunch
|
|
562
|
+
? startDevProcess(target, this.logFile)
|
|
563
|
+
: { started: false, command: target.command, url: target.url, skipped: true };
|
|
564
|
+
|
|
565
|
+
const sessionId = input.sessionId || `studio-${crypto.randomUUID()}`;
|
|
566
|
+
const token = crypto.randomBytes(24).toString("base64url");
|
|
567
|
+
const session = {
|
|
568
|
+
sessionId,
|
|
569
|
+
token,
|
|
570
|
+
brandSlug: target.brandSlug,
|
|
571
|
+
appSlug: target.appSlug,
|
|
572
|
+
repoRoot: input.repoRoot || target.cwd,
|
|
573
|
+
cwd: target.cwd,
|
|
574
|
+
devCommand: target.command,
|
|
575
|
+
devUrl: target.url,
|
|
576
|
+
modeDefault: input.modeDefault || "direct_edit",
|
|
577
|
+
agentModel: typeof input.agentModel === "string" && input.agentModel.trim() ? input.agentModel.trim() : null,
|
|
578
|
+
warmAgent: input.warmAgent !== false,
|
|
579
|
+
status: "waiting_for_agent",
|
|
580
|
+
createdAt: nowIso(),
|
|
581
|
+
updatedAt: nowIso(),
|
|
582
|
+
stopped: false,
|
|
583
|
+
events: [],
|
|
584
|
+
replies: [],
|
|
585
|
+
pendingPolls: [],
|
|
586
|
+
launch,
|
|
587
|
+
};
|
|
588
|
+
this.sessions.set(sessionId, session);
|
|
589
|
+
|
|
590
|
+
const relayBaseUrl = `http://127.0.0.1:${this.port}/studio/debug/${sessionId}`;
|
|
591
|
+
const claudeBaseUrl = `http://127.0.0.1:${this.port}/studio/claude/${sessionId}`;
|
|
592
|
+
const pollCommand = `node scripts/studio-debug-poll.mjs --session ${sessionId} --token ${token} --relay ${claudeBaseUrl}`;
|
|
593
|
+
|
|
594
|
+
// When dispatchAgent is wired, events dispatch directly — no polling agent needed.
|
|
595
|
+
if (this.dispatchAgent) {
|
|
596
|
+
session.status = "waiting_for_event";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Acquire this session's warm channel now, at session start — not lazily on the
|
|
600
|
+
// first edit. Every subsequent dispatchAgent call for this sessionId (chat or
|
|
601
|
+
// direct-edit) reuses the same pinned worker. Best-effort: a warm-pool exhaustion
|
|
602
|
+
// here does not fail session start, dispatchToSession() will surface it per-turn.
|
|
603
|
+
if (session.warmAgent && this.acquireSessionWorker) {
|
|
604
|
+
try { this.acquireSessionWorker(sessionId, session.agentModel, session.repoRoot || session.cwd); } catch { /* best-effort */ }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
sessionId,
|
|
610
|
+
token,
|
|
611
|
+
devUrl: target.url,
|
|
612
|
+
relayBaseUrl,
|
|
613
|
+
status: session.status,
|
|
614
|
+
agentMode: session.warmAgent ? "warm" : "disabled",
|
|
615
|
+
launch: {
|
|
616
|
+
started: !!launch.started,
|
|
617
|
+
alreadyRunning: !!launch.alreadyRunning,
|
|
618
|
+
command: launch.command,
|
|
619
|
+
url: launch.url,
|
|
620
|
+
pid: launch.pid || null,
|
|
621
|
+
},
|
|
622
|
+
pollCommand,
|
|
623
|
+
claudeBaseUrl,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
get(sessionId) {
|
|
628
|
+
return this.sessions.get(sessionId) || null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
authenticate(sessionId, token) {
|
|
632
|
+
const session = this.get(sessionId);
|
|
633
|
+
if (!session) return { error: "not_found" };
|
|
634
|
+
if (session.token !== token) return { error: "unauthorized" };
|
|
635
|
+
return { session };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
submitEvent(sessionId, body) {
|
|
639
|
+
const auth = this.authenticate(sessionId, body.token);
|
|
640
|
+
if (auth.error) return auth;
|
|
641
|
+
const session = auth.session;
|
|
642
|
+
if (!session.warmAgent) {
|
|
643
|
+
return {
|
|
644
|
+
error: "agent_disabled",
|
|
645
|
+
message: "This direct-write session does not allow agent-dispatched events.",
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const event = normalizeEvent(session, body);
|
|
649
|
+
|
|
650
|
+
session.updatedAt = nowIso();
|
|
651
|
+
session.status = "event_pending";
|
|
652
|
+
|
|
653
|
+
// If a dispatchAgent handler exists, spawn a full headless Claude agent.
|
|
654
|
+
if (this.dispatchAgent) {
|
|
655
|
+
// Stash images from the event body so the dispatcher can write them to tmp
|
|
656
|
+
if (Array.isArray(body.images) && body.images.length > 0) {
|
|
657
|
+
session._pendingImages = body.images;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// A chat turn is conversational, not a source-edit dispatch: use buildChatPrompt()
|
|
661
|
+
// (plain prose, no "make ONE source edit" instruction, no edit-JSON output contract)
|
|
662
|
+
// and skip the syntax-check-and-retry loop below entirely — there's no edited file
|
|
663
|
+
// to check, and the agent's raw reply text IS the message, not something to parse
|
|
664
|
+
// as JSON. Previously every chat message reused buildEditPrompt(), so the agent was
|
|
665
|
+
// told to edit a file and reply in {"status","message","filesChanged"} JSON for what
|
|
666
|
+
// was just a question — see submitEvent's mode branch below for the corresponding fix.
|
|
667
|
+
if (event.mode === "chat") {
|
|
668
|
+
const chatPrompt = buildChatPrompt(session, event);
|
|
669
|
+
this.dispatchAgent(event.id, session.token, null, session, chatPrompt)
|
|
670
|
+
.then((result) => {
|
|
671
|
+
const message = String(result?.package ?? "").trim() || "(no reply)";
|
|
672
|
+
session.replies.push({
|
|
673
|
+
eventId: event.id,
|
|
674
|
+
status: result?.ok === false ? "error" : "done",
|
|
675
|
+
message,
|
|
676
|
+
filesChanged: [],
|
|
677
|
+
diagnostics: [],
|
|
678
|
+
createdAt: nowIso(),
|
|
679
|
+
});
|
|
680
|
+
session.status = result?.ok === false ? "error" : "done";
|
|
681
|
+
session.updatedAt = nowIso();
|
|
682
|
+
})
|
|
683
|
+
.catch((err) => {
|
|
684
|
+
session.replies.push({
|
|
685
|
+
eventId: event.id,
|
|
686
|
+
status: "error",
|
|
687
|
+
message: `Chat dispatch failed: ${err?.message || "unknown error"}`,
|
|
688
|
+
filesChanged: [],
|
|
689
|
+
diagnostics: [],
|
|
690
|
+
createdAt: nowIso(),
|
|
691
|
+
});
|
|
692
|
+
session.status = "error";
|
|
693
|
+
session.updatedAt = nowIso();
|
|
694
|
+
});
|
|
695
|
+
return { ok: true, eventId: event.id, status: session.status };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const prompt = buildEditPrompt(session, event);
|
|
699
|
+
|
|
700
|
+
const fallbackFiles = filesReferencedByEvent(event);
|
|
701
|
+
|
|
702
|
+
const parseAgentResult = (result) => {
|
|
703
|
+
// BUG FIX (2026-07-02): AgentPool.dispatchToSession()/dispatch() resolve with
|
|
704
|
+
// `result.package` (the agent's raw final-text response, see agent-pool.js:629
|
|
705
|
+
// `package: (text || "").trim()`) -- NOT `result.stdout` or `result.output`,
|
|
706
|
+
// neither of which exist on this result shape. Reading the wrong fields meant
|
|
707
|
+
// JSON.parse("") always threw, parsed silently stayed {}, and EVERY dispatch
|
|
708
|
+
// reported status:"done" / filesChanged:[] regardless of what the agent
|
|
709
|
+
// actually did -- the syntax-check-and-retry loop below was checking an empty
|
|
710
|
+
// file list every single time and never catching anything.
|
|
711
|
+
const rawText = String(result?.package ?? "").trim();
|
|
712
|
+
let parsed = {};
|
|
713
|
+
try {
|
|
714
|
+
parsed = JSON.parse(rawText);
|
|
715
|
+
} catch {
|
|
716
|
+
// Agents don't always emit pure JSON despite instructions -- a trailing/leading
|
|
717
|
+
// sentence around the JSON object is common. Try to recover the LAST {...}
|
|
718
|
+
// block in the text before giving up (never throws further).
|
|
719
|
+
const match = rawText.match(/\{[\s\S]*\}/);
|
|
720
|
+
if (match) {
|
|
721
|
+
try { parsed = JSON.parse(match[0]); } catch { /* give up, use defaults below */ }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const filesChanged = Array.isArray(parsed.filesChanged) && parsed.filesChanged.length > 0
|
|
725
|
+
? parsed.filesChanged
|
|
726
|
+
: fallbackFiles;
|
|
727
|
+
return {
|
|
728
|
+
status: parsed.status || "done",
|
|
729
|
+
message: parsed.message || rawText.slice(0, 500) || "Agent completed",
|
|
730
|
+
filesChanged,
|
|
731
|
+
};
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// D1/D2 (studio-editor-v1-auto-escalation.md): after the dispatched agent
|
|
735
|
+
// reports back, verify the file(s) it touched still parse before trusting
|
|
736
|
+
// "status":"done". A malformed agent JSON reply (bare try/catch above)
|
|
737
|
+
// must NOT bypass this — filesChanged falls back to whatever the original
|
|
738
|
+
// event referenced so even an unparseable reply still gets checked.
|
|
739
|
+
// On a syntax failure, re-dispatch exactly once with the diagnostic
|
|
740
|
+
// appended; a second failure (or a second dispatch error) is a hard stop
|
|
741
|
+
// to status "error" with diagnostics attached — never a silent accept,
|
|
742
|
+
// never an unbounded retry loop.
|
|
743
|
+
this.dispatchAgent(event.id, session.token, null, session, prompt)
|
|
744
|
+
.then(async (result) => {
|
|
745
|
+
const attempt1 = parseAgentResult(result);
|
|
746
|
+
const check1 = checkFilesSyntax(session, attempt1.filesChanged);
|
|
747
|
+
|
|
748
|
+
if (check1.ok) {
|
|
749
|
+
session.replies.push({
|
|
750
|
+
eventId: event.id,
|
|
751
|
+
status: attempt1.status,
|
|
752
|
+
message: attempt1.message,
|
|
753
|
+
filesChanged: attempt1.filesChanged,
|
|
754
|
+
diagnostics: [],
|
|
755
|
+
createdAt: nowIso(),
|
|
756
|
+
});
|
|
757
|
+
session.status = "done";
|
|
758
|
+
session.updatedAt = nowIso();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const retryPrompt = [
|
|
763
|
+
prompt,
|
|
764
|
+
``,
|
|
765
|
+
`Your previous edit broke the file's syntax. Diagnostic: ${check1.diagnostics.join("; ")}.`,
|
|
766
|
+
`Fix the file so it is syntactically valid, preserving your intended change.`,
|
|
767
|
+
].join("\n");
|
|
768
|
+
|
|
769
|
+
let attempt2Result;
|
|
770
|
+
try {
|
|
771
|
+
attempt2Result = await this.dispatchAgent(event.id, session.token, null, session, retryPrompt);
|
|
772
|
+
} catch {
|
|
773
|
+
session.replies.push({
|
|
774
|
+
eventId: event.id,
|
|
775
|
+
status: "error",
|
|
776
|
+
message: "Agent retry dispatch failed after syntax check failure",
|
|
777
|
+
filesChanged: attempt1.filesChanged,
|
|
778
|
+
diagnostics: check1.diagnostics,
|
|
779
|
+
createdAt: nowIso(),
|
|
780
|
+
});
|
|
781
|
+
session.status = "error";
|
|
782
|
+
session.updatedAt = nowIso();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const attempt2 = parseAgentResult(attempt2Result);
|
|
787
|
+
const check2 = checkFilesSyntax(session, attempt2.filesChanged);
|
|
788
|
+
|
|
789
|
+
if (check2.ok) {
|
|
790
|
+
session.replies.push({
|
|
791
|
+
eventId: event.id,
|
|
792
|
+
status: attempt2.status,
|
|
793
|
+
message: attempt2.message,
|
|
794
|
+
filesChanged: attempt2.filesChanged,
|
|
795
|
+
diagnostics: [],
|
|
796
|
+
createdAt: nowIso(),
|
|
797
|
+
});
|
|
798
|
+
session.status = "done";
|
|
799
|
+
session.updatedAt = nowIso();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
session.replies.push({
|
|
804
|
+
eventId: event.id,
|
|
805
|
+
status: "error",
|
|
806
|
+
message: `Agent edit failed syntax check after retry: ${check2.diagnostics.join("; ")}`,
|
|
807
|
+
filesChanged: attempt2.filesChanged,
|
|
808
|
+
diagnostics: check2.diagnostics,
|
|
809
|
+
createdAt: nowIso(),
|
|
810
|
+
});
|
|
811
|
+
session.status = "error";
|
|
812
|
+
session.updatedAt = nowIso();
|
|
813
|
+
})
|
|
814
|
+
.catch(() => {
|
|
815
|
+
session.replies.push({
|
|
816
|
+
eventId: event.id,
|
|
817
|
+
status: "error",
|
|
818
|
+
message: "Agent dispatch failed",
|
|
819
|
+
filesChanged: [],
|
|
820
|
+
diagnostics: [],
|
|
821
|
+
createdAt: nowIso(),
|
|
822
|
+
});
|
|
823
|
+
session.status = "error";
|
|
824
|
+
session.updatedAt = nowIso();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
return { ok: true, eventId: event.id, status: session.status };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Legacy path: queue for a polling agent
|
|
831
|
+
if (session.pendingPolls.length > 0) {
|
|
832
|
+
const poll = session.pendingPolls.shift();
|
|
833
|
+
poll(event);
|
|
834
|
+
} else {
|
|
835
|
+
session.events.push(event);
|
|
836
|
+
if (session.events.length > MAX_EVENT_QUEUE) session.events.shift();
|
|
837
|
+
}
|
|
838
|
+
return { ok: true, eventId: event.id, status: session.status };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
poll(sessionId, token, timeoutMs = DEFAULT_POLL_TIMEOUT) {
|
|
842
|
+
const auth = this.authenticate(sessionId, token);
|
|
843
|
+
if (auth.error) return Promise.resolve(auth);
|
|
844
|
+
const session = auth.session;
|
|
845
|
+
if (session.events.length > 0) {
|
|
846
|
+
session.status = "agent_received";
|
|
847
|
+
session.updatedAt = nowIso();
|
|
848
|
+
return Promise.resolve(session.events.shift());
|
|
849
|
+
}
|
|
850
|
+
if (session.stopped) return Promise.resolve({ type: "stopped" });
|
|
851
|
+
|
|
852
|
+
const timeout = Math.min(Math.max(Number(timeoutMs) || DEFAULT_POLL_TIMEOUT, 1), MAX_POLL_TIMEOUT);
|
|
853
|
+
session.status = "waiting_for_event";
|
|
854
|
+
session.updatedAt = nowIso();
|
|
855
|
+
|
|
856
|
+
return new Promise(resolve => {
|
|
857
|
+
const timer = setTimeout(() => {
|
|
858
|
+
session.pendingPolls = session.pendingPolls.filter(fn => fn !== finish);
|
|
859
|
+
resolve({ type: "timeout" });
|
|
860
|
+
}, timeout);
|
|
861
|
+
const finish = event => {
|
|
862
|
+
clearTimeout(timer);
|
|
863
|
+
session.status = "agent_received";
|
|
864
|
+
session.updatedAt = nowIso();
|
|
865
|
+
resolve(event);
|
|
866
|
+
};
|
|
867
|
+
session.pendingPolls.push(finish);
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
reply(sessionId, body) {
|
|
872
|
+
const auth = this.authenticate(sessionId, body.token);
|
|
873
|
+
if (auth.error) return auth;
|
|
874
|
+
const session = auth.session;
|
|
875
|
+
const reply = {
|
|
876
|
+
eventId: body.eventId,
|
|
877
|
+
status: body.status || body.type || "done",
|
|
878
|
+
message: body.message || "",
|
|
879
|
+
filesChanged: Array.isArray(body.filesChanged)
|
|
880
|
+
? body.filesChanged
|
|
881
|
+
: body.file
|
|
882
|
+
? [body.file]
|
|
883
|
+
: [],
|
|
884
|
+
createdAt: nowIso(),
|
|
885
|
+
};
|
|
886
|
+
session.replies.push(reply);
|
|
887
|
+
session.status = reply.status === "done" ? "done" : reply.status;
|
|
888
|
+
session.updatedAt = nowIso();
|
|
889
|
+
return { ok: true, status: session.status, reply };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
writeText(sessionId, body) {
|
|
893
|
+
const auth = this.authenticate(sessionId, body.token);
|
|
894
|
+
if (auth.error) return auth;
|
|
895
|
+
const session = auth.session;
|
|
896
|
+
const textEdit = body.textEdit && typeof body.textEdit === "object" ? body.textEdit : {};
|
|
897
|
+
const source = textEdit.source && typeof textEdit.source === "object" ? textEdit.source : {};
|
|
898
|
+
const file = source.file || textEdit.file || body.file;
|
|
899
|
+
const fileInfo = resolveSourceFile(session, file, TEXT_WRITE_EXTENSIONS);
|
|
900
|
+
if (!fileInfo) {
|
|
901
|
+
return { error: "invalid_source", message: `text-write requires a source file inside the debug session repo (got: ${file || "(none)"}).` };
|
|
902
|
+
}
|
|
903
|
+
if (typeof textEdit.newText !== "string") {
|
|
904
|
+
return { error: "invalid_body", message: "text-write requires textEdit.newText." };
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
const original = fs.readFileSync(fileInfo.path, "utf8");
|
|
908
|
+
const updated = applyTextRangeEdit(original, { ...textEdit, source });
|
|
909
|
+
fs.writeFileSync(fileInfo.path, updated, "utf8");
|
|
910
|
+
const reply = {
|
|
911
|
+
eventId: body.id || `text_${crypto.randomUUID()}`,
|
|
912
|
+
status: "done",
|
|
913
|
+
message: `Direct text write (source_replace [${source.range?.start}, ${source.range?.end}])`,
|
|
914
|
+
filesChanged: [fileInfo.relativePath],
|
|
915
|
+
createdAt: nowIso(),
|
|
916
|
+
};
|
|
917
|
+
session.replies.push(reply);
|
|
918
|
+
session.status = "done";
|
|
919
|
+
session.updatedAt = nowIso();
|
|
920
|
+
return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged };
|
|
921
|
+
} catch (err) {
|
|
922
|
+
return { error: "text_write_failed", message: err instanceof Error ? err.message : String(err) };
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
writeStyle(sessionId, body) {
|
|
927
|
+
const auth = this.authenticate(sessionId, body.token);
|
|
928
|
+
if (auth.error) return auth;
|
|
929
|
+
const session = auth.session;
|
|
930
|
+
const styleEdit = body.styleEdit && typeof body.styleEdit === "object" ? body.styleEdit : {};
|
|
931
|
+
const deltas = Array.isArray(styleEdit.deltas) ? styleEdit.deltas : [];
|
|
932
|
+
const source = styleEdit.source && typeof styleEdit.source === "object" ? styleEdit.source : {};
|
|
933
|
+
const fileInfo = resolveSourceFile(session, source.file || body.file);
|
|
934
|
+
if (!fileInfo) return { error: "invalid_source", message: "style-write requires a source file inside the debug session repo." };
|
|
935
|
+
if (deltas.length !== 1) return { error: "invalid_delta", message: "style-write currently accepts exactly one deterministic CSS delta." };
|
|
936
|
+
|
|
937
|
+
const delta = deltas[0] || {};
|
|
938
|
+
if (typeof delta.property !== "string" || typeof delta.value !== "string") {
|
|
939
|
+
return { error: "invalid_delta", message: "style-write requires string property and value." };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
const original = fs.readFileSync(fileInfo.path, "utf8");
|
|
944
|
+
const ext = path.extname(fileInfo.path).toLowerCase();
|
|
945
|
+
const result = ext === ".jsx" || ext === ".tsx"
|
|
946
|
+
? applyJsxStyleEdit(original, delta.property, delta.value, source.line || body.line || null)
|
|
947
|
+
: applyCssTextEdit(original, delta.property, delta.value, source.line || body.line || null);
|
|
948
|
+
fs.writeFileSync(fileInfo.path, result.content, "utf8");
|
|
949
|
+
const reply = {
|
|
950
|
+
eventId: body.id || `style_${crypto.randomUUID()}`,
|
|
951
|
+
status: "done",
|
|
952
|
+
message: `Direct style write (${result.strategy})`,
|
|
953
|
+
filesChanged: [fileInfo.relativePath],
|
|
954
|
+
createdAt: nowIso(),
|
|
955
|
+
};
|
|
956
|
+
session.replies.push(reply);
|
|
957
|
+
session.status = "done";
|
|
958
|
+
session.updatedAt = nowIso();
|
|
959
|
+
return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged, strategy: result.strategy };
|
|
960
|
+
} catch (err) {
|
|
961
|
+
return { error: "style_write_failed", message: err instanceof Error ? err.message : String(err) };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
status(sessionId, token) {
|
|
966
|
+
const auth = this.authenticate(sessionId, token);
|
|
967
|
+
if (auth.error) return auth;
|
|
968
|
+
const session = auth.session;
|
|
969
|
+
return {
|
|
970
|
+
ok: true,
|
|
971
|
+
sessionId: session.sessionId,
|
|
972
|
+
brandSlug: session.brandSlug,
|
|
973
|
+
appSlug: session.appSlug,
|
|
974
|
+
devUrl: session.devUrl,
|
|
975
|
+
status: session.status,
|
|
976
|
+
createdAt: session.createdAt,
|
|
977
|
+
updatedAt: session.updatedAt,
|
|
978
|
+
pendingEvents: session.events.length,
|
|
979
|
+
pendingPolls: session.pendingPolls.length,
|
|
980
|
+
replies: session.replies,
|
|
981
|
+
launch: {
|
|
982
|
+
started: !!session.launch?.started,
|
|
983
|
+
alreadyRunning: !!session.launch?.alreadyRunning,
|
|
984
|
+
command: session.launch?.command || session.devCommand,
|
|
985
|
+
url: session.launch?.url || session.devUrl,
|
|
986
|
+
pid: session.launch?.pid || null,
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
stop(sessionId, token) {
|
|
992
|
+
const auth = this.authenticate(sessionId, token);
|
|
993
|
+
if (auth.error) return auth;
|
|
994
|
+
const session = auth.session;
|
|
995
|
+
session.stopped = true;
|
|
996
|
+
session.status = "stopped";
|
|
997
|
+
session.updatedAt = nowIso();
|
|
998
|
+
if (session.launch?.process && !session.launch.process.killed) {
|
|
999
|
+
try {
|
|
1000
|
+
session.launch.process.kill();
|
|
1001
|
+
} catch {}
|
|
1002
|
+
}
|
|
1003
|
+
for (const poll of session.pendingPolls.splice(0)) poll({ type: "stopped" });
|
|
1004
|
+
this.sessions.delete(sessionId);
|
|
1005
|
+
if (this.releaseSessionWorker) {
|
|
1006
|
+
try { this.releaseSessionWorker(sessionId); } catch { /* best-effort */ }
|
|
1007
|
+
}
|
|
1008
|
+
return { ok: true, status: "stopped", sessionId };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function createStudioDebugRuntime(options) {
|
|
1013
|
+
const store = new StudioDebugSessionStore(options);
|
|
1014
|
+
|
|
1015
|
+
async function handle(req, res, url, cors) {
|
|
1016
|
+
const reqPath = url.pathname;
|
|
1017
|
+
const method = req.method;
|
|
1018
|
+
|
|
1019
|
+
if (method === "POST" && reqPath === "/studio/debug/start") {
|
|
1020
|
+
try {
|
|
1021
|
+
const body = await readBody(req);
|
|
1022
|
+
const result = await store.start(body);
|
|
1023
|
+
return writeJson(res, result.ok ? 200 : 400, result, cors);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Editable-prompts settings — the UI for .studio-editor-settings.json (loadEditorSettings()
|
|
1030
|
+
// above / buildFullSystemPrompt()+buildStudioEditorSystemPrompt() in agent-pool.js). GET
|
|
1031
|
+
// returns what's on disk (empty object if never set); POST merges a partial patch.
|
|
1032
|
+
if (reqPath === "/studio/editor-settings") {
|
|
1033
|
+
const repoRoot = url.searchParams.get("repoRoot");
|
|
1034
|
+
if (!repoRoot) return writeJson(res, 400, { ok: false, error: "repoRoot query param required" }, cors);
|
|
1035
|
+
if (method === "GET") {
|
|
1036
|
+
return writeJson(res, 200, { ok: true, settings: loadEditorSettings(repoRoot) }, cors);
|
|
1037
|
+
}
|
|
1038
|
+
if (method === "POST") {
|
|
1039
|
+
try {
|
|
1040
|
+
const patch = await readBody(req);
|
|
1041
|
+
const settings = saveEditorSettings(repoRoot, patch || {});
|
|
1042
|
+
return writeJson(res, 200, { ok: true, settings }, cors);
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return writeJson(res, 405, { ok: false, error: "method not allowed" }, cors);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|text-write|status|stop)$/);
|
|
1051
|
+
const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
|
|
1052
|
+
if (!debugMatch && !claudeMatch) return false;
|
|
1053
|
+
const [, sessionId, action] = debugMatch || claudeMatch;
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
if (method === "POST" && action === "events") {
|
|
1057
|
+
const result = store.submitEvent(sessionId, await readBody(req));
|
|
1058
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1059
|
+
}
|
|
1060
|
+
if (method === "POST" && action === "style-write") {
|
|
1061
|
+
const result = store.writeStyle(sessionId, await readBody(req));
|
|
1062
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
1063
|
+
}
|
|
1064
|
+
if (method === "POST" && action === "text-write") {
|
|
1065
|
+
const result = store.writeText(sessionId, await readBody(req));
|
|
1066
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
1067
|
+
}
|
|
1068
|
+
if (method === "GET" && action === "poll") {
|
|
1069
|
+
const result = await store.poll(sessionId, url.searchParams.get("token"), url.searchParams.get("timeout"));
|
|
1070
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1071
|
+
}
|
|
1072
|
+
if (method === "POST" && action === "reply") {
|
|
1073
|
+
const result = store.reply(sessionId, await readBody(req));
|
|
1074
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1075
|
+
}
|
|
1076
|
+
if (method === "GET" && action === "status") {
|
|
1077
|
+
const result = store.status(sessionId, url.searchParams.get("token"));
|
|
1078
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1079
|
+
}
|
|
1080
|
+
if (method === "POST" && action === "stop") {
|
|
1081
|
+
const body = await readBody(req);
|
|
1082
|
+
const result = store.stop(sessionId, body.token || url.searchParams.get("token"));
|
|
1083
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|
|
1084
|
+
}
|
|
1085
|
+
return writeJson(res, 405, { ok: false, error: "method_not_allowed" }, cors);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
return writeJson(res, 400, { ok: false, error: err.message }, cors);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return { store, handle };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export const studioDebugTargets = APP_TARGETS;
|
|
1095
|
+
export { buildEditPrompt, buildChatPrompt, loadEditorSettings };
|