@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/modes.js CHANGED
@@ -155,7 +155,9 @@
155
155
  context: context,
156
156
  history: historyPayload.messages,
157
157
  historyTruncated: historyPayload.truncated,
158
- conversationId: conversationId
158
+ conversationId: conversationId,
159
+ strategy: "classic",
160
+ entry: "chat"
159
161
  };
160
162
 
161
163
  function handleSendResult(data) {
@@ -300,13 +302,133 @@
300
302
  // ---------------------------------------------------------------------
301
303
  var AGENT_LOOP_MAX_STEPS = 8;
302
304
  var AGENT_LOOP_TOKEN_CEILING = 50000;
305
+ // W7 §16 point 5: ask_user round-trips get their own small budget,
306
+ // separate from AGENT_LOOP_MAX_STEPS, so a couple of clarifying
307
+ // questions don't eat the real step budget.
308
+ var AGENT_ASK_USER_MAX_ROUNDTRIPS = 3;
303
309
 
304
310
  var fpAgentStopRequested = false;
305
-
306
- function runAgentLoop(firstEndpoint, payload, stepExtra, onDone, onError) {
311
+ // CLAUDE-022: the in-flight jqXHR for this run's current ajaxJson call
312
+ // (first request / postNextStep / fallbackToPlain), so the Stop button
313
+ // can actually abort it instead of only setting the flag above. Cleared
314
+ // back to null the moment that call's own success/error callback runs,
315
+ // so a stale reference never lingers into the next step.
316
+ var fpCurrentAgentRequest = null;
317
+
318
+ // P10-D1 (ADR-001 R5): monotonic suffix so two runs minted in the same
319
+ // millisecond (Date.now() collision) still get distinct runIds.
320
+ var runIdCounter = 0;
321
+
322
+ function runAgentLoop(firstEndpoint, payload, stepExtra, realOnDone, realOnError) {
323
+ if (!payload || !payload.strategy || !payload.entry) {
324
+ throw new Error("runAgentLoop requires strategy and entry in the initial payload.");
325
+ }
326
+ var runStrategy = payload.strategy;
327
+ var runEntry = payload.entry;
328
+ // P10-D1 follow-up (sr-dev review): captured once, like
329
+ // runStrategy/runEntry/runId — using the free-variable
330
+ // conversationId instead would let a stale run's dedup lookup land
331
+ // in the wrong conversation's bucket if the user switches
332
+ // conversations mid-run.
333
+ var runConversationId = payload.conversationId;
334
+ // P10-D1: minted once per run, included in every step payload so
335
+ // the server can echo it into logs beside strategy/entry. Also the
336
+ // namespace for this run's WRITE tool opIds (opId = runId + ":" +
337
+ // call.id, ADR-001 R5).
338
+ var runId = "run-" + Date.now().toString(36) + "-" + (++runIdCounter);
339
+ // CLAUDE-013: accumulates placeholder->real-id mappings across every
340
+ // WRITE-tool call resolved THIS run, so a later call in the same run
341
+ // (e.g. group_nodes) can resolve a placeholder id (e.g. "fp-new-2")
342
+ // that an earlier call (e.g. apply_step) minted via applyInsertions —
343
+ // each WRITE executor's call-local idMap only covers ids it created
344
+ // itself, not ids from a prior call in the same agent loop.
345
+ var runIdMap = {};
346
+ // CLAUDE-027: accumulates the individual RED.history event(s) each
347
+ // WRITE-tool call this run would otherwise have pushed on its own
348
+ // (apply_step/remove_step/rename_node/group_nodes — see
349
+ // applyInsertions/applyModifications in apply-review.js and
350
+ // executeGroupNodesTool in main.js, all of which push into this
351
+ // array instead of RED.history directly whenever it's passed
352
+ // through). Flushed as ONE RED.history entry — via RED.history's own
353
+ // t:"multi" wrapper, confirmed against @node-red/editor-client's
354
+ // red.js (e.g. its deleteSelection(), which collapses a mixed
355
+ // delete+move into one push the exact same way) when there's more
356
+ // than one, or pushed unwrapped when there's exactly one, matching
357
+ // that same core convention — the moment this run actually ends, by
358
+ // flushRunHistory()/onDone/onError below. A run that never calls a
359
+ // WRITE tool (e.g. a pure ask_user round-trip, or read-only chat)
360
+ // leaves this empty, so flush is a no-op and no spurious entry is
361
+ // pushed.
362
+ var runHistoryEvents = [];
307
363
  var step = 0;
308
364
  var totalTokens = 0;
365
+ var askUserRounds = 0;
309
366
  fpAgentStopRequested = false;
367
+ fpCurrentAgentRequest = null;
368
+ // W7: one entry per WRITE tool call resolved THIS runAgentLoop
369
+ // invocation, in order (proceed-and-pass / proceed-and-fail /
370
+ // user-declined all count, read-tool calls don't) — gives
371
+ // handleModifyResult a real 1:1 todo-item correlation instead of
372
+ // the CLAUDE-005 all-together fallback. Attached to the final
373
+ // data as _agentWriteResults right before onDone(data); untouched
374
+ // (empty) whenever no WRITE tool call was made this turn, which
375
+ // keeps the existing all-together path completely unchanged.
376
+ var agentWriteResults = [];
377
+ // CLAUDE-014: plain-language note for the decision that triggered the
378
+ // NEXT agent-step round trip (consent-gate Proceed/Skip, ask_user
379
+ // answer) — set right before the decision resumes the loop, read and
380
+ // cleared by postNextStep so CODEX-012's debug.log can show what the
381
+ // user actually decided instead of only raw tool-result JSON. Only
382
+ // populated when settings.debugLogging is on.
383
+ var pendingDebugNote = null;
384
+
385
+ // CLAUDE-027: pushes this run's accumulated WRITE-tool history
386
+ // event(s) (runHistoryEvents above) to RED.history as ONE entry —
387
+ // coalesced via t:"multi" when more than one WRITE-tool call
388
+ // mutated this run, unwrapped when exactly one did, a no-op when
389
+ // none did. MUST run at every single point this run can end, not
390
+ // just the "clean" success path — a run that errors out (step
391
+ // budget/token ceiling exceeded, user Stop, a failed request) after
392
+ // ALREADY applying one or more WRITE tool calls still needs its
393
+ // partial progress to land in undo history, or Ctrl+Z would be
394
+ // unable to remove mutations that are visibly sitting on the
395
+ // canvas. Rather than call this at each of those call sites
396
+ // individually (easy to miss one), onDone/onError below shadow the
397
+ // real callback params so EVERY exit from this closure flushes
398
+ // first automatically.
399
+ function flushRunHistory() {
400
+ if (!runHistoryEvents.length) { return; }
401
+ if (runHistoryEvents.length === 1) {
402
+ RED.history.push(runHistoryEvents[0]);
403
+ } else {
404
+ RED.history.push({ t: "multi", events: runHistoryEvents.slice() });
405
+ }
406
+ runHistoryEvents = [];
407
+ }
408
+ function onDone(data) { flushRunHistory(); realOnDone(data); }
409
+ function onError(err, xhr) { flushRunHistory(); realOnError(err, xhr); }
410
+
411
+ // P10-D2 (ADR-001 R5): run events on the record store. runRec is
412
+ // created LAZILY, the first time something WRITE-tool/ask_user-
413
+ // worthy happens — never for a plain chat/read-only turn, so this
414
+ // is a no-op for every mode/strategy that never offers WRITE tools
415
+ // (chat, document, build, classic Modify — agentToolsFor only
416
+ // offers WRITE_TOOLS for strategy:"agent" + mode:"modify"). Once
417
+ // created, it's a "todo" record (the same W4 machinery that
418
+ // already survives /refresh via rerenderRecord) so a mid-run
419
+ // refresh finds it; handleModifyResult upgrades it in place with
420
+ // real Plan: items via data._agentRunRecord instead of creating a
421
+ // second record, once the final turn's explanation arrives.
422
+ var runEvents = [];
423
+ var runRec = null;
424
+
425
+ function recordRunEvent(t, extra) {
426
+ if (!runRec) {
427
+ runRec = addRecord("todo", { action: runEntry, items: [], events: runEvents });
428
+ }
429
+ runEvents.push(Object.assign({ t: t, at: Date.now() }, extra || {}));
430
+ runRec.events = runEvents;
431
+ }
310
432
 
311
433
  function addUsage(usage) {
312
434
  if (usage && typeof usage.total_tokens === "number") {
@@ -316,14 +438,179 @@
316
438
 
317
439
  function fallbackToPlain() {
318
440
  setAgentNarration("Continuing without tools…");
319
- ajaxJson("POST", firstEndpoint, payload, onDone, onError);
441
+ fpCurrentAgentRequest = ajaxJson("POST", firstEndpoint, payload, function (data) {
442
+ fpCurrentAgentRequest = null;
443
+ if (runRec) { recordRunEvent("done", {}); data._agentRunRecord = runRec; }
444
+ onDone(data);
445
+ }, function (err, xhr) {
446
+ fpCurrentAgentRequest = null;
447
+ if (fpAgentStopRequested) {
448
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
449
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
450
+ return;
451
+ }
452
+ if (runRec) { recordRunEvent("interrupted", { detail: "fallback request failed" }); rerenderTodoRecord(runRec); }
453
+ // CLAUDE-023: forward xhr so a genuine parse error's raw
454
+ // response survives to handleExecuteError (via onModifyError)
455
+ // instead of being dropped here — ajaxJson's error callback
456
+ // is (msg, xhr), and onError expects the same shape.
457
+ onError(err, xhr);
458
+ });
459
+ }
460
+
461
+ function postNextStep(nextMessages) {
462
+ setAgentNarration("Thinking… (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
463
+ var stepPayload = Object.assign({
464
+ messages: nextMessages,
465
+ conversationId: payload.conversationId
466
+ }, stepExtra, {
467
+ strategy: runStrategy,
468
+ entry: runEntry,
469
+ runId: runId
470
+ });
471
+ if (pendingDebugNote) {
472
+ stepPayload.debugNote = pendingDebugNote;
473
+ pendingDebugNote = null;
474
+ }
475
+ fpCurrentAgentRequest = ajaxJson("POST", "flowpilot/agent-step", stepPayload,
476
+ function (stepData) { fpCurrentAgentRequest = null; handleStep(stepData, nextMessages); },
477
+ function (err, xhr) {
478
+ fpCurrentAgentRequest = null;
479
+ if (fpAgentStopRequested) {
480
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
481
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
482
+ return;
483
+ }
484
+ if (runRec) { recordRunEvent("interrupted", { detail: "agent-step request failed" }); rerenderTodoRecord(runRec); }
485
+ // CLAUDE-023: forward xhr — see fallbackToPlain's error
486
+ // callback above for why this must not be dropped.
487
+ onError(err, xhr);
488
+ });
489
+ }
490
+
491
+ // Processes calls[idx..] one at a time. A WRITE-gated call (per
492
+ // toolTiers, classified against SAFE_NODE_TYPES) or an ask_user
493
+ // call PAUSES here — rendering a chip/question and waiting for a
494
+ // user action — instead of executing synchronously like the
495
+ // existing 6 READ tools still do. Once the whole batch is
496
+ // resolved, posts the next step exactly as before.
497
+ function processToolCallsFrom(calls, idx, nextMessages, toolTiers) {
498
+ if (idx >= calls.length) { postNextStep(nextMessages); return; }
499
+
500
+ var call = calls[idx];
501
+ var name = call.function.name;
502
+ var args = parseToolCallArgs(call);
503
+ if (name === "redirect_mode") {
504
+ var redirectResult = executeAgentToolCall(call, runIdMap, runHistoryEvents);
505
+ nextMessages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(redirectResult) });
506
+ if (runRec) {
507
+ recordRunEvent("redirect", { detail: (redirectResult && redirectResult.suggestedAction && redirectResult.suggestedAction.mode) || args.mode || "unknown" });
508
+ recordRunEvent("done", {});
509
+ }
510
+ onDone({
511
+ explanation: (redirectResult && redirectResult.explanation) || "",
512
+ prose: true,
513
+ flow: null,
514
+ suggestedAction: redirectResult && redirectResult.suggestedAction ? redirectResult.suggestedAction : null
515
+ });
516
+ return;
517
+ }
518
+ var isWriteTool = name === "apply_step" || name === "remove_step" || name === "rename_node" || name === "group_nodes";
519
+ // P10-D1: only WRITE tool calls get an opId — idempotency is
520
+ // about preventing double MUTATION, not about read tools.
521
+ var opId = isWriteTool ? (runId + ":" + call.id) : null;
522
+
523
+ // P10-D1: a repeat opId (duplicate delivery, a retry, or the
524
+ // model repeating a call) returns the SAME result without
525
+ // re-invoking the executor — the graph is mutated at most once
526
+ // per opId, checked against the per-conversation applied-ops
527
+ // map before mutating (main.js).
528
+ function runExecutorIdempotent() {
529
+ if (opId) {
530
+ var recorded = getAppliedOp(runConversationId, opId);
531
+ if (recorded) { return recorded; }
532
+ }
533
+ var result = executeAgentToolCall(call, runIdMap, runHistoryEvents);
534
+ // CLAUDE-013: merge this call's own new placeholder->real-id
535
+ // mappings (e.g. apply_step's newNodes) into the run-scoped
536
+ // map so a LATER call this run can resolve the same ids.
537
+ if (result && result.idMap) { Object.assign(runIdMap, result.idMap); }
538
+ if (opId) { recordAppliedOp(runConversationId, opId, result); }
539
+ return result;
540
+ }
541
+
542
+ function continueWithResult(resultObj) {
543
+ if (isWriteTool) { agentWriteResults.push({ allPass: !!(resultObj && resultObj.allPass) }); }
544
+ nextMessages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(resultObj) });
545
+ processToolCallsFrom(calls, idx + 1, nextMessages, toolTiers);
546
+ }
547
+
548
+ if (name === "ask_user") {
549
+ if (askUserRounds >= AGENT_ASK_USER_MAX_ROUNDTRIPS) {
550
+ continueWithResult({ error: "ask_user budget (" + AGENT_ASK_USER_MAX_ROUNDTRIPS +
551
+ ") exhausted for this turn — proceed with your best judgment or give a final answer." });
552
+ return;
553
+ }
554
+ askUserRounds++;
555
+ setAgentNarration(describeAgentToolCall(name, args));
556
+ recordRunEvent("asked", { detail: args.question });
557
+ renderAskUserQuestion({ question: args.question, options: args.options,
558
+ onAnswer: function (answerText) {
559
+ recordRunEvent("answered", { detail: answerText });
560
+ if (currentSettings.debugLogging) { pendingDebugNote = "user answered: " + answerText; }
561
+ continueWithResult({ answer: answerText });
562
+ } });
563
+ return; // PAUSES here until the question is answered
564
+ }
565
+
566
+ if (isWriteTool) { recordRunEvent("step", { opId: opId, detail: name }); }
567
+
568
+ var tier = toolTiers && toolTiers[call.id];
569
+ if (writeToolCallNeedsConsent(tier, name, args)) {
570
+ if (isWriteTool) { recordRunEvent("consent", { opId: opId, detail: name }); }
571
+ renderAgentToolConsentGate({
572
+ name: name, args: args,
573
+ onResume: function (granted) {
574
+ if (currentSettings.debugLogging) {
575
+ pendingDebugNote = granted ? "user clicked Proceed" : "user clicked Skip this step";
576
+ }
577
+ if (!granted) {
578
+ continueWithResult({ skipped: true, reason: "user declined — this call was not applied" });
579
+ return;
580
+ }
581
+ setAgentNarration(describeAgentToolCall(name, args) + " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
582
+ var result = runExecutorIdempotent();
583
+ if (isWriteTool) {
584
+ recordRunEvent("applied", { opId: opId });
585
+ recordRunEvent("verified", { opId: opId, detail: !!(result && result.allPass) });
586
+ }
587
+ continueWithResult(result);
588
+ }
589
+ });
590
+ return; // PAUSES here until Proceed/Skip is clicked
591
+ }
592
+
593
+ setAgentNarration(describeAgentToolCall(name, args) + " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
594
+ var autoResult = runExecutorIdempotent();
595
+ if (isWriteTool) {
596
+ recordRunEvent("applied", { opId: opId });
597
+ recordRunEvent("verified", { opId: opId, detail: !!(autoResult && autoResult.allPass) });
598
+ }
599
+ continueWithResult(autoResult);
320
600
  }
321
601
 
322
602
  function handleStep(data, messages) {
323
603
  addUsage(data.usage);
324
604
 
605
+ if (data.fallbackToClassic) {
606
+ fallbackToPlain();
607
+ return;
608
+ }
609
+
325
610
  if (!data.toolCalls || !data.toolCalls.length) {
326
611
  if (step > 0) { addAgentStatsNote(step, totalTokens); }
612
+ if (agentWriteResults.length) { data._agentWriteResults = agentWriteResults.slice(); }
613
+ if (runRec) { recordRunEvent("done", {}); data._agentRunRecord = runRec; }
327
614
  onDone(data);
328
615
  return;
329
616
  }
@@ -336,15 +623,24 @@
336
623
  return;
337
624
  }
338
625
 
339
- step++;
340
- if (step > AGENT_LOOP_MAX_STEPS) {
341
- onError("FlowPilot stopped after " + AGENT_LOOP_MAX_STEPS +
342
- " tool call(s) without a final answer. Try breaking your " +
343
- "request into smaller steps, or be more specific about " +
344
- "which node(s) or flow you mean.");
345
- return;
626
+ // An ask_user-ONLY batch doesn't consume the real step budget —
627
+ // see AGENT_ASK_USER_MAX_ROUNDTRIPS above. A batch mixing
628
+ // ask_user with any other tool call counts normally, since real
629
+ // work happened in it too.
630
+ var isAskUserOnlyBatch = data.toolCalls.every(function (c) { return c.function.name === "ask_user"; });
631
+ if (!isAskUserOnlyBatch) {
632
+ step++;
633
+ if (step > AGENT_LOOP_MAX_STEPS) {
634
+ if (runRec) { recordRunEvent("interrupted", { detail: "step budget exceeded" }); rerenderTodoRecord(runRec); }
635
+ onError("FlowPilot stopped after " + AGENT_LOOP_MAX_STEPS +
636
+ " tool call(s) without a final answer. Try breaking your " +
637
+ "request into smaller steps, or be more specific about " +
638
+ "which node(s) or flow you mean.");
639
+ return;
640
+ }
346
641
  }
347
642
  if (totalTokens > AGENT_LOOP_TOKEN_CEILING) {
643
+ if (runRec) { recordRunEvent("interrupted", { detail: "token ceiling exceeded" }); rerenderTodoRecord(runRec); }
348
644
  onError("FlowPilot stopped after using " + totalTokens +
349
645
  " tokens on this turn without a final answer. Try " +
350
646
  "selecting fewer nodes, or asking a more specific " +
@@ -352,34 +648,50 @@
352
648
  return;
353
649
  }
354
650
  if (fpAgentStopRequested) {
651
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
355
652
  onError("Stopped after " + (step - 1) + " tool call step(s) at your request.");
356
653
  return;
357
654
  }
358
655
 
359
656
  var nextMessages = (messages || data.messages || []).slice();
360
657
  nextMessages.push({ role: "assistant", content: data.content || null, tool_calls: data.toolCalls });
361
- data.toolCalls.forEach(function (call) {
362
- setAgentNarration(describeAgentToolCall(call.function.name, parseToolCallArgs(call)) +
363
- " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
364
- nextMessages.push({
365
- role: "tool",
366
- tool_call_id: call.id,
367
- content: JSON.stringify(executeAgentToolCall(call))
368
- });
369
- });
370
- setAgentNarration("Thinking… (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
371
- var stepPayload = Object.assign({
372
- messages: nextMessages,
373
- conversationId: payload.conversationId
374
- }, stepExtra);
375
- ajaxJson("POST", "flowpilot/agent-step", stepPayload,
376
- function (stepData) { handleStep(stepData, nextMessages); }, onError);
658
+ processToolCallsFrom(data.toolCalls, 0, nextMessages, data.toolTiers);
377
659
  }
378
660
 
379
- var firstPayload = Object.assign({}, payload, { tools: true });
380
- ajaxJson("POST", firstEndpoint, firstPayload, function (data) {
661
+ var firstPayload = Object.assign({}, payload, { tools: true, runId: runId });
662
+ fpCurrentAgentRequest = ajaxJson("POST", firstEndpoint, firstPayload, function (data) {
663
+ fpCurrentAgentRequest = null;
381
664
  handleStep(data, null);
382
- }, function () {
665
+ }, function (err, xhr) {
666
+ fpCurrentAgentRequest = null;
667
+ if (fpAgentStopRequested) {
668
+ // CLAUDE-022: an abort of the FIRST request must actually
669
+ // stop, not fallbackToPlain() — falling back would ignore
670
+ // the stop and keep going without tools instead.
671
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
672
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
673
+ return;
674
+ }
675
+ // CLAUDE-023: fallbackToPlain() must only fire for failures that
676
+ // actually look like "the provider doesn't support tools" — a
677
+ // genuine parse/validation failure from processGenerationContent
678
+ // (flowpilot.js) always comes back as HTTP 422 with a .raw
679
+ // payload attached (see err.status = 422 / err.raw = content
680
+ // there). A provider-level failure of the tools:true call itself
681
+ // (unrecognized "tools" field, model swapped since the last
682
+ // probe, etc.) never goes through that parser, so it always
683
+ // reaches here as some other status with no .raw. Only the
684
+ // latter should be silently retried without tools; the former is
685
+ // a real failure and must surface as one so handleExecuteError
686
+ // (via onModifyError) gets a chance to show it — including its
687
+ // raw JSON in Debug mode.
688
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
689
+ var looksLikeParseFailure = !!raw || (xhr && xhr.status === 422);
690
+ if (looksLikeParseFailure) {
691
+ if (runRec) { recordRunEvent("interrupted", { detail: "first agentic request failed" }); rerenderTodoRecord(runRec); }
692
+ onError(err, xhr);
693
+ return;
694
+ }
383
695
  // The provider was probed as
384
696
  // supportsTools, but the very first tools:true request failed
385
697
  // outright — e.g. the model was swapped since the last probe, or
@@ -622,8 +934,14 @@
622
934
  // flow; show the raw so the user can see what happened.
623
935
  function handleExecuteError(msg, raw) {
624
936
  popDanglingUserHistory();
625
- addMessage("error", msg);
626
- if (raw) { addGeneratedJson(raw, true); }
937
+ if (raw && currentSettings.debugLogging) {
938
+ addMessage("error", msg);
939
+ addGeneratedJson(raw, true);
940
+ } else if (raw) {
941
+ addMessage("error", msg + " Enable Debug mode in Settings to see the full response next time.");
942
+ } else {
943
+ addMessage("error", msg);
944
+ }
627
945
  setBusy(false);
628
946
  }
629
947
 
@@ -632,6 +950,27 @@
632
950
  function applySuggestedAction(suggestedAction) {
633
951
  if (!suggestedAction || !suggestedAction.mode || !suggestedAction.prompt) { return; }
634
952
  armExecuteAction(suggestedAction.mode);
953
+ // CLAUDE-015: the model already identified which nodes this action
954
+ // targets (including "the whole flow" as a valid identification,
955
+ // not an exemption) — override whatever armExecuteAction's own
956
+ // pinCurrentSelection() pinned from the live canvas selection, the
957
+ // same pinnedSelectionIds every downstream consumer (Document,
958
+ // Modify, their guards, their context builders) already reads via
959
+ // activeSelectionIds(). Absent targetNodeIds: unchanged, today's
960
+ // behavior. Mirrors pinCurrentSelection()'s own rule of only
961
+ // overwriting on a non-empty result, so a bad/empty resolution
962
+ // doesn't blow away a legitimate live-selection pin.
963
+ if (suggestedAction.targetNodeIds === "all") {
964
+ var activeTabId = RED.workspaces && RED.workspaces.active ? RED.workspaces.active() : null;
965
+ var allTabIds = [];
966
+ RED.nodes.eachNode(function (n) { if (n.z === activeTabId) { allTabIds.push(n.id); } });
967
+ if (allTabIds.length) { pinnedSelectionIds = allTabIds; }
968
+ } else if (Array.isArray(suggestedAction.targetNodeIds)) {
969
+ var resolvedIds = suggestedAction.targetNodeIds.filter(function (id) {
970
+ return !!findLiveNode(id);
971
+ });
972
+ if (resolvedIds.length) { pinnedSelectionIds = resolvedIds; }
973
+ }
635
974
  var $promptBox = el("#fp-prompt");
636
975
  if ($promptBox.length) {
637
976
  $promptBox.val(suggestedAction.prompt);
@@ -660,11 +999,23 @@
660
999
  var isChatMode = suggestedAction.mode === "chat";
661
1000
  var titleText = suggestedAction.customTitle || (isChatMode ? "Switch to Chat" : "Cleared for takeoff — " + modeLabel);
662
1001
 
1002
+ // CLAUDE-028: the record must exist before the button does, so the
1003
+ // button can carry data-fp-record-id/-action — the same relay
1004
+ // markers renderAskUserQuestion's quick-reply buttons use to
1005
+ // survive the pop-out's innerHTML clone (bindRecordActionButtons
1006
+ // in init.js rebinds by these attributes and relays the click back
1007
+ // to this window via resolveRecordAction; this window is the only
1008
+ // one with live suggestedAction data to act on). The direct click
1009
+ // handler below still fires normally for the un-popped-out sidebar.
1010
+ var rec = addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
1011
+
663
1012
  var $row = $("<div>").addClass("fp-chip-row");
664
1013
  var $card = $("<button>")
665
1014
  .addClass("fp-chip fp-chip-card")
666
1015
  .attr("type", "button")
667
1016
  .attr("title", suggestedAction.prompt)
1017
+ .attr("data-fp-record-id", rec.id)
1018
+ .attr("data-fp-record-action", "apply-suggested-action")
668
1019
  .on("click", function () { applySuggestedAction(suggestedAction); });
669
1020
  $("<span>").addClass("fp-chip-icon")
670
1021
  .append($("<i>").addClass(isChatMode ? "fa fa-comment" : "fa fa-paper-plane"))
@@ -675,12 +1026,27 @@
675
1026
  $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($card);
676
1027
  $card.appendTo($row);
677
1028
 
678
- if (suggestedAction.selectionHint) {
679
- $("<div>").addClass("fp-chip-hint").text("Tip: " + suggestedAction.selectionHint).appendTo($row);
1029
+ // CLAUDE-030: Document mode always requires a real, non-empty
1030
+ // selection at Send time (server-side: describeSelectionContext
1031
+ // returns null and /flowpilot/document 400s on "Select the node(s)
1032
+ // you want documented first" whenever nothing is actually selected)
1033
+ // — but the model has occasionally generated a selectionHint
1034
+ // implying otherwise (e.g. "leave nothing selected to document...
1035
+ // general Node-RED info") when it had no concrete node to name.
1036
+ // Rather than re-word the model's already-fragile mode-mismatch
1037
+ // prompt guidance under time pressure, override deterministically
1038
+ // here: if this chip targets Document and doesn't carry a real
1039
+ // resolved target, always show the one hint that's actually true.
1040
+ var targetResolved = suggestedAction.targetNodeIds === "all" ||
1041
+ (Array.isArray(suggestedAction.targetNodeIds) && suggestedAction.targetNodeIds.length > 0);
1042
+ var hintText = (suggestedAction.mode === "document" && !targetResolved)
1043
+ ? "Select the node(s) you want documented first."
1044
+ : suggestedAction.selectionHint;
1045
+ if (hintText) {
1046
+ $("<div>").addClass("fp-chip-hint").text("Tip: " + hintText).appendTo($row);
680
1047
  }
681
1048
 
682
1049
  $box.append($row);
683
- addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
684
1050
  scrollMessagesToBottom();
685
1051
  }
686
1052
 
@@ -772,6 +1138,28 @@
772
1138
  scrollMessagesToBottom();
773
1139
  }
774
1140
 
1141
+ // Detects the model's own tool-call envelope leaking through as
1142
+ // "prose" — e.g. a vague first-turn Modify reply the server marked
1143
+ // prose:true but whose explanation is actually the raw
1144
+ // {"explanation":...,"changes":[...]} JSON as literal text. Starting
1145
+ // with "{" alone doesn't make text suspect (real prose can open with
1146
+ // a brace), so this also requires it to parse as an object carrying
1147
+ // one of FlowPilot's own envelope keys.
1148
+ function looksLikeToolEnvelope(text) {
1149
+ if (typeof text !== "string") { return false; }
1150
+ var trimmed = text.trim();
1151
+ if (trimmed.charAt(0) !== "{") { return false; }
1152
+ var parsed;
1153
+ try {
1154
+ parsed = JSON.parse(trimmed);
1155
+ } catch (e) {
1156
+ return false;
1157
+ }
1158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return false; }
1159
+ return ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "explanation"]
1160
+ .some(function (key) { return Object.prototype.hasOwnProperty.call(parsed, key); });
1161
+ }
1162
+
775
1163
  // Shared by Generate/Document/Modify result handlers: renders the model's
776
1164
  // clarifying-question or prose-only envelope as a normal assistant
777
1165
  // message and leaves the action armed for a follow-up. Returns true if it
@@ -789,6 +1177,11 @@
789
1177
  return true;
790
1178
  }
791
1179
  if (data.prose) {
1180
+ if (looksLikeToolEnvelope(data.explanation)) {
1181
+ handleExecuteError("FlowPilot's reply didn't come through as expected.", data.explanation);
1182
+ updateSelectionStatus();
1183
+ return true;
1184
+ }
792
1185
  addMessage("assistant", data.explanation || "(no content returned)");
793
1186
  pushHistory("assistant", data.explanation || "");
794
1187
  renderActionChip(data.suggestedAction);
@@ -812,15 +1205,20 @@
812
1205
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
813
1206
  addMessage("assistant", data.explanation || "(no explanation returned)");
814
1207
  pushHistory("assistant", data.explanation || "(no explanation returned)");
815
- addGeneratedReview(flow);
816
- // After any Generate result, offer the deploy-verify loop as a one-click
817
- // option. Only shown when no loop is already running and the original
818
- // prompt is available (it always is here goalPrompt comes from the
819
- // compose box value captured at send time via wrappedOnResult).
820
- if (goalPrompt && !activeBuildLoop) {
821
- renderActionChip({ mode: "build", prompt: goalPrompt, customTitle: "Run deploy-verify loop on this →" });
822
- }
823
- renderActionChip(data.suggestedAction);
1208
+ // B1: bake the deploy-verify option into the review panel as the
1209
+ // primary chip rather than a separate chip below it. Only for
1210
+ // executable flows (not documentation-only comment nodes) and when no
1211
+ // loop is already active. The secondary "Just add to canvas" button is
1212
+ // always shown alongside it as an escape hatch.
1213
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1214
+ var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
1215
+ ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
1216
+ : null;
1217
+ addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
1218
+ // Suppress a server-suggested build chip when deploy-verify is already
1219
+ // the primary action inside the review panel — it would be a duplicate.
1220
+ renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
1221
+ ? null : data.suggestedAction);
824
1222
  setBusy(false);
825
1223
  updateSelectionStatus();
826
1224
  }
@@ -831,10 +1229,94 @@
831
1229
  hidePending();
832
1230
  if (renderQuestionOrProse(data)) { return; }
833
1231
 
1232
+ // W4: parse and surface the Plan: block if present.
1233
+ var planItems = parseTodoPlan(data.explanation || "");
1234
+ var todoRec = null;
1235
+ // W7: real per-item correlation, and (CLAUDE-011) the signal that
1236
+ // real mutations already happened this turn via WRITE tool calls
1237
+ // (apply_step/remove_step/rename_node/group_nodes) — each already
1238
+ // consent-gated (or, for group_nodes' write-safe tier, always
1239
+ // auto-applied) and applied through its own review chip. Hoisted
1240
+ // above the planItems.length check (rather than declared inside
1241
+ // it) so it's always defined below even on a turn with no Plan:
1242
+ // block, e.g. a wrap-up-only final response after tool calls.
1243
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
1244
+ if (planItems.length) {
1245
+ // Each WRITE tool call this turn resolves ONE plan item, in
1246
+ // order (§16 point 2 — "one WRITE tool call = one plan/todo
1247
+ // item"). This is the deferred half of CLAUDE-005 (c6c2e84),
1248
+ // now unblocked: that fix could only mark the WHOLE plan
1249
+ // active/resolved together because there was no way to know
1250
+ // which check proved which numbered line — a per-call tool
1251
+ // result finally gives that mapping for free. Items beyond the
1252
+ // resolved-call count (or ALL items, when no WRITE tool call
1253
+ // was made this turn — the default, non-agentic-write path)
1254
+ // fall back to CLAUDE-005's original all-together marking
1255
+ // below, resolved via the ordinary aggregate verifySteps
1256
+ // envelope path exactly as before — confirmed unchanged when
1257
+ // _agentWriteResults is empty/absent.
1258
+ if (writeResults.length) {
1259
+ planItems.forEach(function (item, i) {
1260
+ item.status = (i < writeResults.length)
1261
+ ? (writeResults[i].allPass ? "done" : "failed")
1262
+ : "active";
1263
+ });
1264
+ } else {
1265
+ // Verification only produces one aggregate pass/fail result
1266
+ // for the whole Modify response — mark every item active up
1267
+ // front so they all resolve together instead of leaving
1268
+ // items 2+ stuck at "pending" forever (only item 1 would
1269
+ // ever flip otherwise).
1270
+ planItems.forEach(function (item) { item.status = "active"; });
1271
+ }
1272
+ // P10-D2: if this run already has a live "todo" record from
1273
+ // WRITE-tool events (runAgentLoop, modes.js), upgrade it in
1274
+ // place with the real Plan: items instead of creating a
1275
+ // second record for the same run — so a run that paused
1276
+ // mid-flight (refresh, interrupted) and one that completed
1277
+ // normally both end up as ONE record with both events and
1278
+ // items.
1279
+ if (data._agentRunRecord) {
1280
+ todoRec = data._agentRunRecord;
1281
+ todoRec.action = "modify";
1282
+ todoRec.items = planItems;
1283
+ } else {
1284
+ todoRec = addRecord("todo", { action: "modify", items: planItems });
1285
+ }
1286
+ rerenderTodoRecord(todoRec);
1287
+ }
1288
+
834
1289
  addMessage("assistant", data.explanation || "(no explanation returned)");
835
1290
  pushHistory("assistant", data.explanation || "(no explanation returned)");
836
1291
  if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
837
- addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyModifications, null, data.newGroups || []);
1292
+
1293
+ // W4 Phase 2: wrap apply to run a real graph read-back via
1294
+ // verifySteps (Track A, server) instead of unconditionally marking
1295
+ // the todo done. Falls back to the Phase 1 behavior (check off with
1296
+ // no verification) when the server sent no verifySteps.
1297
+ var verifySteps = Array.isArray(data.verifySteps) ? data.verifySteps : [];
1298
+ var applyCallback = todoRec ? function(nodeDiffs, removeNodes, $btn, idMap) {
1299
+ applyModifications(nodeDiffs, removeNodes, $btn, idMap);
1300
+ if (verifySteps.length) {
1301
+ verifyModifySteps(verifySteps, idMap, todoRec);
1302
+ } else {
1303
+ todoRec.items.forEach(function(item) {
1304
+ if (item.status === "active") { item.status = "done"; }
1305
+ });
1306
+ rerenderTodoRecord(todoRec);
1307
+ }
1308
+ } : applyModifications;
1309
+
1310
+ // CLAUDE-011: agentWriteRules tells the model to omit changes/
1311
+ // newNodes/newWires/removeNodes/newGroups on this final response
1312
+ // when writeResults is non-empty, so already-applied work isn't
1313
+ // proposed a second time — but the model doesn't always comply.
1314
+ // Don't rely on the prompt alone: skip rendering a second review/
1315
+ // apply flow whenever real WRITE-tool work happened this turn,
1316
+ // regardless of what the final envelope contains.
1317
+ if (!writeResults.length) {
1318
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
1319
+ }
838
1320
  renderActionChip(data.suggestedAction);
839
1321
  setBusy(false);
840
1322
  updateSelectionStatus();
@@ -1011,7 +1493,9 @@
1011
1493
  var payload = {
1012
1494
  prompt: prompt, context: context,
1013
1495
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1014
- conversationId: conversationId
1496
+ conversationId: conversationId,
1497
+ strategy: "classic",
1498
+ entry: endpointName
1015
1499
  };
1016
1500
 
1017
1501
  function onError(msg, xhr) {
@@ -1055,8 +1539,329 @@
1055
1539
  ajaxJson("POST", fullEndpoint, payload, wrappedOnResult, onError);
1056
1540
  }
1057
1541
 
1542
+ // W4: parse a "Plan:" block from the model's explanation field.
1543
+ // Returns an array of { text, status } items, or [] if none found.
1544
+ // Each numbered/bulleted line under "Plan:" up to the first blank line
1545
+ // becomes one item. Status starts as "pending" for all items — the
1546
+ // caller sets the first to "active" before rendering.
1547
+ function parseTodoPlan(explanation) {
1548
+ if (!explanation || typeof explanation !== "string") { return []; }
1549
+ var planStart = explanation.indexOf("Plan:");
1550
+ if (planStart === -1) { return []; }
1551
+ var afterPlan = explanation.slice(planStart + 5);
1552
+ var planBlock = afterPlan.split(/\n\n/)[0];
1553
+ var lines = planBlock.split("\n");
1554
+ var items = [];
1555
+ lines.forEach(function (line) {
1556
+ var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1557
+ if (stripped) { items.push({ text: stripped, status: "pending" }); }
1558
+ });
1559
+ return items;
1560
+ }
1561
+
1562
+ // W4: render or re-render a "todo" record. For a 1-item plan, renders
1563
+ // as a compact status line (one chip). For N>1 items, renders as a
1564
+ // checklist card. Updates in place when the record already has a
1565
+ // data-fp-todo-id element in the message box (e.g. on verify check-off).
1566
+ function rerenderTodoRecord(rec) {
1567
+ if (!rec || !rec.items) { return; }
1568
+ var $box = el("#fp-messages");
1569
+ if (!$box.length) { return; }
1570
+ var items = rec.items;
1571
+ var $existing = $box.find("[data-fp-todo-id='" + rec.id + "']");
1572
+
1573
+ var $wrap;
1574
+ if (items.length === 1) {
1575
+ var item = items[0];
1576
+ var icon = item.status === "done" ? "✓" : item.status === "failed" ? "✗" : "▶";
1577
+ $wrap = $("<div>")
1578
+ .addClass("fp-todo-status fp-todo-" + item.status)
1579
+ .attr("data-fp-todo-id", rec.id)
1580
+ .text(icon + " " + item.text);
1581
+ } else {
1582
+ $wrap = $("<div>")
1583
+ .addClass("fp-todo-card")
1584
+ .attr("data-fp-todo-id", rec.id);
1585
+ var $ul = $("<ul>").addClass("fp-todo-list");
1586
+ items.forEach(function (item) {
1587
+ var icon = item.status === "done" ? "✓" :
1588
+ item.status === "failed" ? "✗" :
1589
+ item.status === "active" ? "▶" : "○";
1590
+ $("<li>")
1591
+ .addClass("fp-todo-item fp-todo-item-" + item.status)
1592
+ .text(icon + " " + item.text)
1593
+ .appendTo($ul);
1594
+ });
1595
+ $wrap.append($ul);
1596
+ }
1597
+
1598
+ // P10-D2 (ADR-001 R5): a run whose last recorded event isn't "done"
1599
+ // was abandoned mid-flight — an explicit interruption (stop button,
1600
+ // step/token budget) or an orphaned request that never returned.
1601
+ // Freeze whatever checklist state exists (possibly none yet, if
1602
+ // interrupted before any Plan: text arrived) and say so plainly,
1603
+ // replacing the old silent-death behavior ("Continuing without
1604
+ // tools…" then nothing). "step N" counts completed (applied) WRITE
1605
+ // tool calls, not raw round-trips.
1606
+ if (Array.isArray(rec.events) && rec.events.length) {
1607
+ var lastEvent = rec.events[rec.events.length - 1];
1608
+ if (lastEvent.t !== "done") {
1609
+ var appliedCount = rec.events.filter(function (e) { return e.t === "applied"; }).length;
1610
+ $("<div>").addClass("fp-todo-interrupted").text(
1611
+ "⚠ This run was interrupted after step " + appliedCount +
1612
+ " — completed steps are applied (Ctrl+Z to undo). Re-send to continue from here."
1613
+ ).appendTo($wrap);
1614
+ }
1615
+ }
1616
+
1617
+ if ($existing.length) {
1618
+ $existing.replaceWith($wrap);
1619
+ } else {
1620
+ $box.append($wrap);
1621
+ }
1622
+ }
1623
+
1624
+ // Step-queue path for Generate (opt-in via enableStepQueue setting).
1625
+ // After the user clicks "Add to workspace", performs a synchronous graph
1626
+ // read-back — calls RED.nodes.node(id) for every node that landed on the
1627
+ // canvas — and surfaces the result as a verification notice. This is the
1628
+ // structural verification step for Generate: "did the import actually land?"
1629
+ // not "does it do the thing?" (that's the semantic loop, Build's domain).
1630
+ // Skips comment and group nodes — those aren't addressable via RED.nodes.node.
1631
+ // todoRec: optional todo record to check off (or fail) after verification.
1632
+ function verifyImportedNodes(importResult, todoRec) {
1633
+ if (!importResult || !importResult.nodeMap) { return; }
1634
+ var nodeMap = importResult.nodeMap;
1635
+ var total = 0, found = 0, missing = [];
1636
+ // Config nodes (e.g. an http-request's TLS config, an mqtt broker
1637
+ // config) ride along in nodeMap whenever the model's own `flow`
1638
+ // array included them, but they aren't part of what the user asked
1639
+ // for — RED.nodes.node() resolves them same as regular nodes (it
1640
+ // checks configNodes[id] before falling back), so left uncounted
1641
+ // they'd silently inflate the headline total (e.g. "8" instead of
1642
+ // "5"). Track and verify them separately instead.
1643
+ var configTotal = 0, configFound = 0;
1644
+ // Node-RED's own RED.nodes.import (the generateIds:true path used
1645
+ // for every Generate/Build import) keys nodeMap TWICE per imported
1646
+ // node: once under the model's own placeholder id (assigned while
1647
+ // constructing the node, before it's added to the live registry)
1648
+ // and again under the freshly-generated real editor id (assigned in
1649
+ // the final addNode/addGroup/addJunction registration loop) — both
1650
+ // entries point to the same live node object. Left undeduped this
1651
+ // doubles every count here (visible AND config alike), independent
1652
+ // of the config/visible split above. Confirmed by reading
1653
+ // @node-red/editor-client/public/red/red.js's importNodes directly.
1654
+ var seenLiveIds = {};
1655
+ Object.keys(nodeMap).forEach(function (pid) {
1656
+ var liveNode = nodeMap[pid];
1657
+ if (!liveNode || !liveNode.id) { return; }
1658
+ if (seenLiveIds[liveNode.id]) { return; }
1659
+ seenLiveIds[liveNode.id] = true;
1660
+ if (liveNode.type === "comment" || liveNode.type === "group") { return; }
1661
+ if (liveNode._def && liveNode._def.category === "config") {
1662
+ configTotal++;
1663
+ if (nodeExists(liveNode.id)) { configFound++; }
1664
+ return;
1665
+ }
1666
+ total++;
1667
+ if (nodeExists(liveNode.id)) {
1668
+ found++;
1669
+ } else {
1670
+ missing.push(liveNode.type || pid);
1671
+ }
1672
+ });
1673
+ var configMissing = configTotal - configFound;
1674
+ var allGood = total > 0 && missing.length === 0 && configMissing === 0;
1675
+ if (total === 0) {
1676
+ // Nothing user-visible to verify (only comments/groups/config
1677
+ // nodes) — skip notice.
1678
+ } else if (allGood) {
1679
+ var configSuffix = configTotal > 0
1680
+ ? " (+" + configTotal + " supporting config node(s))"
1681
+ : "";
1682
+ addMessage("fp-notice", "✓ Verified: all " + found + " node(s) confirmed on canvas" + configSuffix + ".");
1683
+ } else {
1684
+ var allMissing = missing.slice();
1685
+ if (configMissing > 0) { allMissing.push(configMissing + " config node(s)"); }
1686
+ addMessage("fp-notice", "⚠ Verification: " + found + "/" + total + " node(s) on canvas — " +
1687
+ allMissing.length + " not found after import (" + allMissing.join(", ") + "). " +
1688
+ "These may be uninstalled node types that were silently dropped.");
1689
+ }
1690
+ // Check off (or fail) the active todo item.
1691
+ if (todoRec && todoRec.items) {
1692
+ todoRec.items.forEach(function (item) {
1693
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1694
+ });
1695
+ rerenderTodoRecord(todoRec);
1696
+ }
1697
+ }
1698
+
1699
+ // W4 Phase 2: real graph read-back verification for Modify, using the
1700
+ // server-derived verifySteps (Track A — property/exists/absent/wire
1701
+ // checks; see finalizeModifyResult in flowpilot.js). Mirrors
1702
+ // verifyImportedNodes's design for Generate: aggregate pass/fail across
1703
+ // all steps, surface one notice, and check off (or fail) the active
1704
+ // todo item. idMap resolves the response-time placeholder ids that
1705
+ // existence/wire checks on newly-inserted nodes carry (the server can't
1706
+ // know the browser-assigned id at response time — applyInsertions
1707
+ // assigns it and returns idMap). Property/absent checks already use
1708
+ // real existing-node ids, so idMap[id] simply misses and falls through
1709
+ // to the id unchanged.
1710
+ // One check-vocabulary evaluation (property/exists/absent/wire), shared
1711
+ // by verifyModifySteps (aggregate Modify verification) and W7's
1712
+ // per-tool-call check results (runChecksForToolResult, main.js) — same
1713
+ // approach, applied either to the whole verifySteps batch or scoped to
1714
+ // one WRITE tool call's own touched id(s). Returns null for an
1715
+ // unrecognized check type (caller should skip it, not count it either
1716
+ // way) or { ok, label } where label is the human-readable failure
1717
+ // description used in the aggregate "did not land as expected" message.
1718
+ // P10-E: each case delegates to graph-truth.js (same closure) — the
1719
+ // one implementation of graph truth, per ADR-005. Wire checks in
1720
+ // particular must never fall back to reading node.wires: RED.nodes.
1721
+ // addLink/removeLink never re-sync a live node's own .wires array
1722
+ // mid-session, so it's stale for anything added/removed this session.
1723
+ function runSingleVerifyCheck(step, idMap) {
1724
+ idMap = idMap || {};
1725
+ function resolve(id) { return (idMap && idMap[id]) || id; }
1726
+ switch (step.check) {
1727
+ case "property": {
1728
+ var okP = propertyEquals(resolve(step.nodeId), step.prop, step.expected);
1729
+ var labelP = (step.prop || "property") + " on " + step.nodeId;
1730
+ if (!okP) {
1731
+ var readP = readProperty(resolve(step.nodeId), step.prop);
1732
+ labelP += readP.exists ?
1733
+ " (expected " + JSON.stringify(step.expected) + ", found " + JSON.stringify(readP.value) + ")" :
1734
+ " (node does not exist)";
1735
+ }
1736
+ return { ok: okP, label: labelP };
1737
+ }
1738
+ case "exists": {
1739
+ var okE = nodeExists(resolve(step.nodeId));
1740
+ return { ok: okE, label: step.nodeId + " missing" };
1741
+ }
1742
+ case "absent": {
1743
+ var okA = nodeAbsent(resolve(step.nodeId));
1744
+ return { ok: okA, label: step.nodeId + " still present" };
1745
+ }
1746
+ case "wire": {
1747
+ var fromId = resolve(step.fromId);
1748
+ var toId = resolve(step.toId);
1749
+ var port = step.fromPort || 0;
1750
+ var okW = wireExists(fromId, port, toId);
1751
+ var labelW = "wire " + step.fromId + " → " + step.toId;
1752
+ if (!okW) {
1753
+ var fromExists = nodeExists(fromId);
1754
+ var toExists = nodeExists(toId);
1755
+ if (!fromExists && !toExists) {
1756
+ labelW += " (neither node was ever created)";
1757
+ } else if (!fromExists) {
1758
+ labelW += " (" + step.fromId + " was never created)";
1759
+ } else if (!toExists) {
1760
+ labelW += " (" + step.toId + " was never created)";
1761
+ } else {
1762
+ labelW += " (both nodes exist but aren't connected)";
1763
+ }
1764
+ }
1765
+ return { ok: okW, label: labelW };
1766
+ }
1767
+ default:
1768
+ return null; // unrecognized check type — don't count it either way
1769
+ }
1770
+ }
1771
+
1772
+ function verifyModifySteps(verifySteps, idMap, todoRec) {
1773
+ if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
1774
+
1775
+ var total = 0, passed = 0, failures = [], failedSteps = [];
1776
+ verifySteps.forEach(function (step) {
1777
+ var result = runSingleVerifyCheck(step, idMap);
1778
+ if (!result) { return; }
1779
+ total++;
1780
+ if (result.ok) { passed++; } else { failures.push(result.label); failedSteps.push(step); }
1781
+ });
1782
+
1783
+ if (total === 0) { return; }
1784
+ var allGood = failures.length === 0;
1785
+ if (allGood) {
1786
+ addMessage("fp-notice", "✓ Verified: all " + passed + " change(s) confirmed on canvas.");
1787
+ } else {
1788
+ addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
1789
+ failures.length + " did not land as expected (" + failures.join(", ") + ").");
1790
+ // CLAUDE-016: offer a "Fix this" chip naming exactly the nodes
1791
+ // involved in the failed checks, reusing CLAUDE-015/CODEX-014's
1792
+ // targetNodeIds auto-select mechanism rather than a new
1793
+ // retry/loop. User-initiated only — the chip pre-fills a Modify
1794
+ // prompt but still requires the user to review and click Send,
1795
+ // same as every other suggestedAction chip.
1796
+ function resolve(id) { return (idMap && idMap[id]) || id; }
1797
+ var targetNodeIds = [];
1798
+ failedSteps.forEach(function (step) {
1799
+ var ids = step.check === "wire" ? [resolve(step.fromId), resolve(step.toId)] : [resolve(step.nodeId)];
1800
+ ids.forEach(function (id) {
1801
+ if (id && targetNodeIds.indexOf(id) === -1) { targetNodeIds.push(id); }
1802
+ });
1803
+ });
1804
+ renderActionChip({
1805
+ mode: "modify",
1806
+ prompt: "Fix the following change(s) that didn't land as expected: " + failures.join(", "),
1807
+ targetNodeIds: targetNodeIds
1808
+ });
1809
+ }
1810
+ if (todoRec && todoRec.items) {
1811
+ todoRec.items.forEach(function (item) {
1812
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1813
+ });
1814
+ rerenderTodoRecord(todoRec);
1815
+ }
1816
+ }
1817
+
1818
+ function handleStepQueueGenerateResult(data, goalPrompt) {
1819
+ hidePending();
1820
+ if (renderQuestionOrProse(data)) { return; }
1821
+ var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1822
+
1823
+ // Build the todo plan. Parse "Plan:" from explanation if present;
1824
+ // fall back to an implicit single item from the goal prompt.
1825
+ var planItems = parseTodoPlan(data.explanation || "");
1826
+ if (!planItems.length) {
1827
+ planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
1828
+ }
1829
+ // Same aggregate-verification reasoning as the Modify path: mark
1830
+ // every item active up front so a multi-item plan resolves together.
1831
+ planItems.forEach(function (item) { item.status = "active"; });
1832
+ var todoRec = addRecord("todo", { action: "generate", items: planItems });
1833
+ rerenderTodoRecord(todoRec);
1834
+
1835
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1836
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1837
+ // B1: when a build loop is appropriate, bake deploy-verify into the
1838
+ // primary chip (same as handleSimpleGenerationResult). The callback
1839
+ // also runs verifyImportedNodes so the todo record still gets checked
1840
+ // off. Without a build loop, fall back to a plain "Add to canvas"
1841
+ // button that still fires the verify callback.
1842
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1843
+ var _wantLoop = goalPrompt && !activeBuildLoop && _hasDeployable;
1844
+ var _onImported = _wantLoop
1845
+ ? function (importResult) {
1846
+ verifyImportedNodes(importResult, todoRec);
1847
+ startBuildLoop(goalPrompt, flow, importResult);
1848
+ }
1849
+ : function (importResult) { verifyImportedNodes(importResult, todoRec); };
1850
+ addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
1851
+ // Suppress a server-suggested build chip when deploy-verify is already
1852
+ // the primary action inside the review panel.
1853
+ renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
1854
+ ? null : data.suggestedAction);
1855
+ setBusy(false);
1856
+ updateSelectionStatus();
1857
+ }
1858
+
1058
1859
  function generate() {
1059
- runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1860
+ if (currentSettings.enableStepQueue) {
1861
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleStepQueueGenerateResult);
1862
+ } else {
1863
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1864
+ }
1060
1865
  }
1061
1866
 
1062
1867
  // /build's first step. Reuses Generate's pipeline wholesale for the
@@ -1110,7 +1915,9 @@
1110
1915
  var payload = {
1111
1916
  prompt: instruction, context: context,
1112
1917
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1113
- conversationId: conversationId
1918
+ conversationId: conversationId,
1919
+ strategy: "classic",
1920
+ entry: "build-existing"
1114
1921
  };
1115
1922
 
1116
1923
  function onBuildExistingError(msg, xhr) {
@@ -1165,6 +1972,334 @@
1165
1972
  updateSelectionStatus();
1166
1973
  }
1167
1974
 
1975
+ // WS4: real consent gate for side-effecting build steps. Renders one
1976
+ // combined chip covering every side-effecting node this step's
1977
+ // classification found (classifyFlowNodes in flowpilot.js, server-side)
1978
+ // — a single decision rather than per-node chips, which is sufficient
1979
+ // because only the FIRST /flowpilot/build response carries
1980
+ // stepNodeClasses today (fix iterations via /flowpilot/modify don't, so
1981
+ // there's exactly one consent point per loop lifetime under the current
1982
+ // limitation — see the handleBuildResult call site).
1983
+ //
1984
+ // Reconstructed entirely from `src` (plain data, never a live closure)
1985
+ // on every call — the initial render from handleBuildResult AND every
1986
+ // later rerender (refresh, pop-out reopen, the W0A idle/focus
1987
+ // auto-refresh) via rerenderRecord's buildConsentGate branch below.
1988
+ // Mirrors rerenderReviewRecord's pattern in apply-review.js: a fresh
1989
+ // record is added each time from the source's stored fields (including
1990
+ // `decision`, once made), rather than relying on an in-memory callback
1991
+ // surviving a refresh. That in-memory-callback version is exactly what
1992
+ // broke before this fix — a refresh mid-decision fell through to
1993
+ // renderClarifyingQuestion's generic path, whose buttons send the
1994
+ // clicked label as a new chat message instead of resolving Proceed/Skip,
1995
+ // permanently stranding the loop.
1996
+ //
1997
+ // src fields: sideEffecting, flow, goalPrompt, fpUidManifest,
1998
+ // suggestedAction — everything runBuildConsentDecision needs — plus,
1999
+ // once resolved, `decision` ("proceed"|"skip") so a later rerender shows
2000
+ // a settled state instead of re-offering an already-made choice.
2001
+ function renderBuildConsentGate(src) {
2002
+ var $box = el("#fp-messages");
2003
+ if (!$box.length) {
2004
+ if (!src.decision) { runBuildConsentDecision(src, true); }
2005
+ return;
2006
+ }
2007
+
2008
+ var _rec = addRecord("question", {
2009
+ buildConsentGate: true,
2010
+ options: ["Auto-verify", "I'll check myself"],
2011
+ sideEffecting: src.sideEffecting,
2012
+ flow: src.flow,
2013
+ goalPrompt: src.goalPrompt,
2014
+ fpUidManifest: src.fpUidManifest,
2015
+ suggestedAction: src.suggestedAction,
2016
+ decision: src.decision
2017
+ });
2018
+
2019
+ var sideEffecting = Array.isArray(_rec.sideEffecting) ? _rec.sideEffecting : [];
2020
+ var labels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
2021
+
2022
+ if (_rec.decision) {
2023
+ // Already resolved before this render (e.g. resolved earlier in
2024
+ // the session, now showing again after a refresh) — settled
2025
+ // state, not an interactive choice.
2026
+ addMessage("assistant", "This step calls an external service: " + labels + ".");
2027
+ var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
2028
+ $("<button>")
2029
+ .addClass("fp-consent-chip")
2030
+ .addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
2031
+ .attr("type", "button").prop("disabled", true)
2032
+ .text(_rec.decision === "proceed" ? "Auto-verify ✓" : "Checking myself ✓")
2033
+ .appendTo($settledRow);
2034
+ $box.append($settledRow);
2035
+ scrollMessagesToBottom();
2036
+ return;
2037
+ }
2038
+
2039
+ addMessage("assistant", "This step calls an external service: " + labels +
2040
+ ". Want it verified automatically once triggered, or would you rather check it yourself?");
2041
+
2042
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
2043
+
2044
+ function decide(proceed) {
2045
+ $row.find("button").prop("disabled", true);
2046
+ _rec.decision = proceed ? "proceed" : "skip";
2047
+ runBuildConsentDecision(_rec, proceed);
2048
+ }
2049
+
2050
+ $("<button>")
2051
+ .addClass("fp-consent-chip fp-consent-chip-primary")
2052
+ .attr("type", "button")
2053
+ .attr("data-fp-record-id", _rec.id)
2054
+ .attr("data-fp-record-action", "proceed")
2055
+ .text("Auto-verify")
2056
+ .on("click", function () { decide(true); })
2057
+ .appendTo($row);
2058
+ $("<button>")
2059
+ .addClass("fp-consent-chip fp-consent-chip-alt")
2060
+ .attr("type", "button")
2061
+ .attr("data-fp-record-id", _rec.id)
2062
+ .attr("data-fp-record-action", "skip")
2063
+ .text("I'll check myself")
2064
+ .on("click", function () { decide(false); })
2065
+ .appendTo($row);
2066
+
2067
+ $box.append($row);
2068
+ scrollMessagesToBottom();
2069
+ }
2070
+
2071
+ // The actual "proceed with review + loop" action, factored out so both
2072
+ // the fresh (no side-effecting nodes) and gated (decision made) paths in
2073
+ // handleBuildResult, and a rerendered consent-gate record's decide(),
2074
+ // all run identical logic sourced from plain data — never a captured
2075
+ // closure. consentGranted=false builds the Skip consent object
2076
+ // (skippedNodeIds/fpUidManifest) that startBuildLoop resolves into real
2077
+ // ids via importResult.nodeMap — see startBuildLoop's own comment.
2078
+ function runBuildConsentDecision(src, consentGranted) {
2079
+ var sideEffecting = Array.isArray(src.sideEffecting) ? src.sideEffecting : [];
2080
+ var flow = src.flow;
2081
+ var goalPrompt = src.goalPrompt;
2082
+ var fpUidManifest = Array.isArray(src.fpUidManifest) ? src.fpUidManifest : [];
2083
+
2084
+ if (sideEffecting.length > 0) {
2085
+ var sideLabels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
2086
+ addMessage("fp-notice", consentGranted
2087
+ ? "⚠ External calls: " + sideLabels + " — the deploy-test loop will auto-verify these once triggered."
2088
+ : "⚠ External calls: " + sideLabels + " — auto-verify skipped for these node(s); confirm the result yourself.");
2089
+ }
2090
+ var consent = consentGranted ? null : {
2091
+ skippedNodeIds: sideEffecting.map(function (n) { return n.id; }),
2092
+ fpUidManifest: fpUidManifest
2093
+ };
2094
+ addGeneratedReview(flow, function (importResult) {
2095
+ startBuildLoop(goalPrompt, flow, importResult, consent);
2096
+ }, goalPrompt);
2097
+ renderActionChip(src.suggestedAction);
2098
+ }
2099
+
2100
+ // W7 — per-call consent gate for a write-gated agent tool call
2101
+ // (apply_step/remove_step/rename_node touching a node type outside
2102
+ // WRITE_GATE_SAFE_NODE_TYPES). Mirrors renderBuildConsentGate's shape
2103
+ // and CLAUDE-008's fp-consent-chip styling, generalized to hold an
2104
+ // arbitrary pending tool call instead of only a build-loop's node set.
2105
+ //
2106
+ // src.onResume is a live function reference, not plain-data-only —
2107
+ // this is a DELIBERATE, reported deviation from renderBuildConsentGate/
2108
+ // runBuildConsentDecision's full data-reconstruction pattern. Reason:
2109
+ // the actual continuation (resuming the SAME in-flight
2110
+ // runAgentLoop/handleStep batch — remaining tool calls, accumulated
2111
+ // messages, then POSTing the next step and continuing the loop) lives
2112
+ // in per-invocation closures that aren't all JSON-serializable
2113
+ // (onDone/onError are themselves ad-hoc closures at each Modify call
2114
+ // site, e.g. onModifyResult/onModifyError capturing goalPrompt/
2115
+ // existingNodeIds). This mirrors the established, already-shipped
2116
+ // rerenderGeneratedReview "onImported is a live function ref, valid
2117
+ // within the same session" pattern rather than the stricter one.
2118
+ // Confirmed the stricter pattern's actual reason — a genuinely separate
2119
+ // pop-out window JS realm — does NOT apply here: the agent loop only
2120
+ // ever runs in the main window (dispatchSend's pop-out branch relays
2121
+ // via postMessage back to the opener rather than running its own loop,
2122
+ // and runBuildConsentDecision/renderBuildConsentGate itself has no
2123
+ // pop-out relay path either — confirmed via grep, so this is no
2124
+ // weaker than the existing shipped precedent). What DOES carry over
2125
+ // from CLAUDE-004-fix is the actual bug class it fixed: rerenderRecord
2126
+ // must find a dispatch branch and re-render from the SAME stored
2127
+ // record on refresh, never silently fall through to a generic path —
2128
+ // that guarantee is fully delivered below.
2129
+ function renderAgentToolConsentGate(src) {
2130
+ var $box = el("#fp-messages");
2131
+ if (!$box.length) {
2132
+ if (!src.decision && typeof src.onResume === "function") { src.onResume(true); }
2133
+ return;
2134
+ }
2135
+ // The pending "typing" indicator is only cleared automatically when
2136
+ // a turn fully completes or errors — a pause here otherwise leaves
2137
+ // it stuck showing stale narration ("Applying step: …") permanently
2138
+ // above the real gate that renders below it.
2139
+ hidePending();
2140
+
2141
+ var _rec = addRecord("question", {
2142
+ agentToolConsent: true,
2143
+ options: ["Proceed", "Skip this step"],
2144
+ name: src.name,
2145
+ args: src.args,
2146
+ label: describeAgentToolCall(src.name, src.args),
2147
+ onResume: src.onResume,
2148
+ decision: src.decision
2149
+ });
2150
+
2151
+ if (_rec.decision) {
2152
+ // Already resolved before this render (e.g. resolved earlier in
2153
+ // the session, now showing again after a refresh) — settled
2154
+ // state, not an interactive choice. Mirrors
2155
+ // renderBuildConsentGate's settled branch exactly.
2156
+ addMessage("assistant", "Consent requested for: " + _rec.label);
2157
+ var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
2158
+ $("<button>")
2159
+ .addClass("fp-consent-chip")
2160
+ .addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
2161
+ .attr("type", "button").prop("disabled", true)
2162
+ .text(_rec.decision === "proceed" ? "Proceeded ✓" : "Skipped ✓")
2163
+ .appendTo($settledRow);
2164
+ $box.append($settledRow);
2165
+ scrollMessagesToBottom();
2166
+ return;
2167
+ }
2168
+
2169
+ addMessage("assistant", "FlowPilot wants to do this: " + _rec.label +
2170
+ ". Let it proceed, or skip just this one step and continue?");
2171
+
2172
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
2173
+
2174
+ function decide(proceed) {
2175
+ $row.find("button").prop("disabled", true);
2176
+ _rec.decision = proceed ? "proceed" : "skip";
2177
+ if (typeof _rec.onResume === "function") { _rec.onResume(proceed); }
2178
+ }
2179
+
2180
+ $("<button>")
2181
+ .addClass("fp-consent-chip fp-consent-chip-primary")
2182
+ .attr("type", "button")
2183
+ .attr("data-fp-record-id", _rec.id)
2184
+ .attr("data-fp-record-action", "proceed")
2185
+ .text("Proceed")
2186
+ .on("click", function () { decide(true); })
2187
+ .appendTo($row);
2188
+ $("<button>")
2189
+ .addClass("fp-consent-chip fp-consent-chip-alt")
2190
+ .attr("type", "button")
2191
+ .attr("data-fp-record-id", _rec.id)
2192
+ .attr("data-fp-record-action", "skip")
2193
+ .text("Skip this step")
2194
+ .on("click", function () { decide(false); })
2195
+ .appendTo($row);
2196
+
2197
+ $box.append($row);
2198
+ scrollMessagesToBottom();
2199
+ }
2200
+
2201
+ // W7 — ask_user tool UI. Reuses renderClarifyingQuestion's button +
2202
+ // free-text presentation, but resumes the SAME in-flight agent-loop
2203
+ // tool-call batch with the answer as a tool result via src.onAnswer —
2204
+ // NOT dispatchSend() (which sends a brand-new user Send, a different
2205
+ // flow entirely from continuing an already-in-progress tool-call turn).
2206
+ // Same refresh-survival shape as renderAgentToolConsentGate (record +
2207
+ // settled state), for the same reason: a mid-answer /refresh must not
2208
+ // strand the loop or silently re-ask a resolved question.
2209
+ function renderAskUserQuestion(src) {
2210
+ var $box = el("#fp-messages");
2211
+ if (!$box.length) {
2212
+ if (src.decision !== "answered" && typeof src.onAnswer === "function") { src.onAnswer(""); }
2213
+ return;
2214
+ }
2215
+ // Same reasoning as renderAgentToolConsentGate: a pause here must
2216
+ // clear the pending indicator itself, since nothing else will.
2217
+ hidePending();
2218
+
2219
+ var _rec = addRecord("question", {
2220
+ askUserTool: true,
2221
+ question: src.question || "FlowPilot has a question.",
2222
+ options: Array.isArray(src.options) ? src.options : [],
2223
+ onAnswer: src.onAnswer,
2224
+ decision: src.decision,
2225
+ answerText: src.answerText
2226
+ });
2227
+
2228
+ if (_rec.decision === "answered") {
2229
+ addMessage("assistant", _rec.question);
2230
+ var $settled = $("<div>").addClass("fp-chip-row fp-question-row");
2231
+ $("<button>")
2232
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
2233
+ .attr("type", "button").prop("disabled", true)
2234
+ .text((_rec.answerText || "") + " ✓")
2235
+ .appendTo($settled);
2236
+ $box.append($settled);
2237
+ scrollMessagesToBottom();
2238
+ return;
2239
+ }
2240
+
2241
+ addMessage("assistant", _rec.question);
2242
+
2243
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
2244
+ var $otherRow;
2245
+
2246
+ function answer(text) {
2247
+ $row.find("button, input").prop("disabled", true);
2248
+ if ($otherRow) { $otherRow.find("button, input").prop("disabled", true); }
2249
+ _rec.decision = "answered";
2250
+ _rec.answerText = text;
2251
+ if (typeof _rec.onAnswer === "function") { _rec.onAnswer(text); }
2252
+ }
2253
+
2254
+ _rec.options.forEach(function (opt) {
2255
+ $("<button>")
2256
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
2257
+ .attr("type", "button")
2258
+ .attr("data-fp-record-id", _rec.id)
2259
+ .attr("data-fp-record-action", "answer")
2260
+ .attr("data-fp-record-value", opt)
2261
+ .text(opt)
2262
+ .on("click", function () { answer(opt); })
2263
+ .appendTo($row);
2264
+ });
2265
+
2266
+ $otherRow = $("<div>").addClass("fp-question-other-row fp-hidden");
2267
+ var $otherInput = $("<input>")
2268
+ .attr("type", "text")
2269
+ .attr("placeholder", "Type your answer…")
2270
+ .addClass("fp-question-other-input");
2271
+ var $otherSend = $("<button>")
2272
+ .addClass("red-ui-button red-ui-button-small")
2273
+ .attr("type", "button")
2274
+ .attr("data-fp-record-id", _rec.id)
2275
+ .attr("data-fp-record-action", "answer-other")
2276
+ .append($("<i>").addClass("fa fa-paper-plane"));
2277
+
2278
+ function submitOther() {
2279
+ var val = $otherInput.val().trim();
2280
+ if (!val) { return; }
2281
+ answer(val);
2282
+ }
2283
+ $otherSend.on("click", submitOther);
2284
+ $otherInput.on("keydown", function (e) { if (e.key === "Enter") { submitOther(); } });
2285
+ $otherRow.append($otherInput).append($otherSend);
2286
+
2287
+ $("<button>")
2288
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-other")
2289
+ .attr("type", "button")
2290
+ .attr("data-fp-record-id", _rec.id)
2291
+ .attr("data-fp-record-action", "show-other")
2292
+ .text("Other…")
2293
+ .on("click", function () {
2294
+ $otherRow.removeClass("fp-hidden");
2295
+ $otherInput.focus();
2296
+ })
2297
+ .appendTo($row);
2298
+
2299
+ $box.append($row).append($otherRow);
2300
+ scrollMessagesToBottom();
2301
+ }
2302
+
1168
2303
  function handleBuildResult(data, goalPrompt) {
1169
2304
  hidePending();
1170
2305
  if (renderQuestionOrProse(data)) { return; }
@@ -1173,8 +2308,24 @@
1173
2308
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1174
2309
  addMessage("assistant", data.explanation || "(no explanation returned)");
1175
2310
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1176
- addGeneratedReview(flow, function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }, goalPrompt);
1177
- renderActionChip(data.suggestedAction);
2311
+
2312
+ var nodeClasses = data.stepNodeClasses;
2313
+ var sideEffecting = (nodeClasses && Array.isArray(nodeClasses.sideEffecting))
2314
+ ? nodeClasses.sideEffecting : [];
2315
+ var fpUidManifest = Array.isArray(data.fpUidManifest) ? data.fpUidManifest : [];
2316
+ var consentSrc = {
2317
+ sideEffecting: sideEffecting,
2318
+ flow: flow,
2319
+ goalPrompt: goalPrompt,
2320
+ fpUidManifest: fpUidManifest,
2321
+ suggestedAction: data.suggestedAction
2322
+ };
2323
+
2324
+ if (sideEffecting.length > 0) {
2325
+ renderBuildConsentGate(consentSrc);
2326
+ } else {
2327
+ runBuildConsentDecision(consentSrc, true);
2328
+ }
1178
2329
  setBusy(false);
1179
2330
  updateSelectionStatus();
1180
2331
  }
@@ -1212,7 +2363,9 @@
1212
2363
  var payload = {
1213
2364
  prompt: notes, context: context,
1214
2365
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1215
- conversationId: conversationId
2366
+ conversationId: conversationId,
2367
+ strategy: "classic",
2368
+ entry: "document"
1216
2369
  };
1217
2370
 
1218
2371
  function onDocumentError(msg, xhr) {
@@ -1265,6 +2418,8 @@
1265
2418
  addMessage("error", "Describe what you want to change.");
1266
2419
  return;
1267
2420
  }
2421
+ var existingNodeIds = context.nodes.map(function (n) { return n.id; });
2422
+
1268
2423
  var label = "Modify: " + instruction + contextAttachmentNote(context);
1269
2424
  addMessage("user", label);
1270
2425
  // Snapshot history before pushing this turn (see send()).
@@ -1273,14 +2428,17 @@
1273
2428
  $promptBox.val("");
1274
2429
 
1275
2430
  var ap = activeProvider();
1276
- var isAgentLoop = ap && ap.supportsTools;
2431
+ var isAgentLoop = ap && ap.supportsTools &&
2432
+ currentSettings.enableAgentWrite === true;
1277
2433
 
1278
2434
  setBusy(true);
1279
2435
  showPending(isAgentLoop);
1280
2436
  var payload = {
1281
2437
  prompt: instruction, context: context,
1282
2438
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1283
- conversationId: conversationId
2439
+ conversationId: conversationId,
2440
+ strategy: isAgentLoop ? "agent" : "classic",
2441
+ entry: "modify"
1284
2442
  };
1285
2443
 
1286
2444
  function onModifyError(msg, xhr) {
@@ -1289,6 +2447,10 @@
1289
2447
  handleExecuteError(msg, raw);
1290
2448
  }
1291
2449
 
2450
+ function onModifyResult(data) {
2451
+ handleModifyResult(data);
2452
+ }
2453
+
1292
2454
  // Explore-then-propose, same as generate(). The
1293
2455
  // model may call read tools (e.g. to re-check the selected node's
1294
2456
  // current config) before producing the modify envelope; the final
@@ -1296,7 +2458,7 @@
1296
2458
  if (isAgentLoop) {
1297
2459
  runAgentLoop("flowpilot/modify", payload,
1298
2460
  { mode: "modify", context: context, prompt: instruction },
1299
- handleModifyResult, onModifyError);
2461
+ onModifyResult, onModifyError);
1300
2462
  return;
1301
2463
  }
1302
2464
 
@@ -1304,11 +2466,11 @@
1304
2466
  // generate() for details.
1305
2467
  if (currentSettings.streamingEnabled) {
1306
2468
  payload.stream = true;
1307
- sendExecuteStream("modify", payload, handleModifyResult);
2469
+ sendExecuteStream("modify", payload, onModifyResult);
1308
2470
  return;
1309
2471
  }
1310
2472
 
1311
- ajaxJson("POST", "flowpilot/modify", payload, handleModifyResult, onModifyError);
2473
+ ajaxJson("POST", "flowpilot/modify", payload, onModifyResult, onModifyError);
1312
2474
  }
1313
2475
 
1314
2476
  // Render generated flow JSON in a preformatted, copyable block. Used for
@@ -1352,11 +2514,23 @@
1352
2514
  // held in these states.
1353
2515
  var activeBuildLoop = null;
1354
2516
 
2517
+ // CLAUDE-014: plain-language note for a loop-checkpoint "Continue" click,
2518
+ // read and cleared by runBuildReview's payload build. Mirrors
2519
+ // runAgentLoop's pendingDebugNote, but module-scoped since
2520
+ // renderLoopCheckpoint/runBuildReview aren't nested inside runAgentLoop.
2521
+ // No equivalent exists for "Stop loop" — stopBuildLoop makes no server
2522
+ // round trip to attach a note to.
2523
+ var pendingLoopDebugNote = null;
2524
+
1355
2525
  // How long onDebugMessage's auto-attach waits, after each matching
1356
2526
  // message, for another one to arrive before locking in and running
1357
2527
  // the review — see onDebugMessage for why (a forked/split flow can
1358
2528
  // fire its debug node more than once per trigger).
1359
2529
  var BUILD_LOOP_ATTACH_DEBOUNCE_MS = 1200;
2530
+ // W0.3: how many times the model can bail (emit a prose reply with a
2531
+ // suggestedAction mode-redirect) before the loop gives up with an
2532
+ // honest-timeout instead of silently treating the bail as success.
2533
+ var BUILD_LOOP_MAX_BAILS = 2;
1360
2534
  var buildLoopAttachTimer = null;
1361
2535
  // Fires when "attach" waits too long with no debug — surfaces a prompt
1362
2536
  // for flows that don't produce automatic debug output (HTTP endpoints, etc).
@@ -1382,9 +2556,25 @@
1382
2556
 
1383
2557
  var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1384
2558
 
2559
+ var _rec = addRecord("question", {
2560
+ options: ["Continue → AI review", "Stop loop"],
2561
+ loopCheckpoint: true,
2562
+ onResume: function (action) {
2563
+ if (action === "continue") {
2564
+ if (activeBuildLoop) {
2565
+ if (currentSettings.debugLogging) { pendingLoopDebugNote = "user clicked Continue"; }
2566
+ runBuildReview(activeBuildLoop);
2567
+ }
2568
+ } else if (action === "stop") {
2569
+ stopBuildLoop("Build loop stopped — applied nodes remain as-is.");
2570
+ }
2571
+ }
2572
+ });
2573
+
1385
2574
  function onContinue() {
1386
2575
  $row.find("button").prop("disabled", true);
1387
2576
  if (!activeBuildLoop) { return; }
2577
+ if (currentSettings.debugLogging) { pendingLoopDebugNote = "user clicked Continue"; }
1388
2578
  runBuildReview(activeBuildLoop);
1389
2579
  }
1390
2580
  function onStop() {
@@ -1395,21 +2585,73 @@
1395
2585
  $("<button>")
1396
2586
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1397
2587
  .attr("type", "button")
2588
+ .attr("data-fp-record-id", _rec.id)
2589
+ .attr("data-fp-record-action", "continue")
1398
2590
  .text("Continue → AI review")
1399
2591
  .on("click", onContinue)
1400
2592
  .appendTo($row);
1401
2593
  $("<button>")
1402
2594
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1403
2595
  .attr("type", "button")
2596
+ .attr("data-fp-record-id", _rec.id)
2597
+ .attr("data-fp-record-action", "stop")
1404
2598
  .text("Stop loop")
1405
2599
  .on("click", onStop)
1406
2600
  .appendTo($row);
1407
2601
 
1408
2602
  $box.append($row);
1409
- addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1410
2603
  scrollMessagesToBottom();
1411
2604
  }
1412
2605
 
2606
+ // WS3: remove FP-UID checkpoint tap nodes that the build prompt placed
2607
+ // on the canvas as FlowPilot scaffolding. These are debug nodes named
2608
+ // FP-UID001, FP-UID002, etc. — wired in parallel to external-call nodes
2609
+ // so the loop can attribute debug messages to specific checkpoints.
2610
+ // Called at loop end (any outcome) to clean up before returning control
2611
+ // to the user. Replicates the remove-and-history pattern from
2612
+ // applyModifications in apply-review.js (confirmed group-cleanup bookkeeping).
2613
+ function removeFpUidTaps(loop) {
2614
+ if (!loop || !Array.isArray(loop.nodeIds) || !loop.nodeIds.length) { return 0; }
2615
+ var FP_UID_RE = /^FP-UID\d+$/;
2616
+ var removed = 0;
2617
+ loop.nodeIds.forEach(function (id) {
2618
+ var liveNode = RED.nodes.node(id);
2619
+ if (!liveNode || !FP_UID_RE.test(liveNode.name)) { return; }
2620
+ var connectedLinks = [];
2621
+ RED.nodes.eachLink(function (l) {
2622
+ if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
2623
+ connectedLinks.push(l);
2624
+ }
2625
+ });
2626
+ try { RED.nodes.remove(liveNode.id); } catch (e) { return; }
2627
+ if (liveNode.g && RED.nodes.group) {
2628
+ var ownerGroup = RED.nodes.group(liveNode.g);
2629
+ if (ownerGroup) {
2630
+ var idx = ownerGroup.nodes.indexOf(liveNode);
2631
+ if (idx !== -1) { ownerGroup.nodes.splice(idx, 1); }
2632
+ RED.group.markDirty(ownerGroup);
2633
+ }
2634
+ }
2635
+ RED.history.push({
2636
+ t: "delete",
2637
+ nodes: [liveNode],
2638
+ links: connectedLinks,
2639
+ groups: [],
2640
+ junctions: [],
2641
+ subflow: { id: undefined, instances: [] },
2642
+ subflowInputs: [],
2643
+ subflowOutputs: [],
2644
+ dirty: RED.nodes.dirty()
2645
+ });
2646
+ removed++;
2647
+ });
2648
+ if (removed) {
2649
+ RED.nodes.dirty(true);
2650
+ RED.view.redraw(true);
2651
+ }
2652
+ return removed;
2653
+ }
2654
+
1413
2655
  // The single exit point for every way a build loop ends — Touchdown,
1414
2656
  // the cap being reached, pausing on a clarifying question, or the user
1415
2657
  // clicking Stop. Releases Build mode and its pinned selection too: once
@@ -1420,6 +2662,7 @@
1420
2662
  // visible as a completion badge. success=false (default): remove the stepper
1421
2663
  // (user stop, cap reached, paused for question).
1422
2664
  function stopBuildLoop(note, success) {
2665
+ var tapCount = activeBuildLoop ? removeFpUidTaps(activeBuildLoop) : 0;
1423
2666
  if (success && activeBuildLoop) {
1424
2667
  activeBuildLoop.waypoint = "done";
1425
2668
  renderLoopStepper(activeBuildLoop);
@@ -1430,6 +2673,7 @@
1430
2673
  if (!success) { el("#fp-loop-stepper").remove(); }
1431
2674
  disarmExecuteAction();
1432
2675
  if (note) { addMessage("assistant", note); }
2676
+ if (tapCount) { addMessage("assistant", "Removed " + tapCount + " FP-UID checkpoint tap(s) from the canvas."); }
1433
2677
  }
1434
2678
 
1435
2679
  // Applies a build-loop review's fix envelope, then keeps the loop's
@@ -1462,6 +2706,10 @@
1462
2706
  } else {
1463
2707
  loop.iteration++;
1464
2708
  loop.waypoint = "apply";
2709
+ // CLAUDE-025: this fix still needs its OWN fresh deploy before
2710
+ // any evidence counts — the next "deploy" event re-stamps this
2711
+ // once the new apply->attach transition actually fires.
2712
+ loop.deployedAt = null;
1465
2713
  renderLoopStepper(loop);
1466
2714
  }
1467
2715
  }
@@ -1492,7 +2740,21 @@
1492
2740
  if (loop.waypoint === "apply") {
1493
2741
  hint = "Click the canvas to place the new node(s), then Deploy — I'll move on automatically once you deploy.";
1494
2742
  } else if (loop.waypoint === "attach") {
1495
- hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
2743
+ var eps = loop.httpEndpoints;
2744
+ if (eps && eps.length > 0) {
2745
+ var ep = eps[0];
2746
+ var baseUrl = (typeof window !== "undefined" && window.location)
2747
+ ? window.location.origin : "";
2748
+ var curlMethod = ep.method === "GET" ? "" : " -X " + ep.method;
2749
+ hint = "Send " + ep.method + " " + ep.url + " to trigger the flow — " +
2750
+ "e.g. curl" + curlMethod + " " + baseUrl + ep.url +
2751
+ ". I’ll attach the debug output automatically.";
2752
+ if (eps.length > 1) {
2753
+ hint += " (" + (eps.length - 1) + " more endpoint(s) in this flow.)";
2754
+ }
2755
+ } else {
2756
+ hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
2757
+ }
1496
2758
  } else if (loop.waypoint === "review") {
1497
2759
  hint = "Debug output attached — reviewing against the goal…";
1498
2760
  }
@@ -1513,7 +2775,8 @@
1513
2775
  iteration: loop.iteration,
1514
2776
  maxIterations: loop.maxIterations,
1515
2777
  goal: loop.goal,
1516
- nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : []
2778
+ nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : [],
2779
+ httpEndpoints: Array.isArray(loop.httpEndpoints) ? loop.httpEndpoints.slice() : []
1517
2780
  });
1518
2781
 
1519
2782
  $box.append($msg);
@@ -1526,7 +2789,8 @@
1526
2789
  iteration: rec.iteration || 1,
1527
2790
  maxIterations: rec.maxIterations || 5,
1528
2791
  goal: rec.goal || "",
1529
- nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : []
2792
+ nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : [],
2793
+ httpEndpoints: Array.isArray(rec.httpEndpoints) ? rec.httpEndpoints : []
1530
2794
  });
1531
2795
  }
1532
2796
 
@@ -1542,12 +2806,22 @@
1542
2806
  // end up on the canvas — importResult.nodeMap maps each placeholder id
1543
2807
  // to the real live node object, which is the only way later review/fix
1544
2808
  // requests can target the right nodes via collectSelectionContext.
1545
- function startBuildLoop(goal, nodeIdsOrNodes, importResult) {
2809
+ //
2810
+ // consent (WS4, optional): { skippedNodeIds, fpUidManifest } from a
2811
+ // Skip decision at the build consent gate (see handleBuildResult /
2812
+ // renderBuildConsentGate) — skippedNodeIds are the side-effecting
2813
+ // nodes' own PLACEHOLDER ids, fpUidManifest maps each FP-UID debug tap's
2814
+ // placeholder id to the placeholder id of the node it's wired from
2815
+ // (wiredFrom). Resolved here into two REAL-id sets: the skipped nodes
2816
+ // themselves (onNodeStatus's status/<nodeId> path checks against these)
2817
+ // and the taps wired to them (onDebugMessage's msg.id check does) — so
2818
+ // both auto-verify evidence paths honor the same Skip decision.
2819
+ function startBuildLoop(goal, nodeIdsOrNodes, importResult, consent) {
1546
2820
  var nodeIds = [];
2821
+ var nodeMap = importResult && importResult.nodeMap;
1547
2822
  if (importResult) {
1548
2823
  // Fresh build: map placeholder ids from the proposal to the real
1549
2824
  // ids importNodes assigned on the canvas.
1550
- var nodeMap = importResult.nodeMap;
1551
2825
  if (nodeMap && Array.isArray(nodeIdsOrNodes)) {
1552
2826
  nodeIdsOrNodes.forEach(function (n) {
1553
2827
  var real = n && n.id && nodeMap[n.id];
@@ -1558,13 +2832,48 @@
1558
2832
  // Existing-flow build: ids are already resolved real canvas ids.
1559
2833
  nodeIds = nodeIdsOrNodes.filter(function (id) { return typeof id === "string" && id; });
1560
2834
  }
2835
+ // Detect HTTP-in endpoints so the "attach" step can show a specific
2836
+ // trigger hint instead of the generic "trigger the flow" message.
2837
+ var httpEndpoints = [];
2838
+ nodeIds.forEach(function (id) {
2839
+ var n = RED.nodes.node(id);
2840
+ if (n && n.type === "http in" && n.url) {
2841
+ httpEndpoints.push({ method: (n.method || "get").toUpperCase(), url: n.url });
2842
+ }
2843
+ });
2844
+
2845
+ var skipCheckpointNodeIds = [];
2846
+ var skipCheckpointTapIds = [];
2847
+ var skippedPlaceholderIds = consent && Array.isArray(consent.skippedNodeIds) ? consent.skippedNodeIds : [];
2848
+ if (skippedPlaceholderIds.length && nodeMap) {
2849
+ skippedPlaceholderIds.forEach(function (placeholderId) {
2850
+ var real = nodeMap[placeholderId];
2851
+ if (real && real.id) { skipCheckpointNodeIds.push(real.id); }
2852
+ });
2853
+ var manifest = Array.isArray(consent.fpUidManifest) ? consent.fpUidManifest : [];
2854
+ manifest.forEach(function (tap) {
2855
+ if (!tap || skippedPlaceholderIds.indexOf(tap.wiredFrom) === -1) { return; }
2856
+ var realTap = nodeMap[tap.id];
2857
+ if (realTap && realTap.id) { skipCheckpointTapIds.push(realTap.id); }
2858
+ });
2859
+ }
2860
+
1561
2861
  activeBuildLoop = {
1562
2862
  goal: goal,
1563
2863
  nodeIds: nodeIds,
1564
2864
  iteration: 1,
1565
2865
  maxIterations: getAgentLoopMaxIterations(),
1566
2866
  waypoint: "apply",
1567
- conversationId: conversationId
2867
+ conversationId: conversationId,
2868
+ bailCount: 0,
2869
+ httpEndpoints: httpEndpoints,
2870
+ skipCheckpointNodeIds: skipCheckpointNodeIds,
2871
+ skipCheckpointTapIds: skipCheckpointTapIds,
2872
+ // CLAUDE-025: stamped by the RED "deploy" listener (init.js) the
2873
+ // moment THIS attempt's own apply->attach transition fires — see
2874
+ // freshBuildLoopEvidence. Starts null: no deploy has happened for
2875
+ // this attempt yet, so nothing can count as evidence.
2876
+ deployedAt: null
1568
2877
  };
1569
2878
  renderLoopStepper(activeBuildLoop);
1570
2879
  }
@@ -1579,8 +2888,62 @@
1579
2888
  // node ids instead of the live/pinned canvas selection) are synthetic.
1580
2889
  function runBuildReview(loop) {
1581
2890
  var context = collectSelectionContext(loop.nodeIds);
1582
- context = attachDebugContext(context);
1583
- var instruction = "Review the attached debug output against this build goal: \"" +
2891
+ // CLAUDE-025: deliberately NOT attachDebugContext() here — that pulls
2892
+ // in the full sticky attachedDebugMessages buffer, which can still
2893
+ // hold evidence from an earlier Build attempt (or a manual attach)
2894
+ // that has nothing to do with THIS attempt's own deploy. Only
2895
+ // messages that arrived at/after this attempt's own deploy (see
2896
+ // freshBuildLoopEvidence) count as evidence for its review.
2897
+ var freshEvidence = freshBuildLoopEvidence(loop);
2898
+ if (freshEvidence.length) {
2899
+ context = context || { nodes: [], connections: {} };
2900
+ context = Object.assign({}, context, {
2901
+ debugMessages: freshEvidence.map(function (m) {
2902
+ return { id: m.id, timestamp: m.timestamp, sourceKind: m.sourceKind, name: m.name, topic: m.topic, value: m.value };
2903
+ })
2904
+ });
2905
+ }
2906
+ var reviewEvidence = context && Array.isArray(context.debugMessages)
2907
+ ? context.debugMessages : [];
2908
+ var statusOnlyEvidence = reviewEvidence.length > 0 &&
2909
+ reviewEvidence.every(function (entry) {
2910
+ return entry && entry.sourceKind === "status";
2911
+ });
2912
+ // W0.3: framing block — suppresses the Modify escape hatch
2913
+ // (suggestedAction mode-redirect) inside the build loop context.
2914
+ // The code-side handler (handleBuildReviewResult) also detects and
2915
+ // counts bail attempts so N bails trigger an honest-timeout instead
2916
+ // of silently treating a redirect as success.
2917
+ var instruction = "CONTEXT: You are the fix engine inside a build-test-fix loop. " +
2918
+ "Your only valid responses are: (1) plain text when the goal is fully " +
2919
+ "satisfied, or (2) a {\"explanation\", \"changes\", ...} fix envelope " +
2920
+ "when something needs patching." +
2921
+ (statusOnlyEvidence
2922
+ ? " (3) Because the attached evidence contains ONLY coarse node-status " +
2923
+ "lines, you may instead return the atomic {\"question\", " +
2924
+ "\"questionOptions\"} envelope described below."
2925
+ : "") +
2926
+ " Do NOT use the <<<FLOWPILOT_DATA>>> " +
2927
+ "block or suggest switching to chat/generate/document — you are already " +
2928
+ "in the right context and any mode-redirect will be ignored. If you are " +
2929
+ "genuinely uncertain what to fix, " +
2930
+ (statusOnlyEvidence
2931
+ ? "use the status-only confirmation question below."
2932
+ : "describe the uncertainty inside \"explanation\" in a fix envelope.") +
2933
+ "\n\nEach attached evidence object has sourceKind. sourceKind:\"debug\" " +
2934
+ "is real message content emitted by a debug node. sourceKind:\"status\" " +
2935
+ "is only a coarse connection/status line synthesized from node status; " +
2936
+ "never treat it as proof of message payload content or successful " +
2937
+ "end-to-end behavior. " +
2938
+ (statusOnlyEvidence
2939
+ ? "STATUS-ONLY FALLBACK: if the coarse status does not prove whether " +
2940
+ "the deployed node is actually connected/working, do not guess or " +
2941
+ "assert failure. Ask one concrete yes/no confirmation such as " +
2942
+ "\"Does the node show connected after deploy?\" by returning ONLY " +
2943
+ "{\"question\":\"...\",\"questionOptions\":[\"Yes\",\"No\"]}. "
2944
+ : "") +
2945
+ "\n\n" +
2946
+ "Review the attached debug output against this build goal: \"" +
1584
2947
  loop.goal + "\". Before concluding anything, list out every distinct " +
1585
2948
  "piece of data or behavior the goal actually requires, then check the " +
1586
2949
  "attached debug payload(s) contain EACH one — a payload that's merely " +
@@ -1588,7 +2951,22 @@
1588
2951
  "goal asked to combine two things but the payload only shows one), " +
1589
2952
  "does NOT fully satisfy it. If more than one debug message is " +
1590
2953
  "attached, treat them together as the full picture from one trigger, " +
1591
- "not as separate independent attempts. If it fully satisfies the goal, " +
2954
+ "not as separate independent attempts. " +
2955
+ "SPECIAL CASE — network errors: if the debug output shows ONLY a " +
2956
+ "network-level error (EHOSTUNREACH, ECONNREFUSED, ETIMEDOUT, " +
2957
+ "ENOTFOUND, getaddrinfo ENOTFOUND, EAI_AGAIN, EAI_NODATA), the " +
2958
+ "flow MIGHT be correctly built — BUT you MUST first check the node " +
2959
+ "context: if any http-request node has an empty url field, a " +
2960
+ "placeholder, or a clearly malformed url (no hostname, no protocol, " +
2961
+ "etc.), the DNS or connection error is a CONFIGURATION problem — " +
2962
+ "fix the url field, do NOT declare it an infrastructure issue. Only " +
2963
+ "apply this special case when the url is a real, non-empty, " +
2964
+ "well-formed URL and the external service is simply unreachable. In " +
2965
+ "that case reply in plain text acknowledging the flow is structurally " +
2966
+ "correct and the network error is an infrastructure issue outside the " +
2967
+ "flow. Do NOT propose any changes; this error cannot be resolved by " +
2968
+ "modifying the flow. " +
2969
+ "If it fully satisfies the goal, " +
1592
2970
  "say so in plain text — no changes needed. If something's wrong " +
1593
2971
  "(including a node that never fired, or a value that's missing/empty " +
1594
2972
  "when the goal needed it), propose the fix directly as a patch in " +
@@ -1612,8 +2990,14 @@
1612
2990
  var payload = {
1613
2991
  prompt: instruction, context: context,
1614
2992
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1615
- conversationId: loop.conversationId
2993
+ conversationId: loop.conversationId,
2994
+ strategy: "classic",
2995
+ entry: "build-review"
1616
2996
  };
2997
+ if (pendingLoopDebugNote) {
2998
+ payload.debugNote = pendingLoopDebugNote;
2999
+ pendingLoopDebugNote = null;
3000
+ }
1617
3001
 
1618
3002
  function onReviewError(msg, xhr) {
1619
3003
  hidePending();
@@ -1644,6 +3028,26 @@
1644
3028
  // diff-then-Apply pipeline as a manual Modify, then the loop advances
1645
3029
  // back to "apply" for the next deploy/test cycle, or stops if the
1646
3030
  // iteration cap is reached).
3031
+ // Returns false when all of data's proposed changes are sentinel-echoed
3032
+ // with no insertions, removals, or wire changes. Used by
3033
+ // handleBuildReviewResult to avoid showing an all-blocked review panel
3034
+ // when the model said "no changes needed" but still emitted a modify
3035
+ // envelope (a common model behavior after a build-loop review).
3036
+ function reviewHasRealDiffs(data) {
3037
+ if ((data.newNodes && data.newNodes.length) ||
3038
+ (data.removeNodes && data.removeNodes.length) ||
3039
+ (data.newWires && data.newWires.length) ||
3040
+ (data.newGroups && data.newGroups.length)) { return true; }
3041
+ var nodes = Array.isArray(data.flow) ? data.flow : [];
3042
+ return nodes.some(function (modNode) {
3043
+ if (!modNode || !modNode.id) { return false; }
3044
+ var liveNode = findLiveNode(modNode.id);
3045
+ if (!liveNode) { return false; }
3046
+ var diff = computeNodeDiff(liveNode, modNode);
3047
+ return diff.propertyChanges.length > 0 || diff.wiresChanged;
3048
+ });
3049
+ }
3050
+
1647
3051
  function handleBuildReviewResult(data) {
1648
3052
  hidePending();
1649
3053
  var loop = activeBuildLoop;
@@ -1667,6 +3071,58 @@
1667
3071
  }
1668
3072
 
1669
3073
  if (data.prose) {
3074
+ var explanation = data.explanation || "(no content returned)";
3075
+
3076
+ if (looksLikeToolEnvelope(data.explanation)) {
3077
+ handleExecuteError("FlowPilot's reply didn't come through as expected.", data.explanation);
3078
+ stopBuildLoop("Build loop stopped — the review response wasn't in the expected format. Continue manually with Modify, or start a fresh /build.", false);
3079
+ updateSelectionStatus();
3080
+ return;
3081
+ }
3082
+
3083
+ // W0.3: bail detection — a prose reply with a mode-redirect
3084
+ // suggestedAction means the model tried to exit the loop
3085
+ // context via the Modify escape hatch. Count it and retry or
3086
+ // honest-timeout rather than treating it as success.
3087
+ var sa = data.suggestedAction;
3088
+ var isBail = sa && (sa.mode === "chat" || sa.mode === "generate" || sa.mode === "document");
3089
+ if (isBail) {
3090
+ loop.bailCount = (loop.bailCount || 0) + 1;
3091
+ console.warn("[FlowPilot] build-loop bail #" + loop.bailCount +
3092
+ " mode=" + sa.mode + ": " + explanation);
3093
+ addMessage("assistant", explanation);
3094
+ pushHistory("assistant", explanation);
3095
+ if (loop.bailCount >= BUILD_LOOP_MAX_BAILS) {
3096
+ stopBuildLoop("Build loop could not assess the debug output — the AI kept redirecting instead of reviewing. Try attaching more debug context or continuing manually with Modify.", false);
3097
+ setBusy(false);
3098
+ updateSelectionStatus();
3099
+ } else {
3100
+ addMessage("fp-notice", "Build-loop: review redirected to " + sa.mode +
3101
+ " — staying in build context and retrying (bail " + loop.bailCount +
3102
+ "/" + BUILD_LOOP_MAX_BAILS + ").");
3103
+ runBuildReview(loop);
3104
+ }
3105
+ return;
3106
+ }
3107
+
3108
+ addMessage("assistant", explanation);
3109
+ pushHistory("assistant", explanation);
3110
+ renderActionChip(data.suggestedAction);
3111
+ var stopMsg = /EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EAI_NODATA|getaddrinfo|unreachable|infrastructure/i
3112
+ .test(explanation)
3113
+ ? "Build complete — the flow is correctly structured, but the external endpoint was unreachable during testing (infrastructure issue, not a flow problem)."
3114
+ : "Touchdown — the debug output matches the goal.";
3115
+ stopBuildLoop(stopMsg, true);
3116
+ setBusy(false);
3117
+ updateSelectionStatus();
3118
+ return;
3119
+ }
3120
+
3121
+ // Modify envelope where every proposed change is a sentinel echo —
3122
+ // the model emitted a changes object but all fields are redacted
3123
+ // placeholders with no insertions, removals, or wire changes. Treat
3124
+ // it as "no changes needed" rather than showing an all-blocked panel.
3125
+ if (!reviewHasRealDiffs(data)) {
1670
3126
  addMessage("assistant", data.explanation || "(no content returned)");
1671
3127
  pushHistory("assistant", data.explanation || "");
1672
3128
  renderActionChip(data.suggestedAction);