comfyui-mcp 0.52.74 → 0.52.76

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.
@@ -4297,6 +4297,8 @@ const ANIMA_REGIONAL_WIRED_PROMPT_INPUT = {
4297
4297
  scene_prompt: "scene_prompt_in",
4298
4298
  negative_prompt: "negative_prompt_in",
4299
4299
  };
4300
+ const DASIWA_STACK_WIDGET = "stack_data";
4301
+ const DASIWA_LTX2_LORA_LOADER = "DaSiWa_LTX2LoraLoader";
4300
4302
  function isAnimaRegionalPromptWidget(widget) {
4301
4303
  return ANIMA_REGIONAL_PROMPT_WIDGETS.has(widget);
4302
4304
  }
@@ -4338,6 +4340,101 @@ function parseQueriedNodeType(payload) {
4338
4340
  }
4339
4341
  return null;
4340
4342
  }
4343
+ function canonicalQueriedNodeId(value) {
4344
+ if (typeof value === "number") {
4345
+ return Number.isSafeInteger(value) ? String(value) : null;
4346
+ }
4347
+ if (typeof value !== "string" || !NODE_ID_PATTERN.test(value))
4348
+ return null;
4349
+ const normalized = normalizeNodeId(value);
4350
+ return typeof normalized === "number" && !Number.isSafeInteger(normalized)
4351
+ ? null
4352
+ : String(normalized);
4353
+ }
4354
+ function parseQueriedNodeIdentityRow(row) {
4355
+ if (!row || typeof row !== "object" || Array.isArray(row))
4356
+ return null;
4357
+ const record = row;
4358
+ const id = canonicalQueriedNodeId(record.id);
4359
+ if (!id)
4360
+ return null;
4361
+ const type = record.type;
4362
+ const classType = record.class_type;
4363
+ if (type !== undefined && typeof type !== "string")
4364
+ return null;
4365
+ if (classType !== undefined && typeof classType !== "string")
4366
+ return null;
4367
+ if (typeof type === "string" && typeof classType === "string" && type !== classType) {
4368
+ return null;
4369
+ }
4370
+ const resolvedType = typeof type === "string" ? type : classType;
4371
+ return resolvedType ? { id, type: resolvedType } : null;
4372
+ }
4373
+ /** Strict identity parser for the DaSiWa refusal gate. Unlike the older type-only
4374
+ * parser above, this accepts exactly one non-truncated row and authenticates its id. */
4375
+ function parseVerifiedQueriedNodeIdentity(payload) {
4376
+ if (!payload)
4377
+ return null;
4378
+ if (Object.prototype.hasOwnProperty.call(payload, "truncated") &&
4379
+ payload.truncated !== false) {
4380
+ return null;
4381
+ }
4382
+ if (Object.prototype.hasOwnProperty.call(payload, "nodes")) {
4383
+ return Array.isArray(payload.nodes) && payload.nodes.length === 1
4384
+ ? parseQueriedNodeIdentityRow(payload.nodes[0])
4385
+ : null;
4386
+ }
4387
+ if (typeof payload.text !== "string")
4388
+ return null;
4389
+ const rows = [];
4390
+ const lines = payload.text.split(/\r?\n/);
4391
+ for (const [index, line] of lines.entries()) {
4392
+ const trimmed = line.trim();
4393
+ if (!trimmed)
4394
+ continue;
4395
+ // The live compact projection always prefixes its rows with this bounded summary.
4396
+ // Keep the grammar narrow so arbitrary prose cannot make an unverified row usable,
4397
+ // while accepting the documented header that older tests and panels may omit.
4398
+ if (index === 0 &&
4399
+ /^\d+ match\(es\) of \d+ in scope \(viewing: \d+ nodes\)(?: · traversal depth≤-?\d+)?$/.test(trimmed)) {
4400
+ continue;
4401
+ }
4402
+ // A compact row may be followed by one of the panel's bounded clipping notes or
4403
+ // truncation remedies. They do not identify nodes and are ignored only after a row
4404
+ // has been authenticated below.
4405
+ if (rows.length > 0 &&
4406
+ (/^\(\d+ widget value\(s\) clipped to \d+ chars/.test(trimmed) ||
4407
+ /^… truncated at \d+ of \d+/.test(trimmed))) {
4408
+ continue;
4409
+ }
4410
+ if (trimmed.startsWith("#")) {
4411
+ const compact = trimmed.match(/^#(\S+)\s+(\S+)(?:\s|$)/);
4412
+ if (!compact)
4413
+ return null;
4414
+ const id = canonicalQueriedNodeId(compact[1]);
4415
+ if (!id)
4416
+ return null;
4417
+ rows.push({ id, type: compact[2] });
4418
+ continue;
4419
+ }
4420
+ if (trimmed.startsWith("{")) {
4421
+ let row;
4422
+ try {
4423
+ row = JSON.parse(trimmed);
4424
+ }
4425
+ catch {
4426
+ return null;
4427
+ }
4428
+ const parsed = parseQueriedNodeIdentityRow(row);
4429
+ if (!parsed)
4430
+ return null;
4431
+ rows.push(parsed);
4432
+ continue;
4433
+ }
4434
+ return null;
4435
+ }
4436
+ return rows.length === 1 ? rows[0] : null;
4437
+ }
4341
4438
  function animaRegionalPromptRefusal(type, widget) {
4342
4439
  const wired = ANIMA_REGIONAL_WIRED_PROMPT_INPUT[widget];
4343
4440
  const route = wired
@@ -4367,6 +4464,162 @@ async function refuseAnimaRegionalPromptWrite(ctx, nodeId, widget) {
4367
4464
  return null;
4368
4465
  return animaRegionalPromptRefusal(type, widget);
4369
4466
  }
4467
+ function daSiWaStackRefusal(type) {
4468
+ return fail(`panel_set_widget cannot set "${DASIWA_STACK_WIDGET}" on ${type}. ` +
4469
+ `The node's custom multi-row widget owns the LoRA stack in its internal JS state and ` +
4470
+ `re-serializes that state over widget.value, so the normal graph_set_widget success ` +
4471
+ `echo would be a false success. Edit the stack rows in the node UI instead; do not retry ` +
4472
+ `panel_set_widget for this widget.`);
4473
+ }
4474
+ function daSiWaIdentityRefusal(reason) {
4475
+ return fail(`panel_set_widget refused "${DASIWA_STACK_WIDGET}" because the panel probe could not ` +
4476
+ `prove the requested node is the intended ${DASIWA_LTX2_LORA_LOADER} (${reason}). ` +
4477
+ `No graph_set_widget was dispatched. Edit the stack rows in the node UI instead; ` +
4478
+ `do not retry this write until the node identity can be read exactly.`);
4479
+ }
4480
+ function isUsablePanelConnectionIdentity(identity) {
4481
+ return (!!identity &&
4482
+ Number.isSafeInteger(identity.generation) &&
4483
+ typeof identity.tabSessionId === "string" &&
4484
+ identity.tabSessionId.length > 0);
4485
+ }
4486
+ function samePanelConnectionIdentity(left, right) {
4487
+ return left.generation === right.generation && left.tabSessionId === right.tabSessionId;
4488
+ }
4489
+ /** Refuse the DaSiWa stack widget whose custom UI deterministically overwrites a
4490
+ * graph_set_widget write after acknowledging it. This gate fails closed unless
4491
+ * the probe proves the requested node and remains bound to the same panel tab. */
4492
+ async function refuseDaSiWaStackWrite(ctx, nodeId, widget) {
4493
+ if (widget !== DASIWA_STACK_WIDGET)
4494
+ return null;
4495
+ try {
4496
+ if (ctx.tabExpectedNodeTypeFenceCapability?.() !== true) {
4497
+ return daSiWaIdentityRefusal("the bound panel does not advertise the atomic expected-node-type write fence; update the panel and hard-refresh");
4498
+ }
4499
+ }
4500
+ catch {
4501
+ return daSiWaIdentityRefusal("the panel expected-node-type write fence could not be verified");
4502
+ }
4503
+ const tabBefore = ctx.tabId;
4504
+ let identityBefore;
4505
+ try {
4506
+ identityBefore = ctx.panelConnectionIdentity?.();
4507
+ }
4508
+ catch {
4509
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unreadable");
4510
+ }
4511
+ if (!isUsablePanelConnectionIdentity(identityBefore)) {
4512
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unavailable");
4513
+ }
4514
+ let probe;
4515
+ try {
4516
+ probe = await ctx.call({
4517
+ cmd: "graph_query",
4518
+ ids: [nodeId],
4519
+ fields: "compact",
4520
+ limit: 1,
4521
+ });
4522
+ }
4523
+ catch {
4524
+ return daSiWaIdentityRefusal("the graph_query probe failed before identity could be verified");
4525
+ }
4526
+ if (ctx.tabId !== tabBefore) {
4527
+ return daSiWaIdentityRefusal("the probe was answered by a different panel tab");
4528
+ }
4529
+ let identityAfter;
4530
+ try {
4531
+ identityAfter = ctx.panelConnectionIdentity?.();
4532
+ }
4533
+ catch {
4534
+ return daSiWaIdentityRefusal("the panel-tab identity became unreadable after the probe");
4535
+ }
4536
+ if (!isUsablePanelConnectionIdentity(identityAfter)) {
4537
+ return daSiWaIdentityRefusal("the panel-tab identity became unavailable after the probe");
4538
+ }
4539
+ if (!samePanelConnectionIdentity(identityBefore, identityAfter)) {
4540
+ return daSiWaIdentityRefusal("the panel-tab connection changed during the probe");
4541
+ }
4542
+ if (probe.isError) {
4543
+ return daSiWaIdentityRefusal("the graph_query probe returned an error");
4544
+ }
4545
+ const identity = parseVerifiedQueriedNodeIdentity(parseToolResultJson(probe));
4546
+ const requestedId = canonicalQueriedNodeId(nodeId);
4547
+ if (!identity || !requestedId) {
4548
+ return daSiWaIdentityRefusal("the graph_query reply was malformed, truncated, or did not contain exactly one verifiable row");
4549
+ }
4550
+ if (identity.id !== requestedId) {
4551
+ return daSiWaIdentityRefusal("the graph_query row identified a different node_id");
4552
+ }
4553
+ if (identity.type !== DASIWA_LTX2_LORA_LOADER)
4554
+ return null;
4555
+ return daSiWaStackRefusal(identity.type);
4556
+ }
4557
+ /** Recheck the target immediately before a stack_data mutation. The first probe
4558
+ * prevents the known refusal from dispatching; this final fence catches a tab
4559
+ * reconnect or node replacement observed between that probe and graph_set_widget. */
4560
+ async function verifyDaSiWaStackWriteFence(ctx, nodeId) {
4561
+ try {
4562
+ if (ctx.tabExpectedNodeTypeFenceCapability?.() !== true) {
4563
+ return daSiWaIdentityRefusal("the bound panel does not advertise the atomic expected-node-type write fence; update the panel and hard-refresh");
4564
+ }
4565
+ }
4566
+ catch {
4567
+ return daSiWaIdentityRefusal("the panel expected-node-type write fence could not be verified");
4568
+ }
4569
+ const tabBefore = ctx.tabId;
4570
+ let identityBefore;
4571
+ try {
4572
+ identityBefore = ctx.panelConnectionIdentity?.();
4573
+ }
4574
+ catch {
4575
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unreadable before dispatch");
4576
+ }
4577
+ if (!isUsablePanelConnectionIdentity(identityBefore)) {
4578
+ return daSiWaIdentityRefusal("the bound browser-tab identity was unavailable before dispatch");
4579
+ }
4580
+ let probe;
4581
+ try {
4582
+ probe = await ctx.call({
4583
+ cmd: "graph_query",
4584
+ ids: [nodeId],
4585
+ fields: "compact",
4586
+ limit: 1,
4587
+ });
4588
+ }
4589
+ catch {
4590
+ return daSiWaIdentityRefusal("the final graph_query fence failed before dispatch");
4591
+ }
4592
+ if (ctx.tabId !== tabBefore) {
4593
+ return daSiWaIdentityRefusal("the final fence was answered by a different panel tab");
4594
+ }
4595
+ let identityAfter;
4596
+ try {
4597
+ identityAfter = ctx.panelConnectionIdentity?.();
4598
+ }
4599
+ catch {
4600
+ return daSiWaIdentityRefusal("the panel-tab identity became unreadable before dispatch");
4601
+ }
4602
+ if (!isUsablePanelConnectionIdentity(identityAfter)) {
4603
+ return daSiWaIdentityRefusal("the panel-tab identity became unavailable before dispatch");
4604
+ }
4605
+ if (!samePanelConnectionIdentity(identityBefore, identityAfter)) {
4606
+ return daSiWaIdentityRefusal("the panel-tab connection changed before dispatch");
4607
+ }
4608
+ if (probe.isError) {
4609
+ return daSiWaIdentityRefusal("the final graph_query fence returned an error");
4610
+ }
4611
+ const identity = parseVerifiedQueriedNodeIdentity(parseToolResultJson(probe));
4612
+ const requestedId = canonicalQueriedNodeId(nodeId);
4613
+ if (!identity || !requestedId) {
4614
+ return daSiWaIdentityRefusal("the final graph_query fence was malformed, truncated, or did not contain exactly one verifiable row");
4615
+ }
4616
+ if (identity.id !== requestedId) {
4617
+ return daSiWaIdentityRefusal("the final graph_query fence identified a different node_id");
4618
+ }
4619
+ return identity.type === DASIWA_LTX2_LORA_LOADER
4620
+ ? daSiWaStackRefusal(identity.type)
4621
+ : { expectedNodeType: identity.type };
4622
+ }
4370
4623
  // ---- #809: turn the panel's silent `truncated: true` booleans into a remedy --------
4371
4624
  //
4372
4625
  // A bare boolean is the WORST truncation signal there is: it is a field, not prose, so
@@ -6554,6 +6807,103 @@ NOTE: an API-format load CAN re-mint the canvas workflow instance. If your next
6554
6807
  `Clear it with panel_set_workflow_target({mode:"current"}), which re-derives the fence ` +
6555
6808
  `from the live canvas, then retry. If the next command is not refused, nothing needs doing.`);
6556
6809
  }
6810
+ /**
6811
+ * #2106 — a panel can apply a UI graph and then lose the fetch carrying the reply.
6812
+ *
6813
+ * Reconcile only the narrow, post-dispatch transport failure reported by the issue. A
6814
+ * dispatched request id is required: without it, "Failed to fetch" may be a pre-write
6815
+ * refusal and there is no mutation to reconcile. The live graph must also have the exact
6816
+ * requested content and still belong to the workflow instance that was active before the
6817
+ * load. Anything less remains outcome-unknown, so a real load failure is never turned into
6818
+ * a success by a coincidental canvas shape.
6819
+ */
6820
+ function isBarePanelFetchFailure(res) {
6821
+ return res.isError === true && /^(?:Error:\s*)?Failed to fetch$/i.test(textOfToolResult(res));
6822
+ }
6823
+ function uiWorkflowNodeCount(value) {
6824
+ if (!value || typeof value !== "object" || !Array.isArray(value.nodes)) {
6825
+ return undefined;
6826
+ }
6827
+ return value.nodes.length;
6828
+ }
6829
+ function loadOutcomeUnknown(res, dispatchedRid) {
6830
+ if (!dispatchedRid)
6831
+ return res;
6832
+ return appendToolResultText(res, `\n\nOUTCOME UNKNOWN: panel_load_workflow was dispatched, but the panel reply failed with ` +
6833
+ `"Failed to fetch" and the live canvas did not prove the requested UI graph on the ` +
6834
+ `same workflow instance. Do not retry blindly. To retry this exact mutation, re-issue ` +
6835
+ `identical args plus retry_of:"${dispatchedRid}"; otherwise call normally.`);
6836
+ }
6837
+ async function reconcileFailedPanelLoad(res, ctx, data, graphBefore, tabAtDispatch, fenceBefore, dispatchedRid) {
6838
+ if (!isBarePanelFetchFailure(res) || !dispatchedRid)
6839
+ return res;
6840
+ const expectedNodeCount = uiWorkflowNodeCount(data);
6841
+ if (expectedNodeCount === undefined ||
6842
+ graphBefore == null ||
6843
+ fenceBefore.known !== true ||
6844
+ typeof fenceBefore.uuid !== "string" ||
6845
+ ctx.tabId !== tabAtDispatch) {
6846
+ return loadOutcomeUnknown(res, dispatchedRid);
6847
+ }
6848
+ // A matching post-failure graph is only a causal proof when the pre-dispatch
6849
+ // graph was different. If the requested graph was already present, the reads
6850
+ // cannot tell whether this RID changed anything, so keep the retry token.
6851
+ if (openLiveMatchesDestContent(graphBefore, data)) {
6852
+ return loadOutcomeUnknown(res, dispatchedRid);
6853
+ }
6854
+ try {
6855
+ const serialized = await ctx.call({ cmd: "graph_serialize" }, 8000);
6856
+ if (ctx.tabId !== tabAtDispatch || serialized.isError) {
6857
+ return loadOutcomeUnknown(res, dispatchedRid);
6858
+ }
6859
+ const serializedPayload = parseToolResultJson(serialized);
6860
+ const liveWorkflow = serializedPayload?.workflow;
6861
+ const liveNodeCount = typeof serializedPayload?.node_count === "number" &&
6862
+ Number.isInteger(serializedPayload.node_count) &&
6863
+ serializedPayload.node_count >= 0
6864
+ ? serializedPayload.node_count
6865
+ : uiWorkflowNodeCount(liveWorkflow);
6866
+ if (liveNodeCount !== expectedNodeCount) {
6867
+ return loadOutcomeUnknown(res, dispatchedRid);
6868
+ }
6869
+ // Count alone is not proof: an old same-sized graph on the same workflow
6870
+ // instance would otherwise be reported as the requested load. Reuse the
6871
+ // existing fail-closed content matcher, which checks node identities/types,
6872
+ // link topology, non-empty widget values, and nested subgraph content while
6873
+ // tolerating frontend schema/presentation normalization.
6874
+ if (!openLiveMatchesDestContent(liveWorkflow, data)) {
6875
+ return loadOutcomeUnknown(res, dispatchedRid);
6876
+ }
6877
+ const listed = await ctx.call({ cmd: "workflow_list" }, 6000);
6878
+ if (ctx.tabId !== tabAtDispatch || listed.isError) {
6879
+ return loadOutcomeUnknown(res, dispatchedRid);
6880
+ }
6881
+ const listedPayload = parseToolResultJson(listed);
6882
+ if (!listedPayload)
6883
+ return loadOutcomeUnknown(res, dispatchedRid);
6884
+ const corroborated = corroborateActiveForFence(listedPayload);
6885
+ if (!corroborated.ok)
6886
+ return loadOutcomeUnknown(res, dispatchedRid);
6887
+ const liveUuid = responseWorkflowUuid(corroborated.active);
6888
+ if (!liveUuid || liveUuid !== fenceBefore.uuid) {
6889
+ return loadOutcomeUnknown(res, dispatchedRid);
6890
+ }
6891
+ return ok({
6892
+ loaded: true,
6893
+ reconciled: true,
6894
+ acknowledged_after_error: true,
6895
+ format: "ui",
6896
+ node_count: liveNodeCount,
6897
+ workflow_uuid: liveUuid,
6898
+ response_error: "Failed to fetch",
6899
+ note: "The panel applied the requested UI graph, but its reply failed after dispatch. " +
6900
+ "The same workflow instance now contains the expected graph; do not retry.",
6901
+ });
6902
+ }
6903
+ catch {
6904
+ return loadOutcomeUnknown(res, dispatchedRid);
6905
+ }
6906
+ }
6557
6907
  async function rebindWorkflowFence(ctx, opts) {
6558
6908
  const tabAtStart = ctx.tabId;
6559
6909
  let before = currentWorkflowFence(ctx);
@@ -10119,6 +10469,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets, onRunTicketOpen
10119
10469
  ctx.panelConnectionIdentity = panelConnectionIdentity;
10120
10470
  ctx.awaitPostRestartReachable = awaitPostRestartReachable;
10121
10471
  ctx.tabCanMutateGraph = () => bridge.tabCanMutateGraph(ctx.tabId);
10472
+ ctx.tabExpectedNodeTypeFenceCapability = () => bridge.tabExpectedNodeTypeFenceCapability(ctx.tabId);
10122
10473
  ctx.tabGraphMutationCapability = () => bridge.tabGraphMutationCapability(ctx.tabId);
10123
10474
  return ctx;
10124
10475
  }
@@ -12195,9 +12546,27 @@ export function buildPanelToolDefs() {
12195
12546
  throw new Error("Provide one of `pack` (a bundled pack name), `path` (a workflow .json on disk), or `graph` (a UI workflow).");
12196
12547
  }
12197
12548
  // Generous timeout — loading a large graph onto the live canvas can take a moment.
12198
- const loaded = await ctx.call({ cmd: "graph_load", graph: data }, 30000);
12199
- if (loaded.isError)
12200
- return loaded;
12549
+ const tabAtDispatch = ctx.tabId;
12550
+ const fenceBefore = currentWorkflowFence(ctx);
12551
+ let graphBefore;
12552
+ if (uiWorkflowNodeCount(data) !== undefined) {
12553
+ try {
12554
+ const before = await ctx.call({ cmd: "graph_serialize" }, 8000);
12555
+ if (!before.isError)
12556
+ graphBefore = parseToolResultJson(before)?.workflow;
12557
+ }
12558
+ catch {
12559
+ // Without a pre-dispatch snapshot, a later matching graph cannot
12560
+ // prove that this request caused the transition.
12561
+ }
12562
+ }
12563
+ let dispatchedRid;
12564
+ const loaded = await ctx.call({ cmd: "graph_load", graph: data }, 30000, (rid) => {
12565
+ dispatchedRid = rid;
12566
+ });
12567
+ if (loaded.isError) {
12568
+ return reconcileFailedPanelLoad(loaded, ctx, data, graphBefore, tabAtDispatch, fenceBefore, dispatchedRid);
12569
+ }
12201
12570
  // Keyed on the reply SAYING the graph was replaced, not merely on the call not
12202
12571
  // erroring (codex r2). A non-error envelope that reports `loaded:false` did not
12203
12572
  // replace anything, and telling that caller their fence is stale would be a
@@ -12271,7 +12640,7 @@ export function buildPanelToolDefs() {
12271
12640
  node_id: nodeId().describe("Node id whose input to disconnect."),
12272
12641
  input: slotRef.optional().describe("Input slot name or index (default 0)."),
12273
12642
  }, async (args, ctx) => ctx.call({ cmd: "graph_disconnect", node_id: args.node_id, input: args.input })),
12274
- 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 (a string longer than 1000 chars is echoed as {chars, sha256, preview} so batched calls stay inside the outer tool-result budget — pass `echo: \"full\"` for the verbatim string). 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). For AnimaRegionalCanvasInline / Krea2RegionalCanvasInline (LC123), quality/scene/red/green/blue/negative prompt writes are refused: the custom textarea and node.properties.animaPrompts overwrite widget.value on APPLY. Drive quality/scene/negative via a PrimitiveStringMultiline wired into quality_prompt_in / scene_prompt_in / negative_prompt_in; red/green/blue have no socket.", {
12643
+ 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 (a string longer than 1000 chars is echoed as {chars, sha256, preview} so batched calls stay inside the outer tool-result budget — pass `echo: \"full\"` for the verbatim string). 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). For AnimaRegionalCanvasInline / Krea2RegionalCanvasInline (LC123), quality/scene/red/green/blue/negative prompt writes are refused: the custom textarea and node.properties.animaPrompts overwrite widget.value on APPLY. Drive quality/scene/negative via a PrimitiveStringMultiline wired into quality_prompt_in / scene_prompt_in / negative_prompt_in; red/green/blue have no socket. DaSiWa_LTX2LoraLoader's `stack_data` write is also refused: its custom multi-row widget reserializes its own JS state over `widget.value`, so the echoed write would be a false success; edit the stack rows in the node UI instead.", {
12275
12644
  node_id: nodeId().describe("Node id from panel_graph_outline / panel_query_graph."),
12276
12645
  widget: z.string().describe("Widget name (e.g. 'steps', 'cfg', 'text')."),
12277
12646
  value: z
@@ -12302,6 +12671,16 @@ export function buildPanelToolDefs() {
12302
12671
  const blocked = await refuseAnimaRegionalPromptWrite(ctx, args.node_id, args.widget);
12303
12672
  if (blocked)
12304
12673
  return blocked;
12674
+ const daSiWaBlocked = await refuseDaSiWaStackWrite(ctx, args.node_id, args.widget);
12675
+ if (daSiWaBlocked)
12676
+ return daSiWaBlocked;
12677
+ let expectedNodeType;
12678
+ if (args.widget === DASIWA_STACK_WIDGET) {
12679
+ const daSiWaFence = await verifyDaSiWaStackWriteFence(ctx, args.node_id);
12680
+ if (daSiWaFence && "content" in daSiWaFence)
12681
+ return daSiWaFence;
12682
+ expectedNodeType = daSiWaFence?.expectedNodeType;
12683
+ }
12305
12684
  // #599: the frontend runs refresh-before-validate here (pulls a fresh
12306
12685
  // /object_info so a just-staged/-downloaded/-installed value is accepted on
12307
12686
  // a single revalidation, #338/#458) — that authoritative fetch can outlast
@@ -12314,7 +12693,19 @@ export function buildPanelToolDefs() {
12314
12693
  // to the raw success reply so a later appendToolResultText disclosure
12315
12694
  // (which makes the text no longer parse as JSON) cannot dodge it.
12316
12695
  const echoFull = args.echo === "full";
12317
- const write = async (nodeId, widget) => stripVerifiedLastObservedSchemaNote(summarizeSetWidgetEcho(await ctx.call({ cmd: "graph_set_widget", node_id: nodeId, widget, value }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), echoFull));
12696
+ const write = async (nodeId, widget, targetExpectedNodeType = expectedNodeType) => stripVerifiedLastObservedSchemaNote(summarizeSetWidgetEcho(await ctx.call({
12697
+ cmd: "graph_set_widget",
12698
+ node_id: nodeId,
12699
+ widget,
12700
+ value,
12701
+ // The caller supplies the type proven for THIS addressed node.
12702
+ // The outer final fence is used for direct/re-mapped writes;
12703
+ // promoted recovery re-probes its inner node after entering
12704
+ // and supplies that node's own type.
12705
+ ...(targetExpectedNodeType
12706
+ ? { expected_node_type: targetExpectedNodeType }
12707
+ : {}),
12708
+ }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), echoFull));
12318
12709
  let first = await write(args.node_id, args.widget);
12319
12710
  if (!first.isError) {
12320
12711
  markPanelSchemaReady(panelSchemaKey(ctx));
@@ -12412,7 +12803,38 @@ export function buildPanelToolDefs() {
12412
12803
  `Resolved it to inner node ${inner.innerNodeId} but panel_enter_subgraph FAILED: ` +
12413
12804
  `${textOfToolResult(entered)})`);
12414
12805
  }
12415
- const written = await write(inner.innerNodeId, inner.widget);
12806
+ let innerExpectedNodeType;
12807
+ if (expectedNodeType !== undefined) {
12808
+ // The inner mapping was discovered before an awaited enter. Re-query
12809
+ // the addressed inner node after entering so the stack_data write gets
12810
+ // its OWN live type fence; carrying the outer wrapper's type would
12811
+ // reject a valid inner node, while omitting a fence would let a same-
12812
+ // type replacement mutate a detached object and report false success.
12813
+ const innerProbe = await ctx.call({ cmd: "graph_query", ids: [inner.innerNodeId], fields: "compact", limit: 1 }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS);
12814
+ const innerIdentity = innerProbe.isError
12815
+ ? null
12816
+ : parseVerifiedQueriedNodeIdentity(parseToolResultJson(innerProbe));
12817
+ const expectedInnerId = canonicalQueriedNodeId(inner.innerNodeId);
12818
+ if (!innerIdentity || !expectedInnerId || innerIdentity.id !== expectedInnerId) {
12819
+ const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12820
+ return appendToolResultText(first, `\n\n(The panel listed "${refusal.widget}" as promoted and mapped it to inner node ` +
12821
+ `${inner.innerNodeId}, but the post-enter identity probe did not verify that ` +
12822
+ `exact live node${innerProbe.isError ? `: ${textOfToolResult(innerProbe)}` : "."} ` +
12823
+ `The write was not retried.${exited.isError
12824
+ ? ` panel_exit_subgraph also FAILED: ${textOfToolResult(exited)}`
12825
+ : ""})`);
12826
+ }
12827
+ if (innerIdentity.type === DASIWA_LTX2_LORA_LOADER) {
12828
+ const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12829
+ return appendToolResultText(first, `\n\n(The promoted inner node ${inner.innerNodeId} was identified as ` +
12830
+ `${DASIWA_LTX2_LORA_LOADER}; ${textOfToolResult(daSiWaStackRefusal(innerIdentity.type))} ` +
12831
+ `No inner graph_set_widget was dispatched.${exited.isError
12832
+ ? ` panel_exit_subgraph also FAILED: ${textOfToolResult(exited)}`
12833
+ : ""})`);
12834
+ }
12835
+ innerExpectedNodeType = innerIdentity.type;
12836
+ }
12837
+ const written = await write(inner.innerNodeId, inner.widget, innerExpectedNodeType);
12416
12838
  const exited = await ctx.call({ cmd: "graph_exit_subgraph" }, 15000);
12417
12839
  if (!written.isError) {
12418
12840
  const via = `\n\n(Applied via the inner widget this promotion lists: node ${inner.innerNodeId} ` +