@manny-est/node-red-flowpilot 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -271,6 +273,65 @@
271
273
  dispatch();
272
274
  }
273
275
 
276
+ function summarizeConversationHistory() {
277
+ if (isPopoutContext) {
278
+ addMessage("assistant", "`/summarize` only runs in the main FlowPilot panel.");
279
+ return;
280
+ }
281
+
282
+ var totalMessages = conversationHistory.length;
283
+ if (totalMessages <= 4) {
284
+ addMessage("assistant", "Nothing much to summarize yet — only " + totalMessages +
285
+ " message(s) so far.");
286
+ return;
287
+ }
288
+
289
+ var recentTail = conversationHistory.slice(-4);
290
+ var olderSlice = conversationHistory.slice(0, -4);
291
+ var beforeTokens = estimateTokens(olderSlice);
292
+ var prompt = "Summarize the earlier conversation history provided here. " +
293
+ "Keep it concise and factual. Preserve decisions, constraints, important context, " +
294
+ "and unresolved questions. Return plain text only.";
295
+ var payload = {
296
+ prompt: prompt,
297
+ context: null,
298
+ history: olderSlice.slice(),
299
+ historyTruncated: false,
300
+ conversationId: conversationId,
301
+ strategy: "classic",
302
+ entry: "chat"
303
+ };
304
+
305
+ setBusy(true);
306
+ showPending(false);
307
+
308
+ ajaxJson("POST", "flowpilot/chat", payload, function (data) {
309
+ hidePending();
310
+ var summaryText = String(data && data.message ? data.message : "").trim();
311
+ if (!summaryText) {
312
+ addMessage("error", "Summarize failed: no summary text returned.");
313
+ setBusy(false);
314
+ updateSelectionStatus();
315
+ return;
316
+ }
317
+
318
+ var summaryMessage = {
319
+ role: "assistant",
320
+ content: "[Summary of earlier conversation]\n" + summaryText
321
+ };
322
+ conversationHistory = [summaryMessage].concat(recentTail);
323
+ addMessage("assistant", "Compacted " + olderSlice.length + " earlier message(s) into a summary (~" +
324
+ beforeTokens.toLocaleString() + " → ~" + estimateTokens(summaryMessage).toLocaleString() + " tokens).");
325
+ setBusy(false);
326
+ updateSelectionStatus();
327
+ }, function (msg) {
328
+ hidePending();
329
+ addMessage("error", "Summarize failed: " + msg);
330
+ setBusy(false);
331
+ updateSelectionStatus();
332
+ });
333
+ }
334
+
274
335
  // ---------------------------------------------------------------------
275
336
  // Bounded read-tool loop, shared by chat and
276
337
  // generate/document/modify ("explore-then-propose"). Sends the
@@ -299,14 +360,150 @@
299
360
  // than erroring out.
300
361
  // ---------------------------------------------------------------------
301
362
  var AGENT_LOOP_MAX_STEPS = 8;
302
- var AGENT_LOOP_TOKEN_CEILING = 50000;
363
+ // W7 §16 point 5: ask_user round-trips get their own small budget,
364
+ // separate from AGENT_LOOP_MAX_STEPS, so a couple of clarifying
365
+ // questions don't eat the real step budget.
366
+ var AGENT_ASK_USER_MAX_ROUNDTRIPS = 3;
303
367
 
304
368
  var fpAgentStopRequested = false;
305
-
306
- function runAgentLoop(firstEndpoint, payload, stepExtra, onDone, onError) {
369
+ // CLAUDE-022: the in-flight jqXHR for this run's current ajaxJson call
370
+ // (first request / postNextStep / fallbackToPlain), so the Stop button
371
+ // can actually abort it instead of only setting the flag above. Cleared
372
+ // back to null the moment that call's own success/error callback runs,
373
+ // so a stale reference never lingers into the next step.
374
+ var fpCurrentAgentRequest = null;
375
+
376
+ // P10-D1 (ADR-001 R5): monotonic suffix so two runs minted in the same
377
+ // millisecond (Date.now() collision) still get distinct runIds.
378
+ var runIdCounter = 0;
379
+
380
+ function runAgentLoop(firstEndpoint, payload, stepExtra, realOnDone, realOnError) {
381
+ if (!payload || !payload.strategy || !payload.entry) {
382
+ throw new Error("runAgentLoop requires strategy and entry in the initial payload.");
383
+ }
384
+ var AGENT_LOOP_TOKEN_CEILING = (Number(currentSettings.agentLoopTokenCeiling) > 0)
385
+ ? Number(currentSettings.agentLoopTokenCeiling) : 50000;
386
+ var runStrategy = payload.strategy;
387
+ var runEntry = payload.entry;
388
+ // P10-D1 follow-up (sr-dev review): captured once, like
389
+ // runStrategy/runEntry/runId — using the free-variable
390
+ // conversationId instead would let a stale run's dedup lookup land
391
+ // in the wrong conversation's bucket if the user switches
392
+ // conversations mid-run.
393
+ var runConversationId = payload.conversationId;
394
+ // P10-D1: minted once per run, included in every step payload so
395
+ // the server can echo it into logs beside strategy/entry. Also the
396
+ // namespace for this run's WRITE tool opIds (opId = runId + ":" +
397
+ // call.id, ADR-001 R5).
398
+ var runId = "run-" + Date.now().toString(36) + "-" + (++runIdCounter);
399
+ // CLAUDE-013: accumulates placeholder->real-id mappings across every
400
+ // WRITE-tool call resolved THIS run, so a later call in the same run
401
+ // (e.g. group_nodes) can resolve a placeholder id (e.g. "fp-new-2")
402
+ // that an earlier call (e.g. apply_step) minted via applyInsertions —
403
+ // each WRITE executor's call-local idMap only covers ids it created
404
+ // itself, not ids from a prior call in the same agent loop.
405
+ var runIdMap = {};
406
+ // CLAUDE-027: accumulates the individual RED.history event(s) each
407
+ // WRITE-tool call this run would otherwise have pushed on its own
408
+ // (apply_step/remove_step/rename_node/group_nodes — see
409
+ // applyInsertions/applyModifications in apply-review.js and
410
+ // executeGroupNodesTool in main.js, all of which push into this
411
+ // array instead of RED.history directly whenever it's passed
412
+ // through). Flushed as ONE RED.history entry — via RED.history's own
413
+ // t:"multi" wrapper, confirmed against @node-red/editor-client's
414
+ // red.js (e.g. its deleteSelection(), which collapses a mixed
415
+ // delete+move into one push the exact same way) when there's more
416
+ // than one, or pushed unwrapped when there's exactly one, matching
417
+ // that same core convention — the moment this run actually ends, by
418
+ // flushRunHistory()/onDone/onError below. A run that never calls a
419
+ // WRITE tool (e.g. a pure ask_user round-trip, or read-only chat)
420
+ // leaves this empty, so flush is a no-op and no spurious entry is
421
+ // pushed.
422
+ var runHistoryEvents = [];
307
423
  var step = 0;
308
424
  var totalTokens = 0;
425
+ var askUserRounds = 0;
309
426
  fpAgentStopRequested = false;
427
+ fpCurrentAgentRequest = null;
428
+ // W7: one entry per WRITE tool call resolved THIS runAgentLoop
429
+ // invocation, in order (proceed-and-pass / proceed-and-fail /
430
+ // user-declined all count, read-tool calls don't) — gives
431
+ // handleModifyResult a real 1:1 todo-item correlation instead of
432
+ // the CLAUDE-005 all-together fallback. Attached to the final
433
+ // data as _agentWriteResults right before onDone(data); untouched
434
+ // (empty) whenever no WRITE tool call was made this turn, which
435
+ // keeps the existing all-together path completely unchanged.
436
+ var agentWriteResults = [];
437
+ // CLAUDE-014: plain-language note for the decision that triggered the
438
+ // NEXT agent-step round trip (consent-gate Proceed/Skip, ask_user
439
+ // answer) — set right before the decision resumes the loop, read and
440
+ // cleared by postNextStep so CODEX-012's debug.log can show what the
441
+ // user actually decided instead of only raw tool-result JSON. Only
442
+ // populated when settings.debugLogging is on.
443
+ var pendingDebugNote = null;
444
+
445
+ // CLAUDE-027: pushes this run's accumulated WRITE-tool history
446
+ // event(s) (runHistoryEvents above) to RED.history as ONE entry —
447
+ // coalesced via t:"multi" when more than one WRITE-tool call
448
+ // mutated this run, unwrapped when exactly one did, a no-op when
449
+ // none did. MUST run at every single point this run can end, not
450
+ // just the "clean" success path — a run that errors out (step
451
+ // budget/token ceiling exceeded, user Stop, a failed request) after
452
+ // ALREADY applying one or more WRITE tool calls still needs its
453
+ // partial progress to land in undo history, or Ctrl+Z would be
454
+ // unable to remove mutations that are visibly sitting on the
455
+ // canvas. Rather than call this at each of those call sites
456
+ // individually (easy to miss one), onDone/onError below shadow the
457
+ // real callback params so EVERY exit from this closure flushes
458
+ // first automatically.
459
+ function flushRunHistory() {
460
+ if (!runHistoryEvents.length) { return; }
461
+ if (runHistoryEvents.length === 1) {
462
+ RED.history.push(runHistoryEvents[0]);
463
+ } else {
464
+ RED.history.push({ t: "multi", events: runHistoryEvents.slice() });
465
+ }
466
+ runHistoryEvents = [];
467
+ }
468
+ function onDone(data) { flushRunHistory(); realOnDone(data); }
469
+ function onError(err, xhr) { flushRunHistory(); realOnError(err, xhr); }
470
+
471
+ // P10-D2 (ADR-001 R5): run events on the record store. runRec is
472
+ // created LAZILY, the first time something WRITE-tool/ask_user-
473
+ // worthy happens — never for a plain chat/read-only turn, so this
474
+ // is a no-op for every mode/strategy that never offers WRITE tools
475
+ // (chat, document, build, classic Modify — agentToolsFor only
476
+ // offers WRITE_TOOLS for strategy:"agent" + mode:"modify"). Once
477
+ // created, it's a "todo" record (the same W4 machinery that
478
+ // already survives /refresh via rerenderRecord) so a mid-run
479
+ // refresh finds it; handleModifyResult upgrades it in place with
480
+ // real Plan: items via data._agentRunRecord instead of creating a
481
+ // second record, once the final turn's explanation arrives.
482
+ var runEvents = [];
483
+ var runRec = null;
484
+
485
+ function syncRunMarker(reason) {
486
+ if (!runRec) { return; }
487
+ writeRunMarker({
488
+ runId: runId,
489
+ action: runEntry,
490
+ appliedCount: runEvents.filter(function (e) { return e.t === "applied"; }).length,
491
+ conversationId: runConversationId
492
+ }, reason || "syncRunMarker");
493
+ }
494
+
495
+ function recordRunEvent(t, extra) {
496
+ if (!runRec) {
497
+ runRec = addRecord("todo", { action: runEntry, items: [], events: runEvents });
498
+ }
499
+ runEvents.push(Object.assign({ t: t, at: Date.now() }, extra || {}));
500
+ runRec.events = runEvents;
501
+ if (t === "done") {
502
+ clearRunMarker("run done: " + runId);
503
+ } else {
504
+ syncRunMarker("run event: " + t);
505
+ }
506
+ }
310
507
 
311
508
  function addUsage(usage) {
312
509
  if (usage && typeof usage.total_tokens === "number") {
@@ -316,14 +513,184 @@
316
513
 
317
514
  function fallbackToPlain() {
318
515
  setAgentNarration("Continuing without tools…");
319
- ajaxJson("POST", firstEndpoint, payload, onDone, onError);
516
+ fpCurrentAgentRequest = ajaxJson("POST", firstEndpoint, payload, function (data) {
517
+ fpCurrentAgentRequest = null;
518
+ if (runRec) { recordRunEvent("done", {}); data._agentRunRecord = runRec; }
519
+ onDone(data);
520
+ }, function (err, xhr) {
521
+ fpCurrentAgentRequest = null;
522
+ if (fpAgentStopRequested) {
523
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
524
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
525
+ return;
526
+ }
527
+ if (runRec) { recordRunEvent("interrupted", { detail: "fallback request failed" }); rerenderTodoRecord(runRec); }
528
+ // CLAUDE-023: forward xhr so a genuine parse error's raw
529
+ // response survives to handleExecuteError (via onModifyError)
530
+ // instead of being dropped here — ajaxJson's error callback
531
+ // is (msg, xhr), and onError expects the same shape.
532
+ onError(err, xhr);
533
+ });
534
+ }
535
+
536
+ function postNextStep(nextMessages) {
537
+ setAgentNarration("Thinking… (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
538
+ var stepPayload = Object.assign({
539
+ messages: nextMessages,
540
+ conversationId: payload.conversationId
541
+ }, stepExtra, {
542
+ strategy: runStrategy,
543
+ entry: runEntry,
544
+ runId: runId,
545
+ events: runEvents.slice()
546
+ });
547
+ if (pendingDebugNote) {
548
+ stepPayload.debugNote = pendingDebugNote;
549
+ pendingDebugNote = null;
550
+ }
551
+ fpCurrentAgentRequest = ajaxJson("POST", "flowpilot/agent-step", stepPayload,
552
+ function (stepData) { fpCurrentAgentRequest = null; handleStep(stepData, nextMessages); },
553
+ function (err, xhr) {
554
+ fpCurrentAgentRequest = null;
555
+ if (fpAgentStopRequested) {
556
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
557
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
558
+ return;
559
+ }
560
+ if (runRec) { recordRunEvent("interrupted", { detail: "agent-step request failed" }); rerenderTodoRecord(runRec); }
561
+ // CLAUDE-023: forward xhr — see fallbackToPlain's error
562
+ // callback above for why this must not be dropped.
563
+ onError(err, xhr);
564
+ });
565
+ }
566
+
567
+ // Processes calls[idx..] one at a time. A WRITE-gated call (per
568
+ // toolTiers, classified against SAFE_NODE_TYPES) or an ask_user
569
+ // call PAUSES here — rendering a chip/question and waiting for a
570
+ // user action — instead of executing synchronously like the
571
+ // existing 6 READ tools still do. Once the whole batch is
572
+ // resolved, posts the next step exactly as before.
573
+ function processToolCallsFrom(calls, idx, nextMessages, toolTiers) {
574
+ if (idx >= calls.length) { postNextStep(nextMessages); return; }
575
+
576
+ var call = calls[idx];
577
+ var name = call.function.name;
578
+ var args = parseToolCallArgs(call);
579
+ if (name === "redirect_mode") {
580
+ var redirectResult = executeAgentToolCall(call, runIdMap, runHistoryEvents);
581
+ nextMessages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(redirectResult) });
582
+ if (runRec) {
583
+ recordRunEvent("redirect", { detail: (redirectResult && redirectResult.suggestedAction && redirectResult.suggestedAction.mode) || args.mode || "unknown" });
584
+ recordRunEvent("done", {});
585
+ }
586
+ onDone({
587
+ explanation: (redirectResult && redirectResult.explanation) || "",
588
+ prose: true,
589
+ flow: null,
590
+ suggestedAction: redirectResult && redirectResult.suggestedAction ? redirectResult.suggestedAction : null
591
+ });
592
+ return;
593
+ }
594
+ var isWriteTool = name === "apply_step" || name === "remove_step" || name === "rename_node" || name === "group_nodes";
595
+ // P10-D1: only WRITE tool calls get an opId — idempotency is
596
+ // about preventing double MUTATION, not about read tools.
597
+ var opId = isWriteTool ? (runId + ":" + call.id) : null;
598
+
599
+ // P10-D1: a repeat opId (duplicate delivery, a retry, or the
600
+ // model repeating a call) returns the SAME result without
601
+ // re-invoking the executor — the graph is mutated at most once
602
+ // per opId, checked against the per-conversation applied-ops
603
+ // map before mutating (main.js).
604
+ function runExecutorIdempotent() {
605
+ if (opId) {
606
+ var recorded = getAppliedOp(runConversationId, opId);
607
+ if (recorded) { return recorded; }
608
+ }
609
+ var result = executeAgentToolCall(call, runIdMap, runHistoryEvents);
610
+ // CLAUDE-013: merge this call's own new placeholder->real-id
611
+ // mappings (e.g. apply_step's newNodes) into the run-scoped
612
+ // map so a LATER call this run can resolve the same ids.
613
+ if (result && result.idMap) { Object.assign(runIdMap, result.idMap); }
614
+ if (opId) { recordAppliedOp(runConversationId, opId, result); }
615
+ return result;
616
+ }
617
+
618
+ function continueWithResult(resultObj) {
619
+ var _reason = null;
620
+ if (resultObj && !resultObj.allPass) {
621
+ _reason = resultObj.error || resultObj.reason || null;
622
+ }
623
+ if (isWriteTool) { agentWriteResults.push({ allPass: !!(resultObj && resultObj.allPass), reason: _reason }); }
624
+ nextMessages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(resultObj) });
625
+ processToolCallsFrom(calls, idx + 1, nextMessages, toolTiers);
626
+ }
627
+
628
+ if (name === "ask_user") {
629
+ if (askUserRounds >= AGENT_ASK_USER_MAX_ROUNDTRIPS) {
630
+ continueWithResult({ error: "ask_user budget (" + AGENT_ASK_USER_MAX_ROUNDTRIPS +
631
+ ") exhausted for this turn — proceed with your best judgment or give a final answer." });
632
+ return;
633
+ }
634
+ askUserRounds++;
635
+ setAgentNarration(describeAgentToolCall(name, args));
636
+ recordRunEvent("asked", { detail: args.question });
637
+ renderAskUserQuestion({ question: args.question, options: args.options,
638
+ onAnswer: function (answerText) {
639
+ recordRunEvent("answered", { detail: answerText });
640
+ if (currentSettings.debugLogging) { pendingDebugNote = "user answered: " + answerText; }
641
+ continueWithResult({ answer: answerText });
642
+ } });
643
+ return; // PAUSES here until the question is answered
644
+ }
645
+
646
+ if (isWriteTool) { recordRunEvent("step", { opId: opId, detail: name }); }
647
+
648
+ var tier = toolTiers && toolTiers[call.id];
649
+ if (writeToolCallNeedsConsent(tier, name, args)) {
650
+ if (isWriteTool) { recordRunEvent("consent", { opId: opId, detail: name }); }
651
+ renderAgentToolConsentGate({
652
+ name: name, args: args,
653
+ onResume: function (granted) {
654
+ if (currentSettings.debugLogging) {
655
+ pendingDebugNote = granted ? "user clicked Proceed" : "user clicked Skip this step";
656
+ }
657
+ if (!granted) {
658
+ continueWithResult({ skipped: true, reason: "user declined — this call was not applied" });
659
+ return;
660
+ }
661
+ setAgentNarration(describeAgentToolCall(name, args) + " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
662
+ var result = runExecutorIdempotent();
663
+ if (isWriteTool) {
664
+ recordRunEvent("applied", { opId: opId });
665
+ recordRunEvent("verified", { opId: opId, detail: !!(result && result.allPass) });
666
+ }
667
+ continueWithResult(result);
668
+ }
669
+ });
670
+ return; // PAUSES here until Proceed/Skip is clicked
671
+ }
672
+
673
+ setAgentNarration(describeAgentToolCall(name, args) + " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
674
+ var autoResult = runExecutorIdempotent();
675
+ if (isWriteTool) {
676
+ recordRunEvent("applied", { opId: opId });
677
+ recordRunEvent("verified", { opId: opId, detail: !!(autoResult && autoResult.allPass) });
678
+ }
679
+ continueWithResult(autoResult);
320
680
  }
321
681
 
322
682
  function handleStep(data, messages) {
323
683
  addUsage(data.usage);
324
684
 
685
+ if (data.fallbackToClassic) {
686
+ fallbackToPlain();
687
+ return;
688
+ }
689
+
325
690
  if (!data.toolCalls || !data.toolCalls.length) {
326
691
  if (step > 0) { addAgentStatsNote(step, totalTokens); }
692
+ if (agentWriteResults.length) { data._agentWriteResults = agentWriteResults.slice(); }
693
+ if (runRec) { recordRunEvent("done", {}); data._agentRunRecord = runRec; }
327
694
  onDone(data);
328
695
  return;
329
696
  }
@@ -336,50 +703,76 @@
336
703
  return;
337
704
  }
338
705
 
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;
706
+ // An ask_user-ONLY batch doesn't consume the real step budget —
707
+ // see AGENT_ASK_USER_MAX_ROUNDTRIPS above. A batch mixing
708
+ // ask_user with any other tool call counts normally, since real
709
+ // work happened in it too.
710
+ var isAskUserOnlyBatch = data.toolCalls.every(function (c) { return c.function.name === "ask_user"; });
711
+ if (!isAskUserOnlyBatch) {
712
+ step++;
713
+ if (step > AGENT_LOOP_MAX_STEPS) {
714
+ if (runRec) { recordRunEvent("interrupted", { detail: "step budget exceeded" }); rerenderTodoRecord(runRec); }
715
+ onError("FlowPilot stopped after " + AGENT_LOOP_MAX_STEPS +
716
+ " tool call(s) without a final answer. Try breaking your " +
717
+ "request into smaller steps, or be more specific about " +
718
+ "which node(s) or flow you mean.");
719
+ return;
720
+ }
346
721
  }
347
722
  if (totalTokens > AGENT_LOOP_TOKEN_CEILING) {
723
+ if (runRec) { recordRunEvent("interrupted", { detail: "token ceiling exceeded" }); rerenderTodoRecord(runRec); }
348
724
  onError("FlowPilot stopped after using " + totalTokens +
349
- " tokens on this turn without a final answer. Try " +
350
- "selecting fewer nodes, or asking a more specific " +
351
- "question so fewer tool calls are needed.");
725
+ " tokens on this turn (limit: " + AGENT_LOOP_TOKEN_CEILING +
726
+ ") without a final answer. Try selecting fewer nodes, asking a " +
727
+ "more specific question, or raising \"Max total tokens per " +
728
+ "agent turn\" in Settings → Behavior.");
352
729
  return;
353
730
  }
354
731
  if (fpAgentStopRequested) {
732
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
355
733
  onError("Stopped after " + (step - 1) + " tool call step(s) at your request.");
356
734
  return;
357
735
  }
358
736
 
359
737
  var nextMessages = (messages || data.messages || []).slice();
360
738
  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);
739
+ processToolCallsFrom(data.toolCalls, 0, nextMessages, data.toolTiers);
377
740
  }
378
741
 
379
- var firstPayload = Object.assign({}, payload, { tools: true });
380
- ajaxJson("POST", firstEndpoint, firstPayload, function (data) {
742
+ var firstPayload = Object.assign({}, payload, { tools: true, runId: runId });
743
+ fpCurrentAgentRequest = ajaxJson("POST", firstEndpoint, firstPayload, function (data) {
744
+ fpCurrentAgentRequest = null;
381
745
  handleStep(data, null);
382
- }, function () {
746
+ }, function (err, xhr) {
747
+ fpCurrentAgentRequest = null;
748
+ if (fpAgentStopRequested) {
749
+ // CLAUDE-022: an abort of the FIRST request must actually
750
+ // stop, not fallbackToPlain() — falling back would ignore
751
+ // the stop and keep going without tools instead.
752
+ if (runRec) { recordRunEvent("interrupted", { detail: "stopped by user" }); rerenderTodoRecord(runRec); }
753
+ onError("Stopped after " + Math.max(0, step - 1) + " tool call step(s) at your request.");
754
+ return;
755
+ }
756
+ // CLAUDE-023: fallbackToPlain() must only fire for failures that
757
+ // actually look like "the provider doesn't support tools" — a
758
+ // genuine parse/validation failure from processGenerationContent
759
+ // (flowpilot.js) always comes back as HTTP 422 with a .raw
760
+ // payload attached (see err.status = 422 / err.raw = content
761
+ // there). A provider-level failure of the tools:true call itself
762
+ // (unrecognized "tools" field, model swapped since the last
763
+ // probe, etc.) never goes through that parser, so it always
764
+ // reaches here as some other status with no .raw. Only the
765
+ // latter should be silently retried without tools; the former is
766
+ // a real failure and must surface as one so handleExecuteError
767
+ // (via onModifyError) gets a chance to show it — including its
768
+ // raw JSON in Debug mode.
769
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
770
+ var looksLikeParseFailure = !!raw || (xhr && xhr.status === 422);
771
+ if (looksLikeParseFailure) {
772
+ if (runRec) { recordRunEvent("interrupted", { detail: "first agentic request failed" }); rerenderTodoRecord(runRec); }
773
+ onError(err, xhr);
774
+ return;
775
+ }
383
776
  // The provider was probed as
384
777
  // supportsTools, but the very first tools:true request failed
385
778
  // outright — e.g. the model was swapped since the last probe, or
@@ -622,8 +1015,14 @@
622
1015
  // flow; show the raw so the user can see what happened.
623
1016
  function handleExecuteError(msg, raw) {
624
1017
  popDanglingUserHistory();
625
- addMessage("error", msg);
626
- if (raw) { addGeneratedJson(raw, true); }
1018
+ if (raw && currentSettings.debugLogging) {
1019
+ addMessage("error", msg);
1020
+ addGeneratedJson(raw, true);
1021
+ } else if (raw) {
1022
+ addMessage("error", msg + " Enable Debug mode in Settings to see the full response next time.");
1023
+ } else {
1024
+ addMessage("error", msg);
1025
+ }
627
1026
  setBusy(false);
628
1027
  }
629
1028
 
@@ -632,6 +1031,28 @@
632
1031
  function applySuggestedAction(suggestedAction) {
633
1032
  if (!suggestedAction || !suggestedAction.mode || !suggestedAction.prompt) { return; }
634
1033
  armExecuteAction(suggestedAction.mode);
1034
+ // CLAUDE-015: the model already identified which nodes this action
1035
+ // targets (including "the whole flow" as a valid identification,
1036
+ // not an exemption) — override whatever armExecuteAction's own
1037
+ // pinCurrentSelection() pinned from the live canvas selection, the
1038
+ // same pinnedSelectionIds every downstream consumer (Document,
1039
+ // Modify, their guards, their context builders) already reads via
1040
+ // activeSelectionIds(). Absent targetNodeIds: unchanged, today's
1041
+ // behavior. Mirrors pinCurrentSelection()'s own rule of only
1042
+ // overwriting on a non-empty result, so a bad/empty resolution
1043
+ // doesn't blow away a legitimate live-selection pin.
1044
+ if (suggestedAction.targetNodeIds === "all") {
1045
+ var allTabIds = allActiveTabNodeIds();
1046
+ if (allTabIds.length) { pinnedSelectionIds = allTabIds; }
1047
+ } else if (suggestedAction.targetNodeIds === "instance") {
1048
+ var allInstanceIds = allInstanceNodeIds();
1049
+ if (allInstanceIds.length) { pinnedSelectionIds = allInstanceIds; }
1050
+ } else if (Array.isArray(suggestedAction.targetNodeIds)) {
1051
+ var resolvedIds = suggestedAction.targetNodeIds.filter(function (id) {
1052
+ return !!findLiveNode(id);
1053
+ });
1054
+ if (resolvedIds.length) { pinnedSelectionIds = resolvedIds; }
1055
+ }
635
1056
  var $promptBox = el("#fp-prompt");
636
1057
  if ($promptBox.length) {
637
1058
  $promptBox.val(suggestedAction.prompt);
@@ -660,11 +1081,23 @@
660
1081
  var isChatMode = suggestedAction.mode === "chat";
661
1082
  var titleText = suggestedAction.customTitle || (isChatMode ? "Switch to Chat" : "Cleared for takeoff — " + modeLabel);
662
1083
 
1084
+ // CLAUDE-028: the record must exist before the button does, so the
1085
+ // button can carry data-fp-record-id/-action — the same relay
1086
+ // markers renderAskUserQuestion's quick-reply buttons use to
1087
+ // survive the pop-out's innerHTML clone (bindRecordActionButtons
1088
+ // in init.js rebinds by these attributes and relays the click back
1089
+ // to this window via resolveRecordAction; this window is the only
1090
+ // one with live suggestedAction data to act on). The direct click
1091
+ // handler below still fires normally for the un-popped-out sidebar.
1092
+ var rec = addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
1093
+
663
1094
  var $row = $("<div>").addClass("fp-chip-row");
664
1095
  var $card = $("<button>")
665
1096
  .addClass("fp-chip fp-chip-card")
666
1097
  .attr("type", "button")
667
1098
  .attr("title", suggestedAction.prompt)
1099
+ .attr("data-fp-record-id", rec.id)
1100
+ .attr("data-fp-record-action", "apply-suggested-action")
668
1101
  .on("click", function () { applySuggestedAction(suggestedAction); });
669
1102
  $("<span>").addClass("fp-chip-icon")
670
1103
  .append($("<i>").addClass(isChatMode ? "fa fa-comment" : "fa fa-paper-plane"))
@@ -675,12 +1108,28 @@
675
1108
  $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($card);
676
1109
  $card.appendTo($row);
677
1110
 
678
- if (suggestedAction.selectionHint) {
679
- $("<div>").addClass("fp-chip-hint").text("Tip: " + suggestedAction.selectionHint).appendTo($row);
1111
+ // CLAUDE-030: Document mode always requires a real, non-empty
1112
+ // selection at Send time (server-side: describeSelectionContext
1113
+ // returns null and /flowpilot/document 400s on "Select the node(s)
1114
+ // you want documented first" whenever nothing is actually selected)
1115
+ // — but the model has occasionally generated a selectionHint
1116
+ // implying otherwise (e.g. "leave nothing selected to document...
1117
+ // general Node-RED info") when it had no concrete node to name.
1118
+ // Rather than re-word the model's already-fragile mode-mismatch
1119
+ // prompt guidance under time pressure, override deterministically
1120
+ // here: if this chip targets Document and doesn't carry a real
1121
+ // resolved target, always show the one hint that's actually true.
1122
+ var targetResolved = suggestedAction.targetNodeIds === "all" ||
1123
+ suggestedAction.targetNodeIds === "instance" ||
1124
+ (Array.isArray(suggestedAction.targetNodeIds) && suggestedAction.targetNodeIds.length > 0);
1125
+ var hintText = (suggestedAction.mode === "document" && !targetResolved)
1126
+ ? "Select the node(s) you want documented first."
1127
+ : suggestedAction.selectionHint;
1128
+ if (hintText) {
1129
+ $("<div>").addClass("fp-chip-hint").text("Tip: " + hintText).appendTo($row);
680
1130
  }
681
1131
 
682
1132
  $box.append($row);
683
- addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
684
1133
  scrollMessagesToBottom();
685
1134
  }
686
1135
 
@@ -772,6 +1221,28 @@
772
1221
  scrollMessagesToBottom();
773
1222
  }
774
1223
 
1224
+ // Detects the model's own tool-call envelope leaking through as
1225
+ // "prose" — e.g. a vague first-turn Modify reply the server marked
1226
+ // prose:true but whose explanation is actually the raw
1227
+ // {"explanation":...,"changes":[...]} JSON as literal text. Starting
1228
+ // with "{" alone doesn't make text suspect (real prose can open with
1229
+ // a brace), so this also requires it to parse as an object carrying
1230
+ // one of FlowPilot's own envelope keys.
1231
+ function looksLikeToolEnvelope(text) {
1232
+ if (typeof text !== "string") { return false; }
1233
+ var trimmed = text.trim();
1234
+ if (trimmed.charAt(0) !== "{") { return false; }
1235
+ var parsed;
1236
+ try {
1237
+ parsed = JSON.parse(trimmed);
1238
+ } catch (e) {
1239
+ return false;
1240
+ }
1241
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return false; }
1242
+ return ["changes", "newNodes", "newWires", "removeNodes", "newGroups", "explanation"]
1243
+ .some(function (key) { return Object.prototype.hasOwnProperty.call(parsed, key); });
1244
+ }
1245
+
775
1246
  // Shared by Generate/Document/Modify result handlers: renders the model's
776
1247
  // clarifying-question or prose-only envelope as a normal assistant
777
1248
  // message and leaves the action armed for a follow-up. Returns true if it
@@ -789,6 +1260,11 @@
789
1260
  return true;
790
1261
  }
791
1262
  if (data.prose) {
1263
+ if (looksLikeToolEnvelope(data.explanation)) {
1264
+ handleExecuteError("FlowPilot's reply didn't come through as expected.", data.explanation);
1265
+ updateSelectionStatus();
1266
+ return true;
1267
+ }
792
1268
  addMessage("assistant", data.explanation || "(no content returned)");
793
1269
  pushHistory("assistant", data.explanation || "");
794
1270
  renderActionChip(data.suggestedAction);
@@ -810,7 +1286,34 @@
810
1286
 
811
1287
  // Lay nodes out before review/import — see layoutGeneratedFlow for why.
812
1288
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
813
- addMessage("assistant", data.explanation || "(no explanation returned)");
1289
+ var planItems = parseTodoPlan(data.explanation || "");
1290
+ var todoRec = null;
1291
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
1292
+ if (writeResults.length) {
1293
+ if (!planItems.length) {
1294
+ planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
1295
+ }
1296
+ planItems.forEach(function (item, i) {
1297
+ item.status = (i < writeResults.length)
1298
+ ? (writeResults[i].allPass ? "done" : "failed")
1299
+ : "active";
1300
+ });
1301
+ if (data._agentRunRecord) {
1302
+ todoRec = data._agentRunRecord;
1303
+ todoRec.action = "generate";
1304
+ todoRec.items = planItems;
1305
+ } else {
1306
+ todoRec = addRecord("todo", { action: "generate", items: planItems });
1307
+ }
1308
+ rerenderTodoRecord(todoRec);
1309
+ var deterministicSummary = buildDeterministicRunSummary(planItems, writeResults);
1310
+ addMessage("assistant", deterministicSummary || "(no explanation returned)");
1311
+ if (shouldShowSecondaryExplanation(deterministicSummary, data.explanation)) {
1312
+ addMessage("fp-notice", data.explanation);
1313
+ }
1314
+ } else {
1315
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1316
+ }
814
1317
  pushHistory("assistant", data.explanation || "(no explanation returned)");
815
1318
  // B1: bake the deploy-verify option into the review panel as the
816
1319
  // primary chip rather than a separate chip below it. Only for
@@ -821,7 +1324,9 @@
821
1324
  var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
822
1325
  ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
823
1326
  : null;
824
- addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
1327
+ if (!writeResults.length) {
1328
+ addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
1329
+ }
825
1330
  // Suppress a server-suggested build chip when deploy-verify is already
826
1331
  // the primary action inside the review panel — it would be a duplicate.
827
1332
  renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
@@ -839,17 +1344,69 @@
839
1344
  // W4: parse and surface the Plan: block if present.
840
1345
  var planItems = parseTodoPlan(data.explanation || "");
841
1346
  var todoRec = null;
1347
+ // W7: real per-item correlation, and (CLAUDE-011) the signal that
1348
+ // real mutations already happened this turn via WRITE tool calls
1349
+ // (apply_step/remove_step/rename_node/group_nodes) — each already
1350
+ // consent-gated (or, for group_nodes' write-safe tier, always
1351
+ // auto-applied) and applied through its own review chip. Hoisted
1352
+ // above the planItems.length check (rather than declared inside
1353
+ // it) so it's always defined below even on a turn with no Plan:
1354
+ // block, e.g. a wrap-up-only final response after tool calls.
1355
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
842
1356
  if (planItems.length) {
843
- // Verification only produces one aggregate pass/fail result for
844
- // the whole Modify responsemark every item active up front so
845
- // they all resolve together instead of leaving items 2+ stuck at
846
- // "pending" forever (only item 1 would ever flip otherwise).
847
- planItems.forEach(function (item) { item.status = "active"; });
848
- todoRec = addRecord("todo", { action: "modify", items: planItems });
1357
+ // Each WRITE tool call this turn resolves ONE plan item, in
1358
+ // order (§16 point 2"one WRITE tool call = one plan/todo
1359
+ // item"). This is the deferred half of CLAUDE-005 (c6c2e84),
1360
+ // now unblocked: that fix could only mark the WHOLE plan
1361
+ // active/resolved together because there was no way to know
1362
+ // which check proved which numbered line a per-call tool
1363
+ // result finally gives that mapping for free. Items beyond the
1364
+ // resolved-call count (or ALL items, when no WRITE tool call
1365
+ // was made this turn — the default, non-agentic-write path)
1366
+ // fall back to CLAUDE-005's original all-together marking
1367
+ // below, resolved via the ordinary aggregate verifySteps
1368
+ // envelope path exactly as before — confirmed unchanged when
1369
+ // _agentWriteResults is empty/absent.
1370
+ if (writeResults.length) {
1371
+ planItems.forEach(function (item, i) {
1372
+ item.status = (i < writeResults.length)
1373
+ ? (writeResults[i].allPass ? "done" : "failed")
1374
+ : "active";
1375
+ });
1376
+ } else {
1377
+ // Verification only produces one aggregate pass/fail result
1378
+ // for the whole Modify response — mark every item active up
1379
+ // front so they all resolve together instead of leaving
1380
+ // items 2+ stuck at "pending" forever (only item 1 would
1381
+ // ever flip otherwise).
1382
+ planItems.forEach(function (item) { item.status = "active"; });
1383
+ }
1384
+ // P10-D2: if this run already has a live "todo" record from
1385
+ // WRITE-tool events (runAgentLoop, modes.js), upgrade it in
1386
+ // place with the real Plan: items instead of creating a
1387
+ // second record for the same run — so a run that paused
1388
+ // mid-flight (refresh, interrupted) and one that completed
1389
+ // normally both end up as ONE record with both events and
1390
+ // items.
1391
+ if (data._agentRunRecord) {
1392
+ todoRec = data._agentRunRecord;
1393
+ todoRec.action = "modify";
1394
+ todoRec.items = planItems;
1395
+ } else {
1396
+ todoRec = addRecord("todo", { action: "modify", items: planItems });
1397
+ }
849
1398
  rerenderTodoRecord(todoRec);
850
1399
  }
851
1400
 
852
- addMessage("assistant", data.explanation || "(no explanation returned)");
1401
+ if (writeResults.length) {
1402
+ var deterministicModifySummary = buildDeterministicRunSummary(planItems, writeResults);
1403
+ addMessage("assistant", deterministicModifySummary || "(no explanation returned)");
1404
+ if (shouldShowSecondaryExplanation(deterministicModifySummary, data.explanation)) {
1405
+ addMessage("fp-notice", data.explanation);
1406
+ }
1407
+ } else {
1408
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1409
+ }
853
1410
  pushHistory("assistant", data.explanation || "(no explanation returned)");
854
1411
  if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
855
1412
 
@@ -870,7 +1427,16 @@
870
1427
  }
871
1428
  } : applyModifications;
872
1429
 
873
- addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
1430
+ // CLAUDE-011: agentWriteRules tells the model to omit changes/
1431
+ // newNodes/newWires/removeNodes/newGroups on this final response
1432
+ // when writeResults is non-empty, so already-applied work isn't
1433
+ // proposed a second time — but the model doesn't always comply.
1434
+ // Don't rely on the prompt alone: skip rendering a second review/
1435
+ // apply flow whenever real WRITE-tool work happened this turn,
1436
+ // regardless of what the final envelope contains.
1437
+ if (!writeResults.length) {
1438
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
1439
+ }
874
1440
  renderActionChip(data.suggestedAction);
875
1441
  setBusy(false);
876
1442
  updateSelectionStatus();
@@ -1041,13 +1607,17 @@
1041
1607
 
1042
1608
  var ap = activeProvider();
1043
1609
  var isAgentLoop = ap && ap.supportsTools;
1610
+ var useAgentStrategy = isAgentLoop && endpointName === "generate" &&
1611
+ currentSettings.enableAgentWrite === true;
1044
1612
 
1045
1613
  setBusy(true);
1046
1614
  showPending(isAgentLoop);
1047
1615
  var payload = {
1048
1616
  prompt: prompt, context: context,
1049
1617
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1050
- conversationId: conversationId
1618
+ conversationId: conversationId,
1619
+ strategy: useAgentStrategy ? "agent" : "classic",
1620
+ entry: endpointName
1051
1621
  };
1052
1622
 
1053
1623
  function onError(msg, xhr) {
@@ -1111,6 +1681,40 @@
1111
1681
  return items;
1112
1682
  }
1113
1683
 
1684
+ function buildDeterministicRunSummary(planItems, writeResults) {
1685
+ var lines = [];
1686
+ var unreached = 0;
1687
+ var items = planItems || [];
1688
+ var counted = Math.max(items.length, (writeResults || []).length);
1689
+ for (var i = 0; i < counted; i++) {
1690
+ var item = items[i];
1691
+ if (item && (i >= writeResults.length || item.status === "active" || item.status === "pending")) {
1692
+ unreached++;
1693
+ continue;
1694
+ }
1695
+ var itemText = (item && item.text) ? item.text : ("Step " + (i + 1));
1696
+ if (writeResults[i] && writeResults[i].allPass) {
1697
+ lines.push("✓ " + itemText);
1698
+ continue;
1699
+ }
1700
+ var reason = (writeResults[i] && writeResults[i].reason) || "failed verification";
1701
+ lines.push("✗ " + itemText + " — " + reason);
1702
+ }
1703
+ if (unreached) {
1704
+ lines.push("▶ " + unreached + " step(s) not reached");
1705
+ }
1706
+ return lines.join("\n");
1707
+ }
1708
+
1709
+ function shouldShowSecondaryExplanation(summaryText, explanationText) {
1710
+ if (!explanationText || typeof explanationText !== "string") { return false; }
1711
+ if (!summaryText || typeof summaryText !== "string") { return true; }
1712
+ function normalize(text) {
1713
+ return String(text || "").replace(/\s+/g, " ").trim().toLowerCase();
1714
+ }
1715
+ return normalize(summaryText) !== normalize(explanationText);
1716
+ }
1717
+
1114
1718
  // W4: render or re-render a "todo" record. For a 1-item plan, renders
1115
1719
  // as a compact status line (one chip). For N>1 items, renders as a
1116
1720
  // checklist card. Updates in place when the record already has a
@@ -1147,6 +1751,25 @@
1147
1751
  $wrap.append($ul);
1148
1752
  }
1149
1753
 
1754
+ // P10-D2 (ADR-001 R5): a run whose last recorded event isn't "done"
1755
+ // was abandoned mid-flight — an explicit interruption (stop button,
1756
+ // step/token budget) or an orphaned request that never returned.
1757
+ // Freeze whatever checklist state exists (possibly none yet, if
1758
+ // interrupted before any Plan: text arrived) and say so plainly,
1759
+ // replacing the old silent-death behavior ("Continuing without
1760
+ // tools…" then nothing). "step N" counts completed (applied) WRITE
1761
+ // tool calls, not raw round-trips.
1762
+ if (Array.isArray(rec.events) && rec.events.length) {
1763
+ var lastEvent = rec.events[rec.events.length - 1];
1764
+ if (lastEvent.t !== "done") {
1765
+ var appliedCount = rec.events.filter(function (e) { return e.t === "applied"; }).length;
1766
+ $("<div>").addClass("fp-todo-interrupted").text(
1767
+ "⚠ This run was interrupted after step " + appliedCount +
1768
+ " — completed steps are applied (Ctrl+Z to undo). Re-send to continue from here."
1769
+ ).appendTo($wrap);
1770
+ }
1771
+ }
1772
+
1150
1773
  if ($existing.length) {
1151
1774
  $existing.replaceWith($wrap);
1152
1775
  } else {
@@ -1193,11 +1816,11 @@
1193
1816
  if (liveNode.type === "comment" || liveNode.type === "group") { return; }
1194
1817
  if (liveNode._def && liveNode._def.category === "config") {
1195
1818
  configTotal++;
1196
- if (RED.nodes.node(liveNode.id)) { configFound++; }
1819
+ if (nodeExists(liveNode.id)) { configFound++; }
1197
1820
  return;
1198
1821
  }
1199
1822
  total++;
1200
- if (RED.nodes.node(liveNode.id)) {
1823
+ if (nodeExists(liveNode.id)) {
1201
1824
  found++;
1202
1825
  } else {
1203
1826
  missing.push(liveNode.type || pid);
@@ -1240,58 +1863,77 @@
1240
1863
  // assigns it and returns idMap). Property/absent checks already use
1241
1864
  // real existing-node ids, so idMap[id] simply misses and falls through
1242
1865
  // to the id unchanged.
1243
- function verifyModifySteps(verifySteps, idMap, todoRec) {
1244
- if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
1866
+ // One check-vocabulary evaluation (property/exists/absent/wire), shared
1867
+ // by verifyModifySteps (aggregate Modify verification) and W7's
1868
+ // per-tool-call check results (runChecksForToolResult, main.js) — same
1869
+ // approach, applied either to the whole verifySteps batch or scoped to
1870
+ // one WRITE tool call's own touched id(s). Returns null for an
1871
+ // unrecognized check type (caller should skip it, not count it either
1872
+ // way) or { ok, label } where label is the human-readable failure
1873
+ // description used in the aggregate "did not land as expected" message.
1874
+ // P10-E: each case delegates to graph-truth.js (same closure) — the
1875
+ // one implementation of graph truth, per ADR-005. Wire checks in
1876
+ // particular must never fall back to reading node.wires: RED.nodes.
1877
+ // addLink/removeLink never re-sync a live node's own .wires array
1878
+ // mid-session, so it's stale for anything added/removed this session.
1879
+ function runSingleVerifyCheck(step, idMap) {
1245
1880
  idMap = idMap || {};
1246
1881
  function resolve(id) { return (idMap && idMap[id]) || id; }
1247
-
1248
- var total = 0, passed = 0, failures = [];
1249
- verifySteps.forEach(function (step) {
1250
- var ok = false;
1251
- switch (step.check) {
1252
- case "property": {
1253
- var pNode = RED.nodes.node(resolve(step.nodeId));
1254
- ok = !!pNode && pNode[step.prop] === step.expected;
1255
- if (!ok) { failures.push((step.prop || "property") + " on " + step.nodeId); }
1256
- break;
1257
- }
1258
- case "exists": {
1259
- ok = !!RED.nodes.node(resolve(step.nodeId));
1260
- if (!ok) { failures.push(step.nodeId + " missing"); }
1261
- break;
1262
- }
1263
- case "absent": {
1264
- ok = !RED.nodes.node(resolve(step.nodeId));
1265
- if (!ok) { failures.push(step.nodeId + " still present"); }
1266
- break;
1267
- }
1268
- case "wire": {
1269
- // Read from the live link registry (RED.nodes.eachLink), not
1270
- // node.wires — RED.nodes.addLink/removeLink never re-sync a
1271
- // live node's own .wires array mid-session (it's only set at
1272
- // import and recomputed at export), so fromNode.wires[port] is
1273
- // stale for any wire added/removed during the current editing
1274
- // session. Mirrors computeWireDiff (apply-review.js).
1275
- var fromId = resolve(step.fromId);
1276
- var toId = resolve(step.toId);
1277
- var port = step.fromPort || 0;
1278
- ok = false;
1279
- RED.nodes.eachLink(function (l) {
1280
- if (ok) { return; }
1281
- if (l.source && l.source.id === fromId &&
1282
- (l.sourcePort || 0) === port &&
1283
- l.target && l.target.id === toId) {
1284
- ok = true;
1285
- }
1286
- });
1287
- if (!ok) { failures.push("wire " + step.fromId + " → " + step.toId); }
1288
- break;
1882
+ switch (step.check) {
1883
+ case "property": {
1884
+ var okP = propertyEquals(resolve(step.nodeId), step.prop, step.expected);
1885
+ var labelP = (step.prop || "property") + " on " + step.nodeId;
1886
+ if (!okP) {
1887
+ var readP = readProperty(resolve(step.nodeId), step.prop);
1888
+ labelP += readP.exists ?
1889
+ " (expected " + JSON.stringify(step.expected) + ", found " + JSON.stringify(readP.value) + ")" :
1890
+ " (node does not exist)";
1289
1891
  }
1290
- default:
1291
- return; // unrecognized check type — don't count it either way
1892
+ return { ok: okP, label: labelP };
1893
+ }
1894
+ case "exists": {
1895
+ var okE = nodeExists(resolve(step.nodeId));
1896
+ return { ok: okE, label: step.nodeId + " missing" };
1897
+ }
1898
+ case "absent": {
1899
+ var okA = nodeAbsent(resolve(step.nodeId));
1900
+ return { ok: okA, label: step.nodeId + " still present" };
1901
+ }
1902
+ case "wire": {
1903
+ var fromId = resolve(step.fromId);
1904
+ var toId = resolve(step.toId);
1905
+ var port = step.fromPort || 0;
1906
+ var okW = wireExists(fromId, port, toId);
1907
+ var labelW = "wire " + step.fromId + " → " + step.toId;
1908
+ if (!okW) {
1909
+ var fromExists = nodeExists(fromId);
1910
+ var toExists = nodeExists(toId);
1911
+ if (!fromExists && !toExists) {
1912
+ labelW += " (neither node was ever created)";
1913
+ } else if (!fromExists) {
1914
+ labelW += " (" + step.fromId + " was never created)";
1915
+ } else if (!toExists) {
1916
+ labelW += " (" + step.toId + " was never created)";
1917
+ } else {
1918
+ labelW += " (both nodes exist but aren't connected)";
1919
+ }
1292
1920
  }
1921
+ return { ok: okW, label: labelW };
1922
+ }
1923
+ default:
1924
+ return null; // unrecognized check type — don't count it either way
1925
+ }
1926
+ }
1927
+
1928
+ function verifyModifySteps(verifySteps, idMap, todoRec) {
1929
+ if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
1930
+
1931
+ var total = 0, passed = 0, failures = [], failedSteps = [];
1932
+ verifySteps.forEach(function (step) {
1933
+ var result = runSingleVerifyCheck(step, idMap);
1934
+ if (!result) { return; }
1293
1935
  total++;
1294
- if (ok) { passed++; }
1936
+ if (result.ok) { passed++; } else { failures.push(result.label); failedSteps.push(step); }
1295
1937
  });
1296
1938
 
1297
1939
  if (total === 0) { return; }
@@ -1301,6 +1943,25 @@
1301
1943
  } else {
1302
1944
  addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
1303
1945
  failures.length + " did not land as expected (" + failures.join(", ") + ").");
1946
+ // CLAUDE-016: offer a "Fix this" chip naming exactly the nodes
1947
+ // involved in the failed checks, reusing CLAUDE-015/CODEX-014's
1948
+ // targetNodeIds auto-select mechanism rather than a new
1949
+ // retry/loop. User-initiated only — the chip pre-fills a Modify
1950
+ // prompt but still requires the user to review and click Send,
1951
+ // same as every other suggestedAction chip.
1952
+ function resolve(id) { return (idMap && idMap[id]) || id; }
1953
+ var targetNodeIds = [];
1954
+ failedSteps.forEach(function (step) {
1955
+ var ids = step.check === "wire" ? [resolve(step.fromId), resolve(step.toId)] : [resolve(step.nodeId)];
1956
+ ids.forEach(function (id) {
1957
+ if (id && targetNodeIds.indexOf(id) === -1) { targetNodeIds.push(id); }
1958
+ });
1959
+ });
1960
+ renderActionChip({
1961
+ mode: "modify",
1962
+ prompt: "Fix the following change(s) that didn't land as expected: " + failures.join(", "),
1963
+ targetNodeIds: targetNodeIds
1964
+ });
1304
1965
  }
1305
1966
  if (todoRec && todoRec.items) {
1306
1967
  todoRec.items.forEach(function (item) {
@@ -1314,6 +1975,7 @@
1314
1975
  hidePending();
1315
1976
  if (renderQuestionOrProse(data)) { return; }
1316
1977
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1978
+ var writeResults = Array.isArray(data._agentWriteResults) ? data._agentWriteResults : [];
1317
1979
 
1318
1980
  // Build the todo plan. Parse "Plan:" from explanation if present;
1319
1981
  // fall back to an implicit single item from the goal prompt.
@@ -1323,11 +1985,34 @@
1323
1985
  }
1324
1986
  // Same aggregate-verification reasoning as the Modify path: mark
1325
1987
  // every item active up front so a multi-item plan resolves together.
1326
- planItems.forEach(function (item) { item.status = "active"; });
1327
- var todoRec = addRecord("todo", { action: "generate", items: planItems });
1988
+ if (writeResults.length) {
1989
+ planItems.forEach(function (item, i) {
1990
+ item.status = (i < writeResults.length)
1991
+ ? (writeResults[i].allPass ? "done" : "failed")
1992
+ : "active";
1993
+ });
1994
+ } else {
1995
+ planItems.forEach(function (item) { item.status = "active"; });
1996
+ }
1997
+ var todoRec;
1998
+ if (data._agentRunRecord) {
1999
+ todoRec = data._agentRunRecord;
2000
+ todoRec.action = "generate";
2001
+ todoRec.items = planItems;
2002
+ } else {
2003
+ todoRec = addRecord("todo", { action: "generate", items: planItems });
2004
+ }
1328
2005
  rerenderTodoRecord(todoRec);
1329
2006
 
1330
- addMessage("assistant", data.explanation || "(no explanation returned)");
2007
+ if (writeResults.length) {
2008
+ var deterministicQueueSummary = buildDeterministicRunSummary(planItems, writeResults);
2009
+ addMessage("assistant", deterministicQueueSummary || "(no explanation returned)");
2010
+ if (shouldShowSecondaryExplanation(deterministicQueueSummary, data.explanation)) {
2011
+ addMessage("fp-notice", data.explanation);
2012
+ }
2013
+ } else {
2014
+ addMessage("assistant", data.explanation || "(no explanation returned)");
2015
+ }
1331
2016
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1332
2017
  // B1: when a build loop is appropriate, bake deploy-verify into the
1333
2018
  // primary chip (same as handleSimpleGenerationResult). The callback
@@ -1342,7 +2027,9 @@
1342
2027
  startBuildLoop(goalPrompt, flow, importResult);
1343
2028
  }
1344
2029
  : function (importResult) { verifyImportedNodes(importResult, todoRec); };
1345
- addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
2030
+ if (!writeResults.length) {
2031
+ addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
2032
+ }
1346
2033
  // Suppress a server-suggested build chip when deploy-verify is already
1347
2034
  // the primary action inside the review panel.
1348
2035
  renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
@@ -1410,7 +2097,9 @@
1410
2097
  var payload = {
1411
2098
  prompt: instruction, context: context,
1412
2099
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1413
- conversationId: conversationId
2100
+ conversationId: conversationId,
2101
+ strategy: "classic",
2102
+ entry: "build-existing"
1414
2103
  };
1415
2104
 
1416
2105
  function onBuildExistingError(msg, xhr) {
@@ -1543,12 +2232,16 @@
1543
2232
  $("<button>")
1544
2233
  .addClass("fp-consent-chip fp-consent-chip-primary")
1545
2234
  .attr("type", "button")
2235
+ .attr("data-fp-record-id", _rec.id)
2236
+ .attr("data-fp-record-action", "proceed")
1546
2237
  .text("Auto-verify")
1547
2238
  .on("click", function () { decide(true); })
1548
2239
  .appendTo($row);
1549
2240
  $("<button>")
1550
2241
  .addClass("fp-consent-chip fp-consent-chip-alt")
1551
2242
  .attr("type", "button")
2243
+ .attr("data-fp-record-id", _rec.id)
2244
+ .attr("data-fp-record-action", "skip")
1552
2245
  .text("I'll check myself")
1553
2246
  .on("click", function () { decide(false); })
1554
2247
  .appendTo($row);
@@ -1586,6 +2279,209 @@
1586
2279
  renderActionChip(src.suggestedAction);
1587
2280
  }
1588
2281
 
2282
+ // W7 — per-call consent gate for a write-gated agent tool call
2283
+ // (apply_step/remove_step/rename_node touching a node type outside
2284
+ // WRITE_GATE_SAFE_NODE_TYPES). Mirrors renderBuildConsentGate's shape
2285
+ // and CLAUDE-008's fp-consent-chip styling, generalized to hold an
2286
+ // arbitrary pending tool call instead of only a build-loop's node set.
2287
+ //
2288
+ // src.onResume is a live function reference, not plain-data-only —
2289
+ // this is a DELIBERATE, reported deviation from renderBuildConsentGate/
2290
+ // runBuildConsentDecision's full data-reconstruction pattern. Reason:
2291
+ // the actual continuation (resuming the SAME in-flight
2292
+ // runAgentLoop/handleStep batch — remaining tool calls, accumulated
2293
+ // messages, then POSTing the next step and continuing the loop) lives
2294
+ // in per-invocation closures that aren't all JSON-serializable
2295
+ // (onDone/onError are themselves ad-hoc closures at each Modify call
2296
+ // site, e.g. onModifyResult/onModifyError capturing goalPrompt/
2297
+ // existingNodeIds). This mirrors the established, already-shipped
2298
+ // rerenderGeneratedReview "onImported is a live function ref, valid
2299
+ // within the same session" pattern rather than the stricter one.
2300
+ // Confirmed the stricter pattern's actual reason — a genuinely separate
2301
+ // pop-out window JS realm — does NOT apply here: the agent loop only
2302
+ // ever runs in the main window (dispatchSend's pop-out branch relays
2303
+ // via postMessage back to the opener rather than running its own loop,
2304
+ // and runBuildConsentDecision/renderBuildConsentGate itself has no
2305
+ // pop-out relay path either — confirmed via grep, so this is no
2306
+ // weaker than the existing shipped precedent). What DOES carry over
2307
+ // from CLAUDE-004-fix is the actual bug class it fixed: rerenderRecord
2308
+ // must find a dispatch branch and re-render from the SAME stored
2309
+ // record on refresh, never silently fall through to a generic path —
2310
+ // that guarantee is fully delivered below.
2311
+ function renderAgentToolConsentGate(src) {
2312
+ var $box = el("#fp-messages");
2313
+ if (!$box.length) {
2314
+ if (!src.decision && typeof src.onResume === "function") { src.onResume(true); }
2315
+ return;
2316
+ }
2317
+ // The pending "typing" indicator is only cleared automatically when
2318
+ // a turn fully completes or errors — a pause here otherwise leaves
2319
+ // it stuck showing stale narration ("Applying step: …") permanently
2320
+ // above the real gate that renders below it.
2321
+ hidePending();
2322
+
2323
+ var _rec = addRecord("question", {
2324
+ agentToolConsent: true,
2325
+ options: ["Proceed", "Skip this step"],
2326
+ name: src.name,
2327
+ args: src.args,
2328
+ label: describeAgentToolCall(src.name, src.args),
2329
+ onResume: src.onResume,
2330
+ decision: src.decision
2331
+ });
2332
+
2333
+ if (_rec.decision) {
2334
+ // Already resolved before this render (e.g. resolved earlier in
2335
+ // the session, now showing again after a refresh) — settled
2336
+ // state, not an interactive choice. Mirrors
2337
+ // renderBuildConsentGate's settled branch exactly.
2338
+ addMessage("assistant", "Consent requested for: " + _rec.label);
2339
+ var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
2340
+ $("<button>")
2341
+ .addClass("fp-consent-chip")
2342
+ .addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
2343
+ .attr("type", "button").prop("disabled", true)
2344
+ .text(_rec.decision === "proceed" ? "Proceeded ✓" : "Skipped ✓")
2345
+ .appendTo($settledRow);
2346
+ $box.append($settledRow);
2347
+ scrollMessagesToBottom();
2348
+ return;
2349
+ }
2350
+
2351
+ addMessage("assistant", "FlowPilot wants to do this: " + _rec.label +
2352
+ ". Let it proceed, or skip just this one step and continue?");
2353
+
2354
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
2355
+
2356
+ function decide(proceed) {
2357
+ $row.find("button").prop("disabled", true);
2358
+ _rec.decision = proceed ? "proceed" : "skip";
2359
+ if (typeof _rec.onResume === "function") { _rec.onResume(proceed); }
2360
+ }
2361
+
2362
+ $("<button>")
2363
+ .addClass("fp-consent-chip fp-consent-chip-primary")
2364
+ .attr("type", "button")
2365
+ .attr("data-fp-record-id", _rec.id)
2366
+ .attr("data-fp-record-action", "proceed")
2367
+ .text("Proceed")
2368
+ .on("click", function () { decide(true); })
2369
+ .appendTo($row);
2370
+ $("<button>")
2371
+ .addClass("fp-consent-chip fp-consent-chip-alt")
2372
+ .attr("type", "button")
2373
+ .attr("data-fp-record-id", _rec.id)
2374
+ .attr("data-fp-record-action", "skip")
2375
+ .text("Skip this step")
2376
+ .on("click", function () { decide(false); })
2377
+ .appendTo($row);
2378
+
2379
+ $box.append($row);
2380
+ scrollMessagesToBottom();
2381
+ }
2382
+
2383
+ // W7 — ask_user tool UI. Reuses renderClarifyingQuestion's button +
2384
+ // free-text presentation, but resumes the SAME in-flight agent-loop
2385
+ // tool-call batch with the answer as a tool result via src.onAnswer —
2386
+ // NOT dispatchSend() (which sends a brand-new user Send, a different
2387
+ // flow entirely from continuing an already-in-progress tool-call turn).
2388
+ // Same refresh-survival shape as renderAgentToolConsentGate (record +
2389
+ // settled state), for the same reason: a mid-answer /refresh must not
2390
+ // strand the loop or silently re-ask a resolved question.
2391
+ function renderAskUserQuestion(src) {
2392
+ var $box = el("#fp-messages");
2393
+ if (!$box.length) {
2394
+ if (src.decision !== "answered" && typeof src.onAnswer === "function") { src.onAnswer(""); }
2395
+ return;
2396
+ }
2397
+ // Same reasoning as renderAgentToolConsentGate: a pause here must
2398
+ // clear the pending indicator itself, since nothing else will.
2399
+ hidePending();
2400
+
2401
+ var _rec = addRecord("question", {
2402
+ askUserTool: true,
2403
+ question: src.question || "FlowPilot has a question.",
2404
+ options: Array.isArray(src.options) ? src.options : [],
2405
+ onAnswer: src.onAnswer,
2406
+ decision: src.decision,
2407
+ answerText: src.answerText
2408
+ });
2409
+
2410
+ if (_rec.decision === "answered") {
2411
+ addMessage("assistant", _rec.question);
2412
+ var $settled = $("<div>").addClass("fp-chip-row fp-question-row");
2413
+ $("<button>")
2414
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
2415
+ .attr("type", "button").prop("disabled", true)
2416
+ .text((_rec.answerText || "") + " ✓")
2417
+ .appendTo($settled);
2418
+ $box.append($settled);
2419
+ scrollMessagesToBottom();
2420
+ return;
2421
+ }
2422
+
2423
+ addMessage("assistant", _rec.question);
2424
+
2425
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
2426
+ var $otherRow;
2427
+
2428
+ function answer(text) {
2429
+ $row.find("button, input").prop("disabled", true);
2430
+ if ($otherRow) { $otherRow.find("button, input").prop("disabled", true); }
2431
+ _rec.decision = "answered";
2432
+ _rec.answerText = text;
2433
+ if (typeof _rec.onAnswer === "function") { _rec.onAnswer(text); }
2434
+ }
2435
+
2436
+ _rec.options.forEach(function (opt) {
2437
+ $("<button>")
2438
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
2439
+ .attr("type", "button")
2440
+ .attr("data-fp-record-id", _rec.id)
2441
+ .attr("data-fp-record-action", "answer")
2442
+ .attr("data-fp-record-value", opt)
2443
+ .text(opt)
2444
+ .on("click", function () { answer(opt); })
2445
+ .appendTo($row);
2446
+ });
2447
+
2448
+ $otherRow = $("<div>").addClass("fp-question-other-row fp-hidden");
2449
+ var $otherInput = $("<input>")
2450
+ .attr("type", "text")
2451
+ .attr("placeholder", "Type your answer…")
2452
+ .addClass("fp-question-other-input");
2453
+ var $otherSend = $("<button>")
2454
+ .addClass("red-ui-button red-ui-button-small")
2455
+ .attr("type", "button")
2456
+ .attr("data-fp-record-id", _rec.id)
2457
+ .attr("data-fp-record-action", "answer-other")
2458
+ .append($("<i>").addClass("fa fa-paper-plane"));
2459
+
2460
+ function submitOther() {
2461
+ var val = $otherInput.val().trim();
2462
+ if (!val) { return; }
2463
+ answer(val);
2464
+ }
2465
+ $otherSend.on("click", submitOther);
2466
+ $otherInput.on("keydown", function (e) { if (e.key === "Enter") { submitOther(); } });
2467
+ $otherRow.append($otherInput).append($otherSend);
2468
+
2469
+ $("<button>")
2470
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-other")
2471
+ .attr("type", "button")
2472
+ .attr("data-fp-record-id", _rec.id)
2473
+ .attr("data-fp-record-action", "show-other")
2474
+ .text("Other…")
2475
+ .on("click", function () {
2476
+ $otherRow.removeClass("fp-hidden");
2477
+ $otherInput.focus();
2478
+ })
2479
+ .appendTo($row);
2480
+
2481
+ $box.append($row).append($otherRow);
2482
+ scrollMessagesToBottom();
2483
+ }
2484
+
1589
2485
  function handleBuildResult(data, goalPrompt) {
1590
2486
  hidePending();
1591
2487
  if (renderQuestionOrProse(data)) { return; }
@@ -1622,12 +2518,34 @@
1622
2518
  // Generate — a comment node is just a regular flow-JSON node, so there's
1623
2519
  // nothing import-mechanism-specific to build here. The prompt box holds
1624
2520
  // OPTIONAL notes to steer the explanation; the selection is the real input.
2521
+ // Nothing selected/pinned for a Document send: rather than hard-erroring
2522
+ // (the old behavior — Document previously only ever meant "the
2523
+ // selection"), offer a deterministic one-click scope choice. This is a
2524
+ // pure client-side UX decision, not something worth routing through the
2525
+ // model — a weak/local provider's suggestedAction may omit
2526
+ // targetNodeIds even when the user's intent was clear (see the "all"/
2527
+ // "instance" vocabulary in the system prompts), so this is the backstop
2528
+ // that always works regardless of model reliability.
2529
+ function offerDocumentScopeClarification() {
2530
+ addMessage("info", "Nothing is selected. What would you like documented?");
2531
+ renderChip("This flow", "fa fa-sitemap", function () {
2532
+ var ids = allActiveTabNodeIds();
2533
+ if (ids.length) { pinnedSelectionIds = ids; }
2534
+ documentFlow();
2535
+ });
2536
+ renderChip("Entire instance", "fa fa-server", function () {
2537
+ var ids = allInstanceNodeIds();
2538
+ if (ids.length) { pinnedSelectionIds = ids; }
2539
+ documentFlow();
2540
+ });
2541
+ }
2542
+
1625
2543
  function documentFlow() {
1626
2544
  // Falls back to the pinned selection if nothing is currently
1627
2545
  // selected, so follow-up turns need no reselection.
1628
2546
  var context = collectSelectionContext(activeSelectionIds());
1629
2547
  if (!context || !Array.isArray(context.nodes) || context.nodes.length === 0) {
1630
- addMessage("error", "Select the node(s) you want documented first.");
2548
+ offerDocumentScopeClarification();
1631
2549
  return;
1632
2550
  }
1633
2551
  context = attachDebugContext(context);
@@ -1649,7 +2567,9 @@
1649
2567
  var payload = {
1650
2568
  prompt: notes, context: context,
1651
2569
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1652
- conversationId: conversationId
2570
+ conversationId: conversationId,
2571
+ strategy: "classic",
2572
+ entry: "document"
1653
2573
  };
1654
2574
 
1655
2575
  function onDocumentError(msg, xhr) {
@@ -1712,14 +2632,17 @@
1712
2632
  $promptBox.val("");
1713
2633
 
1714
2634
  var ap = activeProvider();
1715
- var isAgentLoop = ap && ap.supportsTools;
2635
+ var isAgentLoop = ap && ap.supportsTools &&
2636
+ currentSettings.enableAgentWrite === true;
1716
2637
 
1717
2638
  setBusy(true);
1718
2639
  showPending(isAgentLoop);
1719
2640
  var payload = {
1720
2641
  prompt: instruction, context: context,
1721
2642
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1722
- conversationId: conversationId
2643
+ conversationId: conversationId,
2644
+ strategy: isAgentLoop ? "agent" : "classic",
2645
+ entry: "modify"
1723
2646
  };
1724
2647
 
1725
2648
  function onModifyError(msg, xhr) {
@@ -1795,6 +2718,14 @@
1795
2718
  // held in these states.
1796
2719
  var activeBuildLoop = null;
1797
2720
 
2721
+ // CLAUDE-014: plain-language note for a loop-checkpoint "Continue" click,
2722
+ // read and cleared by runBuildReview's payload build. Mirrors
2723
+ // runAgentLoop's pendingDebugNote, but module-scoped since
2724
+ // renderLoopCheckpoint/runBuildReview aren't nested inside runAgentLoop.
2725
+ // No equivalent exists for "Stop loop" — stopBuildLoop makes no server
2726
+ // round trip to attach a note to.
2727
+ var pendingLoopDebugNote = null;
2728
+
1798
2729
  // How long onDebugMessage's auto-attach waits, after each matching
1799
2730
  // message, for another one to arrive before locking in and running
1800
2731
  // the review — see onDebugMessage for why (a forked/split flow can
@@ -1829,9 +2760,25 @@
1829
2760
 
1830
2761
  var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1831
2762
 
2763
+ var _rec = addRecord("question", {
2764
+ options: ["Continue → AI review", "Stop loop"],
2765
+ loopCheckpoint: true,
2766
+ onResume: function (action) {
2767
+ if (action === "continue") {
2768
+ if (activeBuildLoop) {
2769
+ if (currentSettings.debugLogging) { pendingLoopDebugNote = "user clicked Continue"; }
2770
+ runBuildReview(activeBuildLoop);
2771
+ }
2772
+ } else if (action === "stop") {
2773
+ stopBuildLoop("Build loop stopped — applied nodes remain as-is.");
2774
+ }
2775
+ }
2776
+ });
2777
+
1832
2778
  function onContinue() {
1833
2779
  $row.find("button").prop("disabled", true);
1834
2780
  if (!activeBuildLoop) { return; }
2781
+ if (currentSettings.debugLogging) { pendingLoopDebugNote = "user clicked Continue"; }
1835
2782
  runBuildReview(activeBuildLoop);
1836
2783
  }
1837
2784
  function onStop() {
@@ -1842,18 +2789,21 @@
1842
2789
  $("<button>")
1843
2790
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1844
2791
  .attr("type", "button")
2792
+ .attr("data-fp-record-id", _rec.id)
2793
+ .attr("data-fp-record-action", "continue")
1845
2794
  .text("Continue → AI review")
1846
2795
  .on("click", onContinue)
1847
2796
  .appendTo($row);
1848
2797
  $("<button>")
1849
2798
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1850
2799
  .attr("type", "button")
2800
+ .attr("data-fp-record-id", _rec.id)
2801
+ .attr("data-fp-record-action", "stop")
1851
2802
  .text("Stop loop")
1852
2803
  .on("click", onStop)
1853
2804
  .appendTo($row);
1854
2805
 
1855
2806
  $box.append($row);
1856
- addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1857
2807
  scrollMessagesToBottom();
1858
2808
  }
1859
2809
 
@@ -1960,6 +2910,10 @@
1960
2910
  } else {
1961
2911
  loop.iteration++;
1962
2912
  loop.waypoint = "apply";
2913
+ // CLAUDE-025: this fix still needs its OWN fresh deploy before
2914
+ // any evidence counts — the next "deploy" event re-stamps this
2915
+ // once the new apply->attach transition actually fires.
2916
+ loop.deployedAt = null;
1963
2917
  renderLoopStepper(loop);
1964
2918
  }
1965
2919
  }
@@ -2118,7 +3072,12 @@
2118
3072
  bailCount: 0,
2119
3073
  httpEndpoints: httpEndpoints,
2120
3074
  skipCheckpointNodeIds: skipCheckpointNodeIds,
2121
- skipCheckpointTapIds: skipCheckpointTapIds
3075
+ skipCheckpointTapIds: skipCheckpointTapIds,
3076
+ // CLAUDE-025: stamped by the RED "deploy" listener (init.js) the
3077
+ // moment THIS attempt's own apply->attach transition fires — see
3078
+ // freshBuildLoopEvidence. Starts null: no deploy has happened for
3079
+ // this attempt yet, so nothing can count as evidence.
3080
+ deployedAt: null
2122
3081
  };
2123
3082
  renderLoopStepper(activeBuildLoop);
2124
3083
  }
@@ -2133,7 +3092,21 @@
2133
3092
  // node ids instead of the live/pinned canvas selection) are synthetic.
2134
3093
  function runBuildReview(loop) {
2135
3094
  var context = collectSelectionContext(loop.nodeIds);
2136
- context = attachDebugContext(context);
3095
+ // CLAUDE-025: deliberately NOT attachDebugContext() here — that pulls
3096
+ // in the full sticky attachedDebugMessages buffer, which can still
3097
+ // hold evidence from an earlier Build attempt (or a manual attach)
3098
+ // that has nothing to do with THIS attempt's own deploy. Only
3099
+ // messages that arrived at/after this attempt's own deploy (see
3100
+ // freshBuildLoopEvidence) count as evidence for its review.
3101
+ var freshEvidence = freshBuildLoopEvidence(loop);
3102
+ if (freshEvidence.length) {
3103
+ context = context || { nodes: [], connections: {} };
3104
+ context = Object.assign({}, context, {
3105
+ debugMessages: freshEvidence.map(function (m) {
3106
+ return { id: m.id, timestamp: m.timestamp, sourceKind: m.sourceKind, name: m.name, topic: m.topic, value: m.value };
3107
+ })
3108
+ });
3109
+ }
2137
3110
  var reviewEvidence = context && Array.isArray(context.debugMessages)
2138
3111
  ? context.debugMessages : [];
2139
3112
  var statusOnlyEvidence = reviewEvidence.length > 0 &&
@@ -2221,8 +3194,14 @@
2221
3194
  var payload = {
2222
3195
  prompt: instruction, context: context,
2223
3196
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
2224
- conversationId: loop.conversationId
3197
+ conversationId: loop.conversationId,
3198
+ strategy: "classic",
3199
+ entry: "build-review"
2225
3200
  };
3201
+ if (pendingLoopDebugNote) {
3202
+ payload.debugNote = pendingLoopDebugNote;
3203
+ pendingLoopDebugNote = null;
3204
+ }
2226
3205
 
2227
3206
  function onReviewError(msg, xhr) {
2228
3207
  hidePending();
@@ -2298,6 +3277,13 @@
2298
3277
  if (data.prose) {
2299
3278
  var explanation = data.explanation || "(no content returned)";
2300
3279
 
3280
+ if (looksLikeToolEnvelope(data.explanation)) {
3281
+ handleExecuteError("FlowPilot's reply didn't come through as expected.", data.explanation);
3282
+ stopBuildLoop("Build loop stopped — the review response wasn't in the expected format. Continue manually with Modify, or start a fresh /build.", false);
3283
+ updateSelectionStatus();
3284
+ return;
3285
+ }
3286
+
2301
3287
  // W0.3: bail detection — a prose reply with a mode-redirect
2302
3288
  // suggestedAction means the model tried to exit the loop
2303
3289
  // context via the Modify escape hatch. Count it and retry or