comfyui-mcp 0.50.7 → 0.50.9

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";
@@ -621,6 +621,33 @@ function isTransientReconnectError(err) {
621
621
  const msg = err instanceof Error ? err.message : String(err ?? "");
622
622
  return /no connected tab|genuinely gone|is not open|Failed to fetch|Panel not reachable|ECONNRESET|socket hang up|premature close|other side closed|ECONNABORTED|EPIPE/i.test(msg);
623
623
  }
624
+ /**
625
+ * #1027 — the panel's WORKFLOW-SWITCH critical section, which is retryable by
626
+ * construction and was not being retried.
627
+ *
628
+ * While a workflow switch/reload is in flight the panel refuses every graph and
629
+ * workflow executor rather than queueing it, because refusing cannot reorder or
630
+ * double-apply. Its own words:
631
+ *
632
+ * the panel is switching/refreshing "<key>" right now, so "<cmd>" was NOT
633
+ * applied — nothing changed. Retry in a moment.
634
+ *
635
+ * Two facts make this the safest retry in the file: the panel STATES nothing was
636
+ * applied, and the section lasts a fraction of a second. Yet none of that text
637
+ * matched the transient classifier, so a read issued right after
638
+ * panel_open_workflow surfaced the refusal to the agent instead of waiting the
639
+ * ~400ms the panel asked for. A reporter driving several tabs read-only hit it
640
+ * repeatedly, and read the resulting instance-mismatch errors as a wedge.
641
+ *
642
+ * TEXT-matched, deliberately, unlike #1001's typed marker: this error is minted
643
+ * in the browser and arrives over the wire, so there is no object identity to
644
+ * carry a symbol across. The phrasing is distinctive enough to key on, and the
645
+ * retry it enables is bounded to RETRY-SAFE commands either way.
646
+ */
647
+ function isWorkflowSwitchGuardRefusal(err) {
648
+ const msg = err instanceof Error ? err.message : String(err ?? "");
649
+ return /panel is switching\/refreshing|panel is switching or reloading/i.test(msg);
650
+ }
624
651
  let retrySettleMsOverride = null;
625
652
  /** Short pause before the single post-drop retry, letting the replacement tab
626
653
  * finish its reconnect hello so ensureReachable can resolve it. Test-overridable. */
@@ -3956,13 +3983,31 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
3956
3983
  // For idempotent commands, settle briefly, rebind onto the now-live tab, and
3957
3984
  // retry ONE time before surfacing an error (#278/#310/#332/#481). Mutating
3958
3985
  // edits are excluded from RETRY_SAFE_CMDS, so they never double-apply.
3959
- if (isRetrySafeCmd(cmd) && isTransientReconnectError(err)) {
3986
+ // #1027 the workflow-switch critical section is retried on the same path.
3987
+ // The panel refuses during it, states that nothing was applied, and asks
3988
+ // for a retry in a moment; the section lasts a fraction of a second, which
3989
+ // is what retrySettleMs already waits. Still gated on RETRY-SAFE commands,
3990
+ // so a mutation is never re-issued on our own initiative.
3991
+ if (isRetrySafeCmd(cmd) && (isTransientReconnectError(err) || isWorkflowSwitchGuardRefusal(err))) {
3960
3992
  try {
3961
3993
  await sleep(retrySettleMs());
3962
3994
  ensureReachable(); // rebinds a current-mode session onto the reconnected tab
3963
3995
  return ok(await sendRouted(cmd, timeoutMs, observeRid));
3964
3996
  }
3965
3997
  catch (err2) {
3998
+ // #1027 — a switch STILL in progress is not a reconnect, and saying so
3999
+ // would be the #1001 mistake again: the tab is connected and healthy,
4000
+ // it is simply mid-switch. Name the actual state and the actual wait.
4001
+ if (isWorkflowSwitchGuardRefusal(err2)) {
4002
+ const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
4003
+ return fail(`${name} was NOT applied — nothing changed. The panel is still switching or ` +
4004
+ `reloading the workflow on the canvas, which it refuses commands during so a ` +
4005
+ `command cannot land on the wrong graph. The tab is connected and this is not a ` +
4006
+ `reconnect: it normally clears in well under a second, so simply retry. If a ` +
4007
+ `switch appears stuck, check the canvas — a load dialog or an unsaved-changes ` +
4008
+ `prompt can hold it open awaiting the user. ` +
4009
+ `(${err2 instanceof Error ? err2.message : String(err2)})`);
4010
+ }
3966
4011
  // The retry also failed — surface an actionable reconnecting status rather
3967
4012
  // than a bare transport error (#332), while still failing honestly.
3968
4013
  if (isTransientReconnectError(err2)) {
@@ -4992,6 +5037,66 @@ function validatePanelEditNodeArgs(args) {
4992
5037
  * forwarded to the wire UNTOUCHED (contrast workflow_uuid, which is bridge-owned
4993
5038
  * and always overwritten). Optional everywhere; never required.
4994
5039
  */
5040
+ /**
5041
+ * #845 — a node id the tools THEMSELVES printed must be accepted back.
5042
+ *
5043
+ * Every panel tool took `z.number().int()` for a node id, while the graph
5044
+ * readers return ids as STRINGS (`"id": "42"`). So the obvious move — copy an id
5045
+ * out of panel_query_graph, paste it into panel_select_nodes — failed on the
5046
+ * first attempt, every time, with a raw zod `expected number, received string`.
5047
+ * The reporter hit it doing exactly that.
5048
+ *
5049
+ * Nothing about `"42"` is ambiguous. Accept both spellings and normalize to the
5050
+ * number the wire has always carried, so the round trip closes.
5051
+ *
5052
+ * DELIBERATELY STRICT about what counts as a node id: only an integer, or a
5053
+ * string that is exactly an integer. `"42px"`, `"4.5"`, `""` and `"5:12"` are
5054
+ * still rejected. The last one matters — a subgraph-qualified id is a real
5055
+ * shape in newer ComfyUI, and silently truncating it to `5` would target the
5056
+ * WRONG node rather than fail. If those need supporting, that is a separate,
5057
+ * deliberate change to the wire contract, not something to fall out of a coerce.
5058
+ */
5059
+ 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)));
5060
+ /**
5061
+ * #845 — which `panel_canvas` arguments the chosen action actually consumes.
5062
+ *
5063
+ * The tool accepted node_id/dx/dy/scale for every action and forwarded them all,
5064
+ * so an argument the action ignores vanished without a word. The reporter passed
5065
+ * `zoom: 0.55` alongside `center_on_node` and got `scale: 0.067` back — which
5066
+ * reads as "your zoom was applied and then overridden", when in truth it was
5067
+ * never applied at all. The panel's center_on_node case sets the offset and
5068
+ * touches the scale not at all.
5069
+ *
5070
+ * Naming what an action ignores is the whole fix. It is NOT an error — passing a
5071
+ * harmless extra argument should not fail a viewport move — but it must not be
5072
+ * silent either.
5073
+ */
5074
+ const CANVAS_ACTION_ARGS = {
5075
+ fit: [], // computes its own framing from the graph bounds
5076
+ center_on_node: ["node_id"],
5077
+ pan: ["dx", "dy"],
5078
+ zoom: ["scale"],
5079
+ };
5080
+ /** Supplied-but-unused argument names for `action`, in a caller-facing spelling. */
5081
+ export function ignoredCanvasArgs(action, supplied) {
5082
+ const used = CANVAS_ACTION_ARGS[action];
5083
+ if (!used)
5084
+ return []; // unknown action — the enum rejects it; never guess here
5085
+ const label = { scale: "scale/zoom" };
5086
+ return ["node_id", "dx", "dy", "scale"]
5087
+ .filter((k) => supplied[k] !== undefined && !used.includes(k))
5088
+ .map((k) => label[k] ?? k);
5089
+ }
5090
+ /** Append a disclosure line to a successful text result, leaving errors alone. */
5091
+ function appendNote(res, note) {
5092
+ const first = res.content[0];
5093
+ if (!first || first.type !== "text")
5094
+ return res;
5095
+ return {
5096
+ ...res,
5097
+ content: [{ ...first, text: `${first.text}\n\n${note}` }, ...res.content.slice(1)],
5098
+ };
5099
+ }
4995
5100
  const RETRY_OF_ARG = {
4996
5101
  retry_of: z
4997
5102
  .string()
@@ -5252,7 +5357,7 @@ export function buildPanelToolDefs() {
5252
5357
  },
5253
5358
  ])),
5254
5359
  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 }), [
5360
+ 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
5361
  {
5257
5362
  flag: "truncated",
5258
5363
  key: "truncation_hint",
@@ -5353,7 +5458,7 @@ export function buildPanelToolDefs() {
5353
5458
  // placeholder — that fetch can outlast the 6000 ms default on a large
5354
5459
  // install. Give it the bounded refresh ack budget.
5355
5460
  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 })),
5461
+ 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
5462
  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
5463
  const decision = await ctx.confirm("Clear the canvas? This removes every node from the open workflow. (One Ctrl+Z undoes it.)", "Clear canvas");
5359
5464
  if (decision === "timeout") {
@@ -5573,11 +5678,11 @@ export function buildPanelToolDefs() {
5573
5678
  }
5574
5679
  }),
5575
5680
  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."),
5681
+ from_node_id: nodeId().describe("Source node id."),
5577
5682
  from_output: slotRef
5578
5683
  .optional()
5579
5684
  .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."),
5685
+ to_node_id: nodeId().describe("Target node id."),
5581
5686
  to_input: slotRef
5582
5687
  .optional()
5583
5688
  .describe("Target input slot name or index; omit to auto-match by type (prefers an unconnected, exact-type input; `*` wildcards match last)."),
@@ -5604,11 +5709,11 @@ export function buildPanelToolDefs() {
5604
5709
  auto_match: args.auto_match,
5605
5710
  })),
5606
5711
  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."),
5712
+ node_id: nodeId().describe("Node id whose input to disconnect."),
5608
5713
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
5609
5714
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
5610
5715
  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."),
5716
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
5612
5717
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
5613
5718
  value: z
5614
5719
  .union([z.string(), z.number(), z.boolean()])
@@ -5634,7 +5739,7 @@ export function buildPanelToolDefs() {
5634
5739
  return ctx.call({ cmd: "graph_set_widget", node_id: args.node_id, widget: args.widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
5635
5740
  }),
5636
5741
  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."),
5742
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
5638
5743
  name: z
5639
5744
  .string()
5640
5745
  .describe("Property name from the node's right-click → Properties panel (e.g. 'matchTitle', 'matchColors', 'sort', 'toggleRestriction')."),
@@ -5643,8 +5748,8 @@ export function buildPanelToolDefs() {
5643
5748
  .describe("New property value (string/number/boolean/null). For the rgthree Fast Groups Bypasser, matchTitle is a title substring/regex filter."),
5644
5749
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_property", node_id: args.node_id, name: args.name, value: args.value })),
5645
5750
  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."),
5751
+ node_id: nodeId().optional().describe("One node id from panel_graph_outline / panel_query_graph. Provide this OR node_ids, not both."),
5752
+ 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
5753
  pos: xy().optional().describe("New canvas [x, y]."),
5649
5754
  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
5755
  title: z.string().optional().describe("New header title."),
@@ -5680,8 +5785,8 @@ export function buildPanelToolDefs() {
5680
5785
  // Keep legacy bridge commands behind compatibility tool names. graph_edit_node
5681
5786
  // is newer than several installed panels, while current panels adapt these
5682
5787
  // 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 })),
5788
+ 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 })),
5789
+ 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
5790
  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
5791
  node_ids: z
5687
5792
  .array(z.number().int())
@@ -5712,18 +5817,45 @@ export function buildPanelToolDefs() {
5712
5817
  }, 15000)),
5713
5818
  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
5819
  action: z.enum(["fit", "center_on_node", "pan", "zoom"]),
5715
- node_id: z.number().int().optional().describe("Required for center_on_node."),
5820
+ node_id: nodeId().optional().describe("Required for center_on_node."),
5716
5821
  dx: z.number().optional().describe("Pan delta x."),
5717
5822
  dy: z.number().optional().describe("Pan delta y."),
5718
5823
  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
- })),
5824
+ // #845 the tool used two names for one concept: `action:"zoom"` requires
5825
+ // `scale`, and a `zoom` argument was simply not in the schema, so passing
5826
+ // it was dropped without a word. Accept it as the alias it obviously is.
5827
+ zoom: z.number().optional().describe("Alias for `scale`."),
5828
+ }, async (args, ctx) => {
5829
+ const scale = args.scale ?? args.zoom;
5830
+ const res = await ctx.call({
5831
+ cmd: "graph_canvas",
5832
+ action: args.action,
5833
+ node_id: args.node_id,
5834
+ dx: args.dx,
5835
+ dy: args.dy,
5836
+ scale,
5837
+ });
5838
+ // #845 — an argument this action does not consume was SILENTLY dropped.
5839
+ // The reporter passed zoom:0.55 to center_on_node, got scale 0.067 back,
5840
+ // and had no way to tell the zoom had been ignored rather than applied
5841
+ // and overridden. Only `zoom` applies a scale — the panel's
5842
+ // center_on_node sets the offset alone — so say which arguments this
5843
+ // action actually used.
5844
+ const ignored = ignoredCanvasArgs(String(args.action), {
5845
+ node_id: args.node_id,
5846
+ dx: args.dx,
5847
+ dy: args.dy,
5848
+ scale,
5849
+ });
5850
+ if (!ignored.length || res.isError)
5851
+ return res;
5852
+ return appendNote(res, `Note: action:"${args.action}" does not use ${ignored.join(", ")} — ` +
5853
+ `${ignored.length === 1 ? "it was" : "they were"} ignored, not applied. ` +
5854
+ (ignored.includes("scale/zoom")
5855
+ ? `To change the zoom, call panel_canvas again with action:"zoom" and scale. `
5856
+ : "") +
5857
+ `Any values in this result are the canvas's actual state.`);
5858
+ }),
5727
5859
  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
5860
  batch_count: z
5729
5861
  .number()
@@ -6366,8 +6498,13 @@ export function buildPanelToolDefs() {
6366
6498
  items: z
6367
6499
  .array(z.object({
6368
6500
  text: z.string().describe("Short step description (a few words)."),
6501
+ // #1018 — accept the synonyms agents actually produce (in_progress,
6502
+ // completed, …) and normalize them in the handler. The DESCRIPTION
6503
+ // still teaches only the canonical trio: one vocabulary to learn,
6504
+ // and no round trip lost to a rejected first call over a spelling
6505
+ // whose intent was never in doubt.
6369
6506
  status: z
6370
- .enum(["pending", "active", "done"])
6507
+ .enum(TODO_STATUS_INPUTS)
6371
6508
  .optional()
6372
6509
  .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
6373
6510
  }))
@@ -6382,6 +6519,10 @@ export function buildPanelToolDefs() {
6382
6519
  // bound tab is a headless (mobile/remote) client, resolve the live desktop
6383
6520
  // canvas tab instead of dispatching at the headless client — which would
6384
6521
  // reject with the misleading "mobile client has no open canvas".
6522
+ // #1018 — canonicalize ONCE, here, before anything reads the list. The
6523
+ // panel, recordTodo's snapshot (#977) and the completion-directive logic
6524
+ // all keep seeing only pending/active/done.
6525
+ const items = normalizeTodoItems(args.items);
6385
6526
  const redirect = desktopCanvasRedirect(ctx, "panel_set_todo");
6386
6527
  if (redirect?.error)
6387
6528
  return fail(redirect.error);
@@ -6389,14 +6530,14 @@ export function buildPanelToolDefs() {
6389
6530
  // #977 — the desktop redirect writes the checklist to ANOTHER tab, so
6390
6531
  // record it against that one. Keying it on ctx.tabId would leave the
6391
6532
  // 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"));
6533
+ recordTodo(redirect.tabId, items);
6534
+ return dispatchToTab(ctx, redirect.tabId, { cmd: "set_todo", items }, 15000, () => reResolveDesktopTab(ctx, "panel_set_todo"));
6394
6535
  }
6395
6536
  // #977 — retain what the agent DECLARED, so a later render completion can
6396
6537
  // tell "you are mid-sweep" from "that was the last thing you were doing".
6397
6538
  // 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);
6539
+ recordTodo(ctx.tabId, items);
6540
+ return ctx.call({ cmd: "set_todo", items }, 15000);
6400
6541
  }),
6401
6542
  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
6543
  query: z
@@ -6923,7 +7064,53 @@ export function buildPanelToolDefs() {
6923
7064
  note: hint + rebindNote + (fence?.note ?? ""),
6924
7065
  });
6925
7066
  }),
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)),
7067
+ 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) => {
7068
+ const res = await ctx.call({ cmd: "workflow_new" }, 15000);
7069
+ if (res.isError)
7070
+ return res;
7071
+ // #932 (recurrence on 0.50.6) — a NEW canvas needs a NEW fence.
7072
+ //
7073
+ // workflow_new authoritatively re-points the active workflow, exactly as
7074
+ // workflow_open does — but only the open path re-derived the command
7075
+ // fence afterwards (openWorkflowWithVerify). So this session kept the
7076
+ // PREVIOUS workflow's instance stamp while the user was now looking at a
7077
+ // brand-new blank canvas, and every stamped command after it failed with
7078
+ // "workflow instance mismatch". The reporter created a workflow and could
7079
+ // not add a single node to it.
7080
+ //
7081
+ // Refresh from the panel's own live active record, the same way the open
7082
+ // path does. The panel mints the new canvas's identity EAGERLY at
7083
+ // creation ("so the key exists BEFORE the first edit"), so it is readable
7084
+ // by the time this runs — it is simply not carried on the workflow_new
7085
+ // reply, which returns key/routing_key but no workflow_uuid.
7086
+ //
7087
+ // NEVER fails the call on a rebind miss: the workflow WAS created, and
7088
+ // retracting that would be the worse lie. Disclose instead, so the agent
7089
+ // learns the graph tools are not yet usable here rather than discovering
7090
+ // it one confusing mismatch at a time.
7091
+ const fenceRebind = await rebindWorkflowFence(ctx);
7092
+ let canMutateNow;
7093
+ let refusalCause;
7094
+ try {
7095
+ if (ctx.tabGraphMutationCapability) {
7096
+ const cap = ctx.tabGraphMutationCapability();
7097
+ canMutateNow = cap.known ? cap.canMutate : undefined;
7098
+ if (cap.known && !cap.canMutate)
7099
+ refusalCause = cap.because;
7100
+ }
7101
+ else {
7102
+ canMutateNow = ctx.tabCanMutateGraph?.();
7103
+ }
7104
+ }
7105
+ catch {
7106
+ canMutateNow = undefined; // a guard that can throw is not a guard
7107
+ refusalCause = undefined;
7108
+ }
7109
+ const fence = describeFenceRebind(fenceRebind, canMutateNow, refusalCause);
7110
+ if (!fence || fence.binding === "bound")
7111
+ return res;
7112
+ return appendNote(res, `The blank workflow WAS created.${fence.note}`);
7113
+ }),
6927
7114
  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
7115
  // Verify-after-timeout (#215/#319/#496): a backgrounded/frozen or already-open
6929
7116
  // tab can be slow to ack workflow_open even though the switch succeeded. On an
@@ -6938,8 +7125,8 @@ export function buildPanelToolDefs() {
6938
7125
  path: z.string().optional().describe("Which workflow to close; omit for the active one."),
6939
7126
  force: z.boolean().optional().describe("Close even with unsaved changes (discards them). Default false."),
6940
7127
  }, 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)),
7128
+ 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 })),
7129
+ 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
7130
  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
7131
  group: z
6945
7132
  .union([z.string(), z.number()])
@@ -6959,7 +7146,7 @@ export function buildPanelToolDefs() {
6959
7146
  .describe("Reconnect pasted nodes' inputs to existing nodes where they line up (default false)."),
6960
7147
  }, async (args, ctx) => ctx.call({ cmd: "graph_paste_nodes", pos: args.pos, connect_inputs: args.connect_inputs }, 15000)),
6961
7148
  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."),
7149
+ node_id: nodeId().optional().describe("Subgraph node id to publish (is_subgraph=true). Omit to use the selected subgraph node."),
6963
7150
  name: z.string().optional().describe("Blueprint name. Defaults to the subgraph node's title."),
6964
7151
  }, async (args, ctx) => ctx.call({ cmd: "graph_save_subgraph", node_id: args.node_id, name: args.name }, 20000)),
6965
7152
  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 +7204,14 @@ export function buildPanelToolDefs() {
7017
7204
  bounds: args.bounds,
7018
7205
  }, 15000)),
7019
7206
  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 })),
7207
+ 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)),
7208
+ 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
7209
  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
7210
  "• 'active' — normal: the node executes.\n" +
7024
7211
  "• '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
7212
  "• 'mute' — the node AND everything DOWNSTREAM of it do NOT execute (no pass-through). Use to fully switch off a branch/output.\n" +
7026
7213
  "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."),
7214
+ node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
7028
7215
  mode: z
7029
7216
  .enum(["active", "bypass", "mute"])
7030
7217
  .describe("'active' = runs normally; 'bypass' = skipped, passes input through (downstream still runs); 'mute' = node and everything downstream do not execute."),
@@ -7034,7 +7221,7 @@ export function buildPanelToolDefs() {
7034
7221
  .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
7222
  }, async (args, ctx) => ctx.call({ cmd: "graph_set_node_mode", node_id: args.node_id, mode: args.mode, force: args.force })),
7036
7223
  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(),
7224
+ node_id: nodeId(),
7038
7225
  preset: z.enum(["red", "brown", "green", "blue", "pale_blue", "cyan", "purple", "yellow", "black"]).nullable().optional(),
7039
7226
  color: z.string().nullable().optional(),
7040
7227
  bgcolor: z.string().nullable().optional(),
@@ -7071,19 +7258,19 @@ export function buildPanelToolDefs() {
7071
7258
  return fail(err);
7072
7259
  }
7073
7260
  }),
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)),
7261
+ 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
7262
  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
7263
  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
7264
  rail: z.enum(["input", "output"]).describe("Which boundary rail to move."),
7078
7265
  pos: xy().describe("New top-left [x, y] (two numbers)."),
7079
7266
  }, async (args, ctx) => ctx.call({ cmd: "graph_move_rail", rail: args.rail, pos: args.pos })),
7080
7267
  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)."),
7268
+ node_id: nodeId().describe("Inner node id (from panel_query_graph while inside the subgraph)."),
7082
7269
  widget: z.string().describe("Name of the widget on that node to promote (e.g. 'seed', 'steps', 'text')."),
7083
7270
  demote: z.boolean().optional().describe("Set true to UN-promote (remove the widget from the parent node)."),
7084
7271
  }, async (args, ctx) => ctx.call({ cmd: "graph_promote_widget", node_id: args.node_id, widget: args.widget, demote: args.demote }, 15000)),
7085
7272
  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)."),
7273
+ from_node_id: nodeId().describe("Interior (inner) node id whose output to expose (from panel_query_graph while inside the subgraph)."),
7087
7274
  from_output: slotRef.describe("Output slot name (e.g. 'IMAGE', 'LATENT') or numeric index on that node."),
7088
7275
  name: z.string().optional().describe("Optional name for the new subgraph output (boundary slot). Defaults from the source slot."),
7089
7276
  }, async (args, ctx) => ctx.call({
@@ -7093,7 +7280,7 @@ export function buildPanelToolDefs() {
7093
7280
  name: args.name,
7094
7281
  }, 15000)),
7095
7282
  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)."),
7283
+ to_node_id: nodeId().describe("Interior (inner) node id whose input to expose (from panel_query_graph while inside the subgraph)."),
7097
7284
  to_input: slotRef.describe("Input slot name (e.g. 'model', 'pixels') or numeric index on that node."),
7098
7285
  name: z.string().optional().describe("Optional name for the new subgraph input (boundary slot). Defaults from the target slot."),
7099
7286
  }, async (args, ctx) => ctx.call({
@@ -7102,7 +7289,7 @@ export function buildPanelToolDefs() {
7102
7289
  to_input: args.to_input,
7103
7290
  name: args.name,
7104
7291
  }, 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)),
7292
+ 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
7293
  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
7294
  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
7295
  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. " +