comfyui-mcp 0.52.115 → 0.52.116

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.
@@ -191,7 +191,7 @@ import { RunCompletions } from "./run-completion-journal.js";
191
191
  import { AskAnswers, askFingerprint, PANEL_ASK_ID_PREFIX, } from "./ask-answer-journal.js";
192
192
  import { getQueueVerified, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
193
193
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
194
- import { restartComfyUI, preflightLocalRestart, readServingArgv, resolveVerifiedProxyRestartTarget, describeArgvDrift, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
194
+ import { captureComfyUITargetFence, restartComfyUI, preflightLocalRestart, readServingArgv, resolveVerifiedProxyRestartTarget, describeArgvDrift, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, targetFenceMatchesCurrent, targetFencesEqual, } from "../services/process-control.js";
195
195
  import { resetManagerApiCache } from "../services/manager-api-cache.js";
196
196
  import { desktopSavedLaunchArgs, describeSavedLaunchArgDrift, } from "../services/desktop-launch-args.js";
197
197
  import { config, isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, getComfyuiTargetGeneration, } from "../config.js";
@@ -10013,7 +10013,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10013
10013
  const routed = target ? withWorkflowTarget(cmd, target) : cmd;
10014
10014
  return bridge.send(routed, { tabId: ctx.tabId, timeoutMs, onDispatchedRid });
10015
10015
  };
10016
- const callOnce = async (cmd, timeoutMs, onDispatchedRid,
10016
+ const callOnce = async (cmd, timeoutMs, onDispatchedRid, beforeDispatch,
10017
10017
  // #1560 — reports the error each attempt failed on, so the wrapper below can
10018
10018
  // stamp the bridge's structural marks onto the ToolResult from ONE place. The
10019
10019
  // catch has more than a dozen `fail(...)` exits, each shaping its own message;
@@ -10055,6 +10055,10 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10055
10055
  const blocked = graphCmdBlockedByRunningPrompt(cmd);
10056
10056
  if (blocked)
10057
10057
  return fail(blocked);
10058
+ // A caller may hold a target/generation fence across the reachability wait.
10059
+ // Run it synchronously in the final gap before sendRouted: an async check here
10060
+ // would reopen the exact retarget window this guard closes.
10061
+ beforeDispatch?.();
10058
10062
  const firstTry = ok(await sendRouted(cmd, timeoutMs, observeRid));
10059
10063
  // panel#1097 — a guard-domain command that SUCCEEDS is the evidence that the
10060
10064
  // switch is over, whichever attempt lands it. Without this an ordinary
@@ -10138,6 +10142,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10138
10142
  await awaitReachable();
10139
10143
  ensureReachable(); // rebinds a current-mode session onto the reconnected tab
10140
10144
  holdTab = ctx.tabId;
10145
+ beforeDispatch?.();
10141
10146
  const retried = ok(await sendRouted(cmd, timeoutMs, observeRid));
10142
10147
  // Cleared HERE and nowhere else, and only when the failure this retried
10143
10148
  // was a SWITCH refusal. Clearing on every routed success let an unguarded
@@ -10921,9 +10926,9 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10921
10926
  * concurrent `ctx.call`s — including the `rebindWorkflowFence` round trip that
10922
10927
  * `callOnce`'s own catch makes — cannot read each other's.
10923
10928
  */
10924
- const call = async (cmd, timeoutMs, onDispatchedRid) => {
10929
+ const call = async (cmd, timeoutMs, onDispatchedRid, beforeDispatch) => {
10925
10930
  let failure;
10926
- const res = await callOnce(cmd, timeoutMs, onDispatchedRid, (err) => {
10931
+ const res = await callOnce(cmd, timeoutMs, onDispatchedRid, beforeDispatch, (err) => {
10927
10932
  failure = err;
10928
10933
  });
10929
10934
  return carryWorkflowListReadinessMark(failure, carryMidCommandDisconnectMark(failure, carryPanelAnsweredMark(failure, res)));
@@ -12606,7 +12611,7 @@ function withRetryToken(d) {
12606
12611
  // Double-apply is already the #521 panel ledger's job; this only makes the
12607
12612
  // outcome VISIBLE, which is the half of #694 nothing else does.
12608
12613
  const wrapped = Object.create(ctx);
12609
- wrapped.call = (cmd, timeoutMs, onDispatchedRid) => ctx.call(
12614
+ wrapped.call = (cmd, timeoutMs, onDispatchedRid, beforeDispatch) => ctx.call(
12610
12615
  // Ask the RETRY MAP's question, not the workflow fence's (codex gate).
12611
12616
  // These were the same answer only while isMutatingGraphCommand
12612
12617
  // over-classified — the #778 defect. Once the fence got its own effect
@@ -12622,7 +12627,7 @@ function withRetryToken(d) {
12622
12627
  // reads, which is asserted in panel-retry-identity.test.ts.
12623
12628
  RETRY_TOKEN_CMDS.has(typeof cmd.cmd === "string" ? cmd.cmd : "")
12624
12629
  ? { ...cmd, retry_of: retryOf }
12625
- : cmd, timeoutMs, onDispatchedRid);
12630
+ : cmd, timeoutMs, onDispatchedRid, beforeDispatch);
12626
12631
  const out = await d.handler(args, wrapped);
12627
12632
  // Drained AFTER the handler, deliberately, for two measured reasons.
12628
12633
  //
@@ -12676,6 +12681,44 @@ function withRetryToken(d) {
12676
12681
  export function buildPanelToolDefs() {
12677
12682
  // Local helper so each def reads like the original `tool(...)` call.
12678
12683
  const def = (name, description, schema, handler) => ({ name, description, schema, handler });
12684
+ const capturePanelKitchenTarget = (ctx) => {
12685
+ const base = getComfyUIBaseUrl().replace(/\/+$/, "");
12686
+ const observed = ctx.bridge.tabServerOrigin?.(ctx.tabId);
12687
+ // A hello.comfyui_url/tabOrigin value is page-JS-writable and cannot authorize
12688
+ // a process mutation. The server-observed handshake proves only the origin,
12689
+ // not a path mount; refuse mounted targets rather than guessing which instance
12690
+ // this tab fronts. This is the same fail-closed rule used by panel_reboot.
12691
+ if (ctx.bridge.tabIsLocal?.(ctx.tabId) !== true ||
12692
+ !observed ||
12693
+ !sameHttpOrigin(observed, base) ||
12694
+ (() => {
12695
+ try {
12696
+ return new URL(base).pathname.replace(/\/+$/, "") !== "";
12697
+ }
12698
+ catch {
12699
+ return true;
12700
+ }
12701
+ })()) {
12702
+ return undefined;
12703
+ }
12704
+ return { fence: captureComfyUITargetFence(), tabId: ctx.tabId };
12705
+ };
12706
+ const panelKitchenTargetStillCurrent = (ctx, target) => {
12707
+ if (ctx.tabId !== target.tabId || !targetFenceMatchesCurrent(target.fence))
12708
+ return false;
12709
+ const observed = ctx.bridge.tabServerOrigin?.(target.tabId);
12710
+ return (ctx.bridge.tabIsLocal?.(target.tabId) === true &&
12711
+ observed != null &&
12712
+ sameHttpOrigin(observed, target.fence.baseUrl) &&
12713
+ (() => {
12714
+ try {
12715
+ return new URL(target.fence.baseUrl).pathname.replace(/\/+$/, "") === "";
12716
+ }
12717
+ catch {
12718
+ return false;
12719
+ }
12720
+ })());
12721
+ };
12679
12722
  const defs = [
12680
12723
  def("panel_query_graph", "FILTER or TRAVERSE a SUBSET of the live canvas, for when you ALREADY KNOW what you're looking for. NOT for 'show me the canvas' or any whole-graph overview — call panel_graph_outline FIRST for that. NOT get_workflow's query action (that queries a saved file or JSON you provide, not the live canvas). Filters, traverses, projects and aggregates over the workflow the user is CURRENTLY VIEWING without dumping the whole graph (replaces the old panel_get_graph full-JSON dump; output is TOKEN-BOUNDED with an explicit truncation marker, so a big graph can never flood your context). Combine: `types` (node type contains any), `title` (contains), `where` widget predicates ANDed ('cfg>7', 'steps<=20', 'sampler_name=euler', 'text~sunset' — ops = != >= <= > < ~contains), `ids` (exact nodes — THE way to read ONE node's exact slot/widget detail: {ids:[42], fields:'detail'}), `upstream_of`/`downstream_of` + `depth` (dependency traversal: upstream = what FEEDS that node, downstream = what CONSUMES it; seed at depth 0), `fields` ('compact' one line per node [default], 'ids', 'detail' = the full node summary with slots + connections + mode), `group_by:'type'` (counts only), `limit` (default 40). detail rows include each node's MODE — a 'bypass' node is skipped and a 'mute' node kills everything downstream, so check modes on the path you care about before running (fix with panel_set_node_mode). Every result also carries `groups` (id, title, member node_ids — groups are geometric, trust this list) and, when viewing a SUBGRAPH (after panel_enter_subgraph), `rails` (boundary rail ids/slots). `max_chars` bounds the WHOLE result, those riders included, and the rows you asked for are spent first: on a big graph the riders lose their member ids, then drop out entirely, rather than starving your query — and each says in-band when it did, with the true counts. `widget_max_chars` raises the per-widget cap only for `fields:'detail'`; use it only with exactly one explicit `ids` entry (for example `{ids:[42], fields:'detail', widget_max_chars:8192}`), with a default of 2048 and a maximum of 32768. If supplied without those preconditions, it is not sent and the result says why; a legacy Panel that still returns the default cap is named in the result. It does not change compact, ids, or broad reads. Typical flow: panel_graph_outline to orient → panel_query_graph to pinpoint/inspect → edit. Read-only.", {
12681
12724
  types: z.array(z.string()).optional().describe("Node type contains ANY of these (case-insensitive)."),
@@ -12749,6 +12792,10 @@ export function buildPanelToolDefs() {
12749
12792
  });
12750
12793
  return fitQueryGraphReply(panelReply, args.max_chars, widgetMaxChars.note ?? legacyWidgetMaxCharsNote(panelReply, widgetMaxChars.value));
12751
12794
  }),
12795
+ // panel_kitchen mixes panel-routed graph evidence with process-global
12796
+ // ComfyUI probes. Bind both halves to the same tab target before allowing
12797
+ // an assessment or mutation; a concurrent hello from another tab must
12798
+ // produce a refusal, never a cross-instance apply.
12752
12799
  def("panel_graph_outline", "READ THE LIVE CANVAS the user is looking at, as text. 'Show me what's on the canvas' / 'what's on the graph right now' / 'read the current workflow' / 'describe the open graph' -> THIS TOOL, with no arguments. NOT visualize_workflow (it DRAWS A DIAGRAM of a workflow you PASS IN — a saved file or JSON — and never sees the live canvas). NOT panel_query_graph (that FILTERS a SUBSET, for when you already know what you're looking for). Returns one `outline` string covering the WHOLE open graph, topologically sorted (sources first, sinks last): each node as `id Type \"title\" [bypass/mute] [OUTPUT] · group:X widget=value …` with `← inputs` (source_node.output_name) and `→ outputs` (target_node.input_name), after a GROUPS index (title → member node ids). It gives you the WIRING you would otherwise reconstruct by hand — read it FIRST to get oriented, then panel_query_graph to inspect one node ({ids:[42], fields:'detail'}) or panel_find_nodes for free-text search. Over `max_chars` it never cuts the graph short: it sheds per-node detail, or refuses with a reason — never a partial outline. Read-only.", {
12753
12800
  max_chars: z
12754
12801
  .number()
@@ -18499,7 +18546,7 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18499
18546
  def("panel_kitchen", "See what comfy-kitchen can do on this GPU against the OPEN canvas, find where the live graph leaves it on the table, and apply the faster path. Same actions as the core `kitchen` tool, fenced like every other panel mutation. Driven by `action`:\n" +
18500
18547
  '- action:"status" — kitchen version, backends (hip/cuda/triton/eager), INT8 attention, GPU fp8/NVFP4/MXFP8, launch flags. Remote sessions report the import probe and model.quant as unknown.\n' +
18501
18548
  '- action:"assess" — walk the live graph\'s UNETLoaders. A recommendation fires only when every fact it needs is known (fp8_e4m3fn_fast widget; --use-ck-attention flag; NVFP4 swap; ROCm --enable-triton-backend).\n' +
18502
- '- action:"apply" — apply one recommendation_id. Widget edits go through graph_set_widget (Ctrl+Z). Flags need confirm:true; restart_comfyui / panel_restart_comfyui replay argv and do not inject flags. skip_proof:true skips the follow-up panel_run. A black or slower after-run reverts the widget.', {
18549
+ '- action:"apply" — apply one recommendation_id. Widget edits go through graph_set_widget (Ctrl+Z). Flags need confirm:true; directly managed local Python installs get a proven relaunch with the flag appended and retained for later managed starts, while Desktop/remote/external launchers receive an actionable refusal without being stopped or edited. skip_proof:true skips the follow-up panel_run. A black or slower after-run reverts the widget.', {
18503
18550
  action: z
18504
18551
  .enum(["status", "assess", "apply"])
18505
18552
  .describe('Which kitchen operation against the live graph.'),
@@ -18516,10 +18563,17 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18516
18563
  .optional()
18517
18564
  .describe("If true, apply the widget/flag plan without queueing a proof panel_run."),
18518
18565
  }, async (args, ctx) => {
18566
+ const target = capturePanelKitchenTarget(ctx);
18567
+ if (!target) {
18568
+ return fail("panel_kitchen refused: the process target could not be tied to this panel tab's exact ComfyUI target and generation. Keep one target selected, then retry.");
18569
+ }
18519
18570
  const status = await gatherKitchenStatus();
18571
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18572
+ return fail("panel_kitchen refused: the ComfyUI target changed while status was being assessed. No recommendation was applied; retry after the target settles.");
18573
+ }
18520
18574
  if (args.action === "status") {
18521
18575
  const hint = kitchenProactiveHint(status, assessKitchen(status, {}));
18522
- return ok(hint ? { status, hint } : { status });
18576
+ return ok(hint ? { status, hint, target_fence: target.fence } : { status, target_fence: target.fence });
18523
18577
  }
18524
18578
  const query = await ctx.call({
18525
18579
  cmd: "graph_query",
@@ -18530,6 +18584,9 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18530
18584
  });
18531
18585
  if (query.isError)
18532
18586
  return query;
18587
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18588
+ return fail("panel_kitchen refused: the ComfyUI target changed while the live graph was being read. No recommendation was applied; retry after the target settles.");
18589
+ }
18533
18590
  const graph = normalizeGraphQueryResult(query);
18534
18591
  if (graph.truncated === true) {
18535
18592
  const truncationCause = graph.truncated_by === "limit"
@@ -18542,12 +18599,16 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18542
18599
  `more specific workflow or retry after reducing the number of UNETLoaders.`);
18543
18600
  }
18544
18601
  const recs = await assessKitchenGraph(status, graph);
18602
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18603
+ return fail("panel_kitchen refused: the ComfyUI target changed during assessment. No recommendation was applied; retry after the target settles.");
18604
+ }
18545
18605
  if (args.action === "assess") {
18546
18606
  const hint = kitchenProactiveHint(status, recs);
18547
18607
  return ok({
18548
18608
  status,
18549
18609
  loaders: extractLoaders(graph),
18550
18610
  recommendations: recs,
18611
+ target_fence: target.fence,
18551
18612
  ...(hint ? { hint } : {}),
18552
18613
  });
18553
18614
  }
@@ -18561,28 +18622,123 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18561
18622
  if (!rec) {
18562
18623
  return fail(`No recommendation "${args.recommendation_id}" on the live graph. Call panel_kitchen action:"assess" first. Known: ${recs.map((r) => r.id).join(", ") || "(none)"}`);
18563
18624
  }
18564
- const result = await applyKitchenRecommendation({
18565
- rec,
18566
- confirm: args.confirm === true,
18567
- applyWidget: rec.change.type === "widget" || rec.change.type === "model_swap"
18568
- ? async (nodeId, widget, value) => {
18569
- const write = await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
18570
- if (write.isError) {
18571
- throw new Error(write.content.map((c) => ("text" in c ? c.text : "")).join("\n"));
18625
+ let result;
18626
+ try {
18627
+ result = await applyKitchenRecommendation({
18628
+ rec,
18629
+ confirm: args.confirm === true,
18630
+ applyFlag: rec.change.type === "flag"
18631
+ ? async (flag) => {
18632
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18633
+ return {
18634
+ applied: false,
18635
+ note: `Refusing to apply ${flag}: the ComfyUI target changed after assessment. ` +
18636
+ "No launch argument was changed and no restart was attempted.",
18637
+ };
18638
+ }
18639
+ let restart;
18640
+ try {
18641
+ restart = await restartComfyUI({
18642
+ additionalFlags: [flag],
18643
+ targetFence: target.fence,
18644
+ });
18645
+ }
18646
+ catch (err) {
18647
+ return {
18648
+ applied: false,
18649
+ note: `Could not apply ${flag}: ${err instanceof Error ? err.message : String(err)} ` +
18650
+ "No launch argument was changed; verify the launcher and current ComfyUI argv before retrying.",
18651
+ };
18652
+ }
18653
+ const flagObserved = restart.serving_argv?.includes(flag) === true;
18654
+ const targetProven = restart.target_stable !== false &&
18655
+ targetFencesEqual(restart.target_fence, target.fence) &&
18656
+ panelKitchenTargetStillCurrent(ctx, target);
18657
+ const applied = restart.started === true &&
18658
+ restart.startup === "confirmed" &&
18659
+ restart.listener_ownership === "ours" &&
18660
+ flagObserved &&
18661
+ targetProven;
18662
+ return {
18663
+ applied,
18664
+ note: applied
18665
+ ? `Applied ${flag} through the proven local relaunch and observed it in the serving ComfyUI argv. The augmented launch recipe is retained for later managed starts.`
18666
+ :
18667
+ `Did not confirm ${flag} in effect: ${restart.message} ` +
18668
+ "panel_kitchen reports applied:false because the relaunch, target fence, and new serving argv were not all proven.",
18669
+ restart,
18670
+ };
18572
18671
  }
18573
- return parseToolResultJson(write);
18574
- }
18575
- : undefined,
18576
- revertWidget: rec.change.type === "widget" || rec.change.type === "model_swap"
18577
- ? async (nodeId, widget, value) => {
18578
- await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
18579
- }
18580
- : undefined,
18581
- });
18582
- if (result.applied && args.skip_proof !== true && rec.change.type !== "flag") {
18583
- const run = await ctx.call({ cmd: "graph_run" });
18672
+ : undefined,
18673
+ applyWidget: rec.change.type === "widget" || rec.change.type === "model_swap"
18674
+ ? async (nodeId, widget, value) => {
18675
+ const write = await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS, undefined, () => {
18676
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18677
+ throw new Error("Refusing graph_set_widget: the ComfyUI target changed after the reachability wait. No widget mutation was dispatched.");
18678
+ }
18679
+ });
18680
+ if (write.isError) {
18681
+ throw new Error(write.content.map((c) => ("text" in c ? c.text : "")).join("\n"));
18682
+ }
18683
+ return parseToolResultJson(write);
18684
+ }
18685
+ : undefined,
18686
+ revertWidget: rec.change.type === "widget" || rec.change.type === "model_swap"
18687
+ ? async (nodeId, widget, value) => {
18688
+ const revert = await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS, undefined, () => {
18689
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18690
+ throw new Error("Refusing graph_set_widget revert: the ComfyUI target changed after the reachability wait. No widget mutation was dispatched.");
18691
+ }
18692
+ });
18693
+ if (revert.isError) {
18694
+ throw new Error(revert.content.map((c) => ("text" in c ? c.text : "")).join("\n"));
18695
+ }
18696
+ }
18697
+ : undefined,
18698
+ });
18699
+ }
18700
+ catch (err) {
18701
+ result = {
18702
+ applied: false,
18703
+ recommendation: rec,
18704
+ proof: { status: "failed" },
18705
+ flag_note: `Could not apply the recommendation: ${err instanceof Error ? err.message : String(err)} ` +
18706
+ "No further widget mutation was dispatched and the launch outcome is not confirmed.",
18707
+ };
18708
+ }
18709
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18584
18710
  return ok({
18585
18711
  ...result,
18712
+ applied: false,
18713
+ stale: true,
18714
+ target_stable: false,
18715
+ target_fence: target.fence,
18716
+ flag_note: (result.flag_note ? `${result.flag_note} ` : "") +
18717
+ "panel_kitchen reports applied:false because the ComfyUI target changed before the result was returned.",
18718
+ });
18719
+ }
18720
+ const fencedResult = { ...result, target_fence: target.fence };
18721
+ if (fencedResult.applied && args.skip_proof !== true && rec.change.type !== "flag") {
18722
+ const run = await ctx.call({ cmd: "graph_run" });
18723
+ if (!panelKitchenTargetStillCurrent(ctx, target)) {
18724
+ return ok({
18725
+ ...fencedResult,
18726
+ applied: false,
18727
+ stale: true,
18728
+ target_stable: false,
18729
+ proof: {
18730
+ ...fencedResult.proof,
18731
+ status: "stale",
18732
+ summary: (fencedResult.proof.summary ? `${fencedResult.proof.summary}; ` : "") +
18733
+ "graph_run was dispatched for the previously fenced target, but the target changed before its result was returned; no success is claimed for the current target.",
18734
+ },
18735
+ run: parseToolResultJson(run),
18736
+ flag_note: "panel_kitchen reports applied:false/stale because the ComfyUI target changed while the proof graph_run was in flight.",
18737
+ target_note: "panel_kitchen reports applied:false/stale because the ComfyUI target changed while the proof graph_run was in flight.",
18738
+ });
18739
+ }
18740
+ return ok({
18741
+ ...fencedResult,
18586
18742
  proof: {
18587
18743
  ...result.proof,
18588
18744
  status: "queued",
@@ -18592,7 +18748,7 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
18592
18748
  run: parseToolResultJson(run),
18593
18749
  });
18594
18750
  }
18595
- return ok(result);
18751
+ return ok(fencedResult);
18596
18752
  }),
18597
18753
  def("panel_ui_render", "Render an INTERACTIVE UI CARD in the panel chat from an A2UI-subset JSON spec — choice buttons, forms (TextField/Select/Checkbox + a submit Button), node-wiring diagrams (comfy:graph), and bar/line charts (comfy:chart). Use a card whenever the user must pick between options, confirm a plan, fill in parameters, or would understand a wiring explanation better as a diagram. The card is non-blocking: this returns { card_id } immediately; when the user clicks a button (or submits a form) their choice arrives as a NORMAL chat message (the button's `reply` text; submit buttons append 'name: value' lines) — so after rendering a card that asks a question, END YOUR TURN and wait. Set surface:'wide' for diagram-heavy cards (the panel widens and restores automatically). Spec shape: { surface?, title?, root: '<id>', components: [ {id, type, ...} ] } with children referenced by id. Types: Text{text}, Heading{text,level?}, Button{label,reply?,submit?,style?:'primary'|'secondary'}, Row/Column/Card{children:[ids]}, Divider, Image{src:/view-URL,caption?}, TextField{label,name,value?,placeholder?}, Select{label,name,options:[{label,value?}],value?}, Checkbox{label,name,checked?}, 'comfy:graph'{nodes:[{id,label,color?}],edges:[{from,to,label?}],direction?:'lr'|'tb'}, 'comfy:chart'{kind:'bar'|'line',series:[{label,values:[num]}],x?:[labels]}. Caps: ≤64 components, ≤30 graph nodes, ≤8×256 chart points. On a validation error, FIX the spec and retry.", {
18598
18754
  spec: z