comfyui-mcp 0.52.49 → 0.52.51

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.
@@ -48,6 +48,7 @@ import { peekResolvedPanelBase, primePanelBase, verifiedPanelDiskVersion, } from
48
48
  import { conversationOfScopeAddress, isScopeAddress, shortTabId } from "../services/session-scope.js";
49
49
  import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId, PLAIN_NODE_ID_PATTERN, unionErrorFor, } from "./node-id.js";
50
50
  import { unrecognizedKeysError } from "./unrecognized-keys.js";
51
+ import { FIND_NODES_DEFAULT_FIELDS, FIND_NODES_FIELDS, GROUP_ID_MESSAGE, GROUP_ID_PATTERN, normalizeGroupId, normalizeShowMediaItem, normalizeTodoItemKeys, pickAlias, projectFindNodesPayload, } from "./panel-arg-aliases.js";
51
52
  import { addressedNodeMatchesPersistRemedy, parseAmbiguousPromotedWidgetRefusal, parseContradictoryPromotedWidgetRefusal, parseSubgraphScopeRefusal, parseUnpromotedControlPersistRemedy, resolveInnerPromotedTarget, } from "./promoted-widget.js";
52
53
  import { includeRequestedCreateGroupMembers } from "./create-group-membership.js";
53
54
  import { sizeForUncollapse } from "./edit-node-uncollapse.js";
@@ -10627,7 +10628,12 @@ export const __panelAskTestHooks = {
10627
10628
  * verbatim; a `.safeParse()` probe against the returned tool definition confirmed
10628
10629
  * an unrecognized key is rejected with `Unrecognized key: "..."`.
10629
10630
  */
10630
- function strictPanelSchema(shape) {
10631
+ /** Exported for #1968's tests: an alias is a SCHEMA-layer fix, and every failure
10632
+ * in that report was a validation error the handler never saw. A test that calls
10633
+ * `def.handler` directly proves nothing about it — it has to parse through the
10634
+ * very validator both transports install, or it is asserting on a code path the
10635
+ * caller cannot reach. */
10636
+ export function strictPanelSchema(shape) {
10631
10637
  return z.object(shape, { error: unrecognizedKeysError(shape) }).strict();
10632
10638
  }
10633
10639
  const PANEL_EDIT_NODE_FIELDS = ["pos", "size", "title", "preset", "color", "bgcolor", "shape", "collapsed", "pinned", "mode"];
@@ -10689,6 +10695,118 @@ const nodeId = () => z
10689
10695
  error: unionErrorFor(NODE_ID_MESSAGE),
10690
10696
  })
10691
10697
  .transform(normalizeNodeId);
10698
+ /**
10699
+ * #1968 — a GROUP id, in either spelling, normalized to the number the wire takes.
10700
+ *
10701
+ * The same #845 round trip nodeId() fixed, one surface over: `panel_query_graph`
10702
+ * prints a groups[] entry and the group tools took `z.number().int()`, so pasting
10703
+ * the id back failed whenever the reader had rendered it as a string. Coercion
10704
+ * here is not cosmetic — the panel's `resolveGroup` compares with a strict `===`
10705
+ * against `group.id` and its index fallback is guarded by
10706
+ * `typeof groupId === "number"`, so a string on the wire misses a group that
10707
+ * exists and reports "No group with id …" about the one the caller is looking at.
10708
+ *
10709
+ * The union carries an explicit `error` for the #1889 reason: without it zod
10710
+ * renders a failed union as the bare words "Invalid input" and buries the real
10711
+ * message in a nested `errors` array no agent-facing renderer prints.
10712
+ */
10713
+ const groupId = () => z
10714
+ .union([z.number().int(), z.string().regex(GROUP_ID_PATTERN, GROUP_ID_MESSAGE)], {
10715
+ error: unionErrorFor(GROUP_ID_MESSAGE),
10716
+ })
10717
+ .transform(normalizeGroupId);
10718
+ /**
10719
+ * #1968 — resolve a group tool's `group_id` / `group` pair to one id.
10720
+ *
10721
+ * `group` is the alias because it is the first guess and the guess was well
10722
+ * founded: `panel_subgraph_group` — the group tool a caller most recently used —
10723
+ * takes `group`, and `panel_create_group` REPLIES with `group.id`, so "the group"
10724
+ * is what the surface itself calls it. Refusing that spelling with a raw
10725
+ * `unrecognized_keys: ["group"]` beside a separate `expected number, received
10726
+ * undefined` at `group_id` puts both halves of the answer in front of the caller
10727
+ * and makes them join it.
10728
+ */
10729
+ function resolveGroupIdArg(tool, args) {
10730
+ return pickAlias(tool, { name: "group_id", value: args.group_id }, { name: "group", value: args.group });
10731
+ }
10732
+ /**
10733
+ * #1968 — apply `panel_find_nodes`' `fields` projection to the panel's reply.
10734
+ *
10735
+ * Done HERE and not on the wire on purpose. The panel's `graph_find_nodes` takes
10736
+ * eleven named parameters and `fields` is not one of them, so a forwarded
10737
+ * projection would be dropped on arrival and the tool would advertise a bound
10738
+ * nothing enforced. The payload it returns is structured (`matches: [...]` of
10739
+ * whole node summaries), so the orchestrator can do the whole job itself — and
10740
+ * every panel build old and new gets the projection, rather than only those
10741
+ * shipped after this change.
10742
+ *
10743
+ * Runs AFTER withTruncationHints so the `truncated` / `truncation_hint` pair is
10744
+ * computed against the real match count. Projecting first would leave the hint
10745
+ * describing a payload that no longer exists, and under `fields:'ids'` there
10746
+ * would be no rows left for `replyCount` to count.
10747
+ *
10748
+ * FAILS OPEN, silently and deliberately. An unparseable reply, an error reply, or
10749
+ * a payload whose `matches` is not an array is returned untouched, and — this is
10750
+ * the load-bearing half — the `fields` marker is NOT stamped on it. A reply that
10751
+ * announces a projection it did not receive is the "schema says ACCEPT, not DO"
10752
+ * defect wearing the evidence of a fix, and an agent reading `fields:"compact"`
10753
+ * on a full-detail payload has been told something false about its own context.
10754
+ */
10755
+ function projectFindNodesReply(res, requested) {
10756
+ const fields = typeof requested === "string" && FIND_NODES_FIELDS.includes(requested)
10757
+ ? requested
10758
+ : FIND_NODES_DEFAULT_FIELDS;
10759
+ const payload = parseToolResultJson(res);
10760
+ if (!payload)
10761
+ return res;
10762
+ const projected = projectFindNodesPayload(payload, fields);
10763
+ if (!projected)
10764
+ return res;
10765
+ const idx = res.content.findIndex((c) => c.type === "text");
10766
+ if (idx < 0)
10767
+ return res;
10768
+ return {
10769
+ ...res,
10770
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(projected, null, 2) } : c),
10771
+ };
10772
+ }
10773
+ /**
10774
+ * #1968 — one checklist schema, shared by `items` and its alias `todos`.
10775
+ *
10776
+ * `text` is OPTIONAL here and required by `normalizeTodoItemKeys` in the handler,
10777
+ * because `content` is the other accepted spelling and zod cannot express "one of
10778
+ * these two". Optional-in-the-shape is not optional-in-effect: an item with
10779
+ * NEITHER key is refused by name, so the alias widens which spelling lands
10780
+ * without quietly making a step description skippable.
10781
+ */
10782
+ const todoChecklist = () => z.array(z.object({
10783
+ text: z.string().optional().describe("Short step description (a few words)."),
10784
+ // #1968 — `content` is what the analogous built-in TODO surface calls it,
10785
+ // so it is the first guess. It is accepted, and `text` stays canonical.
10786
+ content: z.string().optional().describe("Alias for text."),
10787
+ // #1018 — accept the synonyms agents actually produce (in_progress,
10788
+ // completed, …) and normalize them in the handler. The DESCRIPTION
10789
+ // still teaches only the canonical trio: one vocabulary to learn,
10790
+ // and no round trip lost to a rejected first call over a spelling
10791
+ // whose intent was never in doubt.
10792
+ status: z
10793
+ .enum(TODO_STATUS_INPUTS)
10794
+ .optional()
10795
+ .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
10796
+ }));
10797
+ /** The shared schema pair every group tool takes, so the three cannot drift into
10798
+ * disagreeing about which spellings they accept (#1968). Both are optional in
10799
+ * the SHAPE and exactly one is required by `resolveGroupIdArg` — a cross-field
10800
+ * rule a flat ZodRawShape cannot express, handled the same way
10801
+ * `validatePanelEditNodeArgs` handles node_id/node_ids. */
10802
+ const GROUP_ID_ARGS = {
10803
+ group_id: groupId()
10804
+ .optional()
10805
+ .describe("Group id from panel_query_graph's groups[] / panel_create_group."),
10806
+ group: groupId()
10807
+ .optional()
10808
+ .describe("Alias for group_id — the same id under the name panel_subgraph_group takes and panel_create_group returns."),
10809
+ };
10692
10810
  /**
10693
10811
  * #1497 — the ONE node-id argument #845 never reached: panel_run's `to_node_id`.
10694
10812
  *
@@ -11091,7 +11209,7 @@ export function buildPanelToolDefs() {
11091
11209
  "panel_enter_subgraph into it, then panel_query_graph — which takes limit (max 200) and max_chars. It has no cursor, so beyond 200 inner nodes use its types/where filters to work through them."),
11092
11210
  },
11093
11211
  ])),
11094
- def("panel_find_nodes", "SEARCH the live canvas for nodes matching a term you supply — the right way to PINPOINT a node (a specific loader, sampler, save, switch) in a LARGE graph. Supply a free-text `query` and/or targeted filters; with nothing specific in mind, read the whole graph with panel_graph_outline instead. This searches the LIVE graph ON THE CANVAS — NOT the installable node registry (that's panel_search_nodes). It scans the graph in the canvas's own node order and STOPS once it has `limit` matches (default 40, max 200) — so a capped result is neither exhaustive nor a count of all matches: the result's count field is what was returned, total is the graph's node count, and truncated:true means the scan REACHED the cap — on current panels that proves more matches exist, on older panel builds it can also fire on an exactly-`limit` result that dropped nothing, so read it as 'may be incomplete'. Either way the result carries a truncation_hint naming the fix (raise `limit`, up to 200, or add a filter) — retry, do not conclude the node isn't there. Give a free-text `query` (matched case-insensitively across node type, title, description, widget NAMES, widget VALUES, and input/output port names+types — a node hits if ANY of those contain it) and/or targeted filters: type, title, input, output, widget (name), widget_value (contents), is_output, is_subgraph, mode. Targeted filters are ANDed together; the free `query` ORs across fields. Each match is the SAME rich summary as panel_query_graph's detail rows (id, type, title, widgets, inputs WITH their connected_from sources, outputs, mode, is_output, …) PLUS the node's description and a `matched_on` list saying WHY it matched. Read-only. Examples — the video loader: {query:'tiktok'} or {type:'LoadVideo'} or {input:'video'}; every output node: {is_output:true}; the node whose widget holds a file: {widget_value:'.png'}; a bypassed switch: {type:'Switch', mode:'bypass'}.", {
11212
+ def("panel_find_nodes", "SEARCH the live canvas for nodes matching a term you supply — the right way to PINPOINT a node (a specific loader, sampler, save, switch) in a LARGE graph. Supply a free-text `query` and/or targeted filters; with nothing specific in mind, read the whole graph with panel_graph_outline instead. This searches the LIVE graph ON THE CANVAS — NOT the installable node registry (that's panel_search_nodes). It scans the graph in the canvas's own node order and STOPS once it has `limit` matches (default 40, max 200) — so a capped result is neither exhaustive nor a count of all matches: the result's count field is what was returned, total is the graph's node count, and truncated:true means the scan REACHED the cap — on current panels that proves more matches exist, on older panel builds it can also fire on an exactly-`limit` result that dropped nothing, so read it as 'may be incomplete'. Either way the result carries a truncation_hint naming the fix (raise `limit`, up to 200, or add a filter) — retry, do not conclude the node isn't there. Give a free-text `query` (matched case-insensitively across node type, title, description, widget NAMES, widget VALUES, and input/output port names+types — a node hits if ANY of those contain it) and/or targeted filters: type, title, input, output, widget (name), widget_value (contents), is_output, is_subgraph, mode. Targeted filters are ANDed together; the free `query` ORs across fields. `fields` sets how much comes back per match, exactly as it does on panel_query_graph: 'compact' (THE DEFAULT) gives id, type, title, mode, is_output, is_subgraph and a `matched_on` list saying WHY the node matched — enough to pinpoint a node and decide what to read next, and cheap enough that searching a big graph cannot flood your context; 'ids' gives bare ids; 'detail' gives the SAME rich summary as panel_query_graph's detail rows (widgets, inputs WITH their connected_from sources, outputs) PLUS the node's description. So a match with no `widgets` key means you asked for compact, NOT that the node has no widgets — re-run with fields:'detail', or read one node with panel_query_graph({ids:[id], fields:'detail'}). Every projected reply echoes the `fields` it applied. Read-only. Examples — the video loader: {query:'tiktok'} or {type:'LoadVideo'} or {input:'video'}; every output node: {is_output:true}; the node whose widget holds a file: {widget_value:'.png'}; a bypassed switch: {type:'Switch', mode:'bypass'}.", {
11095
11213
  query: z
11096
11214
  .string()
11097
11215
  .optional()
@@ -11133,7 +11251,15 @@ export function buildPanelToolDefs() {
11133
11251
  .max(FIND_NODES_LIMIT_CEILING)
11134
11252
  .optional()
11135
11253
  .describe(`Max matches to return (default ${FIND_NODES_DEFAULT_LIMIT}, max ${FIND_NODES_LIMIT_CEILING}). The scan STOPS once this many match, so a capped result is not a complete match set.`),
11136
- }, async (args, ctx) => withTruncationHints(await ctx.call({
11254
+ // #1968 — the same three names, with the same meanings, as
11255
+ // panel_query_graph's `fields`. This tool had no verbosity control at
11256
+ // all while its sibling defaulted to compact, so a SEARCH — the cheap
11257
+ // orienting call — was the most expensive read on the surface.
11258
+ fields: z
11259
+ .enum(FIND_NODES_FIELDS)
11260
+ .optional()
11261
+ .describe(`Projection: 'compact' (default) keeps id/type/title/mode/is_output/is_subgraph and the matched_on reasons; 'ids' returns bare ids; 'detail' is the full node summary with every widget, socket and the node description. The reply echoes the projection it applied.`),
11262
+ }, async (args, ctx) => projectFindNodesReply(withTruncationHints(await ctx.call({
11137
11263
  cmd: "graph_find_nodes",
11138
11264
  query: args.query,
11139
11265
  type: args.type,
@@ -11168,7 +11294,7 @@ export function buildPanelToolDefs() {
11168
11294
  `treat it as "not proof a node is absent" rather than as the full match set. ${raise}.`);
11169
11295
  },
11170
11296
  },
11171
- ])),
11297
+ ]), args.fields)),
11172
11298
  def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values. Frontend-only virtual types are addable too: 'Note' and 'MarkdownNote' — the supported way to ANNOTATE a workflow with on-canvas instructions (add the node, then put the text in its 'text' widget via panel_set_widget) — plus 'Reroute' and 'PrimitiveNode'. These are LiteGraph-native and never appear in the backend node registry, so they legitimately bypass the backend class_type check. ComfyUI-LoRA-Manager's 'Lora Loader (LoraManager)' (and other AUTOCOMPLETE_TEXT_* nodes) cannot be added: the pack is healthy, but the add-node guard cannot see its Vue autocomplete widget. Use 'LoRA Text Loader (LoraManager)' — same outputs, lora_syntax is a STRING — or core LoraLoader; reload/retry will not clear it. A 'no installed node outputs' refusal for a custom link type (SEEDVR2_DIT, SEEDVR2_VAE, DICT, …) after sibling producer nodes were just added (ConvertAny2Dict on the canvas while DictGetNode is refused) is the guard looking at a single-class /object_info, not the live graph; retry/refresh will not clear it — copy the node from another workflow (panel_copy_nodes / panel_paste_nodes) or reload the tab. ADD NODES ONE AT A TIME, not as a parallel batch: each add carries a fresh /object_info payload and those register SERIALLY, so N concurrent adds become N sequential refresh cycles. On a large install that outruns the 30s per-command deadline and the later adds time out WHILE STILL QUEUED — they then apply when their turn arrives, leaving nodes you were told had failed (panel#767). Sequential adds each get the refresh to themselves and stay well inside the deadline.", {
11173
11299
  class_type: z.string().describe("Exact ComfyUI node class_type to create."),
11174
11300
  pos: xy()
@@ -12889,20 +13015,21 @@ export function buildPanelToolDefs() {
12889
13015
  }
12890
13016
  }),
12891
13017
  def("panel_set_todo", "Show/update a live TODO checklist in the panel's footer tray — a running view of your plan that the user watches as you work a multi-step task. Pass the FULL ordered list each call (it replaces the tray); update each step's status as you progress (pending → active → done). Pass an empty array to clear it. Use for genuinely multi-step work (3+ steps); skip it for quick one-shot replies. Mark exactly one step 'active' at a time.", {
12892
- items: z
12893
- .array(z.object({
12894
- text: z.string().describe("Short step description (a few words)."),
12895
- // #1018 — accept the synonyms agents actually produce (in_progress,
12896
- // completed, …) and normalize them in the handler. The DESCRIPTION
12897
- // still teaches only the canonical trio: one vocabulary to learn,
12898
- // and no round trip lost to a rejected first call over a spelling
12899
- // whose intent was never in doubt.
12900
- status: z
12901
- .enum(TODO_STATUS_INPUTS)
12902
- .optional()
12903
- .describe("Step state (default 'pending'). Mark the one you're on 'active'."),
12904
- }))
13018
+ // Optional in the SHAPE, required by `pickAlias` in the handler: exactly
13019
+ // one of items/todos must be present, which a flat ZodRawShape cannot
13020
+ // say. Leaving `items` required here would have made the alias dead —
13021
+ // `{todos:[…]}` alone would still fail, on the canonical key.
13022
+ items: todoChecklist()
13023
+ .optional()
12905
13024
  .describe("The full ordered checklist (replaces the current one). Empty array clears the tray."),
13025
+ // #1968 — `todos` is the first guess ("todo" is in the tool's own name)
13026
+ // and it cost the reporter a round trip, then a SECOND one when the
13027
+ // retry tripped `content` vs `text`. Same trade as #1018's status
13028
+ // synonyms: the alias exists so a first call lands; `items` stays the
13029
+ // one spelling the description teaches.
13030
+ todos: todoChecklist()
13031
+ .optional()
13032
+ .describe("Alias for items — the same ordered checklist."),
12906
13033
  },
12907
13034
  // #322: a 5s ack deadline false-timed-out a responsive session whose tab was
12908
13035
  // momentarily backgrounded. set_todo is a non-destructive, idempotent full-
@@ -12916,7 +13043,16 @@ export function buildPanelToolDefs() {
12916
13043
  // #1018 — canonicalize ONCE, here, before anything reads the list. The
12917
13044
  // panel, recordTodo's snapshot (#977) and the completion-directive logic
12918
13045
  // all keep seeing only pending/active/done.
12919
- const items = normalizeTodoItems(args.items);
13046
+ // #1968 — resolve items/todos and text/content BEFORE #1018's status pass,
13047
+ // so everything downstream (the panel, recordTodo's snapshot, the
13048
+ // completion-directive logic) sees exactly one canonical shape.
13049
+ const list = pickAlias("panel_set_todo", { name: "items", value: args.items }, { name: "todos", value: args.todos });
13050
+ if (!list.ok)
13051
+ return fail(list.error);
13052
+ const keyed = normalizeTodoItemKeys(list.value);
13053
+ if (!keyed.ok)
13054
+ return fail(keyed.error);
13055
+ const items = normalizeTodoItems(keyed.value);
12920
13056
  const redirect = desktopCanvasRedirect(ctx, "panel_set_todo");
12921
13057
  if (redirect?.error)
12922
13058
  return fail(redirect.error);
@@ -14016,7 +14152,23 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
14016
14152
  force: z.boolean().optional().describe("Close even with unsaved changes (discards them). Default false."),
14017
14153
  }, async (args, ctx) => ctx.call({ cmd: "workflow_close", path: args.path, force: args.force }, 15000)),
14018
14154
  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 })),
14019
- 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)),
14155
+ 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; rename it with panel_edit_node({node_id, title}). Undoable with Ctrl+Z. To wrap an existing GROUP, prefer panel_subgraph_group (you don't have to list the node_ids yourself).",
14156
+ // #1968 asked for a `title` here, and the argument for it is good: the
14157
+ // sibling panel_create_group takes one, and the node gets a name either
14158
+ // way. It is NOT built, on purpose — see the note in the issue thread.
14159
+ //
14160
+ // The panel's graph_create_subgraph destructures only `{ node_ids }`, so
14161
+ // honouring a title means a SECOND command (graph_set_title) against the id
14162
+ // the first returned. That is a new capability rather than a rejected-valid-
14163
+ // call fix, which the feature freeze parks; and it carries a window this
14164
+ // side cannot close — each dispatch is stamped with ctx.workflowUuid
14165
+ // resolved AT DISPATCH, so a workflow switch between the two commands would
14166
+ // let the rename land on whatever node holds that id on the NEW canvas.
14167
+ //
14168
+ // The rest of #1968 is aliasing keys that already meant something, which
14169
+ // adds no capability and is why it ships here. Naming a subgraph is one
14170
+ // extra call away and always was.
14171
+ { 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)),
14020
14172
  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.)", {
14021
14173
  group: z
14022
14174
  .union([z.string(), z.number()])
@@ -14077,28 +14229,43 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
14077
14229
  color: args.color,
14078
14230
  font_size: args.font_size,
14079
14231
  }, 15000), args.node_ids, (cmd, timeoutMs) => ctx.call(cmd, timeoutMs))),
14080
- def("panel_move_group", "Move a group box to a new top-left [x, y] on the user's open graph. By default the nodes inside the group move with it (like dragging the group header); pass move_nodes:false to move only the box. Group id comes from panel_query_graph (the `groups` array on every result) or panel_create_group. Undoable.", {
14081
- group_id: z.number().int().describe("Group id from panel_query_graph's groups[] / panel_create_group."),
14232
+ def("panel_move_group", "Move a group box to a new top-left [x, y] on the user's open graph. By default the nodes inside the group move with it (like dragging the group header); pass move_nodes:false to move only the box. Group id comes from panel_query_graph (the `groups` array on every result) or panel_create_group, as `group_id` or `group`. Undoable.", {
14233
+ ...GROUP_ID_ARGS,
14082
14234
  pos: xy().describe("New top-left [x, y] (two numbers)."),
14083
14235
  move_nodes: z.boolean().optional().describe("Move the contained nodes too (default true)."),
14084
- }, async (args, ctx) => ctx.call({ cmd: "graph_move_group", group_id: args.group_id, pos: args.pos, move_nodes: args.move_nodes })),
14085
- def("panel_edit_group", "Edit a group box: its title, color, font_size, and/or bounds [x, y, width, height]. Only the fields you pass are changed. Undoable.", {
14086
- group_id: z.number().int().describe("Group id from panel_query_graph's groups[] / panel_create_group."),
14236
+ }, async (args, ctx) => {
14237
+ const id = resolveGroupIdArg("panel_move_group", args);
14238
+ if (!id.ok)
14239
+ return fail(id.error);
14240
+ return ctx.call({ cmd: "graph_move_group", group_id: id.value, pos: args.pos, move_nodes: args.move_nodes });
14241
+ }),
14242
+ def("panel_edit_group", "Edit a group box: its title, color, font_size, and/or bounds [x, y, width, height]. Only the fields you pass are changed. Identify it with `group_id` or `group`. Undoable.", {
14243
+ ...GROUP_ID_ARGS,
14087
14244
  title: z.string().optional().describe("New label."),
14088
14245
  color: z.string().optional().describe("New box/header color, e.g. '#3f789e'."),
14089
14246
  font_size: z.number().optional().describe("New title font size."),
14090
14247
  bounds: rect()
14091
14248
  .optional()
14092
14249
  .describe("Resize/reposition the box: [x, y, width, height] (four numbers)."),
14093
- }, async (args, ctx) => ctx.call({
14094
- cmd: "graph_edit_group",
14095
- group_id: args.group_id,
14096
- title: args.title,
14097
- color: args.color,
14098
- font_size: args.font_size,
14099
- bounds: args.bounds,
14100
- }, 15000)),
14101
- 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)),
14250
+ }, async (args, ctx) => {
14251
+ const id = resolveGroupIdArg("panel_edit_group", args);
14252
+ if (!id.ok)
14253
+ return fail(id.error);
14254
+ return ctx.call({
14255
+ cmd: "graph_edit_group",
14256
+ group_id: id.value,
14257
+ title: args.title,
14258
+ color: args.color,
14259
+ font_size: args.font_size,
14260
+ bounds: args.bounds,
14261
+ }, 15000);
14262
+ }),
14263
+ 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. Pass the id as `group_id` (or `group` — the same argument under either name). Undoable.", { ...GROUP_ID_ARGS }, async (args, ctx) => {
14264
+ const id = resolveGroupIdArg("panel_remove_group", args);
14265
+ if (!id.ok)
14266
+ return fail(id.error);
14267
+ return ctx.call({ cmd: "graph_remove_group", group_id: id.value }, 15000);
14268
+ }),
14102
14269
  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)),
14103
14270
  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 })),
14104
14271
  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" +
@@ -15685,7 +15852,15 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
15685
15852
  def("panel_show_media", "Display one or more images or videos directly in the panel chat. Use this whenever the user asks to SEE or SHOW a file — a disk path you composited/downloaded/generated (absolute path on the orchestrator host) OR a ComfyUI output ref ({ filename, subfolder?, type? }). Items are rendered as media cards in the agent chat area; supply optional captions. Max 8 items per call. A path item OVER the 20 MB inline cap that is not under any directory ComfyUI serves can still be shown by passing stage:true on that item — the orchestrator COPIES it into <output>/_panel_staged (an opt-in, persistent disk write; 512 MB per-file and 2 GB total caps) and displays the copy by reference. NEVER describe an image with emoji or text placeholders — call this tool instead.", {
15686
15853
  items: z
15687
15854
  .array(z.object({
15688
- source: z.union([
15855
+ // #1968 — OPTIONAL in the shape, required by normalizeShowMediaItem
15856
+ // in the handler, which accepts the FLAT spelling below as an
15857
+ // equivalent. The tool's own prose says "pass the output ref
15858
+ // {filename, subfolder, type}" and that is exactly what the
15859
+ // reporter passed — one level too shallow. A doc that describes the
15860
+ // payload and a schema that wants it wrapped is a defect in the
15861
+ // pair, not in the caller.
15862
+ source: z
15863
+ .union([
15689
15864
  // Absolute file path on the orchestrator host
15690
15865
  z.object({
15691
15866
  path: z.string().min(1),
@@ -15700,13 +15875,37 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
15700
15875
  subfolder: z.string().optional(),
15701
15876
  type: z.string().optional(),
15702
15877
  }),
15703
- ]),
15878
+ ])
15879
+ .optional(),
15880
+ // The same two members, FLAT on the item — the shape the tool
15881
+ // description already tells callers to send. Wrapped into `source`
15882
+ // by normalizeShowMediaItem before anything reads it, so the rest of
15883
+ // this handler still sees exactly one shape.
15884
+ filename: z.string().min(1).optional().describe("ComfyUI output ref, flat: accepted in place of source.{filename}."),
15885
+ subfolder: z.string().optional().describe("Goes with a flat filename."),
15886
+ type: z.string().optional().describe("Goes with a flat filename (e.g. 'output', 'temp')."),
15887
+ path: z.string().min(1).optional().describe("Absolute disk path, flat: accepted in place of source.{path}."),
15888
+ stage: z.boolean().optional().describe("Goes with a flat path — see source.stage."),
15704
15889
  caption: z.string().optional(),
15890
+ // #1968 — accepted because a caller who guessed it once will guess
15891
+ // it again. `caption` stays canonical; it has been the key since
15892
+ // v0.19.0 and was never named `label`.
15893
+ label: z.string().optional().describe("Alias for caption."),
15705
15894
  }))
15706
15895
  .min(1)
15707
15896
  .max(8),
15708
15897
  }, async (args, ctx) => {
15709
- const items = args.items;
15898
+ // #1968 — flatten the accepted aliases into the canonical item shape ONCE,
15899
+ // before any of the resolution below runs. Everything downstream keeps
15900
+ // seeing `{source, caption}` and nothing else had to learn the new keys.
15901
+ const normalized = [];
15902
+ for (const [i, raw] of args.items.entries()) {
15903
+ const item = normalizeShowMediaItem(raw, i);
15904
+ if (!item.ok)
15905
+ return fail(item.error);
15906
+ normalized.push(item.value);
15907
+ }
15908
+ const items = normalized;
15710
15909
  const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
15711
15910
  // #811 — this used to be just {.mp4, .webm}, narrower than what this
15712
15911
  // codebase ALREADY treats as video elsewhere: get_image's