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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/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);
@@ -839,13 +1232,57 @@
839
1232
  // W4: parse and surface the Plan: block if present.
840
1233
  var planItems = parseTodoPlan(data.explanation || "");
841
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 : [];
842
1244
  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 });
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
+ }
849
1286
  rerenderTodoRecord(todoRec);
850
1287
  }
851
1288
 
@@ -870,7 +1307,16 @@
870
1307
  }
871
1308
  } : applyModifications;
872
1309
 
873
- addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
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
+ }
874
1320
  renderActionChip(data.suggestedAction);
875
1321
  setBusy(false);
876
1322
  updateSelectionStatus();
@@ -1047,7 +1493,9 @@
1047
1493
  var payload = {
1048
1494
  prompt: prompt, context: context,
1049
1495
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1050
- conversationId: conversationId
1496
+ conversationId: conversationId,
1497
+ strategy: "classic",
1498
+ entry: endpointName
1051
1499
  };
1052
1500
 
1053
1501
  function onError(msg, xhr) {
@@ -1147,6 +1595,25 @@
1147
1595
  $wrap.append($ul);
1148
1596
  }
1149
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
+
1150
1617
  if ($existing.length) {
1151
1618
  $existing.replaceWith($wrap);
1152
1619
  } else {
@@ -1193,11 +1660,11 @@
1193
1660
  if (liveNode.type === "comment" || liveNode.type === "group") { return; }
1194
1661
  if (liveNode._def && liveNode._def.category === "config") {
1195
1662
  configTotal++;
1196
- if (RED.nodes.node(liveNode.id)) { configFound++; }
1663
+ if (nodeExists(liveNode.id)) { configFound++; }
1197
1664
  return;
1198
1665
  }
1199
1666
  total++;
1200
- if (RED.nodes.node(liveNode.id)) {
1667
+ if (nodeExists(liveNode.id)) {
1201
1668
  found++;
1202
1669
  } else {
1203
1670
  missing.push(liveNode.type || pid);
@@ -1240,58 +1707,77 @@
1240
1707
  // assigns it and returns idMap). Property/absent checks already use
1241
1708
  // real existing-node ids, so idMap[id] simply misses and falls through
1242
1709
  // to the id unchanged.
1243
- function verifyModifySteps(verifySteps, idMap, todoRec) {
1244
- if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
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) {
1245
1724
  idMap = idMap || {};
1246
1725
  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;
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)";
1289
1735
  }
1290
- default:
1291
- return; // unrecognized check type — don't count it either way
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
+ }
1292
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; }
1293
1779
  total++;
1294
- if (ok) { passed++; }
1780
+ if (result.ok) { passed++; } else { failures.push(result.label); failedSteps.push(step); }
1295
1781
  });
1296
1782
 
1297
1783
  if (total === 0) { return; }
@@ -1301,6 +1787,25 @@
1301
1787
  } else {
1302
1788
  addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
1303
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
+ });
1304
1809
  }
1305
1810
  if (todoRec && todoRec.items) {
1306
1811
  todoRec.items.forEach(function (item) {
@@ -1410,7 +1915,9 @@
1410
1915
  var payload = {
1411
1916
  prompt: instruction, context: context,
1412
1917
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1413
- conversationId: conversationId
1918
+ conversationId: conversationId,
1919
+ strategy: "classic",
1920
+ entry: "build-existing"
1414
1921
  };
1415
1922
 
1416
1923
  function onBuildExistingError(msg, xhr) {
@@ -1543,12 +2050,16 @@
1543
2050
  $("<button>")
1544
2051
  .addClass("fp-consent-chip fp-consent-chip-primary")
1545
2052
  .attr("type", "button")
2053
+ .attr("data-fp-record-id", _rec.id)
2054
+ .attr("data-fp-record-action", "proceed")
1546
2055
  .text("Auto-verify")
1547
2056
  .on("click", function () { decide(true); })
1548
2057
  .appendTo($row);
1549
2058
  $("<button>")
1550
2059
  .addClass("fp-consent-chip fp-consent-chip-alt")
1551
2060
  .attr("type", "button")
2061
+ .attr("data-fp-record-id", _rec.id)
2062
+ .attr("data-fp-record-action", "skip")
1552
2063
  .text("I'll check myself")
1553
2064
  .on("click", function () { decide(false); })
1554
2065
  .appendTo($row);
@@ -1586,6 +2097,209 @@
1586
2097
  renderActionChip(src.suggestedAction);
1587
2098
  }
1588
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
+
1589
2303
  function handleBuildResult(data, goalPrompt) {
1590
2304
  hidePending();
1591
2305
  if (renderQuestionOrProse(data)) { return; }
@@ -1649,7 +2363,9 @@
1649
2363
  var payload = {
1650
2364
  prompt: notes, context: context,
1651
2365
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1652
- conversationId: conversationId
2366
+ conversationId: conversationId,
2367
+ strategy: "classic",
2368
+ entry: "document"
1653
2369
  };
1654
2370
 
1655
2371
  function onDocumentError(msg, xhr) {
@@ -1712,14 +2428,17 @@
1712
2428
  $promptBox.val("");
1713
2429
 
1714
2430
  var ap = activeProvider();
1715
- var isAgentLoop = ap && ap.supportsTools;
2431
+ var isAgentLoop = ap && ap.supportsTools &&
2432
+ currentSettings.enableAgentWrite === true;
1716
2433
 
1717
2434
  setBusy(true);
1718
2435
  showPending(isAgentLoop);
1719
2436
  var payload = {
1720
2437
  prompt: instruction, context: context,
1721
2438
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1722
- conversationId: conversationId
2439
+ conversationId: conversationId,
2440
+ strategy: isAgentLoop ? "agent" : "classic",
2441
+ entry: "modify"
1723
2442
  };
1724
2443
 
1725
2444
  function onModifyError(msg, xhr) {
@@ -1795,6 +2514,14 @@
1795
2514
  // held in these states.
1796
2515
  var activeBuildLoop = null;
1797
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
+
1798
2525
  // How long onDebugMessage's auto-attach waits, after each matching
1799
2526
  // message, for another one to arrive before locking in and running
1800
2527
  // the review — see onDebugMessage for why (a forked/split flow can
@@ -1829,9 +2556,25 @@
1829
2556
 
1830
2557
  var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1831
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
+
1832
2574
  function onContinue() {
1833
2575
  $row.find("button").prop("disabled", true);
1834
2576
  if (!activeBuildLoop) { return; }
2577
+ if (currentSettings.debugLogging) { pendingLoopDebugNote = "user clicked Continue"; }
1835
2578
  runBuildReview(activeBuildLoop);
1836
2579
  }
1837
2580
  function onStop() {
@@ -1842,18 +2585,21 @@
1842
2585
  $("<button>")
1843
2586
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1844
2587
  .attr("type", "button")
2588
+ .attr("data-fp-record-id", _rec.id)
2589
+ .attr("data-fp-record-action", "continue")
1845
2590
  .text("Continue → AI review")
1846
2591
  .on("click", onContinue)
1847
2592
  .appendTo($row);
1848
2593
  $("<button>")
1849
2594
  .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1850
2595
  .attr("type", "button")
2596
+ .attr("data-fp-record-id", _rec.id)
2597
+ .attr("data-fp-record-action", "stop")
1851
2598
  .text("Stop loop")
1852
2599
  .on("click", onStop)
1853
2600
  .appendTo($row);
1854
2601
 
1855
2602
  $box.append($row);
1856
- addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1857
2603
  scrollMessagesToBottom();
1858
2604
  }
1859
2605
 
@@ -1960,6 +2706,10 @@
1960
2706
  } else {
1961
2707
  loop.iteration++;
1962
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;
1963
2713
  renderLoopStepper(loop);
1964
2714
  }
1965
2715
  }
@@ -2118,7 +2868,12 @@
2118
2868
  bailCount: 0,
2119
2869
  httpEndpoints: httpEndpoints,
2120
2870
  skipCheckpointNodeIds: skipCheckpointNodeIds,
2121
- skipCheckpointTapIds: skipCheckpointTapIds
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
2122
2877
  };
2123
2878
  renderLoopStepper(activeBuildLoop);
2124
2879
  }
@@ -2133,7 +2888,21 @@
2133
2888
  // node ids instead of the live/pinned canvas selection) are synthetic.
2134
2889
  function runBuildReview(loop) {
2135
2890
  var context = collectSelectionContext(loop.nodeIds);
2136
- context = attachDebugContext(context);
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
+ }
2137
2906
  var reviewEvidence = context && Array.isArray(context.debugMessages)
2138
2907
  ? context.debugMessages : [];
2139
2908
  var statusOnlyEvidence = reviewEvidence.length > 0 &&
@@ -2221,8 +2990,14 @@
2221
2990
  var payload = {
2222
2991
  prompt: instruction, context: context,
2223
2992
  history: historyPayload.messages, historyTruncated: historyPayload.truncated,
2224
- conversationId: loop.conversationId
2993
+ conversationId: loop.conversationId,
2994
+ strategy: "classic",
2995
+ entry: "build-review"
2225
2996
  };
2997
+ if (pendingLoopDebugNote) {
2998
+ payload.debugNote = pendingLoopDebugNote;
2999
+ pendingLoopDebugNote = null;
3000
+ }
2226
3001
 
2227
3002
  function onReviewError(msg, xhr) {
2228
3003
  hidePending();
@@ -2298,6 +3073,13 @@
2298
3073
  if (data.prose) {
2299
3074
  var explanation = data.explanation || "(no content returned)";
2300
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
+
2301
3083
  // W0.3: bail detection — a prose reply with a mode-redirect
2302
3084
  // suggestedAction means the model tried to exit the loop
2303
3085
  // context via the Modify escape hatch. Count it and retry or