comfyui-mcp 0.50.7 → 0.50.8

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.
@@ -65,7 +65,7 @@ import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpSe
65
65
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey, receiptDisclosures, shadowedNote, storeDamageNote, } from "../services/panel-secrets.js";
66
66
  import { flattenUiWorkflow } from "../services/flatten-workflow.js";
67
67
  import { describeUnappliedFilters } from "./civitai-filter-guard.js";
68
- import { recordTodo } from "./todo-state.js";
68
+ import { recordTodo, normalizeTodoItems, TODO_STATUS_INPUTS } from "./todo-state.js";
69
69
  import { applyCapturedWidgetValues } from "../services/live-widget-overlay.js";
70
70
  import { listWorkflowLibraryKeys, userdataFetch } from "../services/userdata-library.js";
71
71
  import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
@@ -4992,6 +4992,66 @@ function validatePanelEditNodeArgs(args) {
4992
4992
  * forwarded to the wire UNTOUCHED (contrast workflow_uuid, which is bridge-owned
4993
4993
  * and always overwritten). Optional everywhere; never required.
4994
4994
  */
4995
+ /**
4996
+ * #845 — a node id the tools THEMSELVES printed must be accepted back.
4997
+ *
4998
+ * Every panel tool took `z.number().int()` for a node id, while the graph
4999
+ * readers return ids as STRINGS (`"id": "42"`). So the obvious move — copy an id
5000
+ * out of panel_query_graph, paste it into panel_select_nodes — failed on the
5001
+ * first attempt, every time, with a raw zod `expected number, received string`.
5002
+ * The reporter hit it doing exactly that.
5003
+ *
5004
+ * Nothing about `"42"` is ambiguous. Accept both spellings and normalize to the
5005
+ * number the wire has always carried, so the round trip closes.
5006
+ *
5007
+ * DELIBERATELY STRICT about what counts as a node id: only an integer, or a
5008
+ * string that is exactly an integer. `"42px"`, `"4.5"`, `""` and `"5:12"` are
5009
+ * still rejected. The last one matters — a subgraph-qualified id is a real
5010
+ * shape in newer ComfyUI, and silently truncating it to `5` would target the
5011
+ * WRONG node rather than fail. If those need supporting, that is a separate,
5012
+ * deliberate change to the wire contract, not something to fall out of a coerce.
5013
+ */
5014
+ const nodeId = () => z.union([z.number().int(), z.string().regex(/^-?\d+$/, "a node id must be an integer")]).transform((v) => (typeof v === "number" ? v : Number.parseInt(v, 10)));
5015
+ /**
5016
+ * #845 — which `panel_canvas` arguments the chosen action actually consumes.
5017
+ *
5018
+ * The tool accepted node_id/dx/dy/scale for every action and forwarded them all,
5019
+ * so an argument the action ignores vanished without a word. The reporter passed
5020
+ * `zoom: 0.55` alongside `center_on_node` and got `scale: 0.067` back — which
5021
+ * reads as "your zoom was applied and then overridden", when in truth it was
5022
+ * never applied at all. The panel's center_on_node case sets the offset and
5023
+ * touches the scale not at all.
5024
+ *
5025
+ * Naming what an action ignores is the whole fix. It is NOT an error — passing a
5026
+ * harmless extra argument should not fail a viewport move — but it must not be
5027
+ * silent either.
5028
+ */
5029
+ const CANVAS_ACTION_ARGS = {
5030
+ fit: [], // computes its own framing from the graph bounds
5031
+ center_on_node: ["node_id"],
5032
+ pan: ["dx", "dy"],
5033
+ zoom: ["scale"],
5034
+ };
5035
+ /** Supplied-but-unused argument names for `action`, in a caller-facing spelling. */
5036
+ export function ignoredCanvasArgs(action, supplied) {
5037
+ const used = CANVAS_ACTION_ARGS[action];
5038
+ if (!used)
5039
+ return []; // unknown action — the enum rejects it; never guess here
5040
+ const label = { scale: "scale/zoom" };
5041
+ return ["node_id", "dx", "dy", "scale"]
5042
+ .filter((k) => supplied[k] !== undefined && !used.includes(k))
5043
+ .map((k) => label[k] ?? k);
5044
+ }
5045
+ /** Append a disclosure line to a successful text result, leaving errors alone. */
5046
+ function appendNote(res, note) {
5047
+ const first = res.content[0];
5048
+ if (!first || first.type !== "text")
5049
+ return res;
5050
+ return {
5051
+ ...res,
5052
+ content: [{ ...first, text: `${first.text}\n\n${note}` }, ...res.content.slice(1)],
5053
+ };
5054
+ }
4995
5055
  const RETRY_OF_ARG = {
4996
5056
  retry_of: z
4997
5057
  .string()
@@ -5252,7 +5312,7 @@ export function buildPanelToolDefs() {
5252
5312
  },
5253
5313
  ])),
5254
5314
  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" })),
5255
- 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 }), [
5315
+ 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: nodeId().describe("Subgraph node id (is_subgraph=true).") }, async (args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_get_subgraph", node_id: args.node_id }), [
5256
5316
  {
5257
5317
  flag: "truncated",
5258
5318
  key: "truncation_hint",
@@ -5353,7 +5413,7 @@ export function buildPanelToolDefs() {
5353
5413
  // placeholder — that fetch can outlast the 6000 ms default on a large
5354
5414
  // install. Give it the bounded refresh ack budget.
5355
5415
  ctx.call({ cmd: "graph_add_node", class_type: args.class_type, pos: args.pos, title: args.title }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS)),
5356
- def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
5416
+ def("panel_remove_node", "Remove a node (and its connections) from the user's open graph by id. Undoable with Ctrl+Z.", { node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_node", node_id: args.node_id })),
5357
5417
  def("panel_clear", "Remove EVERY node from the user's open graph — only for an explicit 'clear/reset the canvas'. Just CALL THIS DIRECTLY when they ask to clear: the tool itself pops a confirm card and only wipes on a yes (don't ask separately first). The wipe is a single Ctrl+Z undo. NEVER use this for a 'new workflow' — that's panel_new_workflow (a new tab, leaves this graph intact).", {}, async (_args, ctx) => {
5358
5418
  const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
5359
5419
  if (decision === "timeout") {
@@ -5573,11 +5633,11 @@ export function buildPanelToolDefs() {
5573
5633
  }
5574
5634
  }),
5575
5635
  def("panel_connect", "Connect an output slot of one node to an input slot of another in the user's open graph. Slots accept a name ('MODEL', 'samples') or numeric index. If both slot args are omitted the panel picks the first type-compatible pairing. On failure the error lists every slot with its type and [connected] flag — re-check with panel_query_graph ({ids:[node_id], fields:'detail'}). Undoable.", {
5576
- from_node_id: z.number().int().describe("Source node id."),
5636
+ from_node_id: nodeId().describe("Source node id."),
5577
5637
  from_output: slotRef
5578
5638
  .optional()
5579
5639
  .describe("Source output slot name or index; omit to auto-match by type (prefers an unconnected, exact-type input; `*` wildcards match last)."),
5580
- to_node_id: z.number().int().describe("Target node id."),
5640
+ to_node_id: nodeId().describe("Target node id."),
5581
5641
  to_input: slotRef
5582
5642
  .optional()
5583
5643
  .describe("Target input slot name or index; omit to auto-match by type (prefers an unconnected, exact-type input; `*` wildcards match last)."),
@@ -5604,11 +5664,11 @@ export function buildPanelToolDefs() {
5604
5664
  auto_match: args.auto_match,
5605
5665
  })),
5606
5666
  def("panel_disconnect", "Disconnect an input slot of a node in the user's open graph. Undoable with Ctrl+Z.", {
5607
- node_id: z.number().int().describe("Node id whose input to disconnect."),
5667
+ node_id: nodeId().describe("Node id whose input to disconnect."),
5608
5668
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
5609
5669
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
5610
5670
  def("panel_set_widget", "Set a widget value on a node in the user's open graph (steps, cfg, seed, ckpt_name, text prompts, …). Returns the previous and new value. Undoable with Ctrl+Z. To CLEAR a text widget to an empty string, pass `clear: true` (some MCP clients drop an empty-string `value` from the serialized payload, so `value: \"\"` may not arrive — `clear: true` always works). For the LTXDirector timeline node (WhatDreamsCost CSGlide), set `timeline_data` with the FULL timeline JSON (segments + global_prompt) to drive its custom timeline UI — this re-syncs the editor and regenerates its derived `local_prompts`/`segment_lengths`/`guide_strength` widgets; setting those derived widgets directly is refused (they are silently reverted).", {
5611
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
5671
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
5612
5672
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
5613
5673
  value: z
5614
5674
  .union([z.string(), z.number(), z.boolean()])
@@ -5634,7 +5694,7 @@ export function buildPanelToolDefs() {
5634
5694
  return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
5635
5695
  }),
5636
5696
  def("panel_set_property", "Set a node's LiteGraph PROPERTY (the right-click → Properties panel), NOT a widget — the counterpart to panel_set_widget, which only reaches `widgets`. Many custom nodes are configured entirely through node properties: e.g. the rgthree Fast Groups Bypasser's filters `matchTitle`, `matchColors`, `sort`, and `toggleRestriction` are node properties, and without `matchTitle` the node enumerates EVERY group in the workflow (a footgun). Sets node.properties[name] and, when the node defines an onPropertyChanged callback (rgthree and many LiteGraph nodes do), invokes it so the change takes effect LIVE (e.g. rgthree re-filters its group list). Returns the previous and new value. Undoable with Ctrl+Z.", {
5637
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
5697
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
5638
5698
  name: z
5639
5699
  .string()
5640
5700
  .describe("Property name from the node's right-click → Properties panel (e.g. 'matchTitle', 'matchColors', 'sort', 'toggleRestriction')."),
@@ -5643,8 +5703,8 @@ export function buildPanelToolDefs() {
5643
5703
  .describe("New property value (string/number/boolean/null). For the rgthree Fast Groups Bypasser, matchTitle is a title substring/regex filter."),
5644
5704
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_property", node_id: args.node_id, name: args.name, value: args.value })),
5645
5705
  def("panel_edit_node", "Atomically edit one node, or apply the same edit to several nodes. Pass exactly one of node_id or node_ids, plus at least one field. In one Ctrl+Z step you can move (pos), resize (size — including Note/MarkdownNote), retitle, recolor, change shape, collapse, pin, or set execution mode. Widget values, LiteGraph properties, links, and slot order stay on their dedicated tools. For a multi-node call, position/size/title/mode apply the same value to every target. Color fields accept #RGB, #RGBA, #RRGGBB, or #RRGGBBAA; null clears a color. Bypassing a subgraph retains panel_set_node_mode's unsafe-boundary guard; force:true is required to override it. Undoable with Ctrl+Z.", {
5646
- node_id: z.number().int().optional().describe("One node id from panel_graph_outline / panel_query_graph. Provide this OR node_ids, not both."),
5647
- node_ids: z.array(z.number().int()).min(1).optional().describe("Several node ids that receive the same presentation edit. Provide this OR node_id, not both."),
5706
+ node_id: nodeId().optional().describe("One node id from panel_graph_outline / panel_query_graph. Provide this OR node_ids, not both."),
5707
+ node_ids: z.array(nodeId()).min(1).optional().describe("Several node ids that receive the same presentation edit. Provide this OR node_id, not both."),
5648
5708
  pos: xy().optional().describe("New canvas [x, y]."),
5649
5709
  size: nodeSize().optional().describe("New [width, height] in canvas px. Uses the node's setSize so DOM-widget nodes reflow and minimum sizes are honored."),
5650
5710
  title: z.string().optional().describe("New header title."),
@@ -5680,8 +5740,8 @@ export function buildPanelToolDefs() {
5680
5740
  // Keep legacy bridge commands behind compatibility tool names. graph_edit_node
5681
5741
  // is newer than several installed panels, while current panels adapt these
5682
5742
  // commands into the same atomic implementation.
5683
- def("panel_move_node", "Compatibility wrapper for panel_edit_node(pos).", { node_id: z.number().int(), pos: xy() }, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
5684
- def("panel_resize_node", "Compatibility wrapper for panel_edit_node(size).", { node_id: z.number().int(), size: nodeSize() }, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
5743
+ def("panel_move_node", "Compatibility wrapper for panel_edit_node(pos).", { node_id: nodeId(), pos: xy() }, async (args, ctx) => ctx.call({ cmd: "graph_move_node", node_id: args.node_id, pos: args.pos })),
5744
+ def("panel_resize_node", "Compatibility wrapper for panel_edit_node(size).", { node_id: nodeId(), size: nodeSize() }, async (args, ctx) => ctx.call({ cmd: "graph_resize_node", node_id: args.node_id, size: args.size })),
5685
5745
  def("panel_auto_layout", "Automatically arrange the user's open graph (or a subset of nodes) into a clean left-to-right / top-to-bottom / grid layout based on the real link topology. Group boxes move with their members and are re-fit. Use dry_run:true to preview proposed positions without touching the canvas. Undoable (one Ctrl+Z).", {
5686
5746
  node_ids: z
5687
5747
  .array(z.number().int())
@@ -5712,18 +5772,45 @@ export function buildPanelToolDefs() {
5712
5772
  }, 15000)),
5713
5773
  def("panel_canvas", "MOVE the user's viewport: 'fit' frames the whole graph, 'center_on_node' jumps to a node (give node_id), 'pan' shifts by dx/dy, 'zoom' sets an absolute scale. It changes what they are looking AT and returns nothing about the graph — to find out what is ON the canvas, use panel_graph_outline. View-only.", {
5714
5774
  action: z.enum(["fit", "center_on_node", "pan", "zoom"]),
5715
- node_id: z.number().int().optional().describe("Required for center_on_node."),
5775
+ node_id: nodeId().optional().describe("Required for center_on_node."),
5716
5776
  dx: z.number().optional().describe("Pan delta x."),
5717
5777
  dy: z.number().optional().describe("Pan delta y."),
5718
5778
  scale: z.number().optional().describe("Absolute zoom for 'zoom' (0.05–4, 1 = 100%)."),
5719
- }, async (args, ctx) => ctx.call({
5720
- cmd: "graph_canvas",
5721
- action: args.action,
5722
- node_id: args.node_id,
5723
- dx: args.dx,
5724
- dy: args.dy,
5725
- scale: args.scale,
5726
- })),
5779
+ // #845 the tool used two names for one concept: `action:"zoom"` requires
5780
+ // `scale`, and a `zoom` argument was simply not in the schema, so passing
5781
+ // it was dropped without a word. Accept it as the alias it obviously is.
5782
+ zoom: z.number().optional().describe("Alias for `scale`."),
5783
+ }, async (args, ctx) => {
5784
+ const scale = args.scale ?? args.zoom;
5785
+ const res = await ctx.call({
5786
+ cmd: "graph_canvas",
5787
+ action: args.action,
5788
+ node_id: args.node_id,
5789
+ dx: args.dx,
5790
+ dy: args.dy,
5791
+ scale,
5792
+ });
5793
+ // #845 — an argument this action does not consume was SILENTLY dropped.
5794
+ // The reporter passed zoom:0.55 to center_on_node, got scale 0.067 back,
5795
+ // and had no way to tell the zoom had been ignored rather than applied
5796
+ // and overridden. Only `zoom` applies a scale — the panel's
5797
+ // center_on_node sets the offset alone — so say which arguments this
5798
+ // action actually used.
5799
+ const ignored = ignoredCanvasArgs(String(args.action), {
5800
+ node_id: args.node_id,
5801
+ dx: args.dx,
5802
+ dy: args.dy,
5803
+ scale,
5804
+ });
5805
+ if (!ignored.length || res.isError)
5806
+ return res;
5807
+ return appendNote(res, `Note: action:"${args.action}" does not use ${ignored.join(", ")} — ` +
5808
+ `${ignored.length === 1 ? "it was" : "they were"} ignored, not applied. ` +
5809
+ (ignored.includes("scale/zoom")
5810
+ ? `To change the zoom, call panel_canvas again with action:"zoom" and scale. `
5811
+ : "") +
5812
+ `Any values in this result are the canvas's actual state.`);
5813
+ }),
5727
5814
  def("panel_run", "Queue the workflow the user has OPEN — exactly like them pressing Queue Prompt (current widget values, the live graph they can see). On success it confirms the run was queued; if ComfyUI REFUSES the prompt (validation failure on either channel — per-node node_errors OR a top-level error like a missing node type) it returns a FAILURE with that rejection detail, never a false 'queued'. Pass to_node_id to RUN ONLY ONE BRANCH ('run to node'): ComfyUI renders just that output node plus everything upstream of it and SKIPS every other output branch — handy for previewing or debugging part of a big graph without rendering the whole thing. to_node_id MUST be an OUTPUT node (SaveImage, PreviewImage, SaveVideo, …) — pick the one at the END of the branch you want; nodes are tagged is_output:true in panel_query_graph's detail rows. The output node may be NESTED inside a subgraph — just pass its id (resolved in the scope you're currently viewing, then anywhere in the workflow); the tool builds the nested execution path for you. Omit it to run the whole graph. DUPLICATE FENCE (#862): if a render this session cannot account for is already in flight (after a reconnect this is usually YOUR earlier render still running — the queue record does not survive a restart), the run is REFUSED before anything is queued and the in-flight prompt is named; inspect queue (action:'list') first, or pass allow_duplicate:true only to deliberately stack behind it. Use this so the render runs on THEIR canvas and they see the result.", {
5728
5815
  batch_count: z
5729
5816
  .number()
@@ -6366,8 +6453,13 @@ export function buildPanelToolDefs() {
6366
6453
  items: z
6367
6454
  .array(z.object({
6368
6455
  text: z.string().describe("Short step description (a few words)."),
6456
+ // #1018 — accept the synonyms agents actually produce (in_progress,
6457
+ // completed, …) and normalize them in the handler. The DESCRIPTION
6458
+ // still teaches only the canonical trio: one vocabulary to learn,
6459
+ // and no round trip lost to a rejected first call over a spelling
6460
+ // whose intent was never in doubt.
6369
6461
  status: z
6370
- .enum(["pending", "active", "done"])
6462
+ .enum(TODO_STATUS_INPUTS)
6371
6463
  .optional()
6372
6464
  .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
6373
6465
  }))
@@ -6382,6 +6474,10 @@ export function buildPanelToolDefs() {
6382
6474
  // bound tab is a headless (mobile/remote) client, resolve the live desktop
6383
6475
  // canvas tab instead of dispatching at the headless client — which would
6384
6476
  // reject with the misleading "mobile client has no open canvas".
6477
+ // #1018 — canonicalize ONCE, here, before anything reads the list. The
6478
+ // panel, recordTodo's snapshot (#977) and the completion-directive logic
6479
+ // all keep seeing only pending/active/done.
6480
+ const items = normalizeTodoItems(args.items);
6385
6481
  const redirect = desktopCanvasRedirect(ctx, "panel_set_todo");
6386
6482
  if (redirect?.error)
6387
6483
  return fail(redirect.error);
@@ -6389,14 +6485,14 @@ export function buildPanelToolDefs() {
6389
6485
  // #977 — the desktop redirect writes the checklist to ANOTHER tab, so
6390
6486
  // record it against that one. Keying it on ctx.tabId would leave the
6391
6487
  // tab that actually holds the plan looking planless.
6392
- recordTodo(redirect.tabId, args.items);
6393
- return dispatchToTab(ctx, redirect.tabId, { cmd: "set_todo", items: args.items }, 15000, () => reResolveDesktopTab(ctx, "panel_set_todo"));
6488
+ recordTodo(redirect.tabId, items);
6489
+ return dispatchToTab(ctx, redirect.tabId, { cmd: "set_todo", items }, 15000, () => reResolveDesktopTab(ctx, "panel_set_todo"));
6394
6490
  }
6395
6491
  // #977 — retain what the agent DECLARED, so a later render completion can
6396
6492
  // tell "you are mid-sweep" from "that was the last thing you were doing".
6397
6493
  // The panel keeps the UI copy; this is the orchestrator's own record.
6398
- recordTodo(ctx.tabId, args.items);
6399
- return ctx.call({ cmd: "set_todo", items: args.items }, 15000);
6494
+ recordTodo(ctx.tabId, items);
6495
+ return ctx.call({ cmd: "set_todo", items }, 15000);
6400
6496
  }),
6401
6497
  def("panel_open_civitai", "Open the in-panel CivitAI browser for the user, pre-seeded with a search term and suggested filters, so they can VISUALLY browse and pick a model / LoRA / checkpoint / workflow / image. When the user asks about — or you're recommending — specific CivitAI models/LoRAs/checkpoints (e.g. 'what's a good relight LoRA?'), PREFER opening this docked browser and highlighting your picks over a text-only answer: it docks beside the chat (dock defaults true) so chat and results stay visible together, and it lets the user SEE the actual cards instead of reading a table. Typical show-don't-tell flow: panel_open_civitai (docked) → panel_civitai_search to refine → panel_civitai_results to READ the metadata + URLs → panel_civitai_highlight the one(s) you recommend, with a brief text summary of why. Set a helpful query + filters matched to their goal (including the browsing level). Their selection comes back to you as a normal chat message — UNLESS the panel is muted, in which case they download it directly themselves. Prefer this over guessing a specific model or asking them to paste a URL.", {
6402
6498
  query: z
@@ -6923,7 +7019,53 @@ export function buildPanelToolDefs() {
6923
7019
  note: hint + rebindNote + (fence?.note ?? ""),
6924
7020
  });
6925
7021
  }),
6926
- def("panel_new_workflow", "Open a brand-new BLANK workflow in a NEW TAB. Use this whenever the user wants a 'new workflow' / 'fresh canvas' / 'start over for a new project'. This does NOT touch their current workflow — it opens a separate tab. NEVER use panel_clear for a new workflow (panel_clear wipes the CURRENT graph and is only for 'clear/reset this canvas').", {}, async (_args, ctx) => ctx.call({ cmd: "workflow_new" }, 15000)),
7022
+ def("panel_new_workflow", "Open a brand-new BLANK workflow in a NEW TAB. Use this whenever the user wants a 'new workflow' / 'fresh canvas' / 'start over for a new project'. This does NOT touch their current workflow — it opens a separate tab. NEVER use panel_clear for a new workflow (panel_clear wipes the CURRENT graph and is only for 'clear/reset this canvas').", {}, async (_args, ctx) => {
7023
+ const res = await ctx.call({ cmd: "workflow_new" }, 15000);
7024
+ if (res.isError)
7025
+ return res;
7026
+ // #932 (recurrence on 0.50.6) — a NEW canvas needs a NEW fence.
7027
+ //
7028
+ // workflow_new authoritatively re-points the active workflow, exactly as
7029
+ // workflow_open does — but only the open path re-derived the command
7030
+ // fence afterwards (openWorkflowWithVerify). So this session kept the
7031
+ // PREVIOUS workflow's instance stamp while the user was now looking at a
7032
+ // brand-new blank canvas, and every stamped command after it failed with
7033
+ // "workflow instance mismatch". The reporter created a workflow and could
7034
+ // not add a single node to it.
7035
+ //
7036
+ // Refresh from the panel's own live active record, the same way the open
7037
+ // path does. The panel mints the new canvas's identity EAGERLY at
7038
+ // creation ("so the key exists BEFORE the first edit"), so it is readable
7039
+ // by the time this runs — it is simply not carried on the workflow_new
7040
+ // reply, which returns key/routing_key but no workflow_uuid.
7041
+ //
7042
+ // NEVER fails the call on a rebind miss: the workflow WAS created, and
7043
+ // retracting that would be the worse lie. Disclose instead, so the agent
7044
+ // learns the graph tools are not yet usable here rather than discovering
7045
+ // it one confusing mismatch at a time.
7046
+ const fenceRebind = await rebindWorkflowFence(ctx);
7047
+ let canMutateNow;
7048
+ let refusalCause;
7049
+ try {
7050
+ if (ctx.tabGraphMutationCapability) {
7051
+ const cap = ctx.tabGraphMutationCapability();
7052
+ canMutateNow = cap.known ? cap.canMutate : undefined;
7053
+ if (cap.known && !cap.canMutate)
7054
+ refusalCause = cap.because;
7055
+ }
7056
+ else {
7057
+ canMutateNow = ctx.tabCanMutateGraph?.();
7058
+ }
7059
+ }
7060
+ catch {
7061
+ canMutateNow = undefined; // a guard that can throw is not a guard
7062
+ refusalCause = undefined;
7063
+ }
7064
+ const fence = describeFenceRebind(fenceRebind, canMutateNow, refusalCause);
7065
+ if (!fence || fence.binding === "bound")
7066
+ return res;
7067
+ return appendNote(res, `The blank workflow WAS created.${fence.note}`);
7068
+ }),
6927
7069
  def("panel_open_workflow", "Open / switch to a workflow by path or filename (from panel_list_workflows). Switches the active tab to it. If the workflow was ALREADY open and its .json changed on disk out-of-band, the result carries stale:true (or stale:\"unknown\" when staleness couldn't be verified) with a stale_hint — the canvas still shows the version this tab loaded (it is NOT auto-reloaded); call panel_load_workflow to load the on-disk version.", { path: z.string().describe("Workflow path, filename, or key from panel_list_workflows.") },
6928
7070
  // Verify-after-timeout (#215/#319/#496): a backgrounded/frozen or already-open
6929
7071
  // tab can be slow to ack workflow_open even though the switch succeeded. On an
@@ -6938,8 +7080,8 @@ export function buildPanelToolDefs() {
6938
7080
  path: z.string().optional().describe("Which workflow to close; omit for the active one."),
6939
7081
  force: z.boolean().optional().describe("Close even with unsaved changes (discards them). Default false."),
6940
7082
  }, async (args, ctx) => ctx.call({ cmd: "workflow_close", path: args.path, force: args.force }, 15000)),
6941
- def("panel_select_nodes", "Select nodes on the user's canvas by id (highlights them, sets the multi-selection). Useful before panel_create_subgraph.", { node_ids: z.array(z.number().int()).describe("Node ids to select.") }, async (args, ctx) => ctx.call({ cmd: "graph_select_nodes", node_ids: args.node_ids })),
6942
- def("panel_create_subgraph", "Group the given nodes into a SUBGRAPH (ComfyUI 'Convert to Subgraph') on the user's canvas — collapses them into one subgraph node. Returns the new subgraph node id. Undoable with Ctrl+Z. To wrap an existing GROUP, prefer panel_subgraph_group (you don't have to list the node_ids yourself).", { node_ids: z.array(z.number().int()).describe("Node ids to group into a subgraph.") }, async (args, ctx) => ctx.call({ cmd: "graph_create_subgraph", node_ids: args.node_ids }, 15000)),
7083
+ def("panel_select_nodes", "Select nodes on the user's canvas by id (highlights them, sets the multi-selection). Useful before panel_create_subgraph.", { node_ids: z.array(nodeId()).describe("Node ids to select.") }, async (args, ctx) => ctx.call({ cmd: "graph_select_nodes", node_ids: args.node_ids })),
7084
+ def("panel_create_subgraph", "Group the given nodes into a SUBGRAPH (ComfyUI 'Convert to Subgraph') on the user's canvas — collapses them into one subgraph node. Returns the new subgraph node id. Undoable with Ctrl+Z. To wrap an existing GROUP, prefer panel_subgraph_group (you don't have to list the node_ids yourself).", { node_ids: z.array(nodeId()).describe("Node ids to group into a subgraph.") }, async (args, ctx) => ctx.call({ cmd: "graph_create_subgraph", node_ids: args.node_ids }, 15000)),
6943
7085
  def("panel_subgraph_group", "Wrap an existing GROUP's nodes into ONE subgraph node in a single step — the clean way to refactor a big graph into readable, TOGGLEABLE units. Pass the group by `group` (its title, e.g. 'REPLACEMENT MODE', or its numeric id from panel_query_graph's groups[]). LiteGraph groups don't own nodes — membership is geometric — so this computes which nodes sit inside the group box, selects them, and collapses them via ComfyUI 'Convert to Subgraph', returning the new subgraph node id + the wrapped node ids. After this you can toggle that whole region as ONE unit: panel_set_node_mode(node_id, 'bypass'/'active') on the subgraph node, then panel_run — e.g. queue one run with the region ON and one with it OFF. Undoable with Ctrl+Z. (For an arbitrary set of nodes that isn't a group, use panel_create_subgraph with explicit node_ids.)", {
6944
7086
  group: z
6945
7087
  .union([z.string(), z.number()])
@@ -6959,7 +7101,7 @@ export function buildPanelToolDefs() {
6959
7101
  .describe("Reconnect pasted nodes' inputs to existing nodes where they line up (default false)."),
6960
7102
  }, async (args, ctx) => ctx.call({ cmd: "graph_paste_nodes", pos: args.pos, connect_inputs: args.connect_inputs }, 15000)),
6961
7103
  def("panel_save_subgraph", "Save a SUBGRAPH node to the user's reusable blueprint LIBRARY (publish), so it can be dropped into any workflow later. Pass node_id to pick the subgraph node (else a single selected subgraph node is used) and name to title the blueprint (defaults to the node's title). Runs programmatically — NO save dialog pops. The blueprint becomes the addable type 'SubgraphBlueprint.<name>' (use panel_add_subgraph or panel_list_subgraphs). Returns {saved: {name, type}}.", {
6962
- node_id: z.number().int().optional().describe("Subgraph node id to publish (is_subgraph=true). Omit to use the selected subgraph node."),
7104
+ node_id: nodeId().optional().describe("Subgraph node id to publish (is_subgraph=true). Omit to use the selected subgraph node."),
6963
7105
  name: z.string().optional().describe("Blueprint name. Defaults to the subgraph node's title."),
6964
7106
  }, async (args, ctx) => ctx.call({ cmd: "graph_save_subgraph", node_id: args.node_id, name: args.name }, 20000)),
6965
7107
  def("panel_list_subgraphs", "List the saved subgraph BLUEPRINTS in the user's library (from panel_save_subgraph, plus any global/bundled ones). Each entry has {name, type, display_name, description, is_global} — use name/type with panel_add_subgraph to drop it onto the canvas. Read-only.\n\nTOKEN-BOUNDED like the other reads (panel#690). The reply's count field is always the LIBRARY TOTAL, so compare it against the returned array. When entries are withheld the reply carries truncated:true, a returned field, and a note — an absent entry in a TRUNCATED list is NOT evidence the blueprint does not exist, so narrow with `filter` (or raise `limit`, up to 500) before concluding anything. When a filter is applied the reply's matched field reports how many the filter selected, distinct from the count field, so matched:0 against a non-zero count means the filter missed, not that the library is empty.", {
@@ -7017,14 +7159,14 @@ export function buildPanelToolDefs() {
7017
7159
  bounds: args.bounds,
7018
7160
  }, 15000)),
7019
7161
  def("panel_remove_group", "Remove a group box from the user's open graph. The nodes inside the group are NOT deleted — only the box. Undoable.", { group_id: z.number().int().describe("Group id from panel_query_graph's groups[] / panel_create_group.") }, async (args, ctx) => ctx.call({ cmd: "graph_remove_group", group_id: args.group_id }, 15000)),
7020
- def("panel_set_node_title", "Compatibility wrapper for panel_edit_node(title).", { node_id: z.number().int(), title: z.string() }, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
7021
- def("panel_set_node_collapsed", "Compatibility wrapper for panel_edit_node(collapsed).", { node_id: z.number().int(), collapsed: z.boolean().optional() }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed ?? true })),
7162
+ def("panel_set_node_title", "Compatibility wrapper for panel_edit_node(title).", { node_id: nodeId(), title: z.string() }, async (args, ctx) => ctx.call({ cmd: "graph_set_title", node_id: args.node_id, title: args.title }, 15000)),
7163
+ def("panel_set_node_collapsed", "Compatibility wrapper for panel_edit_node(collapsed).", { node_id: nodeId(), collapsed: z.boolean().optional() }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_collapsed", node_id: args.node_id, collapsed: args.collapsed ?? true })),
7022
7164
  def("panel_set_node_mode", "Set a node's EXECUTION MODE on the user's open graph — active, bypass, or mute — and return { node_id, mode, previous_mode }. This is how you turn a node ON or OFF without deleting it. Modes:\n" +
7023
7165
  "• 'active' — normal: the node executes.\n" +
7024
7166
  "• 'bypass' — the node is SKIPPED and PASSES ITS INPUT THROUGH to its output (downstream still runs, just as if this node weren't there). Use to disable a single processing node (an upscaler, a LoRA, a detailer) while keeping the pipeline connected.\n" +
7025
7167
  "• 'mute' — the node AND everything DOWNSTREAM of it do NOT execute (no pass-through). Use to fully switch off a branch/output.\n" +
7026
7168
  "CRITICAL — modes silently change what a render produces, so they are a top cause of 'wrong output'. A BYPASSED node contributes nothing of its own and a MUTED node kills its branch. Use this tool to ENABLE the path you actually want and DISABLE the one you don't — e.g. to drive a workflow from its Ideogram/JSON prompt builder you must set the manual-prompt node to 'bypass' and the JSON-builder path to 'active' (or vice-versa); likewise to pick one branch of an rgthree 'Fast Groups Bypasser'/Muter or a prompt-source switch. ALWAYS read modes first (panel_graph_outline marks [bypass]/[mute]; panel_query_graph detail rows carry mode): if the intended path is bypassed/muted, fix it HERE before running, and never assume a switch/route is already active. UNSAFE-BYPASS GUARD: bypassing a SUBGRAPH node whose boundary inputs are ordered differently from its outputs is REJECTED — ComfyUI forwards each output from the input at the SAME index, so e.g. an IMAGE output backed by a BBOX_DETECTOR input would silently feed the wrong type downstream. Re-order the boundary inputs or add an explicit ImpactSwitch to choose the passthrough; pass force:true only if you truly intend the positional forward. Undoable with Ctrl+Z.", {
7027
- node_id: z.number().int().describe("Node id from panel_graph_outline / panel_query_graph."),
7169
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
7028
7170
  mode: z
7029
7171
  .enum(["active", "bypass", "mute"])
7030
7172
  .describe("'active' = runs normally; 'bypass' = skipped, passes input through (downstream still runs); 'mute' = node and everything downstream do not execute."),
@@ -7034,7 +7176,7 @@ export function buildPanelToolDefs() {
7034
7176
  .describe("Override the unsafe-bypass guard on a subgraph node (proceed with a positional boundary forward even when input/output types don't line up by index). Omit for normal safe behaviour."),
7035
7177
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode, force: args.force })),
7036
7178
  def("panel_set_node_color", "Legacy color compatibility wrapper. Unlike panel_edit_node, color and bgcolor accept any CSS color string; when preset is supplied it wins over explicit colors, preserving the historical bridge behavior.", {
7037
- node_id: z.number().int(),
7179
+ node_id: nodeId(),
7038
7180
  preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).nullable().optional(),
7039
7181
  color: z.string().nullable().optional(),
7040
7182
  bgcolor: z.string().nullable().optional(),
@@ -7071,19 +7213,19 @@ export function buildPanelToolDefs() {
7071
7213
  return fail(err);
7072
7214
  }
7073
7215
  }),
7074
- def("panel_enter_subgraph", "Navigate INTO a subgraph node so you can read and EDIT its inner nodes — after this, panel_query_graph / panel_graph_outline and all panel_* edit tools target the subgraph's inner graph (the user sees the canvas drill in). This is how you edit inside a subgraph (e.g. tweak a widget on an inner node). Call panel_exit_subgraph when done. Returns the new viewing scope.", { node_id: z.number().int().describe("Subgraph node id (is_subgraph=true).") }, async (args, ctx) => ctx.call({ cmd: "graph_enter_subgraph", node_id: args.node_id }, 15000)),
7216
+ def("panel_enter_subgraph", "Navigate INTO a subgraph node so you can read and EDIT its inner nodes — after this, panel_query_graph / panel_graph_outline and all panel_* edit tools target the subgraph's inner graph (the user sees the canvas drill in). This is how you edit inside a subgraph (e.g. tweak a widget on an inner node). Call panel_exit_subgraph when done. Returns the new viewing scope.", { node_id: nodeId().describe("Subgraph node id (is_subgraph=true).") }, async (args, ctx) => ctx.call({ cmd: "graph_enter_subgraph", node_id: args.node_id }, 15000)),
7075
7217
  def("panel_exit_subgraph", "Leave the current subgraph and return to the root graph (undo a panel_enter_subgraph). After this, panel_* tools target the root graph again.", {}, async (_args, ctx) => ctx.call({ cmd: "graph_exit_subgraph" }, 15000)),
7076
7218
  def("panel_move_rail", "Reposition a subgraph's input or output RAIL (the boundary I/O node that the inner wires connect to). You MUST be INSIDE the subgraph first (panel_enter_subgraph). Read current rail positions from panel_query_graph's `rails` field (present when viewing a subgraph). Use this to place the input rail just left of the first node column and the output rail just right of the last one, so a tidy interior layout doesn't leave the rails stranded. rail is 'input' or 'output'.", {
7077
7219
  rail: z.enum(["input", "output"]).describe("Which boundary rail to move."),
7078
7220
  pos: xy().describe("New top-left [x, y] (two numbers)."),
7079
7221
  }, async (args, ctx) => ctx.call({ cmd: "graph_move_rail", rail: args.rail, pos: args.pos })),
7080
7222
  def("panel_promote_widget", "Expose (promote) an INNER subgraph widget on the PARENT subgraph node, so it can be set from outside without opening the subgraph — e.g. surface an inner KSampler's `seed`/`steps` on the subgraph node. You MUST be inside the subgraph first (call panel_enter_subgraph): `node_id` is an inner node (from panel_query_graph while inside) and `widget` is one of its widget names. Pass demote:true to un-promote. Undoable with Ctrl+Z.", {
7081
- node_id: z.number().int().describe("Inner node id (from panel_query_graph while inside the subgraph)."),
7223
+ node_id: nodeId().describe("Inner node id (from panel_query_graph while inside the subgraph)."),
7082
7224
  widget: z.string().describe("Name of the widget on that node to promote (e.g. 'seed', 'steps', 'text')."),
7083
7225
  demote: z.boolean().optional().describe("Set true to UN-promote (remove the widget from the parent node)."),
7084
7226
  }, async (args, ctx) => ctx.call({ cmd: "graph_promote_widget", node_id: args.node_id, widget: args.widget, demote: args.demote }, 15000)),
7085
7227
  def("panel_expose_subgraph_output", "Wire an interior node's OUTPUT to the subgraph's OUTPUT RAIL — i.e. expose it as a SUBGRAPH OUTPUT on the boundary so the PARENT graph can connect to the subgraph node's new output slot. You MUST be INSIDE the subgraph first (panel_enter_subgraph). This is the correct way to \"wire an internal output to the subgraph's output rail\": do NOT panel_connect to a guessed rail node id — call this with the interior node + the output you want exposed. Read panel_query_graph's `rails` to see the resulting boundary slots. `from_output` is an output slot NAME ('IMAGE', 'LATENT') or numeric index. Optional `name` titles the new boundary output (defaults from the source slot). Undoable with Ctrl+Z.", {
7086
- from_node_id: z.number().int().describe("Interior (inner) node id whose output to expose (from panel_query_graph while inside the subgraph)."),
7228
+ from_node_id: nodeId().describe("Interior (inner) node id whose output to expose (from panel_query_graph while inside the subgraph)."),
7087
7229
  from_output: slotRef.describe("Output slot name (e.g. 'IMAGE', 'LATENT') or numeric index on that node."),
7088
7230
  name: z.string().optional().describe("Optional name for the new subgraph output (boundary slot). Defaults from the source slot."),
7089
7231
  }, async (args, ctx) => ctx.call({
@@ -7093,7 +7235,7 @@ export function buildPanelToolDefs() {
7093
7235
  name: args.name,
7094
7236
  }, 15000)),
7095
7237
  def("panel_expose_subgraph_input", "Wire an interior node's INPUT to the subgraph's INPUT RAIL — i.e. expose it as a SUBGRAPH INPUT on the boundary so the PARENT graph can feed the subgraph node's new input slot. You MUST be INSIDE the subgraph first (panel_enter_subgraph). This is the correct way to wire an internal input to the subgraph's input rail: do NOT panel_connect to a guessed rail node id — call this with the interior node + the input you want exposed. Read panel_query_graph's `rails` to see the resulting boundary slots. `to_input` is an input slot NAME ('model', 'pixels') or numeric index. Optional `name` titles the new boundary input (defaults from the target slot). Undoable with Ctrl+Z.", {
7096
- to_node_id: z.number().int().describe("Interior (inner) node id whose input to expose (from panel_query_graph while inside the subgraph)."),
7238
+ to_node_id: nodeId().describe("Interior (inner) node id whose input to expose (from panel_query_graph while inside the subgraph)."),
7097
7239
  to_input: slotRef.describe("Input slot name (e.g. 'model', 'pixels') or numeric index on that node."),
7098
7240
  name: z.string().optional().describe("Optional name for the new subgraph input (boundary slot). Defaults from the target slot."),
7099
7241
  }, async (args, ctx) => ctx.call({
@@ -7102,7 +7244,7 @@ export function buildPanelToolDefs() {
7102
7244
  to_input: args.to_input,
7103
7245
  name: args.name,
7104
7246
  }, 15000)),
7105
- def("panel_unpack_subgraph", "EXPAND / DISSOLVE a subgraph node on the user's open graph — inline its interior nodes back into the PARENT graph, rewire all external links to those now-inlined nodes, and remove the subgraph wrapper. This is the frontend's \"Unpack Subgraph\" (litegraph LGraph.unpackSubgraph) and the exact INVERSE of panel_create_subgraph. Use it to flatten a stage that was over-nested, or to edit interior nodes directly at the parent level. The interior nodes reappear on the parent canvas with their connections preserved. Undoable with Ctrl+Z.", { node_id: z.number().int().describe("Subgraph node id to unpack/dissolve (is_subgraph=true, from panel_graph_outline / panel_query_graph).") }, async (args, ctx) => ctx.call({ cmd: "graph_unpack_subgraph", node_id: args.node_id }, 15000)),
7247
+ def("panel_unpack_subgraph", "EXPAND / DISSOLVE a subgraph node on the user's open graph — inline its interior nodes back into the PARENT graph, rewire all external links to those now-inlined nodes, and remove the subgraph wrapper. This is the frontend's \"Unpack Subgraph\" (litegraph LGraph.unpackSubgraph) and the exact INVERSE of panel_create_subgraph. Use it to flatten a stage that was over-nested, or to edit interior nodes directly at the parent level. The interior nodes reappear on the parent canvas with their connections preserved. Undoable with Ctrl+Z.", { node_id: nodeId().describe("Subgraph node id to unpack/dissolve (is_subgraph=true, from panel_graph_outline / panel_query_graph).") }, async (args, ctx) => ctx.call({ cmd: "graph_unpack_subgraph", node_id: args.node_id }, 15000)),
7106
7248
  def("panel_search_nodes", "Search installable custom-node packs via the user's BUILT-IN ComfyUI Manager (the same source the Manager UI uses). Returns matching packs {id, title, description}. Use the `id` with panel_install_node. Prefer this over the headless search_custom_nodes tool — it works against the user's actual (Desktop) Manager.", { query: z.string().describe("Search text, e.g. 'kjnodes', 'controlnet', 'ipadapter'."), limit: z.number().int().min(1).max(40).optional() }, async (args, ctx) => ctx.call({ cmd: "nodes_search", query: args.query, limit: args.limit }, 20000)),
7107
7249
  def("panel_list_nodes", "List the custom-node packs currently installed in the user's ComfyUI (via the built-in Manager). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "nodes_list" }, 20000)),
7108
7250
  def("panel_install_node", "Install a custom-node pack into the user's ComfyUI via the BUILT-IN Manager (queues the install). Pass `id` (registry id like 'comfyui-kjnodes' or 'author/repo') from panel_search_nodes, or `repository` (git URL) for a nightly install. A search result whose `id` IS a git URL (legacy/repository-style entries) is auto-routed to a from-source 'nightly' install — 'latest' cannot resolve for those. A ComfyUI restart (panel_restart_comfyui) is usually required afterward to load the nodes — poll panel_node_queue_status first. Prefer this over the headless install_custom_node tool. " +