comfyui-mcp 0.27.0 → 0.29.0

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.
Files changed (54) hide show
  1. package/dist/comfyui/client.js +86 -0
  2. package/dist/comfyui/client.js.map +1 -1
  3. package/dist/orchestrator/backend-readiness.js +54 -0
  4. package/dist/orchestrator/backend-readiness.js.map +1 -1
  5. package/dist/orchestrator/index.js +414 -38
  6. package/dist/orchestrator/index.js.map +1 -1
  7. package/dist/orchestrator/ollama-backend.js +24 -5
  8. package/dist/orchestrator/ollama-backend.js.map +1 -1
  9. package/dist/orchestrator/panel-tools.js +162 -42
  10. package/dist/orchestrator/panel-tools.js.map +1 -1
  11. package/dist/services/calc-evaluator.js +443 -0
  12. package/dist/services/calc-evaluator.js.map +1 -0
  13. package/dist/services/graph-query.js +228 -0
  14. package/dist/services/graph-query.js.map +1 -0
  15. package/dist/services/llamacpp-probe.js +88 -0
  16. package/dist/services/llamacpp-probe.js.map +1 -0
  17. package/dist/services/lmstudio-lifecycle.js +143 -0
  18. package/dist/services/lmstudio-lifecycle.js.map +1 -0
  19. package/dist/services/node-dev.js +718 -0
  20. package/dist/services/node-dev.js.map +1 -0
  21. package/dist/services/node-management.js +2 -2
  22. package/dist/services/node-management.js.map +1 -1
  23. package/dist/services/panel-secrets.js +2 -1
  24. package/dist/services/panel-secrets.js.map +1 -1
  25. package/dist/services/panel-settings.js +9 -0
  26. package/dist/services/panel-settings.js.map +1 -1
  27. package/dist/services/slot-compat.js +53 -0
  28. package/dist/services/slot-compat.js.map +1 -0
  29. package/dist/services/ui-bridge.js +97 -16
  30. package/dist/services/ui-bridge.js.map +1 -1
  31. package/dist/services/workflow-health.js +244 -0
  32. package/dist/services/workflow-health.js.map +1 -0
  33. package/dist/services/workflow-validator.js +28 -2
  34. package/dist/services/workflow-validator.js.map +1 -1
  35. package/dist/tools/calculate.js +97 -0
  36. package/dist/tools/calculate.js.map +1 -0
  37. package/dist/tools/comfyui-settings.js +68 -0
  38. package/dist/tools/comfyui-settings.js.map +1 -0
  39. package/dist/tools/index.js +6 -0
  40. package/dist/tools/index.js.map +1 -1
  41. package/dist/tools/node-dev.js +213 -0
  42. package/dist/tools/node-dev.js.map +1 -0
  43. package/dist/tools/workflow-dsl.js +87 -3
  44. package/dist/tools/workflow-dsl.js.map +1 -1
  45. package/dist/tools/workflow-library.js +128 -2
  46. package/dist/tools/workflow-library.js.map +1 -1
  47. package/dist/tools/workflow-validate.js +21 -4
  48. package/dist/tools/workflow-validate.js.map +1 -1
  49. package/package.json +4 -2
  50. package/plugin/skills/debug-render/SKILL.md +2 -2
  51. package/plugin/skills/director/SKILL.md +1 -1
  52. package/plugin/skills/workflow-layout/SKILL.md +5 -5
  53. package/scripts/gen-changelog.mjs +244 -0
  54. package/scripts/gen-tool-docs.ts +8 -3
@@ -17,6 +17,7 @@ import { randomBytes } from "node:crypto";
17
17
  import readline from "node:readline";
18
18
  import { startUiBridge, isLoopbackBindHost } from "../services/ui-bridge.js";
19
19
  import { setupSecureBridge } from "../services/secure-bridge.js";
20
+ import { startQuickTunnel } from "../services/tunnel.js";
20
21
  import { detectInstallMode } from "../services/self-update.js";
21
22
  import { SessionStore } from "./session-store.js";
22
23
  import { logger } from "../utils/logger.js";
@@ -24,7 +25,7 @@ import { PanelAgentManager, fetchSupportedModels, fetchSupportedCommands, isEffo
24
25
  import { createPanelMcpServer } from "./panel-tools.js";
25
26
  import { readUserMcpServers } from "../services/user-mcp-config.js";
26
27
  import { isForceRemoteFlagSet, isLoopbackHost, detectLocalComfyUIPath } from "../config.js";
27
- import { buildComfyuiMcpEnv, comfyuiSecretKeys, onComfyuiSecretsChanged, hydrateAgentSecretsIntoEnv, onAgentSecretsChanged, setAgentSecret, } from "../services/panel-secrets.js";
28
+ import { buildComfyuiMcpEnv, comfyuiSecretKeys, onComfyuiSecretsChanged, hydrateAgentSecretsIntoEnv, onAgentSecretsChanged, setAgentSecret, setComfyuiSecret, isAllowedComfyuiSecretKey, } from "../services/panel-secrets.js";
28
29
  import { CodexBackend } from "./codex-backend.js";
29
30
  import { GeminiBackend, GEMINI_DEFAULT_MODEL } from "./gemini-backend.js";
30
31
  import { OllamaBackend } from "./ollama-backend.js";
@@ -33,15 +34,17 @@ import { startPanelMcpHttpServer } from "./panel-mcp-http.js";
33
34
  import { readComfyuiCrashLog, formatCrashNote } from "../services/crash-log.js";
34
35
  import { QueueMonitor } from "../services/queue-monitor.js";
35
36
  import { unloadAllOllama, warmOllama, resolveOllamaHost } from "../services/ollama-vram.js";
37
+ import { isLocalLmstudio, startLmstudioServer, unloadAllLmstudio, warmLmstudio, } from "../services/lmstudio-lifecycle.js";
38
+ import { llamacppProps, llamacppToolsReady } from "../services/llamacpp-probe.js";
36
39
  import { getAgentSettings, setAgentSettings } from "../services/panel-settings.js";
37
40
  import { gatherEnvCapabilities, buildPanelSystemAppend, } from "../services/env-capabilities.js";
38
41
  const PANEL_SYSTEM_APPEND = `You are the autonomous assistant embedded directly in a ComfyUI sidebar panel. The person is working in ComfyUI and talks to you through that panel: their messages arrive as your prompts, and everything you write is shown to them in the panel chat. Write for that reader — lead with the result, keep replies short and concrete, and don't narrate routine internal steps.
39
42
 
40
- You can SEE and EDIT the workflow the user currently has open, via the panel_* tools (panel_get_graph, panel_add_node, panel_connect, panel_set_widget, panel_run, panel_get_errors, panel_save_workflow, …). STRONGLY PREFER building on their live canvas: read it with panel_get_graph first, add/wire/configure nodes with the panel_* tools, then panel_run to queue it — so the user watches the work happen and the result loads in their own workflow with full Ctrl+Z undo. Only fall back to the headless generate_image/enqueue_workflow tools when the user explicitly wants a one-off they don't need on their canvas, or when no panel tab is connected (a panel_* call will error if so). On a LARGE graph (a loaded pack/template with dozens of nodes), do NOT dump the whole thing with panel_get_graph and scan it — and NEVER shell out to grep/jq/python over a saved workflow file. To UNDERSTAND the graph, call panel_graph_outline FIRST: a compact, dependency-ordered TEXT map (nodes topologically sorted source→sink, each with its key widgets and ← inputs / → outputs wiring, plus a groups index) made for you to read top-to-bottom. To PINPOINT specific nodes, use panel_find_nodes: filter by type, title, input/output port, widget name, widget VALUE (e.g. a filename), is_output, or mode, or a free-text query across all of those it returns each match's full summary plus why it matched. Reach for panel_get_graph's full JSON only when you need one node's exact slot/widget detail.
43
+ You can SEE and EDIT the workflow the user currently has open, via the panel_* tools (panel_graph_outline, panel_query_graph, panel_add_node, panel_connect, panel_set_widget, panel_run, panel_get_errors, panel_save_workflow, …). STRONGLY PREFER building on their live canvas: read it first (panel_graph_outline, then panel_query_graph for specifics), add/wire/configure nodes with the panel_* tools, then panel_run to queue it — so the user watches the work happen and the result loads in their own workflow with full Ctrl+Z undo. Only fall back to the headless generate_image/enqueue_workflow tools when the user explicitly wants a one-off they don't need on their canvas, or when no panel tab is connected (a panel_* call will error if so). On a LARGE graph (a loaded pack/template with dozens of nodes), do NOT dump the whole thing and scan it — and NEVER shell out to grep/jq/python over a saved workflow file. To UNDERSTAND the graph, call panel_graph_outline FIRST: a compact, dependency-ordered TEXT map (nodes topologically sorted source→sink, each with its key widgets and ← inputs / → outputs wiring, plus a groups index) made for you to read top-to-bottom. To PINPOINT and INSPECT specific nodes, use panel_query_graph: filter by types/title/widget predicates ('cfg>7'), traverse upstream_of/downstream_of a node, aggregate with group_by:'type', and read ONE node's exact slot/widget detail with {ids:[id], fields:'detail'} output is token-bounded so it can never flood your context. panel_find_nodes remains for free-text search across all fields.
41
44
 
42
45
  TRUST REPORTED MANUAL CHANGES. The user can edit the canvas BY HAND between your turns (bypass/mute a node, change a widget, rewire, add/remove nodes). When that happens, your turn opens with a "⟳ MANUAL CANVAS CHANGES since your last turn" block listing exactly what they changed. Treat that block as GROUND TRUTH about the current graph — it overrides what you remember from earlier in the conversation. Do NOT assume the graph still matches your last edit or your earlier reading; if the listed changes are substantial (or contradict a plan you were mid-execution on), re-read with panel_graph_outline before you act or draw conclusions. This is also how you learn the user already tried something (e.g. they bypassed a node and it worked) — believe it over your own prior reasoning.
43
46
 
44
- REFACTOR BIG GRAPHS INTO TOGGLEABLE SUBGRAPHS — don't reconstruct group membership by hand. panel_get_graph reports every group with its member node_ids (groups are geometric — they don't own nodes, so trust this list, not coordinates). To make a region readable and switchable as a UNIT (e.g. a "REPLACEMENT MODE" group), call panel_subgraph_group(group:<title or id>) — it wraps that group's nodes into one subgraph node in a single step (no need to gather node_ids yourself). Then toggle the whole region with panel_set_node_mode(<subgraph node id>, 'bypass' to turn it OFF / 'active' to turn it ON), and to compare variants queue it twice — panel_run with the subgraph active, then panel_set_node_mode to bypass and panel_run again. For an arbitrary node set that isn't a group, use panel_create_subgraph with explicit node_ids.
47
+ REFACTOR BIG GRAPHS INTO TOGGLEABLE SUBGRAPHS — don't reconstruct group membership by hand. panel_query_graph reports every group with its member node_ids on each result's 'groups' (groups are geometric — they don't own nodes, so trust this list, not coordinates). To make a region readable and switchable as a UNIT (e.g. a "REPLACEMENT MODE" group), call panel_subgraph_group(group:<title or id>) — it wraps that group's nodes into one subgraph node in a single step (no need to gather node_ids yourself). Then toggle the whole region with panel_set_node_mode(<subgraph node id>, 'bypass' to turn it OFF / 'active' to turn it ON), and to compare variants queue it twice — panel_run with the subgraph active, then panel_set_node_mode to bypass and panel_run again. For an arbitrary node set that isn't a group, use panel_create_subgraph with explicit node_ids.
45
48
 
46
49
  If a workflow needs a custom node the user doesn't have, don't silently skip it — offer to install it. Use the BUILT-IN Manager tools: panel_search_nodes to find the pack, panel_install_node to install it, panel_node_queue_status to confirm it finished, then panel_restart_comfyui (tell the user first) to load it. NEVER restart while a generation is running or queued — a restart ABORTS the in-progress render (the tool refuses if ComfyUI is busy and tells you; wait for the queue to drain, or only force a restart if the user explicitly agrees to kill the running render). After the restart the panel reconnects and you resume automatically, so you can carry on with what you were building. Prefer these panel_* Manager tools over the headless install_custom_node/search_custom_nodes (which need a separate Manager setup).
47
50
 
@@ -49,11 +52,11 @@ CRASH RECOVERY — when a custom node BREAKS or CRASHED ComfyUI, fix it before g
49
52
 
50
53
  WEDGED RENDER / OOM / VRAM PINNED — when a generation is stuck or hits CUDA out-of-memory, or a cancel didn't actually free GPU memory (models still resident, VRAM pinned, the next run still OOMs), call panel_free_vram to UNLOAD all models and free VRAM before retrying — it does NOT restart ComfyUI, so it's the cheap first move. Escalation ladder: cancel the run → panel_free_vram (unload + free) → retry; only as a LAST RESORT panel_restart_comfyui (which refuses mid-render and guards the running generation). Reach for panel_free_vram before a restart whenever a cancel left memory pinned.
51
54
 
52
- CRITICAL — never destroy the user's work. When they ask for a "new workflow", a "fresh canvas", or to "start over for a new project", call panel_new_workflow (it opens a NEW TAB and leaves their current workflow intact). NEVER use panel_clear for that — panel_clear wipes the CURRENTLY OPEN graph and is ONLY for an explicit "clear/reset this canvas". You can manage tabs with panel_list_workflows / panel_open_workflow / panel_rename_workflow / panel_close_workflow, and group nodes with panel_select_nodes / panel_create_subgraph. To label a node by its purpose, use panel_set_node_title. To read or edit nodes INSIDE a subgraph, call panel_enter_subgraph(node_id) first — then panel_get_graph and the panel_* edit tools operate on the subgraph's inner nodes — and panel_exit_subgraph when you're done.
55
+ CRITICAL — never destroy the user's work. When they ask for a "new workflow", a "fresh canvas", or to "start over for a new project", call panel_new_workflow (it opens a NEW TAB and leaves their current workflow intact). NEVER use panel_clear for that — panel_clear wipes the CURRENTLY OPEN graph and is ONLY for an explicit "clear/reset this canvas". You can manage tabs with panel_list_workflows / panel_open_workflow / panel_rename_workflow / panel_close_workflow, and group nodes with panel_select_nodes / panel_create_subgraph. To label a node by its purpose, use panel_set_node_title. To read or edit nodes INSIDE a subgraph, call panel_enter_subgraph(node_id) first — then panel_query_graph / panel_graph_outline and the panel_* edit tools operate on the subgraph's inner nodes — and panel_exit_subgraph when you're done.
53
56
 
54
- SUBGRAPH I/O — exposing interior nodes to the boundary. To wire an interior node to the subgraph's boundary from INSIDE a subgraph, do NOT panel_connect to a guessed rail node id — that's the rail and you'll get it wrong. Use panel_expose_subgraph_output(from_node_id, from_output) to expose an interior OUTPUT on the output rail (so the parent graph can wire the subgraph node's new output), and panel_expose_subgraph_input(to_node_id, to_input) to expose an interior INPUT on the input rail. Read panel_get_graph's \`rails\` field (present when viewing a subgraph) to see the current boundary slots — what's already exposed and what still needs it. To EXPAND/DISSOLVE a subgraph back into the parent graph (inline its inner nodes and rewire external links, removing the wrapper — the inverse of panel_create_subgraph), use panel_unpack_subgraph(node_id). All three are undoable with Ctrl+Z.
57
+ SUBGRAPH I/O — exposing interior nodes to the boundary. To wire an interior node to the subgraph's boundary from INSIDE a subgraph, do NOT panel_connect to a guessed rail node id — that's the rail and you'll get it wrong. Use panel_expose_subgraph_output(from_node_id, from_output) to expose an interior OUTPUT on the output rail (so the parent graph can wire the subgraph node's new output), and panel_expose_subgraph_input(to_node_id, to_input) to expose an interior INPUT on the input rail. Read panel_query_graph's \`rails\` field (present when viewing a subgraph) to see the current boundary slots — what's already exposed and what still needs it. To EXPAND/DISSOLVE a subgraph back into the parent graph (inline its inner nodes and rewire external links, removing the wrapper — the inverse of panel_create_subgraph), use panel_unpack_subgraph(node_id). All three are undoable with Ctrl+Z.
55
58
 
56
- MERGE / COMPOSE WORKFLOWS — to bring nodes from ONE workflow into ANOTHER (combine two graphs, copy a section across tabs, reuse part of a saved workflow), use copy/paste: panel_open_workflow (the source) → panel_select_nodes (the section you want, or select all the nodes from panel_get_graph) → panel_copy_nodes → panel_open_workflow or panel_new_workflow (the destination) → panel_paste_nodes (returns the new node ids) → then wire and tidy them, applying the workflow-layout skill so the merged result is clean (no overlaps). The clipboard SURVIVES the workflow switch, so the copied nodes carry across tabs. Use connect_inputs only when you want the pasted nodes to auto-reconnect to matching existing nodes; default (false) drops a clean disconnected copy you wire yourself.
59
+ MERGE / COMPOSE WORKFLOWS — to bring nodes from ONE workflow into ANOTHER (combine two graphs, copy a section across tabs, reuse part of a saved workflow), use copy/paste: panel_open_workflow (the source) → panel_select_nodes (the section you want, or select all the nodes from panel_query_graph {fields:'ids'}) → panel_copy_nodes → panel_open_workflow or panel_new_workflow (the destination) → panel_paste_nodes (returns the new node ids) → then wire and tidy them, applying the workflow-layout skill so the merged result is clean (no overlaps). The clipboard SURVIVES the workflow switch, so the copied nodes carry across tabs. Use connect_inputs only when you want the pasted nodes to auto-reconnect to matching existing nodes; default (false) drops a clean disconnected copy you wire yourself.
57
60
 
58
61
  REUSE SUBGRAPHS via the blueprint library — when the user builds a useful subgraph and wants to reuse it (now or in other workflows), SAVE it: panel_create_subgraph to group the nodes (if not already a subgraph), then panel_save_subgraph(node_id, name) publishes it to their library programmatically (no dialog). To drop a saved one into ANY workflow later, list them with panel_list_subgraphs and add with panel_add_subgraph(name). This is the durable way to reuse a building block across projects — distinct from copy/paste (a one-off merge of the current clipboard).
59
62
 
@@ -83,13 +86,13 @@ Adult / NSFW content is gated behind an explicit, persistent consent mode — qu
83
86
 
84
87
  SHOW / DISPLAY IMAGES AND VIDEOS — whenever the user asks to see, show, or display an image or video that you generated, composited, downloaded, or found — whether it is a file on disk (absolute path on the orchestrator host) or a ComfyUI output ref ({ filename, subfolder?, type? }) — call panel_show_media to render it as a media card directly in this chat. NEVER substitute emoji, text descriptions, or placeholder bullets for actual media; always call panel_show_media.
85
88
 
86
- INSPECT NODE MODES BEFORE YOU RUN. After loading a pack/template/workflow — and before any panel_run — call panel_get_graph and CHECK each node's mode. A node in 'bypass' is skipped (it just passes input through); a node in 'mute' does not execute and kills everything downstream. Packs and expert graphs ship with switches (a manual-prompt vs JSON/builder node, an rgthree Fast-Groups Bypasser/Muter, a prompt-source toggle) where the path you want is often BYPASSED/MUTED by default. NEVER assume a switch or route is active: if the path you intend to drive is bypassed/muted, enable it with panel_set_node_mode (set the wanted node 'active' and the unwanted one 'bypass'/'mute') BEFORE running. A wrong/stale mode is a top cause of renders that come out wrong.
89
+ INSPECT NODE MODES BEFORE YOU RUN. After loading a pack/template/workflow — and before any panel_run — check node modes (panel_graph_outline marks [bypass]/[mute]; panel_query_graph detail rows carry mode). A node in 'bypass' is skipped (it just passes input through); a node in 'mute' does not execute and kills everything downstream. Packs and expert graphs ship with switches (a manual-prompt vs JSON/builder node, an rgthree Fast-Groups Bypasser/Muter, a prompt-source toggle) where the path you want is often BYPASSED/MUTED by default. NEVER assume a switch or route is active: if the path you intend to drive is bypassed/muted, enable it with panel_set_node_mode (set the wanted node 'active' and the unwanted one 'bypass'/'mute') BEFORE running. A wrong/stale mode is a top cause of renders that come out wrong.
87
90
 
88
91
  VERIFY THE OUTPUT MATCHES THE REQUEST. After a render completes, actually LOOK at the image/video the panel delivers and confirm it matches what was asked BEFORE you declare success or move to the next step. If it doesn't match, do NOT report progress — diagnose (wrong prompt path? a bypassed/muted builder or switch? wrong widget value?), fix it (often panel_set_node_mode or panel_set_widget), and rerun. Only claim something works once you've SEEN that it does — never report progress you haven't verified.
89
92
 
90
93
  AFTER PANEL_RUN — once you call panel_run to queue a render, you will be notified automatically with the output image(s)/video when it finishes. Do not poll get_queue, get_history, or list_output_images waiting for the result — just end your turn and the finished render will be delivered to you.
91
94
 
92
- DEBUG WRONG RENDERS BY INSPECTING INTERMEDIATE STEPS (run-to-node). When a final asset comes out WRONG — artifacts, wrong subject/pose/composition/color, blur, a ControlNet/IPAdapter/mask/LoRA not taking, a refiner or upscale stage degrading it — do NOT just re-roll the whole graph. LOCALIZE the fault: render only up to one stage and LOOK at what that stage produces. panel_run takes to_node_id to run ONE output branch (ComfyUI partial execution) — only that output node plus everything upstream of it renders, the rest is skipped, so it's fast and cheap, and the result is delivered to you automatically like any run. to_node_id MUST be an OUTPUT node (is_output:true in panel_get_graph). To inspect a point that ISN'T an output — a latent, a preprocessor/depth/pose map, a mask, an intermediate image — TAP it: add a PreviewImage on an IMAGE wire (or VAEDecode→PreviewImage on a LATENT, MaskToImage→PreviewImage on a MASK), panel_run(to_node_id=that preview), read the delivered image, then panel_remove_node the tap when done. Bisect upstream→downstream until you find the FIRST stage whose output is bad — that node (or its inputs/widgets) is what to fix, then run-to-node there again to confirm before a full run. For the full method (probe recipes, symptom→probe map) read the debug-render skill via read_skill. This is for renders that COMPLETE but look wrong; for runs that fail with an error/OOM/missing node, use the troubleshooting skill instead.
95
+ DEBUG WRONG RENDERS BY INSPECTING INTERMEDIATE STEPS (run-to-node). When a final asset comes out WRONG — artifacts, wrong subject/pose/composition/color, blur, a ControlNet/IPAdapter/mask/LoRA not taking, a refiner or upscale stage degrading it — do NOT just re-roll the whole graph. LOCALIZE the fault: render only up to one stage and LOOK at what that stage produces. panel_run takes to_node_id to run ONE output branch (ComfyUI partial execution) — only that output node plus everything upstream of it renders, the rest is skipped, so it's fast and cheap, and the result is delivered to you automatically like any run. to_node_id MUST be an OUTPUT node (is_output:true in panel_query_graph detail rows). To inspect a point that ISN'T an output — a latent, a preprocessor/depth/pose map, a mask, an intermediate image — TAP it: add a PreviewImage on an IMAGE wire (or VAEDecode→PreviewImage on a LATENT, MaskToImage→PreviewImage on a MASK), panel_run(to_node_id=that preview), read the delivered image, then panel_remove_node the tap when done. Bisect upstream→downstream until you find the FIRST stage whose output is bad — that node (or its inputs/widgets) is what to fix, then run-to-node there again to confirm before a full run. For the full method (probe recipes, symptom→probe map) read the debug-render skill via read_skill. This is for renders that COMPLETE but look wrong; for runs that fail with an error/OOM/missing node, use the troubleshooting skill instead.
93
96
 
94
97
  CHAIN A STAGE'S OUTPUT INTO THE NEXT STAGE'S LOADER — when a multi-stage pipeline (e.g. Krea2 image → LTX video → WAN extend) needs one stage's OUTPUT fed into the next stage's loader (LoadImage / VHS_LoadVideo / LoadAudio), call stage_output_as_input with the output's { filename, subfolder?, type? } and drop the returned input filename into the loader's image/video/audio widget. (Or, for a file already on disk, upload_image / upload_video / upload_audio.) NEVER copy the output file into, or guess, a filesystem \`input/\` path: ComfyUI's input AND output directories may be CUSTOM (launched with --input-directory / --output-directory), so a guessed path makes LoadImage reject the file ("Invalid image file") and wastes the render. stage_output_as_input goes through the server API (/view → /upload/image), which resolves the real dirs correctly every time. VERIFY A VIDEO RENDER VIA THE FILESYSTEM, NOT /history — VHS_VideoCombine and similar video nodes write the .mp4 but frequently do NOT register an output in ComfyUI's /history (the prompt shows done with no output and no error), so do NOT conclude a clip "silently dropped" from get_history/get_job_status; confirm it with list_output_images (which now lists videos, each tagged kind:"video") by filename/prefix + fresh mtime, then chain it forward with stage_output_as_input.
95
98
 
@@ -119,6 +122,21 @@ const injectedCrashes = new Set();
119
122
  * doesn't prepend the same warning to every message. A new running prompt id (or
120
123
  * a fresh backlog) produces a new key and warns again. Process-scoped. */
121
124
  const injectedQueueNotes = new Set();
125
+ /** HEADLESS clients (the mobile / remote pseudo-panel) connect with NO ComfyUI
126
+ * browser panel. Two consequences: the live-canvas panel_* tools can't run, AND
127
+ * — critically — nothing observes a render finishing to auto-deliver its output
128
+ * back to the agent (that path is browser-driven; see the `agent_event` handler).
129
+ * generate_image / enqueue_workflow return a prompt_id immediately, so a headless
130
+ * agent that "ends its turn and waits" never surfaces the image. This directive,
131
+ * prepended to each headless-tab turn like the crash/queue notes, tells it to run
132
+ * headless and deliver the result ITSELF, in-turn. */
133
+ const HEADLESS_DIRECTIVE = "[HEADLESS SESSION — no ComfyUI panel/canvas is connected] The panel_* live-canvas tools " +
134
+ "(panel_run, panel_query_graph, panel_set_widget, panel_add_node, …) are UNAVAILABLE here and will fail — " +
135
+ "do everything through the comfyui MCP tools (generate_image, or create_workflow + enqueue_workflow). " +
136
+ "There is NO panel to auto-deliver a finished render, so you MUST deliver the result YOURSELF IN THIS SAME TURN: " +
137
+ "enqueuing returns a prompt_id immediately, so wait for it with get_job_status(prompt_id) — poll it briefly until " +
138
+ "it reports completion (this is the ONE case where polling IS correct) — then fetch the output with get_history and " +
139
+ "show it with panel_show_media. Do NOT end your turn expecting an automatic notification; none will arrive.";
122
140
  /** Live stall threshold (seconds) pushed from the panel setting via a `set_config`
123
141
  * frame — applies WITHOUT a reconnect. null = not set, fall back to env then the
124
142
  * built-in default. Process-global: one ComfyUI per orchestrator. */
@@ -568,6 +586,24 @@ export async function runPanelOrchestrator() {
568
586
  const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
569
587
  const lockPath = orchLockPath(lockPort);
570
588
  const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
589
+ // On-demand phone pairing (the panel "Remote control" button). Off by default:
590
+ // the FIRST pair request lazily binds a SECOND, token-gated listener on all
591
+ // interfaces (so a phone can reach it), while the primary loopback bridge stays
592
+ // token-less. LAN mode returns a same-wifi ws:// URL; tunnel mode fronts the same
593
+ // token-gated listener with a cloudflared wss:// for anywhere access.
594
+ const pairPort = lockPort + 2; // avoid the panel_* HTTP-MCP port (lockPort + 1)
595
+ let pairToken = null;
596
+ let pairListenerStarted = false;
597
+ let pairTunnel = null;
598
+ const ensurePairListener = async () => {
599
+ if (!pairToken)
600
+ pairToken = randomBytes(24).toString("hex");
601
+ if (!pairListenerStarted) {
602
+ await bridge.addListener("0.0.0.0", pairPort, pairToken);
603
+ pairListenerStarted = true;
604
+ }
605
+ return pairToken;
606
+ };
571
607
  if (lanBridge) {
572
608
  // Ready-to-paste connection info: the panel's Settings → Advanced →
573
609
  // Bridge URL takes the full URL incl. ?token= verbatim.
@@ -830,6 +866,40 @@ export async function runPanelOrchestrator() {
830
866
  ...(key ? { apiKey: key } : {}),
831
867
  };
832
868
  };
869
+ // LM Studio (issue #160) = the same openai dialect pinned to LM Studio's
870
+ // local server. No API key, no login. Default model is EMPTY on purpose —
871
+ // LM Studio model ids are user-specific, so ensureModels() below fills it
872
+ // with the first id the server actually offers.
873
+ const LMSTUDIO_BASE_URL = (process.env.COMFYUI_MCP_LMSTUDIO_HOST ?? "http://127.0.0.1:1234/v1").replace(/[/]$/, "");
874
+ let lmstudioModel = process.env.COMFYUI_MCP_LMSTUDIO_MODEL ?? persistedAgent.lmstudio?.model ?? "";
875
+ const lmstudioDeps = () => ({ api: "openai", host: LMSTUDIO_BASE_URL });
876
+ // llama.cpp (issue #161) — llama-server's OpenAI-compatible /v1. No key, no
877
+ // login, ONE model fixed at launch (-m). Context is a LAUNCH flag (-c), and
878
+ // tool calling needs --jinja — both probed at connect (services/llamacpp-probe).
879
+ const LLAMACPP_BASE_URL = (process.env.COMFYUI_MCP_LLAMACPP_HOST ?? "http://127.0.0.1:8080/v1").replace(/[/]$/, "");
880
+ let llamacppModel = process.env.COMFYUI_MCP_LLAMACPP_MODEL ?? persistedAgent.llamacpp?.model ?? "";
881
+ const llamacppDeps = () => ({ api: "openai", host: LLAMACPP_BASE_URL });
882
+ // Custom OpenAI-compatible endpoint (issue #162) — the same openai dialect
883
+ // pointed anywhere the user says: vLLM, DeepSeek, Together, Azure, a box on
884
+ // the LAN… Base URL + model come from panel Settings (persisted) or env; the
885
+ // API key from the 0600 secrets store (COMFYUI_MCP_CUSTOM_API_KEY, set via
886
+ // the panel's masked "Set API key…" — many local endpoints need none). NO
887
+ // default URL on purpose: unconfigured degrades with an actionable ack
888
+ // instead of dialing a guess.
889
+ let customBaseUrl = (process.env.COMFYUI_MCP_CUSTOM_BASE_URL ?? persistedAgent.custom?.baseUrl ?? "").replace(/[/]$/, "");
890
+ let customModel = process.env.COMFYUI_MCP_CUSTOM_MODEL ?? persistedAgent.custom?.model ?? "";
891
+ // Key read FRESH each call (not a startup const) so a key set later via the
892
+ // panel — setAgentSecret hydrates it into env — applies on the next backend
893
+ // build without an orchestrator restart.
894
+ const customApiKey = () => process.env.COMFYUI_MCP_CUSTOM_API_KEY;
895
+ const customDeps = () => {
896
+ const key = customApiKey();
897
+ return {
898
+ api: "openai",
899
+ host: customBaseUrl,
900
+ ...(key ? { apiKey: key } : {}),
901
+ };
902
+ };
833
903
  // ── Per-tab backend (single-port multi-provider) ──────────────────────────
834
904
  // ONE orchestrator on ONE bridge port serves ALL providers; the panel picks a
835
905
  // provider per tab via the `hello`/`set_backend` handshake, instead of the node
@@ -839,10 +909,11 @@ export async function runPanelOrchestrator() {
839
909
  // provider (the panel replays the transcript to seed it) while a same-provider
840
910
  // reconnect RESUMES. `backendId`/`codexModel`/`geminiModel` above are the
841
911
  // DEFAULT + per-provider model config; the process is no longer pinned to one.
842
- const KNOWN_BACKENDS = new Set(["claude", "codex", "gemini", "ollama", "openrouter"]);
912
+ const KNOWN_BACKENDS = new Set(["claude", "codex", "gemini", "ollama", "openrouter", "lmstudio", "llamacpp", "custom"]);
843
913
  const defaultBackend = KNOWN_BACKENDS.has(backendId) ? backendId : "claude";
844
914
  const AGENT_KEY_SEP = "::";
845
915
  const tabBackends = new Map(); // panel tabId -> selected backend
916
+ const headlessTabs = new Set(); // tabs with no ComfyUI canvas (mobile/remote) — deliver renders in-turn
846
917
  const backendForTab = (panelTabId) => tabBackends.get(panelTabId) ?? defaultBackend;
847
918
  const agentKeyFor = (panelTabId) => panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId);
848
919
  // A panel tab id never contains "::"; backend names never do — so split on the
@@ -996,6 +1067,36 @@ export async function runPanelOrchestrator() {
996
1067
  ...openrouterDeps(),
997
1068
  });
998
1069
  }
1070
+ if (backend === "lmstudio") {
1071
+ return new OllamaBackend({
1072
+ cwd: comfyuiPath ?? process.cwd(),
1073
+ model: lmstudioModel,
1074
+ systemAppend: panelSystemAppend,
1075
+ comfyuiUrl,
1076
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
1077
+ ...lmstudioDeps(),
1078
+ });
1079
+ }
1080
+ if (backend === "llamacpp") {
1081
+ return new OllamaBackend({
1082
+ cwd: comfyuiPath ?? process.cwd(),
1083
+ model: llamacppModel,
1084
+ systemAppend: panelSystemAppend,
1085
+ comfyuiUrl,
1086
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
1087
+ ...llamacppDeps(),
1088
+ });
1089
+ }
1090
+ if (backend === "custom") {
1091
+ return new OllamaBackend({
1092
+ cwd: comfyuiPath ?? process.cwd(),
1093
+ model: customModel,
1094
+ systemAppend: panelSystemAppend,
1095
+ comfyuiUrl,
1096
+ mcpServers: makeHttpBackendMcpServers(panelTabId),
1097
+ ...customDeps(),
1098
+ });
1099
+ }
999
1100
  return undefined; // claude → built-in ClaudeBackend
1000
1101
  };
1001
1102
  logger.info(`[panel-orchestrator] single-port multi-provider: default backend=${defaultBackend}; ` +
@@ -1018,7 +1119,13 @@ export async function runPanelOrchestrator() {
1018
1119
  ? new OllamaBackend({ cwd: comfyuiPath ?? process.cwd(), model: ollamaModel, ...ollamaDeps() })
1019
1120
  : backend === "openrouter"
1020
1121
  ? new OllamaBackend({ cwd: comfyuiPath ?? process.cwd(), model: openrouterModel, ...openrouterDeps() })
1021
- : new GeminiBackend({ cwd: comfyuiPath ?? process.cwd(), model: geminiModel });
1122
+ : backend === "lmstudio"
1123
+ ? new OllamaBackend({ cwd: comfyuiPath ?? process.cwd(), model: lmstudioModel, ...lmstudioDeps() })
1124
+ : backend === "llamacpp"
1125
+ ? new OllamaBackend({ cwd: comfyuiPath ?? process.cwd(), model: llamacppModel, ...llamacppDeps() })
1126
+ : backend === "custom"
1127
+ ? new OllamaBackend({ cwd: comfyuiPath ?? process.cwd(), model: customModel, ...customDeps() })
1128
+ : new GeminiBackend({ cwd: comfyuiPath ?? process.cwd(), model: geminiModel });
1022
1129
  probeBackends.set(backend, pb);
1023
1130
  }
1024
1131
  return pb;
@@ -1123,17 +1230,26 @@ export async function runPanelOrchestrator() {
1123
1230
  const pauseLocalDuringGen = process.env.COMFYUI_MCP_OLLAMA_PAUSE_ON_GEN !== "0";
1124
1231
  const anyLocalOllama = () => ollamaApi === "ollama" &&
1125
1232
  (defaultBackend === "ollama" || [...tabBackends.values()].includes("ollama"));
1233
+ // LM Studio joins the same VRAM handoff (issue #160 follow-up): local server
1234
+ // only — a remote COMFYUI_MCP_LMSTUDIO_HOST is someone else's VRAM.
1235
+ const anyLocalLmstudio = () => isLocalLmstudio(LMSTUDIO_BASE_URL) &&
1236
+ (defaultBackend === "lmstudio" || [...tabBackends.values()].includes("lmstudio"));
1126
1237
  // agentKey -> messages held while a render runs (flushed on render end).
1127
1238
  const heldDuringGen = new Map();
1128
1239
  let genPauseActive = false;
1129
1240
  if (pauseLocalDuringGen) {
1130
1241
  QueueMonitor.setTransitionHandlers({
1131
1242
  onRunStart: () => {
1132
- if (!anyLocalOllama())
1243
+ const ol = anyLocalOllama();
1244
+ const ls = anyLocalLmstudio();
1245
+ if (!ol && !ls)
1133
1246
  return;
1134
1247
  genPauseActive = true;
1135
1248
  // Free the local model's VRAM for the render (best-effort, fire-and-forget).
1136
- void unloadAllOllama(resolveOllamaHost());
1249
+ if (ol)
1250
+ void unloadAllOllama(resolveOllamaHost());
1251
+ if (ls)
1252
+ void unloadAllLmstudio(LMSTUDIO_BASE_URL);
1137
1253
  },
1138
1254
  onRunEnd: () => {
1139
1255
  if (!genPauseActive)
@@ -1145,6 +1261,8 @@ export async function runPanelOrchestrator() {
1145
1261
  // loads the model itself — no separate warm needed.
1146
1262
  if (anyLocalOllama() && !hadHeld)
1147
1263
  void warmOllama(resolveOllamaHost(), ollamaModel);
1264
+ if (anyLocalLmstudio() && !hadHeld && lmstudioModel)
1265
+ void warmLmstudio(LMSTUDIO_BASE_URL, lmstudioModel);
1148
1266
  for (const [key, msgs] of heldDuringGen) {
1149
1267
  const tabId = panelTabOf(key);
1150
1268
  if (msgs.length > 0) {
@@ -1227,16 +1345,20 @@ export async function runPanelOrchestrator() {
1227
1345
  // without a reconnect.
1228
1346
  const unsubscribeAgentSecrets = onAgentSecretsChanged(() => {
1229
1347
  hydrateAgentSecretsIntoEnv();
1230
- modelsByBackend.delete("openrouter");
1231
- const pb = probeBackends.get("openrouter");
1232
- if (pb?.close)
1233
- void pb.close().catch(() => { });
1234
- probeBackends.delete("openrouter");
1348
+ // A key change can affect either keyed endpoint provider — drop both
1349
+ // caches so the next probe carries the fresh credentials.
1350
+ for (const b of ["openrouter", "custom"]) {
1351
+ modelsByBackend.delete(b);
1352
+ const pb = probeBackends.get(b);
1353
+ if (pb?.close)
1354
+ void pb.close().catch(() => { });
1355
+ probeBackends.delete(b);
1356
+ }
1235
1357
  for (const tabId of tabBackends.keys()) {
1236
1358
  pushReadiness(tabId);
1237
1359
  pushModels(tabId);
1238
1360
  }
1239
- logger.info("[panel-orchestrator] OpenRouter key saved → provider readiness + models refreshed");
1361
+ logger.info("[panel-orchestrator] provider key saved → readiness + models refreshed");
1240
1362
  });
1241
1363
  // Debounce the connect ack: the panel re-sends `hello` on reconnect and on
1242
1364
  // workflow-title changes, which would otherwise stack duplicate greetings.
@@ -1275,6 +1397,31 @@ export async function runPanelOrchestrator() {
1275
1397
  p = probe.then((list) => {
1276
1398
  if (!list.length)
1277
1399
  modelsByBackend.delete(backend); // don't cache a failed probe
1400
+ // LM Studio / llama.cpp ship no sane hardcoded default (the model is
1401
+ // whatever the user downloaded/launched) — adopt the server's first
1402
+ // offering when unset. llama-server serves exactly one model, so this
1403
+ // IS the model.
1404
+ if (backend === "lmstudio" && !lmstudioModel && list.length) {
1405
+ lmstudioModel = list[0].value ?? "";
1406
+ if (lmstudioModel) {
1407
+ logger.info(`[panel-orchestrator] lmstudio default model → ${lmstudioModel} (first served)`);
1408
+ }
1409
+ }
1410
+ if (backend === "llamacpp" && !llamacppModel && list.length) {
1411
+ llamacppModel = list[0].value ?? "";
1412
+ if (llamacppModel) {
1413
+ logger.info(`[panel-orchestrator] llamacpp model → ${llamacppModel} (the server's loaded model)`);
1414
+ }
1415
+ }
1416
+ // Custom endpoint: same adoption — many self-hosted servers (vLLM,
1417
+ // llama-server, TGI) serve exactly one model, so the first listed id
1418
+ // is the sane default when the user hasn't named one.
1419
+ if (backend === "custom" && !customModel && list.length) {
1420
+ customModel = list[0].value ?? "";
1421
+ if (customModel) {
1422
+ logger.info(`[panel-orchestrator] custom endpoint model → ${customModel} (first served)`);
1423
+ }
1424
+ }
1278
1425
  // User-curated preferred models (panel Settings → set_config) pin to the
1279
1426
  // top of the ollama picker, ahead of the discovered catalog. Read fresh
1280
1427
  // on every probe; set_config evicts the cache so edits apply live.
@@ -1309,6 +1456,12 @@ export async function runPanelOrchestrator() {
1309
1456
  return ollamaModel;
1310
1457
  if (backend === "openrouter")
1311
1458
  return openrouterModel;
1459
+ if (backend === "lmstudio")
1460
+ return lmstudioModel || undefined;
1461
+ if (backend === "llamacpp")
1462
+ return llamacppModel || undefined;
1463
+ if (backend === "custom")
1464
+ return customModel || undefined;
1312
1465
  return model;
1313
1466
  }
1314
1467
  function pushModels(panelTabId) {
@@ -1362,8 +1515,29 @@ export async function runPanelOrchestrator() {
1362
1515
  // no CLI). The panel prefers this frame over its GET /backends probe.
1363
1516
  function pushReadiness(tabId) {
1364
1517
  try {
1365
- const { backends, any_ready } = allBackendReadiness(KNOWN_BACKENDS);
1518
+ const { backends, any_ready } = allBackendReadiness(KNOWN_BACKENDS, {
1519
+ customEndpointConfigured: !!customBaseUrl,
1520
+ });
1366
1521
  bridge.push({ type: "backends", backends, any_ready }, tabId);
1522
+ // llama.cpp reality check (async re-push): the binary is often unzipped
1523
+ // anywhere (not on PATH), so static detection says "not installed" while
1524
+ // a server is HAPPILY ANSWERING. A live endpoint beats a missing binary —
1525
+ // probe it and, when it answers, re-push the frame with llamacpp ready.
1526
+ const lc = backends.find((b) => b.backend === "llamacpp");
1527
+ if (lc && !lc.ready) {
1528
+ void fetch(`${LLAMACPP_BASE_URL.replace(/\/v1$/, "")}/health`, {
1529
+ signal: AbortSignal.timeout(1500),
1530
+ })
1531
+ .then((r) => {
1532
+ if (!r.ok)
1533
+ return;
1534
+ lc.cli = true;
1535
+ lc.auth = true;
1536
+ lc.ready = true;
1537
+ bridge.push({ type: "backends", backends, any_ready: true }, tabId);
1538
+ })
1539
+ .catch(() => { });
1540
+ }
1367
1541
  }
1368
1542
  catch (err) {
1369
1543
  logger.warn(`[panel-orchestrator] readiness probe failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1405,6 +1579,12 @@ export async function runPanelOrchestrator() {
1405
1579
  manager.reset(panelTab + AGENT_KEY_SEP + prev);
1406
1580
  }
1407
1581
  tabBackends.set(panelTab, backend);
1582
+ // A headless client (mobile/remote pseudo-panel, no browser canvas) advertises
1583
+ // itself in the hello frame so its agent gets the in-turn-delivery directive.
1584
+ if (event.headless === true)
1585
+ headlessTabs.add(panelTab);
1586
+ else
1587
+ headlessTabs.delete(panelTab);
1408
1588
  const key = panelTab + AGENT_KEY_SEP + backend;
1409
1589
  // Reload restore: the panel re-sends the last session id it saw. Honored
1410
1590
  // only for a SAME-provider (re)connect — a switch always starts fresh. The
@@ -1431,6 +1611,9 @@ export async function runPanelOrchestrator() {
1431
1611
  const isGm = backend === "gemini";
1432
1612
  const isOl = backend === "ollama";
1433
1613
  const isOr = backend === "openrouter";
1614
+ const isLs = backend === "lmstudio";
1615
+ const isLc = backend === "llamacpp";
1616
+ const isCu = backend === "custom";
1434
1617
  // TRUTHFUL "connected": only claim ready after PROVING the SELECTED backend
1435
1618
  // can run, by probing its model list. If the probe fails — the "connected
1436
1619
  // but dead" wedge — send a degraded ack so the panel shows the real state.
@@ -1448,7 +1631,21 @@ export async function runPanelOrchestrator() {
1448
1631
  logger.warn(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (openrouter) but no API key — degraded ack`);
1449
1632
  return;
1450
1633
  }
1451
- void ensureModels(backend)
1634
+ // Custom endpoint with no URL: don't dial a guess — degrade up front
1635
+ // with the exact fix (mirrors the OpenRouter keyless guard above).
1636
+ if (isCu && !customBaseUrl) {
1637
+ bridge.push({
1638
+ type: "say",
1639
+ text: "⚠️ No endpoint configured — set the base URL in Settings → Custom endpoint (include the /v1, e.g. http://192.168.1.20:8000/v1 for vLLM, or a hosted provider's OpenAI-compatible URL), " +
1640
+ "plus “Set API key…” if the server needs one. Both apply immediately — then Connect again.",
1641
+ }, panelTab);
1642
+ bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
1643
+ logger.warn(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (custom) but no base URL — degraded ack`);
1644
+ return;
1645
+ }
1646
+ void (isLs && isLocalLmstudio(LMSTUDIO_BASE_URL)
1647
+ ? startLmstudioServer(LMSTUDIO_BASE_URL).then(() => ensureModels(backend))
1648
+ : ensureModels(backend))
1452
1649
  .then((models) => {
1453
1650
  if (models.length) {
1454
1651
  const agentLabel = isCx
@@ -1457,9 +1654,42 @@ export async function runPanelOrchestrator() {
1457
1654
  ? (geminiModel ?? models[0].value ?? "Gemini")
1458
1655
  : isOl
1459
1656
  ? (ollamaModel ?? models[0].value ?? "Ollama")
1460
- : isOr
1461
- ? (openrouterModel ?? models[0].value ?? "OpenRouter")
1462
- : model;
1657
+ : isLs
1658
+ ? (lmstudioModel || (models[0].value ?? "LM Studio"))
1659
+ : isLc
1660
+ ? (llamacppModel || (models[0].value ?? "llama.cpp"))
1661
+ : isCu
1662
+ ? (customModel || (models[0].value ?? "Custom endpoint"))
1663
+ : isOr
1664
+ ? (openrouterModel ?? models[0].value ?? "OpenRouter")
1665
+ : model;
1666
+ // llama.cpp launch gotchas (issue #161): a reachable server can still
1667
+ // be useless for us — tool calling needs --jinja (rejected requests),
1668
+ // and a launch-time -c under ~16K silently truncates the tool payload.
1669
+ // Probe both and degrade/warn with the exact fix instead of letting
1670
+ // the first real message fail cryptically.
1671
+ if (isLc) {
1672
+ const activeModel = llamacppModel || (models[0].value ?? "");
1673
+ void (async () => {
1674
+ const [tools, props] = await Promise.all([
1675
+ llamacppToolsReady(LLAMACPP_BASE_URL, activeModel),
1676
+ llamacppProps(LLAMACPP_BASE_URL),
1677
+ ]);
1678
+ if (tools === "no") {
1679
+ bridge.push({
1680
+ type: "say",
1681
+ text: "⚠️ Your llama-server is running WITHOUT `--jinja`, so tool calling is disabled — every agent action would fail. " +
1682
+ "Restart it with tool support: `llama-server -m <model>.gguf --jinja -c 16384` (current builds enable jinja by default; older ones need the flag), then Disconnect → Connect.",
1683
+ }, panelTab);
1684
+ }
1685
+ else if (props.nCtx && props.nCtx < 16384) {
1686
+ bridge.push({
1687
+ type: "say",
1688
+ text: `ℹ️ Your llama-server context is ${props.nCtx} tokens (launch flag -c). The agent's tool payload wants ≥16384 — below that, long turns silently truncate. Consider restarting with \`-c 16384\` or higher.`,
1689
+ }, panelTab);
1690
+ }
1691
+ })();
1692
+ }
1463
1693
  // Greet only on a FRESH session (a resume/reconnect already has the thread).
1464
1694
  if (!resume) {
1465
1695
  const readyText = isCx
@@ -1468,9 +1698,15 @@ export async function runPanelOrchestrator() {
1468
1698
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} on your Google account (Gemini Code Assist). Ask away.`
1469
1699
  : isOl
1470
1700
  ? `🟢 comfyui-mcp agent ready — ${agentLabel} running locally via Ollama (no account, no API key). Small local models are slower and simpler than frontier ones — expect fewer frills. Ask away.`
1471
- : isOr
1472
- ? `🟢 comfyui-mcp agent ready — ${agentLabel} via OpenRouter (hosted API, your OPENROUTER_API_KEY). Ask away.`
1473
- : `🟢 comfyui-mcp agent ready — ${agentLabel} on your Claude subscription. Ask away.`;
1701
+ : isLs
1702
+ ? `🟢 comfyui-mcp agent ready — ${agentLabel} running locally via LM Studio (no account, no API key). Small local models are slower and simpler than frontier ones — expect fewer frills. Ask away.`
1703
+ : isLc
1704
+ ? `🟢 comfyui-mcp agent ready — ${agentLabel} running locally via llama.cpp (no account, no API key). Small local models are slower and simpler than frontier ones — expect fewer frills. Ask away.`
1705
+ : isCu
1706
+ ? `🟢 comfyui-mcp agent ready — ${agentLabel} via your custom endpoint (${customBaseUrl}). Ask away.`
1707
+ : isOr
1708
+ ? `🟢 comfyui-mcp agent ready — ${agentLabel} via OpenRouter (hosted API, your OPENROUTER_API_KEY). Ask away.`
1709
+ : `🟢 comfyui-mcp agent ready — ${agentLabel} on your Claude subscription. Ask away.`;
1474
1710
  bridge.push({ type: "say", text: readyText }, panelTab);
1475
1711
  }
1476
1712
  bridge.push({ type: "ack", ok: true, kind: "ready", agent: agentLabel, backend }, panelTab);
@@ -1483,7 +1719,13 @@ export async function runPanelOrchestrator() {
1483
1719
  ? "⚠️ The background agent isn't responding — the Gemini CLI couldn't start. Make sure the Gemini CLI is installed and signed in (run `gemini` once and complete the Google sign-in), then Disconnect → Connect to retry."
1484
1720
  : isOl
1485
1721
  ? "⚠️ The background agent isn't responding — Ollama isn't reachable. Start it with `ollama serve` and pull our fine-tuned model (`ollama pull artokun/gemma4-comfyui-mcp:e4b` — gemma4 trained on the comfyui-mcp tool suite; `:e2b` for ~2 GB VRAM, `:12b` for ~8 GB), then Disconnect → Connect to retry."
1486
- : "⚠️ The background agent isn't responding — the Claude Agent SDK couldn't start. Make sure you're signed in (run `claude` once), then Disconnect → Connect to retry.";
1722
+ : isLs
1723
+ ? `⚠️ The background agent isn't responding — LM Studio isn't reachable at ${LMSTUDIO_BASE_URL}. Open LM Studio → Developer → Start Server and load a tool-calling model (our gemma4-comfyui-mcp GGUFs from Hugging Face work great), or set COMFYUI_MCP_LMSTUDIO_HOST if it serves elsewhere — then Disconnect → Connect to retry.`
1724
+ : isLc
1725
+ ? `⚠️ The background agent isn't responding — llama-server isn't reachable at ${LLAMACPP_BASE_URL}. Start it with \`llama-server -m <model>.gguf -c 16384\` (our gemma4-comfyui-mcp GGUFs work great; add --jinja on older builds — required there for tool calling), or set COMFYUI_MCP_LLAMACPP_HOST — then Disconnect → Connect to retry.`
1726
+ : isCu
1727
+ ? `⚠️ The background agent isn't responding — your custom endpoint isn't answering at ${customBaseUrl}. Check the base URL in Settings → Custom endpoint (it must be OpenAI-compatible and include the /v1) and the API key if the server requires one — then Connect to retry.`
1728
+ : "⚠️ The background agent isn't responding — the Claude Agent SDK couldn't start. Make sure you're signed in (run `claude` once), then Disconnect → Connect to retry.";
1487
1729
  bridge.push({ type: "say", text: degradedText }, panelTab);
1488
1730
  bridge.push({ type: "ack", ok: false, kind: "degraded" }, panelTab);
1489
1731
  logger.warn(`[panel-orchestrator] tab ${panelTab.slice(0, 8)} connected (${backend}) but model probe empty — degraded ack`);
@@ -1511,6 +1753,21 @@ export async function runPanelOrchestrator() {
1511
1753
  if (prev !== reqBackend)
1512
1754
  manager.reset(panelTab + AGENT_KEY_SEP + prev);
1513
1755
  tabBackends.set(panelTab, reqBackend);
1756
+ // Leaving a LOCAL provider frees its VRAM (no other tab still on it) —
1757
+ // the point of switching to Claude/hosted is usually reclaiming the GPU.
1758
+ if (prev !== reqBackend) {
1759
+ const stillUsed = (b) => [...tabBackends.values()].includes(b) || defaultBackend === b;
1760
+ if (prev === "lmstudio" && !stillUsed("lmstudio") && isLocalLmstudio(LMSTUDIO_BASE_URL)) {
1761
+ void unloadAllLmstudio(LMSTUDIO_BASE_URL);
1762
+ }
1763
+ if (prev === "ollama" && !stillUsed("ollama") && ollamaApi === "ollama") {
1764
+ void unloadAllOllama(resolveOllamaHost());
1765
+ }
1766
+ // Switching TO lmstudio: make sure its server is up (auto-start, no
1767
+ // manual `lms server start`) so the readiness probe finds it alive.
1768
+ if (reqBackend === "lmstudio")
1769
+ void startLmstudioServer(LMSTUDIO_BASE_URL);
1770
+ }
1514
1771
  pushModels(panelTab);
1515
1772
  if (reqBackend === "claude")
1516
1773
  pushCommands(panelTab);
@@ -1567,6 +1824,63 @@ export async function runPanelOrchestrator() {
1567
1824
  logger.info(`[panel-orchestrator] ollama config → model=${ollamaModel} api=${ollamaApi} host=${ollamaBaseUrl ?? "(default)"}`);
1568
1825
  }
1569
1826
  }
1827
+ const lccfg = event.llamacpp;
1828
+ if (lccfg && typeof lccfg === "object") {
1829
+ const o = lccfg;
1830
+ if (typeof o.model === "string" && o.model.trim()) {
1831
+ const m = o.model.trim();
1832
+ if (!process.env.COMFYUI_MCP_LLAMACPP_MODEL)
1833
+ llamacppModel = m;
1834
+ setAgentSettings({ llamacpp: { model: m } });
1835
+ modelsByBackend.delete("llamacpp");
1836
+ logger.info(`[panel-orchestrator] llamacpp config → model=${llamacppModel}`);
1837
+ }
1838
+ }
1839
+ const lcfg = event.lmstudio;
1840
+ if (lcfg && typeof lcfg === "object") {
1841
+ const o = lcfg;
1842
+ if (typeof o.model === "string" && o.model.trim()) {
1843
+ const m = o.model.trim();
1844
+ if (!process.env.COMFYUI_MCP_LMSTUDIO_MODEL)
1845
+ lmstudioModel = m;
1846
+ setAgentSettings({ lmstudio: { model: m } });
1847
+ const pb = probeBackends.get("lmstudio");
1848
+ if (pb?.close)
1849
+ void pb.close().catch(() => { });
1850
+ probeBackends.delete("lmstudio");
1851
+ modelsByBackend.delete("lmstudio");
1852
+ logger.info(`[panel-orchestrator] lmstudio config → model=${lmstudioModel}`);
1853
+ }
1854
+ }
1855
+ // Custom endpoint (issue #162): base_url + model, both user-supplied.
1856
+ // base_url accepts "" (clears the endpoint → provider flips unready);
1857
+ // either change evicts the probe/model caches so the next connect dials
1858
+ // the new target, and re-pushes readiness so the picker flips live.
1859
+ const cucfg = event.custom;
1860
+ if (cucfg && typeof cucfg === "object") {
1861
+ const o = cucfg;
1862
+ const patch = {};
1863
+ if (typeof o.model === "string" && o.model.trim()) {
1864
+ patch.model = o.model.trim();
1865
+ if (!process.env.COMFYUI_MCP_CUSTOM_MODEL)
1866
+ customModel = patch.model;
1867
+ }
1868
+ if (typeof o.base_url === "string") {
1869
+ patch.baseUrl = o.base_url.trim().replace(/[/]$/, "");
1870
+ if (!process.env.COMFYUI_MCP_CUSTOM_BASE_URL)
1871
+ customBaseUrl = patch.baseUrl;
1872
+ }
1873
+ if (Object.keys(patch).length) {
1874
+ setAgentSettings({ custom: patch });
1875
+ const pb = probeBackends.get("custom");
1876
+ if (pb?.close)
1877
+ void pb.close().catch(() => { });
1878
+ probeBackends.delete("custom");
1879
+ modelsByBackend.delete("custom");
1880
+ pushReadiness(event.tab_id);
1881
+ logger.info(`[panel-orchestrator] custom endpoint config → model=${customModel || "(first served)"} host=${customBaseUrl || "(unset)"}`);
1882
+ }
1883
+ }
1570
1884
  if (ollamaChanged) {
1571
1885
  modelsByBackend.delete("ollama");
1572
1886
  pushModels(event.tab_id);
@@ -1574,13 +1888,16 @@ export async function runPanelOrchestrator() {
1574
1888
  bridge.push({ type: "ack", ok: true, kind: "config" }, event.tab_id);
1575
1889
  return;
1576
1890
  }
1577
- // Panel-initiated provider secret (Settings › "Set API key…") — NO agent, no
1891
+ // Panel-initiated secret (Settings › "Set API key/token…") — NO agent, no
1578
1892
  // chat: the panel paints its own masked input and ships the value here
1579
1893
  // directly, over the same loopback/token-gated bridge the agent-initiated
1580
- // request_secret reply already rides. setAgentSecret enforces the allowlist
1581
- // (OPENROUTER_API_KEY), persists 0600, and hydrates process.env immediately,
1582
- // so the refreshed readiness frame flips the provider picker live — a user
1583
- // can enable OpenRouter before ANY backend is ready (no chicken-and-egg).
1894
+ // request_secret reply already rides. Routed by allowlist: PROVIDER keys
1895
+ // (OPENROUTER_API_KEY, COMFYUI_MCP_CUSTOM_API_KEY) → setAgentSecret, which
1896
+ // persists 0600 and hydrates process.env immediately so the refreshed
1897
+ // readiness frame flips the provider picker live; comfyui TOOL tokens
1898
+ // (CivitAI/HuggingFace) → setComfyuiSecret, whose change event already
1899
+ // re-injects the MCP child env + respawns on idle — so EVERY token button
1900
+ // works agent-free with just the bridge connected (no chicken-and-egg).
1584
1901
  if (event.type === "set_secret" && event.tab_id) {
1585
1902
  const rawKey = event.key;
1586
1903
  const rawValue = event.value;
@@ -1590,8 +1907,11 @@ export async function runPanelOrchestrator() {
1590
1907
  try {
1591
1908
  if (!value.trim())
1592
1909
  throw new Error("No token entered — nothing was saved.");
1593
- setAgentSecret(key, value.trim());
1594
- logger.info(`[panel-orchestrator] provider secret set from panel Settings: ${key} (redacted)`);
1910
+ if (isAllowedComfyuiSecretKey(key))
1911
+ setComfyuiSecret(key, value.trim());
1912
+ else
1913
+ setAgentSecret(key, value.trim());
1914
+ logger.info(`[panel-orchestrator] secret set from panel Settings: ${key} (redacted)`);
1595
1915
  }
1596
1916
  catch (err) {
1597
1917
  error = err instanceof Error ? err.message : String(err);
@@ -1601,6 +1921,49 @@ export async function runPanelOrchestrator() {
1601
1921
  pushReadiness(event.tab_id);
1602
1922
  return;
1603
1923
  }
1924
+ // Phone pairing: mint a phone-reachable bridge URL for the panel's QR modal.
1925
+ // `lan` → ws://<lan-ip>:<pairPort>/?token= (same wifi); `tunnel` → a cloudflared
1926
+ // wss://…/?token= (anywhere). Both hit the on-demand token-gated pairing
1927
+ // listener; async (tunnel startup can fail), so reply via a typed frame.
1928
+ if (event.type === "pair" && event.tab_id) {
1929
+ const mode = event.mode === "tunnel" ? "tunnel" : "lan";
1930
+ const tabId = event.tab_id;
1931
+ void (async () => {
1932
+ const token = await ensurePairListener();
1933
+ let url;
1934
+ if (mode === "tunnel") {
1935
+ if (!pairTunnel) {
1936
+ const t = await startQuickTunnel(pairPort, "127.0.0.1");
1937
+ pairTunnel = { url: t.url, stop: t.stop };
1938
+ process.once("exit", () => {
1939
+ try {
1940
+ pairTunnel?.stop();
1941
+ }
1942
+ catch {
1943
+ /* best-effort */
1944
+ }
1945
+ });
1946
+ }
1947
+ const u = new URL(pairTunnel.url);
1948
+ u.protocol = "wss:";
1949
+ u.search = "";
1950
+ u.searchParams.set("token", token);
1951
+ url = u.toString();
1952
+ }
1953
+ else {
1954
+ const ip = firstLanIPv4();
1955
+ if (!ip) {
1956
+ throw new Error("No LAN network found — connect this machine to wifi/ethernet, or use the Internet (tunnel) option.");
1957
+ }
1958
+ url = `ws://${ip}:${pairPort}/?token=${token}`;
1959
+ }
1960
+ logger.info(`[panel-orchestrator] pairing URL minted (${mode})`);
1961
+ bridge.push({ type: "pair_url", mode, url }, tabId);
1962
+ })().catch((err) => {
1963
+ bridge.push({ type: "pair_error", mode, error: err instanceof Error ? err.message : String(err) }, tabId);
1964
+ });
1965
+ return;
1966
+ }
1604
1967
  // Model / effort picker: apply and confirm. Model switches live; an effort
1605
1968
  // change restarts the session (resumed) so the conversation carries over.
1606
1969
  if (event.type === "set_options" && event.tab_id) {
@@ -1623,6 +1986,12 @@ export async function runPanelOrchestrator() {
1623
1986
  nextModel = undefined;
1624
1987
  }
1625
1988
  }
1989
+ // LM Studio model switch: unload everything EXCEPT the incoming model —
1990
+ // the outgoing one would otherwise sit in VRAM next to the JIT-loaded
1991
+ // replacement until its TTL expires.
1992
+ if (nextModel && backendForTab(tabId) === "lmstudio" && isLocalLmstudio(LMSTUDIO_BASE_URL)) {
1993
+ void unloadAllLmstudio(LMSTUDIO_BASE_URL, nextModel);
1994
+ }
1626
1995
  const applied = await manager.setOptions(agentKeyFor(tabId), { model: nextModel, effort: nextEffort });
1627
1996
  bridge.push({
1628
1997
  type: "ack",
@@ -1788,6 +2157,14 @@ export async function runPanelOrchestrator() {
1788
2157
  catch (err) {
1789
2158
  logger.debug(`[panel-orchestrator] queue-note check failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
1790
2159
  }
2160
+ // HEADLESS delivery: a mobile/remote tab has no browser panel to auto-deliver a
2161
+ // finished render, so remind its agent — every turn, since it must hold for the
2162
+ // whole session — to run headless and show the output itself in-turn. The note is
2163
+ // short and always-correct, so (unlike the once-per-episode crash/queue notes) it
2164
+ // is injected on every headless turn.
2165
+ if (headlessTabs.has(event.tab_id)) {
2166
+ outText = `${HEADLESS_DIRECTIVE}\n\n${outText}`;
2167
+ }
1791
2168
  // Transcript replay (single-port provider switch): the panel sends the prior
1792
2169
  // conversation as `context` on the FIRST message to a freshly-switched
1793
2170
  // provider, so the new backend has the thread (minus internal session data —
@@ -1807,10 +2184,9 @@ export async function runPanelOrchestrator() {
1807
2184
  // render is in flight, DON'T run the turn now — that would reload the model
1808
2185
  // on top of the generation (VRAM contention / OOM). Hold it and answer when
1809
2186
  // the render finishes (onRunEnd flushes the queue + warms the model).
1810
- if (pauseLocalDuringGen &&
1811
- ollamaApi === "ollama" &&
1812
- backendForTab(event.tab_id) === "ollama" &&
1813
- QueueMonitor.isBusy()) {
2187
+ const tabIsLocalVram = (ollamaApi === "ollama" && backendForTab(event.tab_id) === "ollama") ||
2188
+ (backendForTab(event.tab_id) === "lmstudio" && isLocalLmstudio(LMSTUDIO_BASE_URL));
2189
+ if (pauseLocalDuringGen && tabIsLocalVram && QueueMonitor.isBusy()) {
1814
2190
  const key = agentKeyFor(event.tab_id);
1815
2191
  const arr = heldDuringGen.get(key) ?? [];
1816
2192
  arr.push({ text: outText, opts: sendOpts });