comfyui-mcp 0.49.5 → 0.49.6

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.
Files changed (45) hide show
  1. package/dist/orchestrator/panel-tools.js +348 -18
  2. package/dist/orchestrator/panel-tools.js.map +1 -1
  3. package/dist/services/crash-log.js +7 -1
  4. package/dist/services/crash-log.js.map +1 -1
  5. package/dist/services/download-cache.js +767 -124
  6. package/dist/services/download-cache.js.map +1 -1
  7. package/dist/services/download-jobs.js +135 -3
  8. package/dist/services/download-jobs.js.map +1 -1
  9. package/dist/services/download-retry.js +253 -0
  10. package/dist/services/download-retry.js.map +1 -0
  11. package/dist/services/graph-query.js +200 -26
  12. package/dist/services/graph-query.js.map +1 -1
  13. package/dist/services/job-history.js +18 -1
  14. package/dist/services/job-history.js.map +1 -1
  15. package/dist/services/listener-ownership.js +132 -0
  16. package/dist/services/listener-ownership.js.map +1 -1
  17. package/dist/services/live-interpreter.js +115 -8
  18. package/dist/services/live-interpreter.js.map +1 -1
  19. package/dist/services/node-dev.js +220 -31
  20. package/dist/services/node-dev.js.map +1 -1
  21. package/dist/services/node-management.js +59 -4
  22. package/dist/services/node-management.js.map +1 -1
  23. package/dist/services/output-dir.js +89 -4
  24. package/dist/services/output-dir.js.map +1 -1
  25. package/dist/services/port-owner.js +329 -33
  26. package/dist/services/port-owner.js.map +1 -1
  27. package/dist/services/process-control.js +444 -30
  28. package/dist/services/process-control.js.map +1 -1
  29. package/dist/services/workflow-converter.js +33 -0
  30. package/dist/services/workflow-converter.js.map +1 -1
  31. package/dist/services/workflow-validator.js +4 -1
  32. package/dist/services/workflow-validator.js.map +1 -1
  33. package/dist/tools/model-management.js +67 -12
  34. package/dist/tools/model-management.js.map +1 -1
  35. package/dist/tools/node-dev.js +10 -6
  36. package/dist/tools/node-dev.js.map +1 -1
  37. package/dist/tools/registry-search.js +8 -1
  38. package/dist/tools/registry-search.js.map +1 -1
  39. package/dist/tools/run-template.js +9 -2
  40. package/dist/tools/run-template.js.map +1 -1
  41. package/dist/tools/template-schema.js +17 -0
  42. package/dist/tools/template-schema.js.map +1 -1
  43. package/dist/tools/workflow-library.js +16 -6
  44. package/dist/tools/workflow-library.js.map +1 -1
  45. package/package.json +1 -1
@@ -987,6 +987,106 @@ function parseToolResultJson(res) {
987
987
  function toolResultText(res) {
988
988
  return res?.content?.find((c) => c.type === "text")?.text ?? "workflow_open failed";
989
989
  }
990
+ // ---- #809: turn the panel's silent `truncated: true` booleans into a remedy --------
991
+ //
992
+ // A bare boolean is the WORST truncation signal there is: it is a field, not prose, so
993
+ // a model reading the result gets no instruction from it at all. The observed failure
994
+ // (a Kimi session on a 690-node graph) is an agent concluding the TOOL cannot do the
995
+ // thing and escalating to the human, when a different argument would have answered it.
996
+ //
997
+ // These riders are applied ORCHESTRATOR-side on purpose. The panel ships as a separate
998
+ // package on its own release cadence, so attaching the remedy here means every user
999
+ // gets it the moment they update the MCP server, regardless of their panel build. When
1000
+ // a newer panel supplies its own hint under the same key, the rider defers to it.
1001
+ // The REAL panel-side clamp for graph_find_nodes (`Math.min(Math.max(limit ?? 40, 1), 200)`
1002
+ // in comfyui-mcp-panel.js). Kept as named constants so the zod `.max()`, the parameter
1003
+ // description and the truncation remedy below cannot drift apart — the drift is exactly
1004
+ // what made `panel_find_nodes` claim "no truncation" while capping at 40 (#809).
1005
+ const FIND_NODES_DEFAULT_LIMIT = 40;
1006
+ const FIND_NODES_LIMIT_CEILING = 200;
1007
+ // #809: panel_graph_outline's budget. Deliberately the SAME name and the SAME
1008
+ // 500–60000 clamp as panel_query_graph's max_chars — one budget concept, one spelling,
1009
+ // so an agent that has learned the lever on one graph read already knows it on the
1010
+ // other. The outline degrades by RESOLUTION, never by coverage: half a map is not a
1011
+ // smaller map, and an agent handed the first 200 of 690 nodes cannot tell what it is
1012
+ // missing. See the panel-side ladder in comfyui-mcp-panel.js (graph_outline).
1013
+ const OUTLINE_MAX_CHARS_FLOOR = 500;
1014
+ const OUTLINE_MAX_CHARS_DEFAULT = 24000;
1015
+ const OUTLINE_MAX_CHARS_CEILING = 60000;
1016
+ /** Count of an array-valued reply field, or null when it isn't an array. */
1017
+ function replyCount(payload, key) {
1018
+ const v = payload[key];
1019
+ return Array.isArray(v) ? v.length : null;
1020
+ }
1021
+ /** "N of M" when both are known, else "N" — never invent a total we weren't given. */
1022
+ function shownOf(shown, total) {
1023
+ const t = typeof total === "number" && Number.isFinite(total) ? total : null;
1024
+ if (shown == null)
1025
+ return t == null ? "some" : `some of ${t}`;
1026
+ return t == null ? `${shown}` : `${shown} of ${t}`;
1027
+ }
1028
+ function withTruncationHints(res, rules) {
1029
+ const payload = parseToolResultJson(res);
1030
+ if (!payload)
1031
+ return res;
1032
+ let changed = false;
1033
+ for (const rule of rules) {
1034
+ if (payload[rule.flag] !== true)
1035
+ continue;
1036
+ // Never clobber a hint the panel itself supplied — a newer panel knows its own caps
1037
+ // better than this rider does.
1038
+ if (payload[rule.key] != null)
1039
+ continue;
1040
+ payload[rule.key] = rule.text(payload);
1041
+ changed = true;
1042
+ }
1043
+ // Synthetic flags (markBudgetIgnored) are plumbing, not part of the tool's result —
1044
+ // strip them so a caller never sees a field the panel did not send.
1045
+ if (payload.__budget_ignored !== undefined) {
1046
+ delete payload.__budget_ignored;
1047
+ changed = true;
1048
+ }
1049
+ if (!changed)
1050
+ return res;
1051
+ // Rewrite ONLY the JSON text block, so an image-carrying reply keeps its other parts.
1052
+ const idx = res.content.findIndex((c) => c.type === "text");
1053
+ if (idx < 0)
1054
+ return res;
1055
+ return {
1056
+ ...res,
1057
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(payload, null, 2) } : c),
1058
+ };
1059
+ }
1060
+ /**
1061
+ * #809 (codex gate): set a synthetic `__budget_ignored` flag when the caller asked for a
1062
+ * `max_chars` on the outline and the reply shows no sign of it. A panel that supports the
1063
+ * budget echoes `max_chars` back; an older build silently returns the full outline, so
1064
+ * the bound this tool advertises did not apply. The flag is stripped again before the
1065
+ * result is returned — it exists only to drive the rider.
1066
+ */
1067
+ function markBudgetIgnored(res, requested) {
1068
+ if (typeof requested !== "number")
1069
+ return res;
1070
+ const payload = parseToolResultJson(res);
1071
+ if (!payload || typeof payload.max_chars === "number")
1072
+ return res;
1073
+ const idx = res.content.findIndex((c) => c.type === "text");
1074
+ if (idx < 0)
1075
+ return res;
1076
+ payload.__budget_ignored = true;
1077
+ return {
1078
+ ...res,
1079
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(payload, null, 2) } : c),
1080
+ };
1081
+ }
1082
+ /** The MAX_STATE_NODES views (#809): a FIXED panel-side cap with no parameter to
1083
+ * raise. Saying so plainly — and naming the tool that CAN target the rest — is the
1084
+ * honest remedy; inventing a lever these tools do not have would be the same defect
1085
+ * in the other direction. */
1086
+ function fixedCapHint(what, shown, total, targeted) {
1087
+ return (`Showing ${shownOf(shown, total)} ${what} — this view has a FIXED cap and no parameter raises it. ` +
1088
+ targeted);
1089
+ }
990
1090
  // ---- panel_civitai_results inline sample thumbnails (#623) -------------------
991
1091
  // The agent recommends CivitAI models/LoRAs for a VISUAL medium, so it must be
992
1092
  // able to SEE the sample images — not just read titles + download counts. The
@@ -3736,7 +3836,7 @@ export function buildPanelToolDefs() {
3736
3836
  // Local helper so each def reads like the original `tool(...)` call.
3737
3837
  const def = (name, description, schema, handler) => ({ name, description, schema, handler });
3738
3838
  const defs = [
3739
- def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
3839
+ def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT query_workflow (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). NOTE: `max_chars` bounds the `text` field ONLY; those two riders are bounded by their own fixed caps and say so in-band when they cut, so lowering `max_chars` shrinks the rows and not the groups list (folding them into one budget is artokun/comfyui-mcp#807). Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
3740
3840
  types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
3741
3841
  title: z.string().optional().describe("Node title contains this."),
3742
3842
  where: z
@@ -3773,7 +3873,8 @@ export function buildPanelToolDefs() {
3773
3873
  .min(500)
3774
3874
  .max(60000)
3775
3875
  .optional()
3776
- .describe("Output character bound (default 12000). Raise only for deliberate full reads, e.g. layout passes needing every node's geometry."),
3876
+ .describe("Output character bound for the `text` field (default 12000, max 60000). Raise only for deliberate full reads, e.g. layout passes needing every node's geometry. " +
3877
+ "It bounds `text` ONLY: the `groups` and `rails` riders are bounded separately by their own fixed caps (each marked in-band when it cuts), so lowering this shrinks the rows and not those (artokun/comfyui-mcp#807 tracks folding them into one budget)."),
3777
3878
  }, async (args, ctx) => ctx.call({
3778
3879
  cmd: "graph_query",
3779
3880
  types: args.types,
@@ -3788,12 +3889,98 @@ export function buildPanelToolDefs() {
3788
3889
  limit: args.limit,
3789
3890
  max_chars: args.max_chars,
3790
3891
  })),
3791
- def("panel_graph_outline", "READ THE LIVE CANVAS the user is looking at, as text. 'Show me what's on the canvas' / 'what's on the graph right now' / 'read the current workflow' / 'describe the open graph' -> THIS TOOL, with no arguments. NOT visualize_workflow or visualize_workflow_hierarchical (those DRAW A DIAGRAM of a workflow you PASS IN — a saved file or JSON — and never see the live canvas). NOT panel_query_graph (that FILTERS a SUBSET, for when you already know what you're looking for). Returns one `outline` string covering the WHOLE open graph, topologically sorted (sources first, sinks last): each node as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (source_node.output_name) and `→ outputs` (target_node.input_name), after a GROUPS index (title → member node ids). It gives you the WIRING you would otherwise reconstruct by hand — read it FIRST to get oriented, then panel_query_graph to inspect one node ({ids:[42], fields:'detail'}) or panel_find_nodes for free-text search. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_outline" })),
3792
- def("panel_view_selected", "What the user has SELECTED on the canvas right now. Call this FIRST whenever they say \"this node\", \"the selected one\", \"the highlighted node\", \"where did I get this from\", or otherwise point at something without giving an id — the selection IS the answer, and reading it costs one call instead of scanning the graph. Returns the full detail summary (id, type, title, widgets, inputs with sources, outputs, mode) for each selected node, plus `selected_count` and any selected groups/reroutes. If `selected_count` is 0, nothing is selected — ask the user to click the node rather than guessing. NEVER dump the whole graph to work out which node they mean. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_view_selected" })),
3793
- def("panel_view_nodes_in_viewport", "ONLY the nodes inside the current VIEWPORT (pan+zoom) — a screen-region subset, NOT the whole open graph (that is panel_graph_outline, which is what 'show me what's on the canvas' means). Use this to SCOPE your work to what's on their screen: when they say \"these nodes\", \"the ones here\", \"what am I looking at right now\", or when a graph is large and you only need the region in front of them. Returns the viewport rect in graph coordinates (x, y, width, height, zoom), `node_count` (whole graph) vs `in_view_count`, and the detail summary of each visible node. A node counts as visible if any part of it overlaps the viewport. On a big canvas this is dramatically cheaper than reading everything. Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_view_nodes_in_viewport" })),
3892
+ def("panel_graph_outline", "READ THE LIVE CANVAS the user is looking at, as text. 'Show me what's on the canvas' / 'what's on the graph right now' / 'read the current workflow' / 'describe the open graph' -> THIS TOOL, with no arguments. NOT visualize_workflow or visualize_workflow_hierarchical (those DRAW A DIAGRAM of a workflow you PASS IN — a saved file or JSON — and never see the live canvas). NOT panel_query_graph (that FILTERS a SUBSET, for when you already know what you're looking for). Returns one `outline` string covering the WHOLE open graph, topologically sorted (sources first, sinks last): each node as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (source_node.output_name) and `→ outputs` (target_node.input_name), after a GROUPS index (title → member node ids). It gives you the WIRING you would otherwise reconstruct by hand — read it FIRST to get oriented, then panel_query_graph to inspect one node ({ids:[42], fields:'detail'}) or panel_find_nodes for free-text search. Over `max_chars` it never cuts the graph short: it sheds per-node detail, or refuses with a reason — never a partial outline. Read-only.", {
3893
+ max_chars: z
3894
+ .number()
3895
+ .int()
3896
+ .min(OUTLINE_MAX_CHARS_FLOOR)
3897
+ .max(OUTLINE_MAX_CHARS_CEILING)
3898
+ .optional()
3899
+ .describe(`Output character bound for the outline (default ${OUTLINE_MAX_CHARS_DEFAULT}, max ${OUTLINE_MAX_CHARS_CEILING}) — the SAME budget concept as panel_query_graph's max_chars. ` +
3900
+ `COVERAGE IS NEVER TRADED AWAY: over budget the outline sheds RESOLUTION, not nodes — first per-node widget values, then titles, and at the floor a per-group summary — so any outline it DOES return describes the whole graph, with real node/group counts. ` +
3901
+ `If even the group-level floor will not fit, it returns NO outline and says so (detail_level:"refused") rather than a partial one that would read as complete. ` +
3902
+ `detail_level names the rung used and degraded_reason says why. Panel builds older than this budget ignore it and return the full outline; the result carries a max_chars field when the budget was actually applied.`),
3903
+ }, async (args, ctx) => withTruncationHints(
3904
+ // The synthetic `__budget_ignored` flag below is derived from the reply, not
3905
+ // sent by the panel: a build that supports the budget echoes `max_chars` back.
3906
+ markBudgetIgnored(await ctx.call({ cmd: "graph_outline", max_chars: args.max_chars }), args.max_chars), [
3907
+ {
3908
+ // #809 (codex gate): a panel older than this budget IGNORES `max_chars` and
3909
+ // returns the full outline, so the bound this tool advertises silently did
3910
+ // not apply. A current panel echoes `max_chars` back; its absence is the
3911
+ // tell. Saying so is the whole point — the caller must not read an
3912
+ // unbounded reply as "this fitted".
3913
+ flag: "__budget_ignored",
3914
+ key: "max_chars_hint",
3915
+ text: (p) => `This panel build does not support \`max_chars\` on the outline, so the budget you set (${typeof args.max_chars === "number" ? args.max_chars : OUTLINE_MAX_CHARS_DEFAULT}) was NOT applied and the outline below is the full, unbounded one (${typeof p.node_count === "number" ? p.node_count : "all"} node(s)). ` +
3916
+ `Update the ComfyUI Agent Panel to bound it, or scope the read with panel_query_graph in the meantime.`,
3917
+ },
3918
+ {
3919
+ // On an older panel this is never set either, so the rider is inert there.
3920
+ flag: "degraded",
3921
+ key: "truncation_hint",
3922
+ text: (p) => {
3923
+ const inForce = typeof args.max_chars === "number" ? args.max_chars : OUTLINE_MAX_CHARS_DEFAULT;
3924
+ // At the ceiling "raise max_chars" is a dead retry (codex gate).
3925
+ const more = inForce >= OUTLINE_MAX_CHARS_CEILING
3926
+ ? `\`max_chars\` is already at its ceiling of ${OUTLINE_MAX_CHARS_CEILING}, so this is the most one outline can carry — read specific nodes with panel_query_graph {ids:[…], fields:'detail'}.`
3927
+ : `Raise \`max_chars\` (up to ${OUTLINE_MAX_CHARS_CEILING}) for more detail, or read specific nodes with panel_query_graph {ids:[…], fields:'detail'}.`;
3928
+ const nodes = typeof p.node_count === "number" ? p.node_count : "the";
3929
+ const groups = typeof p.group_count === "number" ? p.group_count : "all";
3930
+ // `degraded` covers TWO different outcomes and they say opposite things
3931
+ // (codex gate). "refused" means NO outline was produced at all — claiming
3932
+ // it "still covers ALL nodes" would describe content the reader cannot
3933
+ // see, which is the same lie as a silent cut.
3934
+ if (p.detail_level === "refused") {
3935
+ // And if the floor exceeds the CEILING, raising is a guaranteed second
3936
+ // refusal — a dead retry inside the message explaining the first.
3937
+ //
3938
+ // A panel that refuses WITHOUT reporting `floor_chars` (the revision
3939
+ // just before that field existed) leaves this unknowable, and treating
3940
+ // unknown as reachable is what produced the dead retry (codex gate). So
3941
+ // hedge: offer the raise as something to TRY, and name the fallback in
3942
+ // the same breath, instead of asserting it will work.
3943
+ const floor = typeof p.floor_chars === "number" ? p.floor_chars : null;
3944
+ const next = floor != null && floor > OUTLINE_MAX_CHARS_CEILING
3945
+ ? `Its smallest whole-graph form needs ~${floor} chars, past \`max_chars\`'s ceiling of ${OUTLINE_MAX_CHARS_CEILING}, so raising it will NOT produce an outline for this graph — read it in parts with panel_query_graph.`
3946
+ : floor != null || inForce >= OUTLINE_MAX_CHARS_CEILING
3947
+ ? more
3948
+ : `This panel build does not report how large the smallest form would be, so raising \`max_chars\` (up to ${OUTLINE_MAX_CHARS_CEILING}) MAY still refuse — if it does, the graph cannot be outlined in one call; read it in parts with panel_query_graph.`;
3949
+ return (`NO outline was returned: even the smallest whole-graph form did not fit \`max_chars\`=${inForce}, and a PARTIAL outline is deliberately withheld because it would read as complete. ` +
3950
+ `The graph has ${nodes} node(s) and ${groups} group(s). ${next}`);
3951
+ }
3952
+ return (`The outline still covers ALL ${nodes} node(s) and ${groups} group(s), but at reduced detail (detail_level ${JSON.stringify(p.detail_level ?? "reduced")}) to fit \`max_chars\`=${inForce}. ` +
3953
+ more);
3954
+ },
3955
+ },
3956
+ ])),
3957
+ def("panel_view_selected", "What the user has SELECTED on the canvas right now. Call this FIRST whenever they say \"this node\", \"the selected one\", \"the highlighted node\", \"where did I get this from\", or otherwise point at something without giving an id — the selection IS the answer, and reading it costs one call instead of scanning the graph. Returns the full detail summary (id, type, title, widgets, inputs with sources, outputs, mode) for each selected node, plus `selected_count` and any selected groups/reroutes. If `selected_count` is 0, nothing is selected — ask the user to click the node rather than guessing. NEVER dump the whole graph to work out which node they mean. Read-only.", {}, async (_args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_view_selected" }), [
3958
+ {
3959
+ flag: "truncated",
3960
+ key: "truncation_hint",
3961
+ text: (p) => fixedCapHint("selected node(s)", replyCount(p, "nodes"), p.selected_count, "Ask the user to select fewer nodes, or read the ones you need by id with panel_query_graph {ids:[…], fields:'detail'}, which DOES take limit and max_chars."),
3962
+ },
3963
+ ])),
3964
+ def("panel_view_nodes_in_viewport", "ONLY the nodes inside the current VIEWPORT (pan+zoom) — a screen-region subset, NOT the whole open graph (that is panel_graph_outline, which is what 'show me what's on the canvas' means). Use this to SCOPE your work to what's on their screen: when they say \"these nodes\", \"the ones here\", \"what am I looking at right now\", or when a graph is large and you only need the region in front of them. Returns the viewport rect in graph coordinates (x, y, width, height, zoom), `node_count` (whole graph) vs `in_view_count`, and the detail summary of each visible node. A node counts as visible if any part of it overlaps the viewport. On a big canvas this is dramatically cheaper than reading everything. Read-only.", {}, async (_args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_view_nodes_in_viewport" }), [
3965
+ {
3966
+ flag: "truncated",
3967
+ key: "truncation_hint",
3968
+ text: (p) => fixedCapHint("visible node(s)", replyCount(p, "nodes"), p.in_view_count, "Ask the user to zoom in so fewer nodes are on screen, or read the region with panel_query_graph (which DOES take limit and max_chars) / panel_graph_outline for the whole graph."),
3969
+ },
3970
+ ])),
3794
3971
  def("panel_audit_prompt_director", "Audit Prompt Director on the LIVE canvas without changing it. Correlates Prompt Director/Producer/Auto/Context/Reference/Critic widget values and wiring with detected model-loader filenames, every LoRA loader's actual model/CLIP strengths, and Prompt Director's latest sanitized runtime edit plan, resolved Model Explorer metadata, warnings, exact final prompt, and critic verdict. Returns observations plus proposed panel_set_widget changes with requires_confirmation=true. Call this when Prompt Director nodes are present, before saying the model/LoRA setup is correct, or when an edit prompt is ignored. READ-ONLY: present useful findings to the user and ask before applying any recommendation unless they already explicitly asked you to fix it.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_prompt_director_audit" })),
3795
- def("panel_get_subgraph", "Read INSIDE a subgraph node on the user's open graph: ids, types, widget values, and connections of its inner nodes. Use after panel_graph_outline / panel_query_graph shows a node with is_subgraph=true. Read-only.", { node_id: z.number().int().describe("Subgraph node id (is_subgraph=true).") }, async (args, ctx) => ctx.call({ cmd: "graph_get_subgraph", node_id: args.node_id })),
3796
- def("panel_find_nodes", "SEARCH the live canvas for nodes matching a term you supply — the right way to PINPOINT a node (a specific loader, sampler, save, switch) in a LARGE graph. Supply a free-text `query` and/or targeted filters; with nothing specific in mind, read the whole graph with panel_graph_outline instead. This searches the LIVE graph ON THE CANVAS — NOT the installable node registry (that's panel_search_nodes). It scans EVERY node (no truncation). Give a free-text `query` (matched case-insensitively across node type, title, description, widget NAMES, widget VALUES, and input/output port names+types — a node hits if ANY of those contain it) and/or targeted filters: type, title, input, output, widget (name), widget_value (contents), is_output, is_subgraph, mode. Targeted filters are ANDed together; the free `query` ORs across fields. Each match is the SAME rich summary as panel_query_graph's detail rows (id, type, title, widgets, inputs WITH their connected_from sources, outputs, mode, is_output, …) PLUS the node's description and a `matched_on` list saying WHY it matched. Read-only. Examples — the video loader: {query:'tiktok'} or {type:'LoadVideo'} or {input:'video'}; every output node: {is_output:true}; the node whose widget holds a file: {widget_value:'.png'}; a bypassed switch: {type:'Switch', mode:'bypass'}.", {
3972
+ def("panel_get_subgraph", "Read INSIDE a subgraph node on the user's open graph: ids, types, widget values, and connections of its inner nodes. Use after panel_graph_outline / panel_query_graph shows a node with is_subgraph=true. Read-only.", { node_id: z.number().int().describe("Subgraph node id (is_subgraph=true).") }, async (args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_get_subgraph", node_id: args.node_id }), [
3973
+ {
3974
+ flag: "truncated",
3975
+ key: "truncation_hint",
3976
+ text: (p) => fixedCapHint("inner node(s)", replyCount(p, "nodes"), p.node_count,
3977
+ // Honest about the follow-up's OWN ceiling (codex gate): panel_query_graph
3978
+ // clamps limit at 200 and has no cursor, so on a >200-node subgraph it is
3979
+ // a way to read MORE, not a way to read all.
3980
+ "panel_enter_subgraph into it, then panel_query_graph — which takes limit (max 200) and max_chars. It has no cursor, so beyond 200 inner nodes use its types/where filters to work through them."),
3981
+ },
3982
+ ])),
3983
+ def("panel_find_nodes", "SEARCH the live canvas for nodes matching a term you supply — the right way to PINPOINT a node (a specific loader, sampler, save, switch) in a LARGE graph. Supply a free-text `query` and/or targeted filters; with nothing specific in mind, read the whole graph with panel_graph_outline instead. This searches the LIVE graph ON THE CANVAS — NOT the installable node registry (that's panel_search_nodes). It scans the graph in the canvas's own node order and STOPS once it has `limit` matches (default 40, max 200) — so a capped result is neither exhaustive nor a count of all matches: the result's count field is what was returned, total is the graph's node count, and truncated:true means the scan REACHED the cap — on current panels that proves more matches exist, on older panel builds it can also fire on an exactly-`limit` result that dropped nothing, so read it as 'may be incomplete'. Either way the result carries a truncation_hint naming the fix (raise `limit`, up to 200, or add a filter) — retry, do not conclude the node isn't there. Give a free-text `query` (matched case-insensitively across node type, title, description, widget NAMES, widget VALUES, and input/output port names+types — a node hits if ANY of those contain it) and/or targeted filters: type, title, input, output, widget (name), widget_value (contents), is_output, is_subgraph, mode. Targeted filters are ANDed together; the free `query` ORs across fields. Each match is the SAME rich summary as panel_query_graph's detail rows (id, type, title, widgets, inputs WITH their connected_from sources, outputs, mode, is_output, …) PLUS the node's description and a `matched_on` list saying WHY it matched. Read-only. Examples — the video loader: {query:'tiktok'} or {type:'LoadVideo'} or {input:'video'}; every output node: {is_output:true}; the node whose widget holds a file: {widget_value:'.png'}; a bypassed switch: {type:'Switch', mode:'bypass'}.", {
3797
3984
  query: z
3798
3985
  .string()
3799
3986
  .optional()
@@ -3832,10 +4019,10 @@ export function buildPanelToolDefs() {
3832
4019
  .number()
3833
4020
  .int()
3834
4021
  .min(1)
3835
- .max(200)
4022
+ .max(FIND_NODES_LIMIT_CEILING)
3836
4023
  .optional()
3837
- .describe("Max matches to return (default 40)."),
3838
- }, async (args, ctx) => ctx.call({
4024
+ .describe(`Max matches to return (default ${FIND_NODES_DEFAULT_LIMIT}, max ${FIND_NODES_LIMIT_CEILING}). The scan STOPS once this many match, so a capped result is not a complete match set.`),
4025
+ }, async (args, ctx) => withTruncationHints(await ctx.call({
3839
4026
  cmd: "graph_find_nodes",
3840
4027
  query: args.query,
3841
4028
  type: args.type,
@@ -3848,7 +4035,29 @@ export function buildPanelToolDefs() {
3848
4035
  is_subgraph: args.is_subgraph,
3849
4036
  mode: args.mode,
3850
4037
  limit: args.limit,
3851
- })),
4038
+ }), [
4039
+ {
4040
+ flag: "truncated",
4041
+ key: "truncation_hint",
4042
+ // #809 (defect 2): the scan STOPS at the cap, so `count` is not a match
4043
+ // total and the absent matches are not "no more matches".
4044
+ //
4045
+ // The wording is deliberately "may be incomplete", not "there ARE more"
4046
+ // (codex gate): this orchestrator ships ahead of the panel, and a panel
4047
+ // build older than the matching panel PR sets `truncated` on an EXACT-cap
4048
+ // result that dropped nothing. Asserting more exist would manufacture the
4049
+ // very false alarm this issue is removing. A current panel supplies its own
4050
+ // precise hint and the rider defers to it.
4051
+ text: (p) => {
4052
+ const inForce = typeof args.limit === "number" ? args.limit : FIND_NODES_DEFAULT_LIMIT;
4053
+ const raise = inForce >= FIND_NODES_LIMIT_CEILING
4054
+ ? `\`limit\` is already at its ceiling of ${FIND_NODES_LIMIT_CEILING}, so narrow with \`type\`/\`title\`/\`widget_value\` instead`
4055
+ : `Raise \`limit\` up to ${FIND_NODES_LIMIT_CEILING}, or narrow with \`type\`/\`title\`/\`widget_value\``;
4056
+ return (`The scan reached \`limit\`=${inForce} at ${replyCount(p, "matches") ?? "the cap"} match(es), so this result MAY be incomplete — ` +
4057
+ `treat it as "not proof a node is absent" rather than as the full match set. ${raise}.`);
4058
+ },
4059
+ },
4060
+ ])),
3852
4061
  def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values. Frontend-only virtual types are addable too: 'Note' and 'MarkdownNote' — the supported way to ANNOTATE a workflow with on-canvas instructions (add the node, then put the text in its 'text' widget via panel_set_widget) — plus 'Reroute' and 'PrimitiveNode'. These are LiteGraph-native and never appear in the backend node registry, so they legitimately bypass the backend class_type check.", {
3853
4062
  class_type: z.string().describe("Exact ComfyUI node class_type to create."),
3854
4063
  pos: xy()
@@ -4290,7 +4499,21 @@ export function buildPanelToolDefs() {
4290
4499
  }
4291
4500
  return res;
4292
4501
  }),
4293
- def("panel_get_errors", "WHY IS THAT NODE RED / WHY DID THE RUN FAIL? The single error surface for the user's open tab: every errored node JOINED TO ITS CAUSE, which ComfyUI itself does not show — LiteGraph only paints a red outline and stores no reason, which is why users report \"red node, no error message\". Call this whenever the user mentions a red/highlighted/erroring node, a failed run, or \"required models are missing\" — instead of guessing from widget values. Each entry in `nodes[]` is the node's full detail summary plus `red_outline` and `reasons[]`, drawn from every source: `missing_model` (exact file, its models directory, the widget holding it, and a download URL when known), `missing_media` (a referenced input image/video that isn't on disk — the usual cause of a red LoadImage), `validation` (per-input errors from the last queue attempt: message, details, offending input), and `execution` (runtime failure with `exception_type`, e.g. PIL.UnidentifiedImageError). TWO THINGS THAT MAKE THIS ESSENTIAL: (1) missing model/media assets paint nodes red AS SOON AS THE WORKFLOW LOADS, long before any queue attempt — so the raw validation map is still EMPTY while the user is staring at red nodes; (2) a node that throws AT RUNTIME is never painted red at all, so it can't be spotted on the canvas — it appears here with red_outline:false. Also returns graph-level `missing_models`, `missing_media`, `missing_node_types` (or `missing_node_count`), plus the raw `node_errors` map and `last_execution_error` for reference. A ⚠️ GRAPH VALIDATION block is auto-injected at your turn start when this state changes; call this to re-check on demand (e.g. after you edit widgets/links). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_get_errors" })),
4502
+ def("panel_get_errors", "WHY IS THAT NODE RED / WHY DID THE RUN FAIL? The single error surface for the user's open tab: every errored node JOINED TO ITS CAUSE, which ComfyUI itself does not show — LiteGraph only paints a red outline and stores no reason, which is why users report \"red node, no error message\". Call this whenever the user mentions a red/highlighted/erroring node, a failed run, or \"required models are missing\" — instead of guessing from widget values. Each entry in `nodes[]` is the node's full detail summary plus `red_outline` and `reasons[]`, drawn from every source: `missing_model` (exact file, its models directory, the widget holding it, and a download URL when known), `missing_media` (a referenced input image/video that isn't on disk — the usual cause of a red LoadImage), `validation` (per-input errors from the last queue attempt: message, details, offending input), and `execution` (runtime failure with `exception_type`, e.g. PIL.UnidentifiedImageError). TWO THINGS THAT MAKE THIS ESSENTIAL: (1) missing model/media assets paint nodes red AS SOON AS THE WORKFLOW LOADS, long before any queue attempt — so the raw validation map is still EMPTY while the user is staring at red nodes; (2) a node that throws AT RUNTIME is never painted red at all, so it can't be spotted on the canvas — it appears here with red_outline:false. Also returns graph-level `missing_models`, `missing_media`, `missing_node_types` (or `missing_node_count`), plus the raw `node_errors` map and `last_execution_error` for reference. A ⚠️ GRAPH VALIDATION block is auto-injected at your turn start when this state changes; call this to re-check on demand (e.g. after you edit widgets/links). Read-only.", {}, async (_args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_get_errors" }), [
4503
+ {
4504
+ flag: "truncated",
4505
+ key: "truncation_hint",
4506
+ text: (p) => fixedCapHint("errored node(s)", replyCount(p, "nodes"), p.errored_count, "Fix these first and re-check, or inspect specific ids with panel_query_graph {ids:[…], fields:'detail'}."),
4507
+ },
4508
+ {
4509
+ flag: "stale_flags_truncated",
4510
+ key: "stale_flags_truncation_hint",
4511
+ // Older panels send no total for this list, so the rider says so rather than
4512
+ // implying the shown count is the whole of it (codex gate). A current panel
4513
+ // supplies its own hint WITH the total, and the rider defers to it.
4514
+ text: (p) => fixedCapHint("stale red-outline node(s)", replyCount(p, "stale_flags"), undefined, "An unknown number more were cut (this panel build reports no total). They are cosmetic leftovers, not errors; the cap is fixed and there is no parameter to page it."),
4515
+ },
4516
+ ])),
4294
4517
  def("panel_refresh_nodes", "Re-pull the live ComfyUI server's /object_info and rebuild every combo/loader option list in the user's open tab, so an asset that appeared server-side AFTER the tab loaded becomes SELECTABLE without a manual reload (the 'press R' step) or a restart. Use this right after stage_output_as_input (chaining a stage's output into a LoadImage / VHS_LoadVideo / LoadAudio loader — the returned filename won't be in the loader's dropdown until you refresh), after downloading a model / LoRA / VAE (a freshly downloaded file is otherwise 'not a valid option' in its loader), or after installing a node pack. Then panel_set_widget / panel_add_node will accept the new value. Non-destructive: it only re-registers node defs and refreshes combo option lists — it does NOT change your graph and is undo-neutral. Idempotent (safe to call repeatedly). Returns whether the refresh authoritatively fetched fresh defs.", {}, async (_args, ctx) =>
4295
4518
  // Same bounded ack budget as the refresh-before-validate writes (#599): a
4296
4519
  // fresh /object_info on a large install routinely exceeds the 6000 ms
@@ -5347,8 +5570,75 @@ export function buildPanelToolDefs() {
5347
5570
  // (Electron-supervised, #400), unverifiable, or remote — proceeds exactly as
5348
5571
  // before. The binding for this DECISION is captured pre-await; nothing
5349
5572
  // downstream may reuse it (r7).
5573
+ //
5574
+ // #814: AN UNIDENTIFIED LOCAL TARGET IS NOT SENT AN IRREVERSIBLE STOP.
5575
+ //
5576
+ // `captureRebootHealthBase` answers "is the instance this tab fronts provably
5577
+ // our own local boot ComfyUI?" — loopback base, server-observed handshake
5578
+ // Origin, pathless mount. When it cannot say yes, we do not know WHICH
5579
+ // ComfyUI the reboot will reach: the command goes to the bound TAB, not to
5580
+ // the orchestrator's configured target.
5581
+ //
5582
+ // The original code dispatched anyway and reported honestly that it could not
5583
+ // confirm the return — a verdict computed after a stop it should not have
5584
+ // made, and the #814 lost server. A first attempt at this ran the local
5585
+ // preflight in that case and let a PASS proceed, which was worse in a subtle
5586
+ // way: the assessment describes the CONFIGURED instance while the reboot hits
5587
+ // the TAB's, so a safe local install could authorize stopping an orphaned
5588
+ // Desktop backend in some other tab (codex gate round 11). A pass for one
5589
+ // instance is not permission to stop another.
5590
+ //
5591
+ // So: BOUND, assess the instance the reboot will reach and decide on it.
5592
+ // UNBOUND and local, REFUSE — the assessment describes the orchestrator's
5593
+ // CONFIGURED target, which is not shown to be what the reboot reaches, and a
5594
+ // finding about one instance may not be spent on another IN EITHER DIRECTION.
5595
+ //
5596
+ // An asymmetric version was tried (a pass authorizes nothing, a fail still
5597
+ // refuses) and is incoherent: if the configured target is a good enough proxy
5598
+ // to refuse on, it is good enough to proceed on, and if it is not, the refusal
5599
+ // is as unfounded as the permission. Keeping only the refusal would also break
5600
+ // a working tab-fronted instance whenever the unrelated configured one is
5601
+ // stale — while still leaving the dispatch unproven.
5602
+ //
5603
+ // The cost is real and is the point: `captureRebootHealthBase` also returns
5604
+ // null for ordinary local setups (an ambiguous `localhost` origin, a basePath
5605
+ // mount, an older panel), and those lose the panel restart until the binding
5606
+ // can be proven. They keep restart_comfyui, and the note says so. Weighed
5607
+ // against #814 — where exactly such a user's server was stopped and never came
5608
+ // back — declining is the recoverable side.
5350
5609
  const preflightHealthBase = captureRebootHealthBase(ctx);
5351
- if (preflightHealthBase != null && sameHttpBase(getComfyUIBaseUrl(), preflightHealthBase)) {
5610
+ const preflightBound = preflightHealthBase != null &&
5611
+ sameHttpBase(getComfyUIBaseUrl(), preflightHealthBase);
5612
+ // Remote and cloud are excluded for the reason they always were: there is no
5613
+ // local process to assess, and the Manager reboot is their ONLY restart path —
5614
+ // a supervised remote (the tunnelled Desktop app) restarts through it by
5615
+ // design, so refusing there would remove a path that works.
5616
+ if (!preflightBound && !isRemoteMode() && !isCloudMode()) {
5617
+ return ok({
5618
+ rebooting: false,
5619
+ ready: false,
5620
+ confirmed_cycle: false,
5621
+ refused: true,
5622
+ note: "Refusing to restart ComfyUI: I could not confirm that this panel's ComfyUI is " +
5623
+ "the local instance I can account for, so I cannot tell which server the restart " +
5624
+ "would stop — and it STOPS it, relying on whatever supervises it to start it " +
5625
+ // A claim about what I DID, not about a server I have just said I cannot
5626
+ // identify: "it is still running" would be exactly the unvalidated
5627
+ // assertion the stale-target rule forbids (r8).
5628
+ "again. Nothing was dispatched, so nothing was stopped. USE restart_comfyui " +
5629
+ // The alternative is NAMED and the difference EXPLAINED (coordinator
5630
+ // ruling): this tool restarts whatever the calling TAB fronts, which is
5631
+ // exactly the thing that could not be identified here. restart_comfyui is
5632
+ // not tab-scoped — it acts on the ComfyUI this server is configured for,
5633
+ // which it can identify and assess — so it remains available. A user who
5634
+ // loses one entry point must be told the other one works, and why.
5635
+ "INSTEAD: unlike this panel-scoped restart, it is not tied to a browser tab " +
5636
+ "— it acts on the ComfyUI this server is configured for, which it CAN " +
5637
+ "identify and check before stopping. Or restart ComfyUI from whatever " +
5638
+ "launches it (its own launcher, the Desktop app, or your terminal).",
5639
+ });
5640
+ }
5641
+ if (preflightBound) {
5352
5642
  // Snapshot the target GENERATION at the decision (r11): a final-state
5353
5643
  // base comparison (A vs A) cannot detect an intervening A→B→A
5354
5644
  // retarget, so stability is judged by the monotonic epoch bumped on
@@ -5388,6 +5678,9 @@ export function buildPanelToolDefs() {
5388
5678
  "panel_restart_comfyui.",
5389
5679
  });
5390
5680
  }
5681
+ // r9: the danger proof follows the INSTANCE the tab fronts, not the mutable
5682
+ // runtime config — a config-only retarget mid-await must not wash out the
5683
+ // proof that the tab-fronted boot instance is unrelaunchable.
5391
5684
  if (!preflight.ok && tabFrontsSameInstance) {
5392
5685
  // r9: the danger proof follows the INSTANCE the tab fronts, NOT the
5393
5686
  // mutable runtime config — a config-only retarget mid-await must
@@ -5404,11 +5697,19 @@ export function buildPanelToolDefs() {
5404
5697
  ready: false,
5405
5698
  confirmed_cycle: false,
5406
5699
  refused: true,
5407
- note: `Refusing to restart ComfyUI: ${preflight.reason} This looks like an ` +
5408
- "externally-managed install (e.g. Pinokio): a restart from here would STOP " +
5409
- "ComfyUI and nothing would bring it back automatically, so it was refused " +
5410
- "BEFORE anything was stopped ComfyUI is still running. Restart it from the " +
5411
- "launcher that owns it (e.g. Pinokio's own controls), or point COMFYUI_PATH " +
5700
+ note:
5701
+ // The REASON leads, because there is now more than one shape that
5702
+ // reaches here an externally-managed install whose launch command
5703
+ // cannot be rebuilt (Pinokio, #742) and a Desktop instance whose
5704
+ // supervisor has gone (#814) and telling a Desktop user to check
5705
+ // Pinokio would send them somewhere they have never been.
5706
+ `Refusing to restart ComfyUI: ${preflight.reason}` +
5707
+ " A restart from here would " +
5708
+ "STOP ComfyUI and nothing would bring it back automatically, so it was " +
5709
+ "refused BEFORE anything was stopped — ComfyUI is still running. Restart it " +
5710
+ "from whatever launches it (its own launcher — e.g. Pinokio's own controls — " +
5711
+ "the Desktop app, or your terminal); for an externally-managed install you " +
5712
+ "can also point COMFYUI_PATH " +
5412
5713
  "at the live install so a relaunch can be proven and use restart_comfyui.",
5413
5714
  });
5414
5715
  }
@@ -5433,6 +5734,35 @@ export function buildPanelToolDefs() {
5433
5734
  // routing id while the original is still reconnecting.
5434
5735
  const preRestartPanelIdentity = ctx.panelConnectionIdentity?.();
5435
5736
  const healthBase = captureRebootHealthBase(ctx);
5737
+ // THE BINDING RULE APPLIES AT THE DISPATCH POINT, NOT ONLY BEFORE THE AWAIT.
5738
+ //
5739
+ // The check above happens before the preflight; a tab or connection rebind
5740
+ // DURING that await lands here with a target that is no longer bound, and
5741
+ // `tabFrontsSameInstance` being false meant neither post-await refusal fired
5742
+ // — so both a passing and a failing preflight fell through and the fresh,
5743
+ // unidentified tab received the reboot (codex gate round 12). The pre-await
5744
+ // check is kept because it avoids assessing an instance we already know we
5745
+ // may not act on; this one is what actually holds the line.
5746
+ //
5747
+ // Same rule, same exclusions: only a LOCAL target we cannot tie to the
5748
+ // instance this server accounts for is refused.
5749
+ const dispatchBound = healthBase != null && sameHttpBase(getComfyUIBaseUrl(), healthBase);
5750
+ if (!dispatchBound && !isRemoteMode() && !isCloudMode()) {
5751
+ return ok({
5752
+ rebooting: false,
5753
+ ready: false,
5754
+ confirmed_cycle: false,
5755
+ refused: true,
5756
+ note: "Refusing to restart ComfyUI: the panel connection changed while the restart " +
5757
+ "was being prepared, and I can no longer confirm which ComfyUI this tab " +
5758
+ "fronts — a restart STOPS a server, so it is never sent to one I cannot " +
5759
+ // Again: what I did, not what an unidentified instance is doing.
5760
+ "identify. Nothing was dispatched, so nothing was stopped. Retry once the " +
5761
+ "panel has settled, or USE restart_comfyui INSTEAD: unlike this panel-scoped " +
5762
+ "restart, it is not tied to a browser tab — it acts on the ComfyUI this " +
5763
+ "server is configured for, which it CAN identify and check before stopping.",
5764
+ });
5765
+ }
5436
5766
  const timing = getPanelRebootTiming();
5437
5767
  const dispatchTimeout = Math.max(1, Math.min(15000, overallDeadline - Date.now()));
5438
5768
  // CONCURRENT OBSERVATION (coordinator): start probing the fixed boot endpoint NOW,