@lifeaitools/clauth 1.19.4 → 1.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
5
5
  import { spawn } from "child_process";
6
+ import ts from "typescript";
6
7
 
7
8
  const DEFAULT_POLL_TIMEOUT = 600_000;
8
9
  const MAX_POLL_TIMEOUT = 600_000;
@@ -46,6 +47,152 @@ function nowIso() {
46
47
  return new Date().toISOString();
47
48
  }
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
+
49
196
  function safeString(value, max = MAX_SNIPPET) {
50
197
  if (typeof value !== "string") return value;
51
198
  return value.length > max ? value.slice(0, max) : value;
@@ -181,6 +328,89 @@ function applyJsxStyleEdit(source, property, value, lineNumber = null) {
181
328
  throw new Error(`No inline style object for "${property}" was found near the selected source line.`);
182
329
  }
183
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
+
184
414
  async function isUrlReachable(url) {
185
415
  try {
186
416
  const response = await fetch(url, {
@@ -196,8 +426,15 @@ async function isUrlReachable(url) {
196
426
  function resolveTarget(input = {}) {
197
427
  const slug = String(input.appSlug || input.brandSlug || "prt").toLowerCase();
198
428
  const configured = APP_TARGETS[slug];
199
- // No registration required any appSlug proceeds. Sessions without a devUrl
200
- // or devCommand simply have no iframe target; the picker still works via extension.
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
+ }
201
438
 
202
439
  const base = configured || {
203
440
  appSlug: slug,
@@ -215,6 +452,31 @@ function resolveTarget(input = {}) {
215
452
  };
216
453
  }
217
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
+
218
480
  function splitCommand(command) {
219
481
  if (!command || typeof command !== "string") return null;
220
482
  const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
@@ -260,11 +522,22 @@ function startDevProcess(target, logFile) {
260
522
  }
261
523
 
262
524
  export class StudioDebugSessionStore {
263
- constructor({ port = 52437, logFile = path.join(os.tmpdir(), "clauth-serve.log"), dispatchAgent = null } = {}) {
525
+ constructor({
526
+ port = 52437,
527
+ logFile = path.join(os.tmpdir(), "clauth-serve.log"),
528
+ dispatchAgent = null,
529
+ acquireSessionWorker = null,
530
+ releaseSessionWorker = null,
531
+ } = {}) {
264
532
  this.port = port;
265
533
  this.logFile = logFile;
266
534
  this.sessions = new Map();
267
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;
268
541
  }
269
542
 
270
543
  async start(input = {}) {
@@ -275,6 +548,14 @@ export class StudioDebugSessionStore {
275
548
 
276
549
  const shouldLaunch = input.launchDevServer !== false;
277
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
+ }
278
559
  const launch = reachable
279
560
  ? { started: false, command: target.command, url: target.url, alreadyRunning: true }
280
561
  : shouldLaunch
@@ -293,6 +574,7 @@ export class StudioDebugSessionStore {
293
574
  devCommand: target.command,
294
575
  devUrl: target.url,
295
576
  modeDefault: input.modeDefault || "direct_edit",
577
+ agentModel: typeof input.agentModel === "string" && input.agentModel.trim() ? input.agentModel.trim() : null,
296
578
  status: "waiting_for_agent",
297
579
  createdAt: nowIso(),
298
580
  updatedAt: nowIso(),
@@ -313,6 +595,14 @@ export class StudioDebugSessionStore {
313
595
  session.status = "waiting_for_event";
314
596
  }
315
597
 
598
+ // Acquire this session's warm channel now, at session start — not lazily on the
599
+ // first edit. Every subsequent dispatchAgent call for this sessionId (chat or
600
+ // direct-edit) reuses the same pinned worker. Best-effort: a warm-pool exhaustion
601
+ // here does not fail session start, dispatchToSession() will surface it per-turn.
602
+ if (this.acquireSessionWorker) {
603
+ try { this.acquireSessionWorker(sessionId, session.agentModel, session.repoRoot || session.cwd); } catch { /* best-effort */ }
604
+ }
605
+
316
606
  return {
317
607
  ok: true,
318
608
  sessionId,
@@ -358,39 +648,159 @@ export class StudioDebugSessionStore {
358
648
  if (Array.isArray(body.images) && body.images.length > 0) {
359
649
  session._pendingImages = body.images;
360
650
  }
361
- const prompt = [
362
- `You are a Studio local-debug agent. Make ONE source edit to the codebase.`,
363
- ``,
364
- `App: ${session.appSlug} (brand: ${session.brandSlug})`,
365
- `Repo: ${session.repoRoot || session.cwd}`,
366
- `Dev URL: ${session.devUrl || "unknown"}`,
367
- ``,
368
- `Edit event:`,
369
- ` Mode: ${event.mode || "direct_edit"}`,
370
- ` Selector: ${event.selector || "unknown"}`,
371
- ` Instruction: ${event.instruction || "Make the requested edit."}`,
372
- event.textSnippet ? ` Current text: "${event.textSnippet}"` : "",
373
- event.textEdit ? ` New text: "${event.textEdit.newText || ""}"` : "",
374
- event.file ? ` File hint: ${event.file}` : "",
375
- event.notes ? ` Notes: ${event.notes}` : "",
376
- ``,
377
- `Find the source file in the repo that renders this element and make the edit.`,
378
- `After editing, output a JSON object: {"status":"done","message":"<what you did>","filesChanged":["<path>"]}`,
379
- ].filter(Boolean).join("\n");
380
651
 
652
+ // A chat turn is conversational, not a source-edit dispatch: use buildChatPrompt()
653
+ // (plain prose, no "make ONE source edit" instruction, no edit-JSON output contract)
654
+ // and skip the syntax-check-and-retry loop below entirely — there's no edited file
655
+ // to check, and the agent's raw reply text IS the message, not something to parse
656
+ // as JSON. Previously every chat message reused buildEditPrompt(), so the agent was
657
+ // told to edit a file and reply in {"status","message","filesChanged"} JSON for what
658
+ // was just a question — see submitEvent's mode branch below for the corresponding fix.
659
+ if (event.mode === "chat") {
660
+ const chatPrompt = buildChatPrompt(session, event);
661
+ this.dispatchAgent(event.id, session.token, null, session, chatPrompt)
662
+ .then((result) => {
663
+ const message = String(result?.package ?? "").trim() || "(no reply)";
664
+ session.replies.push({
665
+ eventId: event.id,
666
+ status: result?.ok === false ? "error" : "done",
667
+ message,
668
+ filesChanged: [],
669
+ diagnostics: [],
670
+ createdAt: nowIso(),
671
+ });
672
+ session.status = result?.ok === false ? "error" : "done";
673
+ session.updatedAt = nowIso();
674
+ })
675
+ .catch((err) => {
676
+ session.replies.push({
677
+ eventId: event.id,
678
+ status: "error",
679
+ message: `Chat dispatch failed: ${err?.message || "unknown error"}`,
680
+ filesChanged: [],
681
+ diagnostics: [],
682
+ createdAt: nowIso(),
683
+ });
684
+ session.status = "error";
685
+ session.updatedAt = nowIso();
686
+ });
687
+ return { ok: true, eventId: event.id, status: session.status };
688
+ }
689
+
690
+ const prompt = buildEditPrompt(session, event);
691
+
692
+ const fallbackFiles = filesReferencedByEvent(event);
693
+
694
+ const parseAgentResult = (result) => {
695
+ // BUG FIX (2026-07-02): AgentPool.dispatchToSession()/dispatch() resolve with
696
+ // `result.package` (the agent's raw final-text response, see agent-pool.js:629
697
+ // `package: (text || "").trim()`) -- NOT `result.stdout` or `result.output`,
698
+ // neither of which exist on this result shape. Reading the wrong fields meant
699
+ // JSON.parse("") always threw, parsed silently stayed {}, and EVERY dispatch
700
+ // reported status:"done" / filesChanged:[] regardless of what the agent
701
+ // actually did -- the syntax-check-and-retry loop below was checking an empty
702
+ // file list every single time and never catching anything.
703
+ const rawText = String(result?.package ?? "").trim();
704
+ let parsed = {};
705
+ try {
706
+ parsed = JSON.parse(rawText);
707
+ } catch {
708
+ // Agents don't always emit pure JSON despite instructions -- a trailing/leading
709
+ // sentence around the JSON object is common. Try to recover the LAST {...}
710
+ // block in the text before giving up (never throws further).
711
+ const match = rawText.match(/\{[\s\S]*\}/);
712
+ if (match) {
713
+ try { parsed = JSON.parse(match[0]); } catch { /* give up, use defaults below */ }
714
+ }
715
+ }
716
+ const filesChanged = Array.isArray(parsed.filesChanged) && parsed.filesChanged.length > 0
717
+ ? parsed.filesChanged
718
+ : fallbackFiles;
719
+ return {
720
+ status: parsed.status || "done",
721
+ message: parsed.message || rawText.slice(0, 500) || "Agent completed",
722
+ filesChanged,
723
+ };
724
+ };
725
+
726
+ // D1/D2 (studio-editor-v1-auto-escalation.md): after the dispatched agent
727
+ // reports back, verify the file(s) it touched still parse before trusting
728
+ // "status":"done". A malformed agent JSON reply (bare try/catch above)
729
+ // must NOT bypass this — filesChanged falls back to whatever the original
730
+ // event referenced so even an unparseable reply still gets checked.
731
+ // On a syntax failure, re-dispatch exactly once with the diagnostic
732
+ // appended; a second failure (or a second dispatch error) is a hard stop
733
+ // to status "error" with diagnostics attached — never a silent accept,
734
+ // never an unbounded retry loop.
381
735
  this.dispatchAgent(event.id, session.token, null, session, prompt)
382
- .then(result => {
383
- let parsed = {};
384
- try { parsed = JSON.parse((result?.stdout || result?.output || "").trim()); } catch {}
385
- const reply = {
736
+ .then(async (result) => {
737
+ const attempt1 = parseAgentResult(result);
738
+ const check1 = checkFilesSyntax(session, attempt1.filesChanged);
739
+
740
+ if (check1.ok) {
741
+ session.replies.push({
742
+ eventId: event.id,
743
+ status: attempt1.status,
744
+ message: attempt1.message,
745
+ filesChanged: attempt1.filesChanged,
746
+ diagnostics: [],
747
+ createdAt: nowIso(),
748
+ });
749
+ session.status = "done";
750
+ session.updatedAt = nowIso();
751
+ return;
752
+ }
753
+
754
+ const retryPrompt = [
755
+ prompt,
756
+ ``,
757
+ `Your previous edit broke the file's syntax. Diagnostic: ${check1.diagnostics.join("; ")}.`,
758
+ `Fix the file so it is syntactically valid, preserving your intended change.`,
759
+ ].join("\n");
760
+
761
+ let attempt2Result;
762
+ try {
763
+ attempt2Result = await this.dispatchAgent(event.id, session.token, null, session, retryPrompt);
764
+ } catch {
765
+ session.replies.push({
766
+ eventId: event.id,
767
+ status: "error",
768
+ message: "Agent retry dispatch failed after syntax check failure",
769
+ filesChanged: attempt1.filesChanged,
770
+ diagnostics: check1.diagnostics,
771
+ createdAt: nowIso(),
772
+ });
773
+ session.status = "error";
774
+ session.updatedAt = nowIso();
775
+ return;
776
+ }
777
+
778
+ const attempt2 = parseAgentResult(attempt2Result);
779
+ const check2 = checkFilesSyntax(session, attempt2.filesChanged);
780
+
781
+ if (check2.ok) {
782
+ session.replies.push({
783
+ eventId: event.id,
784
+ status: attempt2.status,
785
+ message: attempt2.message,
786
+ filesChanged: attempt2.filesChanged,
787
+ diagnostics: [],
788
+ createdAt: nowIso(),
789
+ });
790
+ session.status = "done";
791
+ session.updatedAt = nowIso();
792
+ return;
793
+ }
794
+
795
+ session.replies.push({
386
796
  eventId: event.id,
387
- status: parsed.status || "done",
388
- message: parsed.message || result?.stdout?.slice(0, 500) || "Agent completed",
389
- filesChanged: parsed.filesChanged || [],
797
+ status: "error",
798
+ message: `Agent edit failed syntax check after retry: ${check2.diagnostics.join("; ")}`,
799
+ filesChanged: attempt2.filesChanged,
800
+ diagnostics: check2.diagnostics,
390
801
  createdAt: nowIso(),
391
- };
392
- session.replies.push(reply);
393
- session.status = "done";
802
+ });
803
+ session.status = "error";
394
804
  session.updatedAt = nowIso();
395
805
  })
396
806
  .catch(() => {
@@ -399,6 +809,7 @@ export class StudioDebugSessionStore {
399
809
  status: "error",
400
810
  message: "Agent dispatch failed",
401
811
  filesChanged: [],
812
+ diagnostics: [],
402
813
  createdAt: nowIso(),
403
814
  });
404
815
  session.status = "error";
@@ -583,6 +994,9 @@ export class StudioDebugSessionStore {
583
994
  }
584
995
  for (const poll of session.pendingPolls.splice(0)) poll({ type: "stopped" });
585
996
  this.sessions.delete(sessionId);
997
+ if (this.releaseSessionWorker) {
998
+ try { this.releaseSessionWorker(sessionId); } catch { /* best-effort */ }
999
+ }
586
1000
  return { ok: true, status: "stopped", sessionId };
587
1001
  }
588
1002
  }
@@ -604,6 +1018,27 @@ export function createStudioDebugRuntime(options) {
604
1018
  }
605
1019
  }
606
1020
 
1021
+ // Editable-prompts settings — the UI for .studio-editor-settings.json (loadEditorSettings()
1022
+ // above / buildFullSystemPrompt()+buildStudioEditorSystemPrompt() in agent-pool.js). GET
1023
+ // returns what's on disk (empty object if never set); POST merges a partial patch.
1024
+ if (reqPath === "/studio/editor-settings") {
1025
+ const repoRoot = url.searchParams.get("repoRoot");
1026
+ if (!repoRoot) return writeJson(res, 400, { ok: false, error: "repoRoot query param required" }, cors);
1027
+ if (method === "GET") {
1028
+ return writeJson(res, 200, { ok: true, settings: loadEditorSettings(repoRoot) }, cors);
1029
+ }
1030
+ if (method === "POST") {
1031
+ try {
1032
+ const patch = await readBody(req);
1033
+ const settings = saveEditorSettings(repoRoot, patch || {});
1034
+ return writeJson(res, 200, { ok: true, settings }, cors);
1035
+ } catch (err) {
1036
+ return writeJson(res, 400, { ok: false, error: err.message }, cors);
1037
+ }
1038
+ }
1039
+ return writeJson(res, 405, { ok: false, error: "method not allowed" }, cors);
1040
+ }
1041
+
607
1042
  const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|text-write|status|stop)$/);
608
1043
  const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
609
1044
  if (!debugMatch && !claudeMatch) return false;
@@ -649,3 +1084,4 @@ export function createStudioDebugRuntime(options) {
649
1084
  }
650
1085
 
651
1086
  export const studioDebugTargets = APP_TARGETS;
1087
+ export { buildEditPrompt, buildChatPrompt, loadEditorSettings };