@manny-est/node-red-flowpilot 0.5.2 → 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) {
@@ -83,6 +106,10 @@
83
106
  renderLoopCheckpoint(activeBuildLoop);
84
107
  } else if (rec.buildConsentGate) {
85
108
  renderBuildConsentGate(rec);
109
+ } else if (rec.agentToolConsent) {
110
+ renderAgentToolConsentGate(rec);
111
+ } else if (rec.askUserTool) {
112
+ renderAskUserQuestion(rec);
86
113
  } else {
87
114
  renderClarifyingQuestion(rec.options || []);
88
115
  }
@@ -223,7 +250,7 @@
223
250
  // side-effecting node's own real id here (status events report the
224
251
  // node itself, not a debug tap wired to it).
225
252
  if ((activeBuildLoop.skipCheckpointNodeIds || []).indexOf(nodeId) !== -1) { return; }
226
- if (attachedDebugMessages.length > 0) { return; }
253
+ if (freshBuildLoopEvidence(activeBuildLoop).length > 0) { return; }
227
254
 
228
255
  // Skip in-progress ("blue") statuses — e.g. http-request emits
229
256
  // fill:"blue" text:"requesting" before any response arrives. Locking
@@ -263,6 +290,27 @@
263
290
  }, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
264
291
  }
265
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
+
266
314
  // The exact shape sent to the backend (and shown by "Preview debug") —
267
315
  // excludes previewValue, which exists only for the debug-log list.
268
316
  function buildDebugMessagesForSend() {
@@ -627,7 +675,21 @@
627
675
 
628
676
  // ---- View switching -------------------------------------------------
629
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
+
630
691
  function showChat() {
692
+ disarmForModeSwitch();
631
693
  el("#fp-chat-panel").removeClass("fp-hidden");
632
694
  el("#fp-settings-panel").addClass("fp-hidden");
633
695
  el("#fp-history-panel").addClass("fp-hidden");
@@ -637,6 +699,7 @@
637
699
  }
638
700
 
639
701
  function showSettings() {
702
+ disarmForModeSwitch();
640
703
  el("#fp-settings-panel").removeClass("fp-hidden");
641
704
  el("#fp-chat-panel").addClass("fp-hidden");
642
705
  el("#fp-history-panel").addClass("fp-hidden");
@@ -646,6 +709,7 @@
646
709
  }
647
710
 
648
711
  function showHistory() {
712
+ disarmForModeSwitch();
649
713
  el("#fp-history-panel").removeClass("fp-hidden");
650
714
  el("#fp-chat-panel").addClass("fp-hidden");
651
715
  el("#fp-settings-panel").addClass("fp-hidden");
@@ -665,6 +729,7 @@
665
729
  attachedDebugMessages = [];
666
730
  activeBuildLoop = null;
667
731
  disarmExecuteAction(); // also clears pinnedSelectionIds
732
+ delete appliedOpsByConversation[conversationId];
668
733
  conversationId = newConversationId();
669
734
  fpChatSnappedToBottom = true;
670
735
  updateSelectionStatus();
@@ -778,8 +843,12 @@
778
843
  // Switches to a past conversation: rebuilds conversationHistory and the
779
844
  // visible chat from its saved transcript, and continues using its
780
845
  // conversationId so new turns append to the same transcript file.
781
- 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) {
782
850
  ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
851
+ delete appliedOpsByConversation[conversationId];
783
852
  conversationId = id;
784
853
  try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
785
854
 
@@ -796,7 +865,7 @@
796
865
  pinnedSelectionIds = null;
797
866
  updateSelectionStatus();
798
867
  showChat();
799
- });
868
+ }, onError);
800
869
  }
801
870
 
802
871
  // Recall — searches OTHER past conversations' transcripts for the
@@ -982,17 +1051,52 @@
982
1051
  // removed in both the success and error paths so it can't get stuck.
983
1052
  // showStop adds a "Stop" button, used by the agent loop
984
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
+
985
1083
  function showPending(showStop) {
986
1084
  var $box = el("#fp-messages");
987
1085
  if (!$box.length) { return; }
988
1086
  // Guard against duplicates (e.g. fast double-send).
989
1087
  $box.find("#fp-pending").remove();
1088
+ if (fpPendingElapsedInterval) {
1089
+ clearInterval(fpPendingElapsedInterval);
1090
+ fpPendingElapsedInterval = null;
1091
+ }
990
1092
 
991
1093
  var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
992
1094
  $("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
993
1095
  var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
994
1096
  $dots.append($("<span>"), $("<span>"), $("<span>"));
995
1097
  $dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
1098
+ var $elapsed = $("<span>").addClass("fp-typing-elapsed");
1099
+ $dots.append($elapsed);
996
1100
  if (showStop) {
997
1101
  $dots.append($("<button>")
998
1102
  .addClass("fp-agent-stop red-ui-button red-ui-button-small")
@@ -1000,6 +1104,7 @@
1000
1104
  .text("Stop")
1001
1105
  .on("click", function () {
1002
1106
  fpAgentStopRequested = true;
1107
+ if (fpCurrentAgentRequest) { fpCurrentAgentRequest.abort(); }
1003
1108
  $(this).prop("disabled", true).text("Stopping…");
1004
1109
  }));
1005
1110
  }
@@ -1007,10 +1112,28 @@
1007
1112
 
1008
1113
  $box.append($msg);
1009
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);
1010
1128
  }
1011
1129
 
1012
1130
  function hidePending() {
1013
1131
  el("#fp-messages").find("#fp-pending").remove();
1132
+ if (fpPendingElapsedInterval) {
1133
+ clearInterval(fpPendingElapsedInterval);
1134
+ fpPendingElapsedInterval = null;
1135
+ }
1136
+ fpPendingStartedAt = null;
1014
1137
  }
1015
1138
 
1016
1139
  // Updates the narration text shown in the pending indicator while the
@@ -1071,7 +1194,7 @@
1071
1194
  }
1072
1195
 
1073
1196
  function ajaxJson(method, url, payload, onSuccess, onError) {
1074
- $.ajax({
1197
+ return $.ajax({
1075
1198
  url: flowpilotUrl(url),
1076
1199
  method: method,
1077
1200
  contentType: "application/json",
@@ -1084,7 +1207,14 @@
1084
1207
  },
1085
1208
  success: onSuccess,
1086
1209
  error: function (xhr) {
1087
- 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) ||
1088
1218
  xhr.responseText || xhr.statusText || "Unknown error";
1089
1219
  if (onError) { onError(msg, xhr); }
1090
1220
  else { addMessage("error", msg); }
@@ -1125,7 +1255,11 @@
1125
1255
  el("#fp-base-url").attr("placeholder", isAnthropic ? "Leave blank for api.anthropic.com" : "http://localhost:8080");
1126
1256
  }
1127
1257
 
1128
- // Write the form fields from a given provider profile.
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).
1129
1263
  function fillProviderFields(p) {
1130
1264
  p = p || {};
1131
1265
  el("#fp-provider-name").val(p.providerName || "");
@@ -1134,20 +1268,23 @@
1134
1268
  toggleAnthropicHint(type);
1135
1269
  el("#fp-base-url").val(p.baseUrl || "");
1136
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");
1137
1272
  el("#fp-model").val(p.model || "");
1138
1273
  el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
1139
1274
  // Test provider is disabled until this provider has a model.
1140
1275
  el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
1141
1276
  }
1142
1277
 
1143
- // Short descriptive band shown under the Personality slider, matching
1144
- // 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.
1145
1282
  function personaLabelFor(n) {
1146
1283
  n = Number(n);
1147
1284
  if (n <= 1) { return "Plain engineer — no aviation language at all."; }
1148
- if (n <= 4) { return "Subtle co-pilot (default) — light, occasional flavor."; }
1149
- if (n <= 7) { return "Noticeable captain energy — more frequent, more colorful."; }
1150
- 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."; }
1151
1288
  return "Full captain — comically over-the-top.";
1152
1289
  }
1153
1290
 
@@ -1165,17 +1302,19 @@
1165
1302
  fillProviderFields(activeProvider());
1166
1303
 
1167
1304
  el("#fp-system-prompt").val(settings.systemPrompt || "");
1168
- el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 3);
1305
+ el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 2);
1169
1306
  updatePersonaLabel();
1170
1307
  el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
1171
1308
  el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
1172
1309
  el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
1173
1310
  el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
1174
- 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);
1175
1313
  el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
1176
1314
  el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
1177
1315
  el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
1178
1316
  el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
1317
+ el("#fp-debug-logging").prop("checked", !!settings.debugLogging);
1179
1318
 
1180
1319
  // The dev/test banner is part of the warning set the user can silence
1181
1320
  // via the type-to-confirm acknowledgement.
@@ -1250,14 +1389,15 @@
1250
1389
  if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
1251
1390
 
1252
1391
  var requestTimeoutSec = Number(el("#fp-request-timeout").val());
1253
- if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
1254
1392
 
1255
1393
  var personaIntensity = Number(el("#fp-persona-intensity").val());
1256
- if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 10) { personaIntensity = 3; }
1394
+ if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 5) { personaIntensity = 2; }
1257
1395
 
1258
1396
  var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
1259
1397
  if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
1260
1398
 
1399
+ var agentTurnMaxTokens = Number(el("#fp-agent-turn-max-tokens").val());
1400
+
1261
1401
  return {
1262
1402
  providers: providersList(),
1263
1403
  activeProviderId: currentSettings.activeProviderId,
@@ -1268,10 +1408,12 @@
1268
1408
  historyMaxExchanges: historyMax,
1269
1409
  streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
1270
1410
  requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
1411
+ agentTurnMaxTokens: agentTurnMaxTokens,
1271
1412
  agentLoopMaxIterations: agentLoopMaxIterations,
1272
1413
  loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
1273
1414
  suppressContextWarnings: suppress,
1274
1415
  redactionEnabled: redactionEnabled,
1416
+ debugLogging: el("#fp-debug-logging").prop("checked"),
1275
1417
  customIntents: Array.isArray(currentSettings.customIntents)
1276
1418
  ? currentSettings.customIntents : []
1277
1419
  };
@@ -1417,6 +1559,21 @@
1417
1559
  var payload = collectSettings();
1418
1560
  var list = payload.providers || [];
1419
1561
 
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
+
1420
1577
  // Validation 1: every non-Anthropic provider needs a base URL.
1421
1578
  // Anthropic providers default to api.anthropic.com when baseUrl is blank.
1422
1579
  var noUrl = list.filter(function (p) {
@@ -1649,6 +1806,470 @@
1649
1806
  return Object.assign({ selected: true }, context);
1650
1807
  }
1651
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
+
1652
2273
  // Per-step narration: a short human-readable description of what a tool
1653
2274
  // call is about to do, shown in the pending indicator (see runAgentChat).
1654
2275
  function describeAgentToolCall(name, args) {
@@ -1666,6 +2287,18 @@
1666
2287
  return "Checking the debug log…";
1667
2288
  case "get_selection":
1668
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…";
1669
2302
  default:
1670
2303
  return "Running " + (name || "a tool") + "…";
1671
2304
  }
@@ -1679,7 +2312,7 @@
1679
2312
  catch (e) { return {}; }
1680
2313
  }
1681
2314
 
1682
- function executeAgentToolCall(call) {
2315
+ function executeAgentToolCall(call, runIdMap, runHistoryEvents) {
1683
2316
  var name = call && call.function && call.function.name;
1684
2317
  var args = parseToolCallArgs(call);
1685
2318
  switch (name) {
@@ -1695,6 +2328,21 @@
1695
2328
  return executeReadDebugTool(args);
1696
2329
  case "get_selection":
1697
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" };
1698
2346
  default:
1699
2347
  return { error: "Unknown tool: " + name };
1700
2348
  }