@manny-est/node-red-flowpilot 0.5.1 → 0.6.0-beta.1

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/lib/core/main.js CHANGED
@@ -40,6 +40,29 @@
40
40
  var messageRecords = [];
41
41
  var _nextRecordId = 0;
42
42
 
43
+ // P10-D1 (ADR-001 R5): per-conversation map of opId -> already-applied
44
+ // WRITE tool result. opId = runId + ":" + call.id (runId minted per
45
+ // runAgentLoop run, modes.js). A repeat opId (duplicate delivery, a
46
+ // retry, or the model repeating a call) returns the SAME result without
47
+ // re-invoking the executor — the graph is mutated at most once per
48
+ // opId. Keyed first by conversationId so switching conversations
49
+ // (clearChat / loading a saved transcript) can't cross-contaminate;
50
+ // cleared for the outgoing conversationId at those same points. In-
51
+ // memory only, matching messageRecords/conversationHistory's existing
52
+ // volatility (ADR-001: "persistence is minimal and schema-ready this
53
+ // phase" — full state-machine persistence is deferred to phase close).
54
+ var appliedOpsByConversation = {};
55
+
56
+ function getAppliedOp(convId, opId) {
57
+ var ops = appliedOpsByConversation[convId];
58
+ return ops ? ops[opId] : undefined;
59
+ }
60
+
61
+ function recordAppliedOp(convId, opId, result) {
62
+ if (!appliedOpsByConversation[convId]) { appliedOpsByConversation[convId] = {}; }
63
+ appliedOpsByConversation[convId][opId] = result;
64
+ }
65
+
43
66
  function addRecord(kind, payload) {
44
67
  var rec = { id: _nextRecordId++, ts: Date.now(), kind: kind };
45
68
  if (payload) {
@@ -81,6 +104,12 @@
81
104
  case "question":
82
105
  if (rec.loopCheckpoint) {
83
106
  renderLoopCheckpoint(activeBuildLoop);
107
+ } else if (rec.buildConsentGate) {
108
+ renderBuildConsentGate(rec);
109
+ } else if (rec.agentToolConsent) {
110
+ renderAgentToolConsentGate(rec);
111
+ } else if (rec.askUserTool) {
112
+ renderAskUserQuestion(rec);
84
113
  } else {
85
114
  renderClarifyingQuestion(rec.options || []);
86
115
  }
@@ -91,6 +120,9 @@
91
120
  case "buildStep":
92
121
  rerenderBuildStepRecord(rec);
93
122
  break;
123
+ case "todo":
124
+ rerenderTodoRecord(rec);
125
+ break;
94
126
  }
95
127
  }
96
128
 
@@ -141,6 +173,7 @@
141
173
  var entry = {
142
174
  id: nextDebugMessageId++,
143
175
  timestamp: Date.now(),
176
+ sourceKind: "debug",
144
177
  name: msg.name || msg.id || "(unnamed node)",
145
178
  topic: redactedTopic,
146
179
  // previewValue: short, for the scannable debug-log list only.
@@ -173,8 +206,14 @@
173
206
  // message from one trigger accumulate into attachedDebugMessages
174
207
  // before review actually runs, so the model sees the full picture
175
208
  // instead of whichever message happened to arrive first.
209
+ // WS4: a Skip decision at the build consent gate excludes this tap's
210
+ // real id from skipCheckpointTapIds — its messages still arrive here
211
+ // (still shown in the debug-log list/updateDebugStatus above) but
212
+ // don't drive the auto-attach/auto-review transition, so the loop
213
+ // falls back to manual confirmation or the honest-timeout instead.
176
214
  if (activeBuildLoop && activeBuildLoop.waypoint === "attach" &&
177
- activeBuildLoop.nodeIds.indexOf(msg.id) !== -1) {
215
+ activeBuildLoop.nodeIds.indexOf(msg.id) !== -1 &&
216
+ (activeBuildLoop.skipCheckpointTapIds || []).indexOf(msg.id) === -1) {
178
217
  attachedDebugMessages.push(entry);
179
218
  updateDebugStatus();
180
219
  if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
@@ -193,11 +232,97 @@
193
232
  }
194
233
  }
195
234
 
235
+ // W3: node-status stream evidence path.
236
+ // Fires for every deployed node that sets a status (fill/text via
237
+ // node.status() in user function code, or core nodes like http-request
238
+ // reporting "200 OK" / error text). Topic is "status/<nodeId>".
239
+ // Used as fallback evidence when the loop is waiting at "attach" but no
240
+ // debug node exists (HTTP endpoints, background workers, etc.) — gives
241
+ // the model something real to review instead of the loop hanging until
242
+ // the 20-second honest-timeout fires.
243
+ // Only used when no debug payload has arrived yet; if debug output is
244
+ // coming the richer payload data wins — let that path take over.
245
+ function onNodeStatus(topic, msg) {
246
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
247
+ var nodeId = topic.split("/")[1];
248
+ if (!nodeId || activeBuildLoop.nodeIds.indexOf(nodeId) === -1) { return; }
249
+ // WS4: same Skip-decision gate as onDebugMessage, keyed on the
250
+ // side-effecting node's own real id here (status events report the
251
+ // node itself, not a debug tap wired to it).
252
+ if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
253
+ if (freshBuildLoopEvidence(activeBuildLoop).length > 0) { return; }
254
+
255
+ // Skip in-progress ("blue") statuses — e.g. http-request emits
256
+ // fill:"blue" text:"requesting" before any response arrives. Locking
257
+ // in this early status starts the debounce before the actual error or
258
+ // response has a chance to land, so the model reviews a placeholder
259
+ // rather than the real result. Only terminal states (red=error,
260
+ // green=success, or no fill) are useful evidence.
261
+ if (msg && msg.fill === "blue") { return; }
262
+
263
+ var statusText = [msg && msg.fill, msg && msg.text].filter(Boolean).join(" — ");
264
+ if (!statusText) { return; }
265
+
266
+ var entry = {
267
+ id: nextDebugMessageId++,
268
+ timestamp: Date.now(),
269
+ sourceKind: "status",
270
+ name: nodeId + " (node status)",
271
+ topic: "node-status",
272
+ previewValue: statusText,
273
+ value: "Node " + nodeId + " reported status: " + statusText
274
+ };
275
+ attachedDebugMessages.push(entry);
276
+ updateDebugStatus();
277
+
278
+ if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
279
+ if (buildLoopAttachTimer) { clearTimeout(buildLoopAttachTimer); }
280
+ buildLoopAttachTimer = setTimeout(function () {
281
+ buildLoopAttachTimer = null;
282
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
283
+ activeBuildLoop.waypoint = "review";
284
+ renderLoopStepper(activeBuildLoop);
285
+ if (currentSettings.loopHoldStep) {
286
+ renderLoopCheckpoint(activeBuildLoop);
287
+ } else {
288
+ runBuildReview(activeBuildLoop);
289
+ }
290
+ }, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
291
+ }
292
+
293
+ // CLAUDE-025: attachedDebugMessages is deliberately STICKY across turns
294
+ // (see the block comment above — "sticky, like conversationHistory,
295
+ // until removed or Clear Chat"), which is exactly right for ordinary
296
+ // chat/generate/modify context. But the /build loop's own verification
297
+ // step must NOT inherit that stickiness: reviewing against a debug
298
+ // message left over from an earlier Build attempt (or one attached
299
+ // manually via the popout, or a still-running unrelated flow) as if it
300
+ // were evidence for THIS attempt's own goal is how a later attempt can
301
+ // declare Touchdown without ever having deployed or checked its own
302
+ // output. loop.deployedAt is stamped (see init.js's "deploy" listener)
303
+ // the moment THIS attempt's own "apply"->"attach" transition fires, so
304
+ // filtering on it scopes evidence to messages that arrived at or after
305
+ // that specific redeploy — never null (build review only runs once the
306
+ // loop has reached "attach", which requires deployedAt to be set).
307
+ function freshBuildLoopEvidence(loop) {
308
+ if (!loop || typeof loop.deployedAt !== "number") { return []; }
309
+ return attachedDebugMessages.filter(function (m) {
310
+ return m.timestamp >= loop.deployedAt;
311
+ });
312
+ }
313
+
196
314
  // The exact shape sent to the backend (and shown by "Preview debug") —
197
315
  // excludes previewValue, which exists only for the debug-log list.
198
316
  function buildDebugMessagesForSend() {
199
317
  return attachedDebugMessages.map(function (m) {
200
- return { id: m.id, timestamp: m.timestamp, name: m.name, topic: m.topic, value: m.value };
318
+ return {
319
+ id: m.id,
320
+ timestamp: m.timestamp,
321
+ sourceKind: m.sourceKind,
322
+ name: m.name,
323
+ topic: m.topic,
324
+ value: m.value
325
+ };
201
326
  });
202
327
  }
203
328
 
@@ -550,7 +675,21 @@
550
675
 
551
676
  // ---- View switching -------------------------------------------------
552
677
 
678
+ // CLAUDE-024: every mode-tab switch disarms whatever Execute action
679
+ // (Generate/Document/Modify) or Query intent was armed before the
680
+ // switch. Without this, arming Modify then tabbing away and back to
681
+ // Chat left armedExecuteAction pointing at the stale mutating action,
682
+ // so a plain question typed on what LOOKS like a fresh Chat tab still
683
+ // dispatched through Modify/Generate/Document on Send — a real
684
+ // unintended-mutation bug (reproduced: an unwanted node deletion).
685
+ // Send must always route to whatever is actually visible.
686
+ function disarmForModeSwitch() {
687
+ disarmExecuteAction();
688
+ disarmQueryIntent();
689
+ }
690
+
553
691
  function showChat() {
692
+ disarmForModeSwitch();
554
693
  el("#fp-chat-panel").removeClass("fp-hidden");
555
694
  el("#fp-settings-panel").addClass("fp-hidden");
556
695
  el("#fp-history-panel").addClass("fp-hidden");
@@ -560,6 +699,7 @@
560
699
  }
561
700
 
562
701
  function showSettings() {
702
+ disarmForModeSwitch();
563
703
  el("#fp-settings-panel").removeClass("fp-hidden");
564
704
  el("#fp-chat-panel").addClass("fp-hidden");
565
705
  el("#fp-history-panel").addClass("fp-hidden");
@@ -569,6 +709,7 @@
569
709
  }
570
710
 
571
711
  function showHistory() {
712
+ disarmForModeSwitch();
572
713
  el("#fp-history-panel").removeClass("fp-hidden");
573
714
  el("#fp-chat-panel").addClass("fp-hidden");
574
715
  el("#fp-settings-panel").addClass("fp-hidden");
@@ -588,6 +729,7 @@
588
729
  attachedDebugMessages = [];
589
730
  activeBuildLoop = null;
590
731
  disarmExecuteAction(); // also clears pinnedSelectionIds
732
+ delete appliedOpsByConversation[conversationId];
591
733
  conversationId = newConversationId();
592
734
  fpChatSnappedToBottom = true;
593
735
  updateSelectionStatus();
@@ -701,8 +843,12 @@
701
843
  // Switches to a past conversation: rebuilds conversationHistory and the
702
844
  // visible chat from its saved transcript, and continues using its
703
845
  // conversationId so new turns append to the same transcript file.
704
- function loadConversation(id) {
846
+ // onError: optional — CLAUDE-029's page-load rehydration passes one to
847
+ // stay quiet (no chat error bubble) and clear a stale sessionStorage
848
+ // entry on 404, instead of the ajaxJson default of addMessage("error", …).
849
+ function loadConversation(id, onError) {
705
850
  ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
851
+ delete appliedOpsByConversation[conversationId];
706
852
  conversationId = id;
707
853
  try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
708
854
 
@@ -719,7 +865,7 @@
719
865
  pinnedSelectionIds = null;
720
866
  updateSelectionStatus();
721
867
  showChat();
722
- });
868
+ }, onError);
723
869
  }
724
870
 
725
871
  // Recall — searches OTHER past conversations' transcripts for the
@@ -905,17 +1051,52 @@
905
1051
  // removed in both the success and error paths so it can't get stuck.
906
1052
  // showStop adds a "Stop" button, used by the agent loop
907
1053
  // (runAgentChat) so the user can interrupt a multi-step tool-call run.
1054
+ //
1055
+ // fpPendingStartedAt / fpPendingElapsedInterval drive the live elapsed-time
1056
+ // counter (.fp-typing-elapsed). Real timing data (corpus + live browser
1057
+ // testing, ~150+ requests) is bimodal: the overwhelming majority finish in
1058
+ // 2-13s, with rare genuine outliers around 73-77s and nothing observed in
1059
+ // between. FP_PENDING_SLOW_MS picks 25s as the "this is a slow one"
1060
+ // threshold — comfortably past every normal request, comfortably before
1061
+ // the real outliers, so the reassurance text only ever appears when it's
1062
+ // actually warranted.
1063
+ var fpPendingStartedAt = null;
1064
+ var fpPendingElapsedInterval = null;
1065
+ var FP_PENDING_SLOW_MS = 25000;
1066
+
1067
+ function fpFormatElapsed(ms) {
1068
+ var totalSeconds = Math.floor(ms / 1000);
1069
+ var text;
1070
+ if (totalSeconds < 60) {
1071
+ text = totalSeconds + "s";
1072
+ } else {
1073
+ var mins = Math.floor(totalSeconds / 60);
1074
+ var secs = totalSeconds % 60;
1075
+ text = mins + "m " + secs + "s";
1076
+ }
1077
+ if (ms >= FP_PENDING_SLOW_MS) {
1078
+ text += " — still working, some requests take a minute or more";
1079
+ }
1080
+ return text;
1081
+ }
1082
+
908
1083
  function showPending(showStop) {
909
1084
  var $box = el("#fp-messages");
910
1085
  if (!$box.length) { return; }
911
1086
  // Guard against duplicates (e.g. fast double-send).
912
1087
  $box.find("#fp-pending").remove();
1088
+ if (fpPendingElapsedInterval) {
1089
+ clearInterval(fpPendingElapsedInterval);
1090
+ fpPendingElapsedInterval = null;
1091
+ }
913
1092
 
914
1093
  var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
915
1094
  $("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
916
1095
  var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
917
1096
  $dots.append($("<span>"), $("<span>"), $("<span>"));
918
1097
  $dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
1098
+ var $elapsed = $("<span>").addClass("fp-typing-elapsed");
1099
+ $dots.append($elapsed);
919
1100
  if (showStop) {
920
1101
  $dots.append($("<button>")
921
1102
  .addClass("fp-agent-stop red-ui-button red-ui-button-small")
@@ -923,6 +1104,7 @@
923
1104
  .text("Stop")
924
1105
  .on("click", function () {
925
1106
  fpAgentStopRequested = true;
1107
+ if (fpCurrentAgentRequest) { fpCurrentAgentRequest.abort(); }
926
1108
  $(this).prop("disabled", true).text("Stopping…");
927
1109
  }));
928
1110
  }
@@ -930,10 +1112,28 @@
930
1112
 
931
1113
  $box.append($msg);
932
1114
  scrollMessagesToBottom();
1115
+
1116
+ fpPendingStartedAt = Date.now();
1117
+ fpPendingElapsedInterval = setInterval(function () {
1118
+ var $label = el("#fp-pending .fp-typing-elapsed");
1119
+ if (!$label.length) {
1120
+ // Panel closed / #fp-pending gone without hidePending firing;
1121
+ // stop polling instead of leaking the interval forever.
1122
+ clearInterval(fpPendingElapsedInterval);
1123
+ fpPendingElapsedInterval = null;
1124
+ return;
1125
+ }
1126
+ $label.text(" · " + fpFormatElapsed(Date.now() - fpPendingStartedAt));
1127
+ }, 1000);
933
1128
  }
934
1129
 
935
1130
  function hidePending() {
936
1131
  el("#fp-messages").find("#fp-pending").remove();
1132
+ if (fpPendingElapsedInterval) {
1133
+ clearInterval(fpPendingElapsedInterval);
1134
+ fpPendingElapsedInterval = null;
1135
+ }
1136
+ fpPendingStartedAt = null;
937
1137
  }
938
1138
 
939
1139
  // Updates the narration text shown in the pending indicator while the
@@ -994,7 +1194,7 @@
994
1194
  }
995
1195
 
996
1196
  function ajaxJson(method, url, payload, onSuccess, onError) {
997
- $.ajax({
1197
+ return $.ajax({
998
1198
  url: flowpilotUrl(url),
999
1199
  method: method,
1000
1200
  contentType: "application/json",
@@ -1007,7 +1207,14 @@
1007
1207
  },
1008
1208
  success: onSuccess,
1009
1209
  error: function (xhr) {
1010
- var msg = (xhr.responseJSON && xhr.responseJSON.error) ||
1210
+ // Prefer .message (human-readable text) over .error (a
1211
+ // machine code like "provider_unconfirmed" or
1212
+ // "agent_strategy_unavailable") when both are present — a
1213
+ // plain-string-only error response (the common case,
1214
+ // {error:"..."} with no .message) is untouched, since
1215
+ // .message is simply absent there.
1216
+ var msg = (xhr.responseJSON && xhr.responseJSON.message) ||
1217
+ (xhr.responseJSON && xhr.responseJSON.error) ||
1011
1218
  xhr.responseText || xhr.statusText || "Unknown error";
1012
1219
  if (onError) { onError(msg, xhr); }
1013
1220
  else { addMessage("error", msg); }
@@ -1042,26 +1249,42 @@
1042
1249
  if (active) { $sel.val(active.id); }
1043
1250
  }
1044
1251
 
1045
- // Write the form fields from a given provider profile.
1252
+ function toggleAnthropicHint(type) {
1253
+ var isAnthropic = type === "anthropic";
1254
+ el("#fp-base-url-hint").toggleClass("fp-hidden", !isAnthropic);
1255
+ el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
1256
+ }
1257
+
1258
+ // Write the form fields from a given provider profile. p.apiKey is
1259
+ // never a real key here — the server masks it to a sentinel/"" on every
1260
+ // response (see maskProviderSecrets, flowpilot.js) — so leaving the
1261
+ // field untouched on save round-trips harmlessly (reconcileProviderSecrets,
1262
+ // lib/storage.js, keeps the real stored key).
1046
1263
  function fillProviderFields(p) {
1047
1264
  p = p || {};
1048
1265
  el("#fp-provider-name").val(p.providerName || "");
1266
+ var type = p.type || "openai-compatible";
1267
+ el("#fp-provider-type").val(type);
1268
+ toggleAnthropicHint(type);
1049
1269
  el("#fp-base-url").val(p.baseUrl || "");
1050
1270
  el("#fp-api-key").val(p.apiKey || "");
1271
+ el("#fp-api-key").attr("placeholder", p.hasApiKey ? "Saved — leave to keep, retype to change" : "Optional");
1051
1272
  el("#fp-model").val(p.model || "");
1052
1273
  el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
1053
1274
  // Test provider is disabled until this provider has a model.
1054
1275
  el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
1055
1276
  }
1056
1277
 
1057
- // Short descriptive band shown under the Personality slider, matching
1058
- // the reference points lib/persona-prompt.js gives the model.
1278
+ // CLAUDE-032: 5 discrete levels (was 1-10 with sparse interpolated
1279
+ // anchors live testing found no discernible voice difference across
1280
+ // the old scale). Labels match lib/persona-prompt.js's PERSONA_LEVELS
1281
+ // exactly, one label per slider position, no interpolation.
1059
1282
  function personaLabelFor(n) {
1060
1283
  n = Number(n);
1061
1284
  if (n <= 1) { return "Plain engineer — no aviation language at all."; }
1062
- if (n <= 4) { return "Subtle co-pilot (default) — light, occasional flavor."; }
1063
- if (n <= 7) { return "Noticeable captain energy — more frequent, more colorful."; }
1064
- if (n <= 9) { return "Heavy captain energy — leans hard into the bit."; }
1285
+ if (n === 2) { return "Subtle co-pilot (default) — light, occasional flavor."; }
1286
+ if (n === 3) { return "Noticeable captain energy — a sentence or two, every time."; }
1287
+ if (n === 4) { return "Heavy captain energy — leans hard into the bit."; }
1065
1288
  return "Full captain — comically over-the-top.";
1066
1289
  }
1067
1290
 
@@ -1079,17 +1302,19 @@
1079
1302
  fillProviderFields(activeProvider());
1080
1303
 
1081
1304
  el("#fp-system-prompt").val(settings.systemPrompt || "");
1082
- el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 3);
1305
+ el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 2);
1083
1306
  updatePersonaLabel();
1084
1307
  el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
1085
1308
  el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
1086
1309
  el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
1087
1310
  el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
1088
- el("#fp-request-timeout").val(Math.round((settings.requestTimeoutMs !== undefined ? settings.requestTimeoutMs : 180000) / 1000));
1311
+ el("#fp-request-timeout").val(settings.requestTimeoutMs !== undefined ? (settings.requestTimeoutMs / 1000) : 180);
1312
+ el("#fp-agent-turn-max-tokens").val(settings.agentTurnMaxTokens !== undefined ? settings.agentTurnMaxTokens : 4096);
1089
1313
  el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
1090
1314
  el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
1091
1315
  el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
1092
1316
  el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
1317
+ el("#fp-debug-logging").prop("checked", !!settings.debugLogging);
1093
1318
 
1094
1319
  // The dev/test banner is part of the warning set the user can silence
1095
1320
  // via the type-to-confirm acknowledgement.
@@ -1134,6 +1359,7 @@
1134
1359
  var ap = activeProvider();
1135
1360
  if (!ap) { return; }
1136
1361
  ap.providerName = el("#fp-provider-name").val() || "Provider";
1362
+ ap.type = el("#fp-provider-type").val() || "openai-compatible";
1137
1363
  ap.baseUrl = el("#fp-base-url").val() || "";
1138
1364
  ap.apiKey = el("#fp-api-key").val() || "";
1139
1365
  ap.model = el("#fp-model").val() || "";
@@ -1163,14 +1389,15 @@
1163
1389
  if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
1164
1390
 
1165
1391
  var requestTimeoutSec = Number(el("#fp-request-timeout").val());
1166
- if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
1167
1392
 
1168
1393
  var personaIntensity = Number(el("#fp-persona-intensity").val());
1169
- if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 10) { personaIntensity = 3; }
1394
+ if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 5) { personaIntensity = 2; }
1170
1395
 
1171
1396
  var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
1172
1397
  if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
1173
1398
 
1399
+ var agentTurnMaxTokens = Number(el("#fp-agent-turn-max-tokens").val());
1400
+
1174
1401
  return {
1175
1402
  providers: providersList(),
1176
1403
  activeProviderId: currentSettings.activeProviderId,
@@ -1181,10 +1408,12 @@
1181
1408
  historyMaxExchanges: historyMax,
1182
1409
  streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
1183
1410
  requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
1411
+ agentTurnMaxTokens: agentTurnMaxTokens,
1184
1412
  agentLoopMaxIterations: agentLoopMaxIterations,
1185
1413
  loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
1186
1414
  suppressContextWarnings: suppress,
1187
1415
  redactionEnabled: redactionEnabled,
1416
+ debugLogging: el("#fp-debug-logging").prop("checked"),
1188
1417
  customIntents: Array.isArray(currentSettings.customIntents)
1189
1418
  ? currentSettings.customIntents : []
1190
1419
  };
@@ -1330,10 +1559,25 @@
1330
1559
  var payload = collectSettings();
1331
1560
  var list = payload.providers || [];
1332
1561
 
1333
- // Validation 1: every provider needs a base URL (the one field a
1334
- // provider cannot function without).
1562
+ if (!isFinite(payload.requestTimeoutMs) || payload.requestTimeoutMs <= 0) {
1563
+ var timeoutMsg = "Cannot save: request timeout must be a positive number of seconds.";
1564
+ addMessage("error", timeoutMsg);
1565
+ if (announce) { showSaveStatus(timeoutMsg, true); }
1566
+ showSettings();
1567
+ return;
1568
+ }
1569
+ if (!isFinite(payload.agentTurnMaxTokens) || payload.agentTurnMaxTokens <= 0 || Math.floor(payload.agentTurnMaxTokens) !== payload.agentTurnMaxTokens) {
1570
+ var tokenMsg = "Cannot save: max tokens per agent turn must be a positive whole number.";
1571
+ addMessage("error", tokenMsg);
1572
+ if (announce) { showSaveStatus(tokenMsg, true); }
1573
+ showSettings();
1574
+ return;
1575
+ }
1576
+
1577
+ // Validation 1: every non-Anthropic provider needs a base URL.
1578
+ // Anthropic providers default to api.anthropic.com when baseUrl is blank.
1335
1579
  var noUrl = list.filter(function (p) {
1336
- return !p.baseUrl || !String(p.baseUrl).trim();
1580
+ return p.type !== "anthropic" && (!p.baseUrl || !String(p.baseUrl).trim());
1337
1581
  });
1338
1582
  if (noUrl.length) {
1339
1583
  var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
@@ -1562,6 +1806,470 @@
1562
1806
  return Object.assign({ selected: true }, context);
1563
1807
  }
1564
1808
 
1809
+ // ---------------------------------------------------------------------
1810
+ // W7 — WRITE tools (§16 of FlowPilot-Phase10-Rescope-Scoping.md).
1811
+ //
1812
+ // Exact argument schemas as landed by CODEX-005 (coordinated via
1813
+ // mailbox, 2026-07-26 19:28/19:37 UTC — these are NOT a client-side
1814
+ // guess): CODEX-005 deliberately reused the EXISTING Modify-envelope
1815
+ // field names/shapes so the client applies WRITE tool calls through
1816
+ // the SAME pipeline as the ordinary (non-agentic) Modify apply path —
1817
+ // apply_step: { summary, changes?: [{id, set}], newNodes?: [{id,
1818
+ // type, ...}], newWires?: [{from, fromPort, to}] }
1819
+ // `changes[].set` is a sparse property patch, `newNodes`/`newWires`
1820
+ // are byte-identical in shape to the top-level Modify envelope's
1821
+ // own fields (applyInsertions already accepts them directly).
1822
+ // "One call is one todo item even when these arrays contain a
1823
+ // small bundle" (§16 point 2) — arrays may hold more than one
1824
+ // entry in a single call.
1825
+ // remove_step: { summary, nodeId }
1826
+ // rename_node: { summary, nodeId, name }
1827
+ // ask_user: { question, options? } — tier "write-safe", non-mutating,
1828
+ // handled entirely in the agent loop (modes.js), never reaches
1829
+ // here in normal flow.
1830
+ //
1831
+ // Tier ("write-gated" vs "write-safe") arrives OUT OF BAND per call as
1832
+ // data.toolTiers[call.id] (modes.js reads this), not by tool name — a
1833
+ // write-gated tier means the call is ELIGIBLE for consent-gating, not
1834
+ // that every call of that tool prompts (CODEX-005's own clarification):
1835
+ // §16 point 4 still requires classifying the ACTUAL touched node
1836
+ // type(s) against SAFE_NODE_TYPES before deciding to gate.
1837
+ //
1838
+ // tool_result shape (§16 point 3 — mirrors the existing verifySteps
1839
+ // check vocabulary, scoped strictly to this call's own touched ids):
1840
+ // { requested, checks: [{check, nodeId|fromId/toId, prop?, expected?,
1841
+ // pass}], allPass, error? }
1842
+ // ---------------------------------------------------------------------
1843
+
1844
+ // Mirrors flowpilot.js's SAFE_NODE_TYPES verbatim (§16 point 4: "reuse
1845
+ // SAFE_NODE_TYPES/classifyFlowNodes verbatim"). This is a client-side
1846
+ // COPY, not a shared module — lib/core/*.js has no require/import, and
1847
+ // the server's set lives in a separate Node.js process. Must be kept in
1848
+ // sync by hand; flagged in the mailbox report as a drift risk to watch
1849
+ // if the server-side list ever changes.
1850
+ var WRITE_GATE_SAFE_NODE_TYPES = new Set([
1851
+ "inject", "function", "change", "switch", "filter", "json", "xml", "csv",
1852
+ "base64", "html", "split", "join", "sort", "batch", "debug", "status",
1853
+ "comment", "link in", "link out", "link call", "junction"
1854
+ ]);
1855
+
1856
+ // Collects the node type(s) a WRITE tool call would touch — a NEW
1857
+ // node's own declared type, or an EXISTING node's live type looked up
1858
+ // via findLiveNode (apply-review.js, same closure). Used by
1859
+ // writeToolCallNeedsConsent (modes.js) to decide whether to gate.
1860
+ function collectWriteToolTouchedTypes(name, args) {
1861
+ args = args || {};
1862
+ var types = [];
1863
+ function addLiveType(id) {
1864
+ if (!id) { return; }
1865
+ var live = findLiveNode(id);
1866
+ if (live) { types.push(live.type); }
1867
+ }
1868
+ switch (name) {
1869
+ case "apply_step":
1870
+ (Array.isArray(args.newNodes) ? args.newNodes : []).forEach(function (n) {
1871
+ if (n && n.type) { types.push(n.type); }
1872
+ });
1873
+ (Array.isArray(args.newWires) ? args.newWires : []).forEach(function (w) {
1874
+ if (!w) { return; }
1875
+ addLiveType(w.from);
1876
+ addLiveType(w.to);
1877
+ });
1878
+ (Array.isArray(args.changes) ? args.changes : []).forEach(function (c) {
1879
+ if (c) { addLiveType(c.id); }
1880
+ });
1881
+ break;
1882
+ case "remove_step":
1883
+ addLiveType(args.nodeId);
1884
+ break;
1885
+ case "rename_node":
1886
+ addLiveType(args.nodeId);
1887
+ break;
1888
+ }
1889
+ return types;
1890
+ }
1891
+
1892
+ // §16 point 4: gate ONLY when the call's tier is "write-gated" AND it
1893
+ // touches a node type outside SAFE_NODE_TYPES; autonomously apply
1894
+ // everything else (including every "write-safe" call, e.g. ask_user,
1895
+ // which never reaches this function at all in practice — see
1896
+ // handleStep). An id that doesn't resolve to a live node at all
1897
+ // (hallucinated id, or a brand-new node referenced by a wire before it
1898
+ // exists) can't be proven safe, so it's treated conservatively as
1899
+ // side-effecting — mirrors WS4's classifyFlowNodes default (unknown =>
1900
+ // sideEffecting).
1901
+ function writeToolCallNeedsConsent(tier, name, args) {
1902
+ if (tier !== "write-gated") { return false; }
1903
+ var types = collectWriteToolTouchedTypes(name, args);
1904
+ if (!types.length) { return true; }
1905
+ return types.some(function (t) { return !WRITE_GATE_SAFE_NODE_TYPES.has(t); });
1906
+ }
1907
+
1908
+ // Builds the tool_result envelope from a checks array already carrying
1909
+ // .pass — shared tail for all three WRITE tool executors below.
1910
+ function buildWriteToolResult(requestedArgs, checks, extra) {
1911
+ var allPass = checks.length > 0 && checks.every(function (c) { return c.pass; });
1912
+ return Object.assign({ requested: requestedArgs, checks: checks, allPass: allPass }, extra || {});
1913
+ }
1914
+
1915
+ // Runs the verifySteps-style check vocabulary (runSingleVerifyCheck,
1916
+ // modes.js, same closure) against a list of steps in this call's own
1917
+ // shape, scoped strictly to the touched id(s) — never a flow-wide
1918
+ // snapshot (§16 point 3).
1919
+ function runChecksForToolResult(steps, idMap) {
1920
+ var checks = [];
1921
+ (steps || []).forEach(function (step) {
1922
+ var result = runSingleVerifyCheck(step, idMap);
1923
+ if (!result) { return; }
1924
+ checks.push(Object.assign({}, step, { pass: result.ok }));
1925
+ });
1926
+ return checks;
1927
+ }
1928
+
1929
+ // apply_step: newNodes/newWires go through applyInsertions — the SAME
1930
+ // layout/collision-avoidance/wiring path Generate/Modify insertions
1931
+ // already use, since CODEX-005 matched that exact field shape — then
1932
+ // changes[].set (sparse property patches on EXISTING nodes) goes
1933
+ // through applyModifications's Tier 1, using the resulting idMap so a
1934
+ // change value can reference a placeholder id from newNodes in the
1935
+ // SAME call (mirrors the existing "insertions run first so their
1936
+ // placeholder→real-id map is available" ordering in
1937
+ // addModifyReview's apply button). `changes[].set.wires` is the ONE
1938
+ // exception: CLAUDE-009-fix — an existing node's wiring is never a
1939
+ // generic property (mirrors computeNodeDiff's `if (k === "wires") {
1940
+ // wiresChanged = true; return; }`), so it's split out and routed
1941
+ // through computeWireDiff/Tier 3 (RED.nodes.addLink/removeLink,
1942
+ // canWire port validation) instead of a raw `liveNode.wires = val`
1943
+ // assignment, which would "succeed" without ever updating the link
1944
+ // registry the canvas and RED.nodes.eachLink actually read from.
1945
+ function executeApplyStepTool(args, runIdMap, runHistoryEvents) {
1946
+ args = args || {};
1947
+ var changes = Array.isArray(args.changes) ? args.changes : [];
1948
+ var newNodes = Array.isArray(args.newNodes) ? args.newNodes : [];
1949
+ var newWires = Array.isArray(args.newWires) ? args.newWires : [];
1950
+
1951
+ var idMap = {};
1952
+ var steps = [];
1953
+
1954
+ // CLAUDE-013: resolves a placeholder id (e.g. "fp-new-2") through
1955
+ // this call's own idMap first (ids it just created via
1956
+ // applyInsertions), then through runIdMap (ids created by an
1957
+ // EARLIER WRITE-tool call in the same agent run) — so referencing a
1958
+ // node created two tool calls ago works the same as referencing one
1959
+ // created in this call.
1960
+ function resolveId(id) {
1961
+ if (typeof id !== "string") { return id; }
1962
+ if (idMap && idMap[id]) { return idMap[id]; }
1963
+ if (runIdMap && runIdMap[id]) { return runIdMap[id]; }
1964
+ return id;
1965
+ }
1966
+
1967
+ if (newNodes.length || newWires.length) {
1968
+ idMap = applyInsertions(newNodes, newWires, [], runHistoryEvents) || {};
1969
+ newNodes.forEach(function (n) {
1970
+ if (n && n.id) { steps.push({ check: "exists", nodeId: n.id }); }
1971
+ });
1972
+ newWires.forEach(function (w) {
1973
+ if (w && w.from && w.to) { steps.push({ check: "wire", fromId: w.from, fromPort: w.fromPort || 0, toId: w.to }); }
1974
+ });
1975
+ }
1976
+
1977
+ if (changes.length) {
1978
+ var propDiffs = changes.filter(function (c) { return c && c.id && c.set && typeof c.set === "object"; })
1979
+ .map(function (c) {
1980
+ var nodeId = resolveId(c.id);
1981
+ var live = findLiveNode(nodeId);
1982
+ var hasWireChange = Object.prototype.hasOwnProperty.call(c.set, "wires");
1983
+ var propertyChanges = Object.keys(c.set).filter(function (k) { return k !== "wires"; })
1984
+ .map(function (k) {
1985
+ return { key: k, oldVal: live ? live[k] : undefined, newVal: c.set[k] };
1986
+ });
1987
+ var wiresDiff = { toAdd: [], toRemove: [] };
1988
+ if (hasWireChange && live) {
1989
+ // The tool-call path has no "flow the model had in
1990
+ // context" boundary the way the classic envelope's
1991
+ // validTargetIds does (scoped to the returned "flow"
1992
+ // array) — the model only sends this one node's
1993
+ // desired wires, not a full flow. Per sr-dev's
1994
+ // guidance, treat every id the diff could actually
1995
+ // flag for removal as valid: computeWireDiff only
1996
+ // ever consults validTargetIds for ids already found
1997
+ // among this node's CURRENT live targets, so seeding
1998
+ // validTargetIds from those current targets is
1999
+ // equivalent to "always valid" without needing a
2000
+ // magic always-true object.
2001
+ var validTargetIds = {};
2002
+ RED.nodes.eachLink(function (l) {
2003
+ if (l.source && l.source.id === nodeId && l.target) { validTargetIds[l.target.id] = true; }
2004
+ });
2005
+ wiresDiff = computeWireDiff(nodeId, c.set.wires, validTargetIds);
2006
+ }
2007
+ return {
2008
+ modNode: { id: nodeId },
2009
+ propertyChanges: propertyChanges,
2010
+ wiresChanged: hasWireChange && !!live,
2011
+ wiresDiff: wiresDiff
2012
+ };
2013
+ });
2014
+ // CLAUDE-013: applyModifications's own Tier 1 substitution
2015
+ // (apply-review.js) resolves c.set[k] placeholder values through
2016
+ // whatever idMap it's given — passing the call-local idMap alone
2017
+ // would WRITE the raw unresolved placeholder string onto the live
2018
+ // node (e.g. an mqtt-out's "broker" left as "fp-new-broker")
2019
+ // whenever the referenced node was created by an EARLIER call
2020
+ // this run, even though the verify step below correctly expects
2021
+ // the real id — a live-value/verify-step mismatch caught by
2022
+ // testing, not named explicitly in the ticket's cited line
2023
+ // numbers. Merge runIdMap in so the actual mutation and the
2024
+ // verify step agree.
2025
+ if (propDiffs.length) { applyModifications(propDiffs, [], null, Object.assign({}, runIdMap, idMap), runHistoryEvents); }
2026
+ changes.forEach(function (c) {
2027
+ if (!c || !c.id || !c.set || typeof c.set !== "object") { return; }
2028
+ var nodeId = resolveId(c.id);
2029
+ Object.keys(c.set).filter(function (k) { return k !== "wires"; }).forEach(function (k) {
2030
+ // Resolve through idMap/runIdMap the SAME way the diff
2031
+ // above just did, so a set value referencing a
2032
+ // placeholder from THIS call's own newNodes (e.g. an
2033
+ // mqtt-out's "broker" pointing at a new mqtt-broker) OR
2034
+ // from an EARLIER call this run is checked against what
2035
+ // actually landed, not the raw unresolved placeholder
2036
+ // string.
2037
+ var expected = resolveId(c.set[k]);
2038
+ steps.push({ check: "property", nodeId: nodeId, prop: k, expected: expected });
2039
+ });
2040
+ if (Object.prototype.hasOwnProperty.call(c.set, "wires")) {
2041
+ // Emit one "wire" check per desired (port, target) pair —
2042
+ // the same check shape/granularity newWires already uses
2043
+ // above — so the model's requested final wiring state is
2044
+ // verified against the live graph, not a property key.
2045
+ var desiredWires = Array.isArray(c.set.wires) ? c.set.wires : [];
2046
+ desiredWires.forEach(function (targets, port) {
2047
+ (Array.isArray(targets) ? targets : []).forEach(function (targetId) {
2048
+ steps.push({ check: "wire", fromId: nodeId, fromPort: port, toId: resolveId(targetId) });
2049
+ });
2050
+ });
2051
+ }
2052
+ });
2053
+ }
2054
+
2055
+ if (!steps.length) {
2056
+ return buildWriteToolResult(args, [], { error: "apply_step call had nothing to apply" });
2057
+ }
2058
+ var checks = runChecksForToolResult(steps, idMap);
2059
+ var extra = {};
2060
+ if (idMap && Object.keys(idMap).length) { extra.idMap = idMap; }
2061
+ return buildWriteToolResult(args, checks, extra);
2062
+ }
2063
+
2064
+ function executeRemoveStepTool(args, runIdMap, runHistoryEvents) {
2065
+ args = args || {};
2066
+ if (!args.nodeId) {
2067
+ return buildWriteToolResult(args, [], { error: "remove_step requires nodeId" });
2068
+ }
2069
+ // CLAUDE-013: args.nodeId may be a placeholder minted by an EARLIER
2070
+ // WRITE-tool call this run (e.g. "insert node, then remove it").
2071
+ var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
2072
+ applyModifications([], [nodeId], null, {}, runHistoryEvents);
2073
+ var checks = runChecksForToolResult([{ check: "absent", nodeId: nodeId }], {});
2074
+ return buildWriteToolResult(args, checks);
2075
+ }
2076
+
2077
+ function executeRenameNodeTool(args, runIdMap, runHistoryEvents) {
2078
+ args = args || {};
2079
+ if (!args.nodeId || typeof args.name !== "string") {
2080
+ return buildWriteToolResult(args, [], { error: "rename_node requires nodeId and name" });
2081
+ }
2082
+ // CLAUDE-013: same placeholder resolution as executeRemoveStepTool.
2083
+ var nodeId = (runIdMap && typeof args.nodeId === "string" && runIdMap[args.nodeId]) || args.nodeId;
2084
+ var live = findLiveNode(nodeId);
2085
+ var diff = [{
2086
+ modNode: { id: nodeId },
2087
+ propertyChanges: [{ key: "name", oldVal: live ? live.name : undefined, newVal: args.name }],
2088
+ wiresChanged: false,
2089
+ wiresDiff: { toAdd: [], toRemove: [] }
2090
+ }];
2091
+ applyModifications(diff, [], null, {}, runHistoryEvents);
2092
+ var checks = runChecksForToolResult([{ check: "property", nodeId: nodeId, prop: "name", expected: args.name }], {});
2093
+ return buildWriteToolResult(args, checks);
2094
+ }
2095
+
2096
+ function executeRedirectModeTool(args) {
2097
+ args = args || {};
2098
+ if (["generate", "document", "chat"].indexOf(args.mode) === -1) {
2099
+ return { error: "redirect_mode requires mode = generate, document, or chat" };
2100
+ }
2101
+ if (typeof args.prompt !== "string" || !args.prompt.trim()) {
2102
+ return { error: "redirect_mode requires prompt" };
2103
+ }
2104
+ if (typeof args.explanation !== "string" || !args.explanation.trim()) {
2105
+ return { error: "redirect_mode requires explanation" };
2106
+ }
2107
+
2108
+ var result = {
2109
+ redirected: true,
2110
+ explanation: args.explanation.trim(),
2111
+ suggestedAction: {
2112
+ mode: args.mode,
2113
+ prompt: args.prompt.trim()
2114
+ }
2115
+ };
2116
+ if (typeof args.selectionHint === "string" && args.selectionHint.trim()) {
2117
+ result.suggestedAction.selectionHint = args.selectionHint.trim();
2118
+ }
2119
+ if (args.targetNodeIds === "all") {
2120
+ result.suggestedAction.targetNodeIds = "all";
2121
+ } else if (Array.isArray(args.targetNodeIds)) {
2122
+ var ids = args.targetNodeIds
2123
+ .filter(function (id) { return typeof id === "string" && id.trim(); })
2124
+ .map(function (id) { return id.trim(); });
2125
+ if (ids.length) { result.suggestedAction.targetNodeIds = ids; }
2126
+ }
2127
+ return result;
2128
+ }
2129
+
2130
+ // group_nodes (ADR-003 R3a / P10-B3): wraps the exact
2131
+ // RED.group.createGroup path already proven in apply-review.js's
2132
+ // applyGroupChanges create branch (~1356-1362) — no new group logic.
2133
+ // Pre-validates every id via findLiveNode (the established resolver
2134
+ // used by the other WRITE tool executors above, a superset of
2135
+ // RED.nodes.node that also resolves groups/junctions — needed here
2136
+ // specifically to detect a group id among nodeIds). "No partial
2137
+ // group": any missing id is an error, not a partial create. A
2138
+ // resolved id that's itself a group (nesting) or already belongs to a
2139
+ // group (would require editing that group's membership) is out of
2140
+ // scope for this minimal tool per ADR-003 and reported as
2141
+ // unsupported_operation instead of attempted.
2142
+ function executeGroupNodesTool(args, runIdMap, runHistoryEvents) {
2143
+ args = args || {};
2144
+ var name = typeof args.name === "string" ? args.name : "";
2145
+ var nodeIds = Array.isArray(args.nodeIds) ? args.nodeIds : [];
2146
+ if (!name || !nodeIds.length) {
2147
+ return buildWriteToolResult(args, [], { error: "group_nodes requires name and at least one nodeId" });
2148
+ }
2149
+
2150
+ // CLAUDE-013: this is the exact call shape that surfaced the "node
2151
+ // not found: fp-new-2" error — a node created by an EARLIER
2152
+ // apply_step call in the same run, referenced here by its
2153
+ // placeholder id, with no idMap of its own to resolve against.
2154
+ var resolvedIds = nodeIds.map(function (id) {
2155
+ return (runIdMap && typeof id === "string" && runIdMap[id]) || id;
2156
+ });
2157
+ var uniqueIds = resolvedIds.filter(function (id, i) { return resolvedIds.indexOf(id) === i; });
2158
+ var missing = [], nested = [], alreadyGrouped = [], resolved = [];
2159
+ uniqueIds.forEach(function (id) {
2160
+ var live = findLiveNode(id);
2161
+ if (!live) { missing.push(id); return; }
2162
+ if (live.type === "group") { nested.push(id); return; }
2163
+ if (live.g) { alreadyGrouped.push(id); return; }
2164
+ resolved.push(live);
2165
+ });
2166
+
2167
+ if (missing.length) {
2168
+ return buildWriteToolResult(args, [], { error: "group_nodes: node id(s) not found: " + missing.join(", ") });
2169
+ }
2170
+ if (nested.length || alreadyGrouped.length) {
2171
+ var reasonParts = [];
2172
+ if (nested.length) { reasonParts.push("already a group: " + nested.join(", ")); }
2173
+ if (alreadyGrouped.length) { reasonParts.push("already in another group: " + alreadyGrouped.join(", ")); }
2174
+ return buildWriteToolResult(args, [], {
2175
+ unsupported: true,
2176
+ operation: "group_nodes",
2177
+ reason: "Nested groups and existing-group membership edits aren't supported (" + reasonParts.join("; ") + ").",
2178
+ available: ["apply_step", "remove_step", "rename_node", "group_nodes"]
2179
+ });
2180
+ }
2181
+
2182
+ // CLAUDE-026: all resolved nodes must share one tab. Node-RED core's
2183
+ // own RED.group.createGroup (confirmed via source) registers an
2184
+ // EMPTY group via RED.nodes.addGroup() FIRST, using resolved[0].z,
2185
+ // and only THEN populates it via addToGroup — which validates every
2186
+ // node's .z matches and throws if not. createGroup catches that
2187
+ // throw itself, RED.notifies it, and returns undefined — but never
2188
+ // undoes the addGroup() call, so a mismatched-z request leaves a
2189
+ // real, empty, orphaned group on resolved[0]'s tab every time.
2190
+ // Checking up front avoids ever creating that orphan for this
2191
+ // (deterministic, reproducible-by-inspection) cause.
2192
+ var groupZ = resolved.length ? resolved[0].z : null;
2193
+ var mismatchedZ = resolved.some(function (n) { return n.z !== groupZ; });
2194
+ if (mismatchedZ) {
2195
+ return buildWriteToolResult(args, [], { error: "group_nodes: all nodes must be on the same tab to be grouped" });
2196
+ }
2197
+
2198
+ // Belt-and-suspenders for any OTHER way createGroup can fail after
2199
+ // already registering that empty shell (a live QA run hit one:
2200
+ // "Node type not installed: group" on 1 of 3 attempts, whose exact
2201
+ // trigger wasn't pinned down) — snapshot the groups already on this
2202
+ // tab before each attempt, and if createGroup comes back falsy,
2203
+ // diff RED.nodes.groups(z) against the snapshot to find and remove
2204
+ // (via RED.group.ungroup — the same full-removal path
2205
+ // applyGroupChanges' disband branch already uses, confirmed safe on
2206
+ // a zero-member group) exactly what OUR call just orphaned, never
2207
+ // anything the user made themselves. One retry after cleanup in
2208
+ // case the underlying cause was transient.
2209
+ function attemptCreateGroup() {
2210
+ var before = {};
2211
+ RED.nodes.groups(groupZ).forEach(function (g) { before[g.id] = true; });
2212
+ var group;
2213
+ var caught = null;
2214
+ try {
2215
+ group = RED.group.createGroup(resolved);
2216
+ } catch (e) {
2217
+ caught = e;
2218
+ }
2219
+ if (!group) {
2220
+ RED.nodes.groups(groupZ).forEach(function (g) {
2221
+ if (!before[g.id]) { RED.group.ungroup(g); }
2222
+ });
2223
+ }
2224
+ return { group: group, error: caught };
2225
+ }
2226
+
2227
+ var attempt = attemptCreateGroup();
2228
+ if (!attempt.group) { attempt = attemptCreateGroup(); }
2229
+ if (!attempt.group) {
2230
+ var errMsg = (attempt.error && attempt.error.message) || attempt.error || "createGroup returned nothing";
2231
+ return buildWriteToolResult(args, [], { error: "Failed to create group: " + errMsg });
2232
+ }
2233
+ var newGroup = attempt.group;
2234
+ newGroup.name = name;
2235
+ RED.group.markDirty(newGroup);
2236
+ // CLAUDE-027: collected into this run's shared accumulator (same
2237
+ // mechanism as applyInsertions/applyModifications above) rather than
2238
+ // pushed straight to RED.history, so this run's flush can fold it
2239
+ // together with the rest of this run's WRITE-tool calls into ONE
2240
+ // undo entry via t:"multi".
2241
+ var createGroupHistoryEvent = { t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() };
2242
+ if (runHistoryEvents) { runHistoryEvents.push(createGroupHistoryEvent); } else { RED.history.push(createGroupHistoryEvent); }
2243
+ // markDirty alone doesn't force the editor to recompute the new
2244
+ // group's visible boundary immediately (apply-review.js's
2245
+ // applyInsertions already redraws after every insertion for the
2246
+ // same reason) — without this the group stays invisible until some
2247
+ // unrelated user action (zoom, node move) triggers a real redraw.
2248
+ RED.view.redraw(true);
2249
+
2250
+ // CLAUDE-020: group_nodes succeeded silently — no chat confirmation,
2251
+ // unlike its sibling WRITE-tool executor (apply-review.js's
2252
+ // applyInsertions), which reports a "Touchdown" note on every
2253
+ // successful insertion. Mirror that here so a Build-plan step that
2254
+ // groups nodes is actually confirmed in the chat, not just visible
2255
+ // on the canvas.
2256
+ var groupedNote = "Touchdown — created group \"" + name + "\" (" +
2257
+ resolved.length + " node(s)). Ctrl+Z to undo.";
2258
+ addMessage("assistant", groupedNote);
2259
+ pushHistory("assistant", groupedNote);
2260
+ updateSelectionStatus();
2261
+
2262
+ var steps = [
2263
+ { check: "exists", nodeId: newGroup.id },
2264
+ { check: "property", nodeId: newGroup.id, prop: "name", expected: name }
2265
+ ];
2266
+ resolved.forEach(function (n) {
2267
+ steps.push({ check: "property", nodeId: n.id, prop: "g", expected: newGroup.id });
2268
+ });
2269
+ var checks = runChecksForToolResult(steps, {});
2270
+ return buildWriteToolResult(args, checks, { groupId: newGroup.id });
2271
+ }
2272
+
1565
2273
  // Per-step narration: a short human-readable description of what a tool
1566
2274
  // call is about to do, shown in the pending indicator (see runAgentChat).
1567
2275
  function describeAgentToolCall(name, args) {
@@ -1579,6 +2287,18 @@
1579
2287
  return "Checking the debug log…";
1580
2288
  case "get_selection":
1581
2289
  return "Checking the current selection…";
2290
+ case "apply_step":
2291
+ return "Applying step" + (args.summary ? ": " + args.summary : "") + "…";
2292
+ case "remove_step":
2293
+ return "Removing node" + (args.summary ? ": " + args.summary : " " + JSON.stringify(args.nodeId || "?")) + "…";
2294
+ case "rename_node":
2295
+ return "Renaming node to " + JSON.stringify(args.name || "?") + "…";
2296
+ case "group_nodes":
2297
+ return "Creating group " + JSON.stringify(args.name || "?") + "…";
2298
+ case "redirect_mode":
2299
+ return "Redirecting to " + JSON.stringify(args.mode || "?") + " mode…";
2300
+ case "ask_user":
2301
+ return "Asking a clarifying question…";
1582
2302
  default:
1583
2303
  return "Running " + (name || "a tool") + "…";
1584
2304
  }
@@ -1592,7 +2312,7 @@
1592
2312
  catch (e) { return {}; }
1593
2313
  }
1594
2314
 
1595
- function executeAgentToolCall(call) {
2315
+ function executeAgentToolCall(call, runIdMap, runHistoryEvents) {
1596
2316
  var name = call && call.function && call.function.name;
1597
2317
  var args = parseToolCallArgs(call);
1598
2318
  switch (name) {
@@ -1608,6 +2328,21 @@
1608
2328
  return executeReadDebugTool(args);
1609
2329
  case "get_selection":
1610
2330
  return executeGetSelectionTool();
2331
+ case "apply_step":
2332
+ return executeApplyStepTool(args, runIdMap, runHistoryEvents);
2333
+ case "remove_step":
2334
+ return executeRemoveStepTool(args, runIdMap, runHistoryEvents);
2335
+ case "rename_node":
2336
+ return executeRenameNodeTool(args, runIdMap, runHistoryEvents);
2337
+ case "group_nodes":
2338
+ return executeGroupNodesTool(args, runIdMap, runHistoryEvents);
2339
+ case "redirect_mode":
2340
+ return executeRedirectModeTool(args);
2341
+ case "ask_user":
2342
+ // Non-mutating and always intercepted by the agent loop
2343
+ // (modes.js's handleStep) before reaching here — this is a
2344
+ // safe fallback only, never expected in normal operation.
2345
+ return { error: "ask_user must be answered via the loop's question UI, not executed directly" };
1611
2346
  default:
1612
2347
  return { error: "Unknown tool: " + name };
1613
2348
  }
@@ -1774,4 +2509,3 @@
1774
2509
 
1775
2510
  renderChip("Open Settings", "fa fa-cog", showSettings);
1776
2511
  }
1777
-