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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,34 +2,61 @@
2
2
 
3
3
  All notable changes to FlowPilot are documented here.
4
4
 
5
- ## [0.6.0-beta.1] - 2026-08-31
5
+ ## [0.6.0] - 2026-09-01
6
6
 
7
- Prerelease published to the `beta` npm tag only. `latest` stays on `0.5.2`.
7
+ Promoted from `0.6.0-beta.1` to the stable `latest` npm tag.
8
8
 
9
9
  ### Added
10
- - **Agentic WRITE-tool loop for Modify** (behind the `enableAgentWrite`
11
- setting, default off): step-by-step tool calls against the live flow
12
- (`apply_step`, `remove_step`, `rename_node`, `group_nodes`), each
13
- write-gated by a per-step consent prompt (Proceed / Skip this step) before
14
- it touches the canvas. Multi-item requests are tracked and executed as
15
- separate, individually-verified steps rather than one all-or-nothing
16
- envelope; an injected mid-run failure (e.g. a referenced node no longer
17
- exists) fails only that item, with the rest completing normally.
10
+ - **Agentic WRITE-tool loop for Modify and Generate** (behind the
11
+ `enableAgentWrite` setting, default off): step-by-step tool calls
12
+ against the live flow (`apply_step`, `remove_step`, `rename_node`,
13
+ `group_nodes`), each write-gated by a per-step consent prompt (Proceed
14
+ / Skip this step) before it touches the canvas. Multi-item requests are
15
+ tracked and executed as separate, individually-verified steps rather
16
+ than one all-or-nothing envelope; an injected mid-run failure (e.g. a
17
+ referenced node no longer exists) fails only that item, with the rest
18
+ completing normally. Generate joined the same agentic runtime Modify
19
+ already used, closing the last place a mutation could land through the
20
+ older, less-verified classic JSON path while write-tool mode is on.
18
21
  - **`ask_user` clarifying-question tool**: an agent-strategy turn can pause
19
22
  mid-run to ask a single focused question (with optional quick-reply
20
23
  buttons) instead of guessing, and resumes exactly where it left off once
21
24
  answered.
25
+ - **Deterministic run summaries**: when a Modify or Generate run actually
26
+ applied WRITE-tool steps, the per-item outcome you see (✓ applied and
27
+ verified / ✗ failed, with why) now comes straight from what the tools
28
+ actually reported — not from the model's own retelling. The model's own
29
+ wrap-up explanation still shows, underneath, clearly secondary — useful
30
+ context, never the thing you have to trust to know what really
31
+ happened.
22
32
  - **Contract-exclusivity enforcement**: a server-side safety net
23
33
  (`enforceAgentContract`) strips any classic-style mutation fields
24
- (`changes`/`newNodes`/`newWires`/`removeNodes`/`newGroups`) that
34
+ (`changes`/`newNodes`/`newWires`/`removeNodes`/`newGroups`/`flow`) that
25
35
  accidentally appear on an agent-strategy turn with no tool calls, before
26
36
  they ever reach the client — the two mutation paths (classic envelope vs.
27
- agentic WRITE tools) stay mutually exclusive per turn.
37
+ agentic WRITE tools) stay mutually exclusive per turn, for every mode
38
+ that can run agentically.
28
39
  - **Run identity and honest interruption**: agent-strategy runs now carry a
29
40
  stable run/operation id so a duplicate tool-call delivery (a retry, or the
30
41
  model repeating itself) is applied at most once; a run that's stopped or
31
42
  hits its step/token ceiling reports an honest "interrupted after N steps"
32
- instead of silently truncating.
43
+ instead of silently truncating. That step-by-step record is also now
44
+ visible to the server (previously client-side only), the foundation for
45
+ the deterministic run summaries above.
46
+
47
+ ### Fixed
48
+ - **The FlowPilot sidebar's entire client script was loading twice on
49
+ every single editor page load** — a packaging issue present since the
50
+ plugin's first release, only now found and fixed. In practice this
51
+ meant a page reload silently lost the current conversation (a second,
52
+ redundant script execution would stomp the first one's restored state)
53
+ and, more subtly, any other one-time setup code in the client ran
54
+ twice per load. Reloading the Node-RED editor now correctly picks the
55
+ conversation back up where you left off.
56
+ - Anthropic (Claude) responses now get the exact same deterministic
57
+ run-summary and contract-exclusivity treatment as every other
58
+ provider — verified directly against Claude Sonnet, not just the
59
+ OpenAI-compatible path.
33
60
 
34
61
  ### Security
35
62
  - **API keys are now write-only over HTTP** (previously exposed on
@@ -60,6 +87,59 @@ Prerelease — published to the `beta` npm tag only. `latest` stays on `0.5.2`.
60
87
  `dev-docs/current/Phase10-Build-Progress.md` and
61
88
  `dev-docs/current/Phase10-Gate-Closeout-Final.md`.
62
89
 
90
+ ## [0.5.2] - 2026-08-04
91
+
92
+ This release continues the 0.5.x line. Phase 10's agentic Modify redesign
93
+ (WRITE-tool loop, per-call consent gates, `ask_user`) is developed
94
+ separately and is not included here — it ships under its own beta line
95
+ until it's ready to replace this one.
96
+
97
+ ### Added
98
+ - **Anthropic provider support**: FlowPilot can now talk directly to the
99
+ Anthropic API (Claude models) alongside any OpenAI-compatible endpoint.
100
+ A new Provider Type dropdown in Settings switches between them; message
101
+ format, tool-call translation, and streaming (SSE) are handled
102
+ transparently by a dedicated adapter. Leave Base URL blank to use
103
+ `api.anthropic.com`.
104
+ - **Reasoning model support**: FlowPilot detects reasoning models
105
+ (Nemotron, DeepSeek, QwQ, and any model returning `reasoning_content` or
106
+ `<think>` blocks) and shows a live collapsing "Thinking…" block during
107
+ streaming, or a pre-collapsed one on the agent-loop path.
108
+ - **Auto-preflight on model change**: switching models and sending a
109
+ message silently probes capabilities first — no more manual "Test
110
+ Provider" click after every model swap.
111
+ - **`/refresh` command**: re-renders the message panel from the in-memory
112
+ record store without losing conversation history — restores interactive
113
+ Apply buttons and review panels that went stale after a long session.
114
+ - **Build loop — consent gates for side-effecting steps, checkpoint
115
+ questions, context-aware start from a selection, and an explicit
116
+ done/fail confirmation** instead of silently stopping.
117
+ - **Modify — todo/plan checklist rendering**: multi-item Modify requests
118
+ now show a checklist that resolves as verification confirms each item
119
+ landed, instead of a single opaque pass/fail.
120
+ - **Config node support**: config nodes (MQTT brokers, TLS configs, etc.)
121
+ are now exposed in context and can be created/connected by Modify.
122
+ - **Server-side validator/repair layer**: malformed model output is
123
+ caught and, where possible, automatically repaired before it reaches
124
+ the canvas.
125
+
126
+ ### Fixed
127
+ - Redaction round-trip poisoning, token-credential redaction gaps, and
128
+ several redaction echo/false-positive fixes across Modify and the build
129
+ loop's review step.
130
+ - Group data corruption from a stray `changes` patch touching a group's
131
+ membership array directly; mixed-membership group create/extend
132
+ corruption.
133
+ - Invalid port wiring (wiring to/from a port index that doesn't exist)
134
+ now guarded instead of silently applied.
135
+ - New-node insertion collision avoidance tuned for faster, more reliable
136
+ separation on dense flows.
137
+ - Several build-loop review false positives (metadata-field echoes,
138
+ debug/function/mqtt node misclassification, stale in-progress status
139
+ read as a real error).
140
+ - Partial id-validation no longer discards an entire valid Modify batch
141
+ over one bad node id.
142
+
63
143
  ## [0.5.1] - 2026-07-24
64
144
 
65
145
  ### Added
package/README.md CHANGED
@@ -26,7 +26,9 @@ installation, the sidebar UI, and a chapter on every feature.
26
26
  - **User-initiated only** — FlowPilot never changes your flow without an
27
27
  explicit request.
28
28
  - **Complete visibility** — every proposed change is shown as a diff/review
29
- before anything is applied.
29
+ before it's applied; in the opt-in agentic mode (below), each step is
30
+ gated by its own Proceed/Skip prompt instead, so you're still in control
31
+ of what actually lands on the canvas.
30
32
  - **Undo first** — every change goes through Node-RED's native undo
31
33
  (Ctrl+Z), including multi-part changes (insertions + rewires + new nodes)
32
34
  as a single step.
@@ -73,6 +75,13 @@ See the [User Guide](USER-GUIDE.md#privacy-and-safety) for the full details.
73
75
  Document, with a clear notice when older messages are truncated.
74
76
  - **Clarifying questions** — if an instruction is too vague to act on
75
77
  safely, FlowPilot asks ONE question instead of guessing.
78
+ - **Agentic mode (opt-in, Settings)** — Generate and Modify can work
79
+ step by step instead of one big review-then-apply diff: each step
80
+ (insert, rewire, rename, remove, group) lands on the canvas as it's
81
+ proposed, gated by its own Proceed/Skip consent prompt for anything
82
+ beyond the safest node types. The summary you see when a run finishes
83
+ is built from what the tools actually reported happened, not just the
84
+ model's own retelling — if a step failed or was skipped, it says so.
76
85
  - **Streaming replies** — optional SSE streaming for chat responses.
77
86
  - **`/build`** — describe a goal and FlowPilot plans it, proposes a first
78
87
  flow, then walks an interactive build → deploy → debug → review → fix loop
@@ -108,6 +108,21 @@
108
108
  border-color: rgba(80, 130, 255, 0.35);
109
109
  }
110
110
 
111
+ /* Subordinate to the deterministic run summary above it (C1): same shape,
112
+ visibly de-emphasized so a model's free-text recap can't be mistaken for
113
+ the authoritative per-item outcome. */
114
+ .fp-secondary {
115
+ padding: 8px 14px;
116
+ background: transparent;
117
+ border-style: dashed;
118
+ font-size: 12px;
119
+ color: var(--red-ui-secondary-text-color, #888);
120
+ }
121
+
122
+ .fp-secondary .fp-label {
123
+ opacity: 0.7;
124
+ }
125
+
111
126
  /* Reasoning / thinking block — shown for models that emit reasoning_content
112
127
  (e.g. Nemotron, DeepSeek-R1). Sits above the assistant response bubble.
113
128
  Expanded while the model thinks; collapses automatically when content starts. */
@@ -0,0 +1,15 @@
1
+ // Node-RED's "nodes" registration entry needs its own .js file, separate
2
+ // from flowpilot.js — Node-RED derives an editor-template path for every
3
+ // declared "nodes" entry by replacing the .js extension with .html
4
+ // (@node-red/registry/lib/loader.js loadNodeConfig: `file.replace(/\.c?js$/,".html")`),
5
+ // with NO awareness of the "plugins" entry that already explicitly declares
6
+ // flowpilot.html. If this entry pointed at flowpilot.js directly, Node-RED
7
+ // would derive "flowpilot.html" as this node's own template, find the real
8
+ // file (same basename), and load its content a SECOND time — the plugin's
9
+ // entire client bundle (flowpilot/core.js and its inline init script)
10
+ // ends up in the editor page twice, executing every module-level
11
+ // side effect (including sessionStorage init) twice per page load.
12
+ // This shim's own basename ("flowpilot-node-entry") has no matching .html,
13
+ // so Node-RED's template lookup misses (ENOENT) and loads no content for
14
+ // it — exactly what a route-registration-only, no-editor-UI node needs.
15
+ module.exports = require("./flowpilot.js");
package/flowpilot.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const http = require("http");
2
+ const https = require("https");
2
3
  const path = require("path");
4
+ const PACKAGE_VERSION = require("./package.json").version;
3
5
  const createStorage = require("./lib/storage");
4
6
  const openaiProvider = require("./lib/provider-openai-compatible");
5
7
  const anthropicProvider = require("./lib/provider-anthropic");
@@ -82,6 +84,110 @@ const { repairEnvelope } = require("./lib/validator");
82
84
  const { enforceAgentContract } = require("./lib/agent-contract");
83
85
  const { isProviderShapedResponse } = require("./lib/provider-shape-check");
84
86
  const API_KEY_UNCHANGED = createStorage.API_KEY_UNCHANGED;
87
+ const UPDATE_CHECK_URL = "https://registry.npmjs.org/-/package/@manny-est/node-red-flowpilot/dist-tags";
88
+ const UPDATE_CHECK_SUCCESS_TTL_MS = 6 * 60 * 60 * 1000;
89
+ const UPDATE_CHECK_FAILURE_TTL_MS = 15 * 60 * 1000;
90
+ const RUN_EVENTS_MAX_STORED = 50;
91
+ const runEventsStore = new Map();
92
+ let updateCheckCache = null;
93
+
94
+ function parseVersion(v) {
95
+ var parts = String(v).split("-");
96
+ var core = parts[0].split(".").map(Number);
97
+ var pre = parts.length > 1 ? parts.slice(1).join("-").split(".") : null;
98
+ return { core: core, pre: pre };
99
+ }
100
+
101
+ function isNewer(remoteVersion, localVersion) {
102
+ var r = parseVersion(remoteVersion), l = parseVersion(localVersion);
103
+ for (var i = 0; i < 3; i++) {
104
+ var rv = r.core[i] || 0, lv = l.core[i] || 0;
105
+ if (rv !== lv) { return rv > lv; }
106
+ }
107
+ // core version numbers are equal
108
+ if (!r.pre && l.pre) { return true; } // remote is a full release, local is a prerelease of the same core
109
+ if (r.pre && !l.pre) { return false; } // remote is a prerelease, local is already past it
110
+ if (!r.pre && !l.pre) { return false; } // identical plain releases
111
+ // both are prereleases of the same core version — compare the last dot segment numerically if possible
112
+ var rn = Number(r.pre[r.pre.length - 1]);
113
+ var ln = Number(l.pre[l.pre.length - 1]);
114
+ if (!isNaN(rn) && !isNaN(ln)) { return rn > ln; }
115
+ return r.pre.join(".") !== l.pre.join(".") && r.pre.join(".") > l.pre.join(".");
116
+ }
117
+
118
+ function updateCheckFallbackResponse() {
119
+ return { enabled: true, updateAvailable: false };
120
+ }
121
+
122
+ function fetchUpdateDistTags() {
123
+ return new Promise((resolve, reject) => {
124
+ let url;
125
+ try {
126
+ url = new URL(UPDATE_CHECK_URL);
127
+ } catch (err) {
128
+ reject(err);
129
+ return;
130
+ }
131
+
132
+ const req = https.request({
133
+ method: "GET",
134
+ hostname: url.hostname,
135
+ port: url.port || 443,
136
+ path: url.pathname + url.search,
137
+ headers: { Accept: "application/json" }
138
+ }, (res) => {
139
+ let data = "";
140
+ res.setEncoding("utf8");
141
+ res.on("data", chunk => { data += chunk; });
142
+ res.on("end", () => {
143
+ let parsed = null;
144
+ try {
145
+ parsed = data ? JSON.parse(data) : null;
146
+ } catch (err) {
147
+ reject(err);
148
+ return;
149
+ }
150
+ if (res.statusCode !== 200 || !parsed || typeof parsed !== "object") {
151
+ reject(new Error("Update check failed."));
152
+ return;
153
+ }
154
+ resolve(parsed);
155
+ });
156
+ });
157
+
158
+ req.on("error", reject);
159
+ req.setTimeout(5000, () => {
160
+ req.destroy(new Error("Timeout"));
161
+ });
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ async function performUpdateCheck() {
167
+ try {
168
+ const distTags = await fetchUpdateDistTags();
169
+ const tag = PACKAGE_VERSION.indexOf("-") !== -1 ? "beta" : "latest";
170
+ const tagVersion = distTags && distTags[tag];
171
+ if (typeof tagVersion !== "string" || !tagVersion) {
172
+ throw new Error("Missing dist-tag.");
173
+ }
174
+ return {
175
+ succeeded: true,
176
+ result: {
177
+ enabled: true,
178
+ updateAvailable: isNewer(tagVersion, PACKAGE_VERSION),
179
+ latestVersion: tagVersion,
180
+ currentVersion: PACKAGE_VERSION,
181
+ tag: tag
182
+ }
183
+ };
184
+ } catch (err) {
185
+ return {
186
+ succeeded: false,
187
+ result: updateCheckFallbackResponse()
188
+ };
189
+ }
190
+ }
85
191
 
86
192
  module.exports = function flowPilotRuntime(RED) {
87
193
  const storage = createStorage(RED.settings.userDir);
@@ -385,14 +491,17 @@ module.exports = function flowPilotRuntime(RED) {
385
491
  },
386
492
  targetNodeIds: {
387
493
  oneOf: [
388
- { type: "string", enum: ["all"] },
494
+ { type: "string", enum: ["all", "instance"] },
389
495
  {
390
496
  type: "array",
391
497
  items: { type: "string" },
392
498
  minItems: 1
393
499
  }
394
500
  ],
395
- description: "Optional resolved node target for Document redirects."
501
+ description: "Optional resolved node target for Document redirects. " +
502
+ "\"all\" is the entire active flow/tab; \"instance\" is every flow " +
503
+ "tab in the whole Node-RED instance. Omit when the scope genuinely " +
504
+ "can't be resolved from context."
396
505
  }
397
506
  },
398
507
  required: ["mode", "prompt", "explanation"],
@@ -432,7 +541,7 @@ module.exports = function flowPilotRuntime(RED) {
432
541
 
433
542
  function agentToolsFor(settings, activeProvider, mode, writesAllowed) {
434
543
  const writesEnabled = settings.enableAgentWrite === true &&
435
- mode === "modify" && writesAllowed !== false &&
544
+ (mode === "modify" || mode === "generate") && writesAllowed !== false &&
436
545
  activeProvider && activeProvider.supportsTools === true;
437
546
  return providerToolDefinitions(AGENT_READ_TOOLS.concat(writesEnabled ? WRITE_TOOLS : []));
438
547
  }
@@ -1233,12 +1342,43 @@ module.exports = function flowPilotRuntime(RED) {
1233
1342
 
1234
1343
  RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
1235
1344
  try {
1236
- res.json(maskProviderSecrets(storage.getSettings()));
1345
+ const responseBody = maskProviderSecrets(storage.getSettings());
1346
+ responseBody.flowpilotVersion = PACKAGE_VERSION;
1347
+ res.json(responseBody);
1237
1348
  } catch (err) {
1238
1349
  res.status(500).json({ error: err.message });
1239
1350
  }
1240
1351
  });
1241
1352
 
1353
+ RED.httpAdmin.get("/flowpilot/update-check", RED.auth.needsPermission("settings.read"), async function (req, res) {
1354
+ const settings = storage.getSettings();
1355
+ if (settings.checkForUpdates === false) {
1356
+ res.json({ enabled: false });
1357
+ return;
1358
+ }
1359
+
1360
+ const now = Date.now();
1361
+ if (updateCheckCache) {
1362
+ const ageMs = now - updateCheckCache.checkedAt;
1363
+ if (updateCheckCache.succeeded && ageMs < UPDATE_CHECK_SUCCESS_TTL_MS) {
1364
+ res.json(updateCheckCache.result);
1365
+ return;
1366
+ }
1367
+ if (!updateCheckCache.succeeded && ageMs < UPDATE_CHECK_FAILURE_TTL_MS) {
1368
+ res.json(updateCheckFallbackResponse());
1369
+ return;
1370
+ }
1371
+ }
1372
+
1373
+ const check = await performUpdateCheck();
1374
+ updateCheckCache = {
1375
+ checkedAt: now,
1376
+ succeeded: check.succeeded,
1377
+ result: check.result
1378
+ };
1379
+ res.json(check.result);
1380
+ });
1381
+
1242
1382
  // ---- Settings: default system prompt (for "Reset to default") -------
1243
1383
 
1244
1384
  RED.httpAdmin.get("/flowpilot/default-system-prompt", RED.auth.needsPermission("settings.read"), function (req, res) {
@@ -1249,6 +1389,11 @@ module.exports = function flowPilotRuntime(RED) {
1249
1389
  }
1250
1390
  });
1251
1391
 
1392
+ RED.httpAdmin.get("/flowpilot/run-events/:runId", RED.auth.needsPermission("settings.read"), function (req, res) {
1393
+ const runId = req.params.runId;
1394
+ res.json({ runId: runId, events: runEventsStore.get(runId) || [] });
1395
+ });
1396
+
1252
1397
  // ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
1253
1398
  // Serves the shared renderer (flowpilot-core.js, the same script
1254
1399
  // flowpilot.html loads for the sidebar) plus its stylesheet and the
@@ -1307,13 +1452,21 @@ module.exports = function flowPilotRuntime(RED) {
1307
1452
  // a known limitation, not fixed by this ticket.
1308
1453
 
1309
1454
  // ---- Provider confirmation gate (ADR-007, SSRF mitigation) -----------
1310
- // No operational request (chat/generate/modify/document/build/agent-step/
1311
- // models) touches a provider's baseUrl until that exact URL has passed a
1312
- // real FlowPilot provider check — see isProviderShapedResponse
1455
+ // No operational request (chat/generate/modify/document/build/agent-step)
1456
+ // touches a provider's baseUrl until that exact URL has passed a real
1457
+ // FlowPilot provider check — see isProviderShapedResponse
1313
1458
  // (lib/provider-shape-check.js) below
1314
- // and /flowpilot/test, /flowpilot/probe, the only two routes allowed to
1315
- // contact an unconfirmed URL. confirmedBaseUrl/confirmedAt are written
1316
- // ONLY by those two routes on a passing check; lib/storage.js's
1459
+ // and /flowpilot/test, /flowpilot/probe, /flowpilot/models — the only
1460
+ // three routes allowed to contact an unconfirmed URL. The first two
1461
+ // WRITE confirmedBaseUrl/confirmedAt on a passing check (the deliberate
1462
+ // confirming action); /flowpilot/models never does — it's read-only
1463
+ // reconnaissance (listing available models), safe to allow pre-confirmation
1464
+ // the same way /probe is, but not itself a confirmation. All three stay
1465
+ // safe against an unconfirmed/malicious target the same way: a strict
1466
+ // shape check on any "success" response (isProviderShapedResponse for
1467
+ // chat-shaped, isModelsListShaped for a models list) and a generic,
1468
+ // never-reflects-the-body error on failure (enforced at the shared HTTP
1469
+ // client layer in lib/provider-*.js). lib/storage.js's
1317
1470
  // reconcileProviderSecrets is the other half — it strips any
1318
1471
  // client-supplied confirmedBaseUrl/confirmedAt on save and clears
1319
1472
  // confirmation whenever baseUrl or apiKey actually changes, so
@@ -1399,16 +1552,22 @@ module.exports = function flowPilotRuntime(RED) {
1399
1552
  // ---- Models: list models via the active provider's /v1/models -------
1400
1553
  // Always acts on the SAVED active provider (the frontend saves the form
1401
1554
  // first, mirroring Pre-flight check), and never errors out for a provider
1402
- // that doesn't support /v1/models — see listModels().
1555
+ // that doesn't support /v1/models — see listModels(). No request body is
1556
+ // read here, so no body-presence check applies (unlike /flowpilot/settings,
1557
+ // which does act on its body).
1558
+ //
1559
+ // This is the THIRD route allowed to touch an unconfirmed baseUrl (ADR-007)
1560
+ // — users need to see a model list before ever running Pre-flight check,
1561
+ // and the confirmation gate would otherwise block that. Kept safe the same
1562
+ // way /flowpilot/test and /flowpilot/probe are: listModels() only trusts a
1563
+ // genuinely provider-shaped response (isModelsListShaped) and never
1564
+ // reflects a raw failure body. Does not itself write confirmedBaseUrl/
1565
+ // confirmedAt — only the deliberate /flowpilot/test action confirms.
1403
1566
 
1404
1567
  RED.httpAdmin.post("/flowpilot/models", RED.auth.needsPermission("settings.write"), async function (req, res) {
1405
- if (!hasRequestBody(req.body)) {
1406
- return res.status(400).json({ error: "Request body is required." });
1407
- }
1408
1568
  try {
1409
1569
  const settings = storage.getSettings();
1410
1570
  const activeProvider = storage.getActiveProvider(settings);
1411
- if (!requireConfirmedProvider(res, activeProvider)) { return; }
1412
1571
  const result = await getProvider(activeProvider).listModels(activeProvider);
1413
1572
  storage.appendAudit({
1414
1573
  action: "list_models",
@@ -1562,6 +1721,17 @@ module.exports = function flowPilotRuntime(RED) {
1562
1721
  const activeProvider = storage.getActiveProvider(settings);
1563
1722
  const execution = requireExecutionContract(req, res, settings, activeProvider);
1564
1723
  if (!execution) { return; }
1724
+ if (Array.isArray(req.body.events) && typeof execution.runId === "string" && execution.runId) {
1725
+ runEventsStore.set(execution.runId, req.body.events);
1726
+ if (runEventsStore.size > RUN_EVENTS_MAX_STORED) {
1727
+ runEventsStore.delete(runEventsStore.keys().next().value);
1728
+ }
1729
+ maybeLogDebugEvent("run_events", {
1730
+ mode: req.body.mode || "chat",
1731
+ runId: execution.runId,
1732
+ events: req.body.events
1733
+ });
1734
+ }
1565
1735
  if (!requireConfirmedProvider(res, activeProvider)) { return; }
1566
1736
 
1567
1737
  const messages = req.body && req.body.messages;
@@ -1650,7 +1820,7 @@ module.exports = function flowPilotRuntime(RED) {
1650
1820
  r, (context && Array.isArray(context.nodes)) ? context.nodes : [], execution
1651
1821
  );
1652
1822
  }
1653
- : finalizeSimpleGeneration;
1823
+ : function (r) { return finalizeSimpleGeneration(r, execution); };
1654
1824
  const { status, body } = finalize(generated);
1655
1825
  return res.status(status).json(body);
1656
1826
  }
@@ -2295,6 +2465,8 @@ module.exports = function flowPilotRuntime(RED) {
2295
2465
  return modifyResult;
2296
2466
  }
2297
2467
 
2468
+ enforceAgentContract(parsed, auditContext, false);
2469
+
2298
2470
  const flow = Array.isArray(parsed.flow) ? parsed.flow : null;
2299
2471
  if (!flow) {
2300
2472
  const err = new Error("The response did not contain a 'flow' array.");
@@ -2319,6 +2491,9 @@ module.exports = function flowPilotRuntime(RED) {
2319
2491
  newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
2320
2492
  newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
2321
2493
  };
2494
+ if (Array.isArray(parsed.strippedFields) && parsed.strippedFields.length) {
2495
+ flowResult.strippedFields = parsed.strippedFields.slice();
2496
+ }
2322
2497
  if (auditAction === "build") {
2323
2498
  const fpUidManifest = buildFpUidManifest(flow);
2324
2499
  if (fpUidManifest.length) { flowResult.fpUidManifest = fpUidManifest; }
@@ -2353,7 +2528,7 @@ module.exports = function flowPilotRuntime(RED) {
2353
2528
  ? agentToolsFor(settings, activeProvider, auditAction, execution && execution.strategy === "agent")
2354
2529
  : [];
2355
2530
  const responseFormat = directCompletionResponseFormat(activeProvider, auditAction, toolsEnabled);
2356
- const toolChoice = auditAction === "modify" && execution &&
2531
+ const toolChoice = (auditAction === "modify" || auditAction === "generate") && execution &&
2357
2532
  execution.strategy === "agent" ? "required" : "auto";
2358
2533
  let chatOptions = toolsEnabled
2359
2534
  ? { tools: offeredTools, toolChoice: toolChoice }
@@ -2498,7 +2673,7 @@ module.exports = function flowPilotRuntime(RED) {
2498
2673
  // Used by both the non-streaming route (res.status(status).json(body)) and
2499
2674
  // the streaming route (relayed as the final SSE event).
2500
2675
  // ---------------------------------------------------------------------
2501
- function finalizeSimpleGeneration(result) {
2676
+ function finalizeSimpleGeneration(result, execution) {
2502
2677
  if (result.question) {
2503
2678
  const body = { explanation: result.explanation, question: result.question, flow: null };
2504
2679
  if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
@@ -2511,6 +2686,19 @@ module.exports = function flowPilotRuntime(RED) {
2511
2686
  if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
2512
2687
  return { status: 200, body: proseBody };
2513
2688
  }
2689
+ if (execution && execution.strategy === "agent") {
2690
+ const agentBody = {
2691
+ explanation: result.explanation || "",
2692
+ prose: true,
2693
+ flow: null
2694
+ };
2695
+ if (Array.isArray(result.strippedFields) && result.strippedFields.length) {
2696
+ agentBody.strippedFields = result.strippedFields.slice();
2697
+ }
2698
+ if (result.skippedNote) { agentBody.skippedNote = result.skippedNote; }
2699
+ if (result.suggestedAction) { agentBody.suggestedAction = result.suggestedAction; }
2700
+ return { status: 200, body: agentBody };
2701
+ }
2514
2702
  return { status: 200, body: result };
2515
2703
  }
2516
2704
 
@@ -2871,6 +3059,11 @@ module.exports = function flowPilotRuntime(RED) {
2871
3059
  }
2872
3060
 
2873
3061
  RED.httpAdmin.post("/flowpilot/generate", RED.auth.needsPermission("settings.write"), async function (req, res) {
3062
+ const settings = storage.getSettings();
3063
+ const activeProvider = storage.getActiveProvider(settings);
3064
+ const execution = requireExecutionContract(req, res, settings, activeProvider);
3065
+ if (!execution) { return; }
3066
+
2874
3067
  const prompt = req.body && req.body.prompt;
2875
3068
 
2876
3069
  if (!prompt || !String(prompt).trim()) {
@@ -2879,11 +3072,12 @@ module.exports = function flowPilotRuntime(RED) {
2879
3072
 
2880
3073
  const history = sanitizeHistory(req.body.history);
2881
3074
  const historyTruncated = !!req.body.historyTruncated;
3075
+ const finalize = function (result) { return finalizeSimpleGeneration(result, execution); };
2882
3076
 
2883
3077
  if (req.body.stream) {
2884
3078
  return runExecuteStream(
2885
3079
  req, res, generationSystemPrompt, "generate", prompt, req.body && req.body.context,
2886
- history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
3080
+ history, historyTruncated, finalize, req.body.conversationId, execution
2887
3081
  );
2888
3082
  }
2889
3083
 
@@ -2891,7 +3085,7 @@ module.exports = function flowPilotRuntime(RED) {
2891
3085
  const useTools = !!req.body.tools;
2892
3086
  const generated = await runFlowGeneration(
2893
3087
  generationSystemPrompt, "generate", prompt, req.body && req.body.context,
2894
- history, historyTruncated, useTools
3088
+ history, historyTruncated, useTools, execution
2895
3089
  );
2896
3090
  if (generated.toolCalls) {
2897
3091
  return res.json({
@@ -2903,7 +3097,7 @@ module.exports = function flowPilotRuntime(RED) {
2903
3097
  });
2904
3098
  }
2905
3099
  recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
2906
- const { status, body } = finalizeSimpleGeneration(generated);
3100
+ const { status, body } = finalize(generated);
2907
3101
  res.status(status).json(body);
2908
3102
  } catch (err) {
2909
3103
  sendGenerationError(res, "generate", err);
@@ -4,8 +4,13 @@
4
4
  // The agent-strategy contract: a "strategy":"agent" turn is only ever
5
5
  // allowed to mutate the flow via a WRITE tool call, never via the classic
6
6
  // JSON-envelope mutation fields (changes/newNodes/newWires/removeNodes/
7
- // newGroups). If a model still emits those fields on a turn that made no
8
- // tool calls, strip them before the response reaches the client and log
7
+ // newGroups for Modify; flow for Generate/Document/Build Generate's own
8
+ // envelope shape uses "flow", not "newNodes"/"newWires", found live during
9
+ // the 0.6.0 FINISH-list pass: the field was missing from this list entirely,
10
+ // so an agent-strategy Generate final turn with no tool calls could emit a
11
+ // full flow array completely unprotected). If a model still emits those
12
+ // fields on a turn that made no tool calls, strip them before the response
13
+ // reaches the client and log
9
14
  // what was stripped — the two mutation code paths (classic envelope vs.
10
15
  // agentic WRITE tools) must stay mutually exclusive per agent turn.
11
16
  // Classic-strategy turns and turns that DID make tool calls are untouched
@@ -13,7 +18,7 @@
13
18
  // violating shape it exists to catch.
14
19
  // ---------------------------------------------------------------------
15
20
 
16
- const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups"];
21
+ const AGENT_MUTATION_FIELDS = ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "flow"];
17
22
 
18
23
  function enforceAgentContract(result, execution, hasToolCalls) {
19
24
  if (!result || !execution || execution.strategy !== "agent" || hasToolCalls) {