comfyui-mcp 0.49.2 → 0.49.4

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 (61) hide show
  1. package/dist/orchestrator/agent-backend.js +8 -0
  2. package/dist/orchestrator/agent-backend.js.map +1 -1
  3. package/dist/orchestrator/codex-backend.js +49 -0
  4. package/dist/orchestrator/codex-backend.js.map +1 -1
  5. package/dist/orchestrator/grok-backend.js +5 -0
  6. package/dist/orchestrator/grok-backend.js.map +1 -1
  7. package/dist/orchestrator/index.js +317 -22
  8. package/dist/orchestrator/index.js.map +1 -1
  9. package/dist/orchestrator/ollama-backend.js +11 -2
  10. package/dist/orchestrator/ollama-backend.js.map +1 -1
  11. package/dist/orchestrator/panel-agent.js +591 -67
  12. package/dist/orchestrator/panel-agent.js.map +1 -1
  13. package/dist/orchestrator/panel-tools.js +518 -65
  14. package/dist/orchestrator/panel-tools.js.map +1 -1
  15. package/dist/orchestrator/run-completion-journal.js +914 -0
  16. package/dist/orchestrator/run-completion-journal.js.map +1 -0
  17. package/dist/orchestrator/session-store.js +11 -3
  18. package/dist/orchestrator/session-store.js.map +1 -1
  19. package/dist/services/asset-reconcile.js +83 -0
  20. package/dist/services/asset-reconcile.js.map +1 -0
  21. package/dist/services/asset-registry.js +9 -2
  22. package/dist/services/asset-registry.js.map +1 -1
  23. package/dist/services/download-jobs.js +178 -14
  24. package/dist/services/download-jobs.js.map +1 -1
  25. package/dist/services/download-progress.js +16 -18
  26. package/dist/services/download-progress.js.map +1 -1
  27. package/dist/services/extra-paths.js +61 -9
  28. package/dist/services/extra-paths.js.map +1 -1
  29. package/dist/services/hello-retarget.js +165 -0
  30. package/dist/services/hello-retarget.js.map +1 -0
  31. package/dist/services/job-history.js +50 -0
  32. package/dist/services/job-history.js.map +1 -1
  33. package/dist/services/job-watcher.js +38 -7
  34. package/dist/services/job-watcher.js.map +1 -1
  35. package/dist/services/manifest.js +92 -9
  36. package/dist/services/manifest.js.map +1 -1
  37. package/dist/services/model-resolver.js +616 -17
  38. package/dist/services/model-resolver.js.map +1 -1
  39. package/dist/services/output-dir.js +63 -15
  40. package/dist/services/output-dir.js.map +1 -1
  41. package/dist/services/panel-pin-guard.js +6 -62
  42. package/dist/services/panel-pin-guard.js.map +1 -1
  43. package/dist/services/ui-bridge.js +111 -14
  44. package/dist/services/ui-bridge.js.map +1 -1
  45. package/dist/services/workspace-env.js +179 -3
  46. package/dist/services/workspace-env.js.map +1 -1
  47. package/dist/tools/assets.js +34 -2
  48. package/dist/tools/assets.js.map +1 -1
  49. package/dist/tools/extra-paths.js +6 -4
  50. package/dist/tools/extra-paths.js.map +1 -1
  51. package/dist/tools/model-extras.js +26 -7
  52. package/dist/tools/model-extras.js.map +1 -1
  53. package/dist/tools/model-management.js +36 -8
  54. package/dist/tools/model-management.js.map +1 -1
  55. package/dist/tools/report-issue.js +12 -2
  56. package/dist/tools/report-issue.js.map +1 -1
  57. package/dist/tools/vocabulary.js +21 -0
  58. package/dist/tools/vocabulary.js.map +1 -1
  59. package/package.json +1 -1
  60. package/scripts/gen-tool-docs.ts +138 -4
  61. package/scripts/tool-doc-examples.ts +794 -0
@@ -24,20 +24,21 @@
24
24
  // so parity is automatic — neither path reimplements a tool.
25
25
  import { z } from "zod";
26
26
  import { randomUUID } from "node:crypto";
27
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
27
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
28
28
  import { extname, isAbsolute, join, resolve, sep } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import { comfyuiFetch } from "../comfyui/fetch.js";
31
31
  import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
32
32
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
33
33
  import { parse as parseYaml } from "yaml";
34
- import { dispatchOutcomeOf, isPanelCmdUnsupportedError, isReplyTimeoutTagged, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
34
+ import { dispatchOutcomeOf, isCapabilityRefusal, isPanelCmdUnsupportedError, isReplyTimeoutTagged, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
35
35
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
36
36
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
37
37
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey } from "../services/panel-secrets.js";
38
38
  import { flattenUiWorkflow } from "../services/flatten-workflow.js";
39
39
  import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
40
40
  import { QueueMonitor } from "../services/queue-monitor.js";
41
+ import { RunCompletions } from "./run-completion-journal.js";
41
42
  import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
42
43
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
43
44
  import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
@@ -1889,7 +1890,21 @@ function readPackWorkflow(packName) {
1889
1890
  // ComfyUI workflows dir always resolves. It does NOT work against a remote
1890
1891
  // ComfyUI whose files the orchestrator can't see — use the inline `graph` option
1891
1892
  // for that.
1892
- /** Candidate ComfyUI workflows directories (where the frontend saves/stages files). */
1893
+ /**
1894
+ * Candidate ComfyUI workflows directories, RECONSTRUCTED from COMFYUI_PATH.
1895
+ *
1896
+ * These are GUESSES at the DEFAULT layout. ComfyUI's `--user-directory` moves the
1897
+ * whole user tree somewhere unguessable, so a hit under one of these dirs is only
1898
+ * ever the file the caller meant when the server happens to run the default
1899
+ * layout. They are therefore a LAST resort, used only when the connected ComfyUI
1900
+ * could not be asked at all (#202).
1901
+ *
1902
+ * `process.cwd()` is deliberately NOT in this list: resolving a library name
1903
+ * against whatever directory the orchestrator happened to be launched from can
1904
+ * only ever produce a file that is not the named library workflow — the
1905
+ * load-the-wrong-graph hazard. Callers who mean a file outside the library pass
1906
+ * an absolute path.
1907
+ */
1893
1908
  function comfyWorkflowsDirs() {
1894
1909
  const base = process.env.COMFYUI_PATH;
1895
1910
  if (!base)
@@ -1899,6 +1914,125 @@ function comfyWorkflowsDirs() {
1899
1914
  join(base, "user", "workflows"),
1900
1915
  ];
1901
1916
  }
1917
+ /**
1918
+ * Issue ONE userdata GET and hand back the raw Response.
1919
+ *
1920
+ * This deliberately bypasses the client's own `fetchApi` (codex/gate MAJOR).
1921
+ * `fetchApi` throws for every non-2xx AND calls `response.json()` while building
1922
+ * that error — so a refusal with a non-JSON body (a `403 text/plain` from a proxy,
1923
+ * an HTML 502) rejects with a STATUSLESS SyntaxError. There is then no way to tell
1924
+ * "the server refused" from "the server was never reached", and the resolver's
1925
+ * local fallback would re-open for a server that had explicitly refused, loading a
1926
+ * different graph (#202).
1927
+ *
1928
+ * Building the request from the client's own `apiURL` + `apiHeaders` keeps every
1929
+ * transport concern identical (host, ssl, base path, clientId, Comfy-User, the
1930
+ * injected COMFYUI_AUTH_* fetch) while making the outcome unambiguous:
1931
+ * - a returned Response = the server ANSWERED; classify by status, decode later
1932
+ * - a thrown error = no Response exists, so the request never got one
1933
+ * Nothing here reads a body, so a body that cannot be decoded can never be
1934
+ * mistaken for a transport failure.
1935
+ */
1936
+ async function userdataFetch(route) {
1937
+ const client = getClient();
1938
+ return await client.fetch(client.apiURL(route), { headers: client.apiHeaders() });
1939
+ }
1940
+ /**
1941
+ * Drop the one leading "workflows/" segment from CALLER input, leaving a key that
1942
+ * is relative to the workflow store root.
1943
+ *
1944
+ * This exists only because panel_list_workflows reports store keys that already
1945
+ * carry the prefix (#414), so a caller may legitimately paste either spelling. It
1946
+ * is applied EXACTLY ONCE, to the caller's `path`, and NEVER to an entry from the
1947
+ * server's listing — those are already store-relative and stripping them again is
1948
+ * what let a nested folder named `workflows` impersonate the store root (gate
1949
+ * MAJOR); see matchName.
1950
+ *
1951
+ * Exactly one separator is consumed (codex MAJOR). "workflows//foo.json" keeps its
1952
+ * second slash and stays "/foo.json" — collapsing runs of slashes would make it an
1953
+ * alias for the different key "foo.json". A leading "/" is not removed either;
1954
+ * caller input can never reach here with one (that is an absolute path, handled
1955
+ * earlier).
1956
+ *
1957
+ * Only the literal lowercase FORWARD-slash spelling counts (codex MAJOR). ComfyUI
1958
+ * store keys are always "/"-separated and lowercase here, so `workflows oo.json`
1959
+ * and `WORKFLOWS/foo.json` are ordinary names — a real subfolder on a
1960
+ * case-sensitive host, or a legal literal filename on POSIX — and stripping either
1961
+ * would rewrite a request into one for a different graph.
1962
+ */
1963
+ const stripLibraryPrefix = (key) => key.replace(/^workflows\//, "");
1964
+ /**
1965
+ * The form two store-relative names are compared in when deciding they are the
1966
+ * SAME NAME. Unicode normalization is the only equivalence applied: NFD "é" and
1967
+ * NFC "é" are one character sequence written two ways, so a name pasted from a
1968
+ * listing (or produced on another OS) can be byte-different yet denote the same
1969
+ * file, and a raw byte comparison 404s on a workflow that visibly exists.
1970
+ *
1971
+ * Nothing is STRIPPED here, which is what keeps request depth matched to entry
1972
+ * depth (gate MAJOR). A recursive listing reports the root file "x.json" bare and
1973
+ * the nested file workflows/workflows/x.json as "workflows/x.json"; stripping a
1974
+ * prefix from the entry made those two collide, so a bare request could match the
1975
+ * nested entry and then fetch the DIFFERENT root file. Compared verbatim, a
1976
+ * depth-0 request can only ever match a depth-0 entry, and a request naming a
1977
+ * subfolder matches only that exact path.
1978
+ *
1979
+ * Path separators are deliberately NOT folded (codex MAJOR): on POSIX a file
1980
+ * literally named `dir oo.json` is a DIFFERENT file from `dir/foo.json`. Case is
1981
+ * not folded either, for the same reason. Both are reported as near misses instead.
1982
+ */
1983
+ const matchName = (name) => name.normalize("NFC");
1984
+ /** The looser form used ONLY to describe a near miss in an error message — never
1985
+ * to select a file to load. Folds the equivalences that hold on some filesystems
1986
+ * and not others: separator flavour, letter case and normalization. Like matchName
1987
+ * it strips nothing, so it cannot report a nested entry as a near miss for a root
1988
+ * name. */
1989
+ const looseName = (name) => name.replace(/\\/g, "/").normalize("NFC").toLowerCase();
1990
+ /**
1991
+ * The connected ComfyUI's OWN list of saved workflow store keys, or null when the
1992
+ * listing could not be read. This is the same source list_workflows reports (the
1993
+ * SAVED library, not the open tabs), so it reflects the server's runtime
1994
+ * `--user-directory` — it is asked, never reconstructed. Used only to turn an
1995
+ * authoritative "no such name" into either the server's EXACT key or an explicit
1996
+ * refusal. Never throws: an unreadable listing simply means "no extra
1997
+ * information", and the refusal says the listing could not be read.
1998
+ *
1999
+ * `recurse=true` is VERIFIED against the installed ComfyUI (0.29.2): a workflow in
2000
+ * a SUBFOLDER is absent from the plain listing and present as "sub/name.json"
2001
+ * under recurse, while root entries stay bare. Without it, a nested name that
2002
+ * differs only by Unicode normalization has no listing entry to match and is
2003
+ * refused. The parameter is safe on builds that do not implement it — an unknown
2004
+ * query arg is ignored and the flat list comes back, which is exactly the
2005
+ * pre-existing behaviour (over-refusal of nested near-misses, never a wrong file),
2006
+ * so this needs no version gate.
2007
+ */
2008
+ async function listUserdataWorkflowKeys() {
2009
+ try {
2010
+ const res = await userdataFetch("/api/userdata?dir=workflows&recurse=true");
2011
+ if (!res.ok)
2012
+ return null;
2013
+ const body = await res.json();
2014
+ if (!Array.isArray(body))
2015
+ return null;
2016
+ // Tolerate both shapes ComfyUI builds return: bare strings (the default on
2017
+ // 0.29.2), and {path} objects (what full_info=true emits, in case a build
2018
+ // returns that shape by default).
2019
+ return body
2020
+ .map((entry) => {
2021
+ if (typeof entry === "string")
2022
+ return entry;
2023
+ const rec = entry;
2024
+ if (rec && typeof rec.path === "string")
2025
+ return rec.path;
2026
+ if (rec && typeof rec.name === "string")
2027
+ return rec.name;
2028
+ return null;
2029
+ })
2030
+ .filter((k) => typeof k === "string" && k.length > 0);
2031
+ }
2032
+ catch {
2033
+ return null;
2034
+ }
2035
+ }
1902
2036
  /** Validate a parsed value is a UI/litegraph workflow (a top-level `nodes`
1903
2037
  * array), throwing a source-labelled error otherwise. */
1904
2038
  function assertUiWorkflow(parsed, sourceLabel) {
@@ -1911,16 +2045,39 @@ function assertUiWorkflow(parsed, sourceLabel) {
1911
2045
  }
1912
2046
  return parsed;
1913
2047
  }
1914
- /** Read + parse a UI workflow JSON by path. Resolves an ABSOLUTE path off the
1915
- * orchestrator's disk, or a RELATIVE name authoritatively through the CONNECTED
1916
- * ComfyUI's userdata API — which resolves under the server's RUNTIME
1917
- * `--user-directory` (custom or default), so the RIGHT file always wins and a
1918
- * stale same-named file under the guessed default dir can never shadow it
1919
- * (#202). Only when the server can't serve the name (404 / unreachable) does it
1920
- * fall back to the orchestrator's guessed local workflows dirs, so a
1921
- * disk-staged file still opens. Guards: must be .json and must parse to a UI
1922
- * workflow (a top-level `nodes` array). Fails loudly (never loads the wrong
1923
- * file) when the name resolves nowhere. */
2048
+ /**
2049
+ * Read + parse a UI workflow JSON by path.
2050
+ *
2051
+ * An ABSOLUTE path is read off the orchestrator's own disk, unchanged.
2052
+ *
2053
+ * A RELATIVE name is resolved AUTHORITATIVELY by the CONNECTED ComfyUI's userdata
2054
+ * API — the same source list_workflows / panel_open_workflow read. That
2055
+ * server resolves the name under its RUNTIME `--user-directory`, so a custom user
2056
+ * directory just works and a same-named file under a reconstructed default-layout
2057
+ * path can never shadow it (#202).
2058
+ *
2059
+ * The governing rule is that loading the WRONG graph is worse than loading none,
2060
+ * so the resolver never guesses:
2061
+ * - server serves the file → load it
2062
+ * - server refuses (401/403/5xx) → error, no local fallback
2063
+ * - server says it has no such name → retry the server's OWN exact key if its
2064
+ * listing has a single UNICODE-NORMALIZATION
2065
+ * match (case and separator differences are
2066
+ * named, never substituted), otherwise
2067
+ * REFUSE — an absence from the authority
2068
+ * means any local hit is a DIFFERENT file
2069
+ * - name matches several library keys → REFUSE as ambiguous, naming them
2070
+ * - NO HTTP RESPONSE AT ALL → best-effort reconstructed local dirs,
2071
+ * and REFUSE if more than one file matches
2072
+ *
2073
+ * That last line is the only branch that guesses, and it is deliberately narrow: a
2074
+ * reply that ARRIVED but could not be decoded is a refusal, not an absence of
2075
+ * authority. See userdataFetch for why the request bypasses the client's
2076
+ * throw-on-non-2xx wrapper to make that distinction sound.
2077
+ *
2078
+ * Guards: must be .json and must parse to a UI workflow (a top-level `nodes`
2079
+ * array). Every failure names exactly what was tried.
2080
+ */
1924
2081
  async function readWorkflowFromPath(rawPath) {
1925
2082
  const p = (rawPath ?? "").trim();
1926
2083
  if (!p)
@@ -1955,7 +2112,7 @@ async function readWorkflowFromPath(rawPath) {
1955
2112
  // path all normalize to the same store-relative name. (A genuinely absolute path
1956
2113
  // — including a Windows leading-slash path — was already handled and returned
1957
2114
  // above, so it never reaches this relative-name normalization.)
1958
- const rel = p.replace(/^[\\/]+/, "").replace(/^workflows[\\/]+/i, "");
2115
+ const rel = stripLibraryPrefix(p);
1959
2116
  // Refuse traversal / drive-relative escapes (codex): a real workflow name is a
1960
2117
  // plain relative path whose segments are filenames/subfolders. Stripping the
1961
2118
  // "workflows/" prefix must never turn the input into something that ESCAPES the
@@ -1971,67 +2128,245 @@ async function readWorkflowFromPath(rawPath) {
1971
2128
  throw new Error(`"${p}" is not a valid workflow name — pass a name relative to the ComfyUI ` +
1972
2129
  `workflows folder (no "..", drive letters, or absolute paths), or an absolute path.`);
1973
2130
  }
1974
- let outcome;
1975
- try {
1976
- const client = getClient();
1977
- const encoded = encodeURIComponent(`workflows/${rel}`);
1978
- const res = await client.fetchApi(`/api/userdata/${encoded}`);
1979
- if (res.ok) {
1980
- // Read the body as TEXT and classify HERE so a malformed 2xx surfaces its
1981
- // OWN error (no fallback), while ComfyUI's "200 + EMPTY body = file does
1982
- // not exist" convention (some builds; see parseWorkflowLock) is treated as
1983
- // an ABSENCE that DOES allow the local fallback — not a malformed error.
1984
- const body = (await res.text()).trim();
1985
- if (body === "") {
1986
- outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (empty 200 response)" };
1987
- }
1988
- else {
1989
- try {
1990
- outcome = { kind: "found", parsed: JSON.parse(body) };
1991
- }
1992
- catch (err) {
1993
- outcome = { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
1994
- }
2131
+ // One userdata GET, classified. `key` is the STORE key (already "workflows/…").
2132
+ //
2133
+ // The request goes through userdataFetch, which returns the raw Response instead
2134
+ // of the client's throw-on-non-2xx wrapper. That is what makes this split sound
2135
+ // (gate MAJOR): the ONLY way to land in the catch is for no Response to exist at
2136
+ // all, so "the server refused with a body we could not decode" can never be
2137
+ // mistaken for "the server was never reached" and re-open the local fallback.
2138
+ const fetchUserdataKey = async (key) => {
2139
+ let res;
2140
+ try {
2141
+ res = await userdataFetch(`/api/userdata/${encodeURIComponent(key)}`);
2142
+ }
2143
+ catch (err) {
2144
+ // No Response object exists — the request never received an answer
2145
+ // (connection refused, DNS, TLS, timeout), or no client could be built at
2146
+ // all. This is the ONLY outcome that leaves the orchestrator without an
2147
+ // authority to defer to.
2148
+ return {
2149
+ kind: "unreachable",
2150
+ detail: `the connected ComfyUI's workflow library could not be reached (${err instanceof Error ? err.message : String(err)})`,
2151
+ };
2152
+ }
2153
+ // From here the server ANSWERED. Every remaining outcome is authoritative, and
2154
+ // none of them may fall back to a reconstructed local path.
2155
+ if (!res.ok) {
2156
+ if (res.status === 404) {
2157
+ return { kind: "absent", detail: `is not in the connected ComfyUI's workflow library (HTTP 404 for "${key}")` };
1995
2158
  }
2159
+ return { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status} for "${key}"` };
1996
2160
  }
1997
- else if (res.status === 404) {
1998
- outcome = { kind: "absent", detail: "was not in the ComfyUI userdata library (HTTP 404)" };
2161
+ // Read the body as TEXT and classify HERE so a malformed 2xx surfaces its OWN
2162
+ // error, while ComfyUI's "200 + EMPTY body = file does not exist" convention
2163
+ // (some builds; see parseWorkflowLock) is an ABSENCE — an authoritative "no
2164
+ // such name", not a malformed file. A body that cannot be read is a REFUSAL:
2165
+ // the server answered, we simply could not decode it.
2166
+ let body;
2167
+ try {
2168
+ body = (await res.text()).trim();
1999
2169
  }
2000
- else {
2001
- outcome = { kind: "refused", detail: `ComfyUI userdata library returned HTTP ${res.status}` };
2170
+ catch (err) {
2171
+ return {
2172
+ kind: "refused",
2173
+ detail: `ComfyUI answered ${res.status} for "${key}" but the response body could not be read ` +
2174
+ `(${err instanceof Error ? err.message : String(err)})`,
2175
+ };
2176
+ }
2177
+ if (body === "") {
2178
+ return { kind: "absent", detail: `is not in the connected ComfyUI's workflow library (empty 200 response for "${key}")` };
2179
+ }
2180
+ try {
2181
+ return { kind: "found", parsed: JSON.parse(body) };
2182
+ }
2183
+ catch (err) {
2184
+ return { kind: "malformed", detail: err instanceof Error ? err.message : String(err) };
2185
+ }
2186
+ };
2187
+ const requestedKey = `workflows/${rel}`;
2188
+ let outcome = await fetchUserdataKey(requestedKey);
2189
+ let resolvedKey = requestedKey;
2190
+ // Every store key this call asked for, in order, so a refusal can name them all.
2191
+ const attempted = [requestedKey];
2192
+ /** Re-ask the server under a different spelling of the SAME name, without letting
2193
+ * the follow-up weaken what the server already told us. Returns true when the
2194
+ * retry settled the outcome (i.e. produced something other than another
2195
+ * authoritative absence). */
2196
+ const reask = async (key, onUnreachable) => {
2197
+ attempted.push(key);
2198
+ const retry = await fetchUserdataKey(key);
2199
+ if (retry.kind === "unreachable") {
2200
+ // The server ALREADY answered. A transport blip on the follow-up does not
2201
+ // un-answer that (codex MAJOR): letting it become "unreachable" would re-open
2202
+ // the reconstructed-local-dir fallback after the authority had spoken.
2203
+ outcome = { kind: "refused", detail: onUnreachable(retry.detail) };
2204
+ return true;
2205
+ }
2206
+ outcome = retry;
2207
+ if (retry.kind !== "absent") {
2208
+ // Remember which key was actually read, so a malformed / non-UI file names the
2209
+ // file that was consulted rather than the caller's spelling.
2210
+ resolvedKey = key;
2211
+ return true;
2212
+ }
2213
+ return false;
2214
+ };
2215
+ // SEPARATOR SPELLING, asked SERVER-FIRST (gate MEDIUM). The store key space is
2216
+ // "/"-separated, so a backslash in the name is ONE literal character to the server
2217
+ // — and on POSIX that is a legal filename — so the literal spelling must be asked
2218
+ // for FIRST. Only once the server has authoritatively said it has no such file is
2219
+ // the Windows-style reading tried, and both attempts are named in any refusal.
2220
+ //
2221
+ // That keeps a Windows caller's "recipes\name.json" working: it used to resolve
2222
+ // through the local fallback, so refusing it outright was a regression on a path
2223
+ // that worked, for panel_strip_workflow as much as panel_load_workflow. And it
2224
+ // never substitutes a file while the literally-named one might still exist,
2225
+ // because the literal key is disproved by the authority before the retry happens.
2226
+ let matchRel = rel;
2227
+ if (outcome.kind === "absent" && rel.includes("\\")) {
2228
+ const slashRel = rel.replace(/\\/g, "/");
2229
+ const slashKey = `workflows/${slashRel}`;
2230
+ if (slashKey !== requestedKey) {
2231
+ const settled = await reask(slashKey, (d) => `"${rel}" is not in the library, and re-reading it as "${slashRel}" failed: ${d}`);
2232
+ // Absent under BOTH spellings: the literal reading is disproved, so the
2233
+ // forward-slash reading is the only one left for the listing lookup below.
2234
+ if (!settled)
2235
+ matchRel = slashRel;
2002
2236
  }
2003
2237
  }
2004
- catch (err) {
2005
- outcome = {
2006
- kind: "unreachable",
2007
- detail: `ComfyUI userdata library was unreachable (${err instanceof Error ? err.message : String(err)})`,
2008
- };
2238
+ // The server ANSWERED "no such name". Before giving up, ask it for its OWN listing
2239
+ // and look for the same name in a different Unicode normal form — a name pasted
2240
+ // from a listing (or produced on another OS) can be byte-different yet denote the
2241
+ // same file, which is how a workflow that list_workflows plainly shows still 404s
2242
+ // here. Only the SERVER's own entry is retried, so this resolves the name the
2243
+ // connected ComfyUI itself reports; it never reconstructs a path. More than one
2244
+ // match is AMBIGUOUS and is refused rather than guessed at.
2245
+ //
2246
+ // Listing entries are store-RELATIVE and carry NO prefix — verified on 0.29.2,
2247
+ // where root files come back bare and nested ones as "sub/name.json" — so the
2248
+ // store key is simply the entry under the library root, and nothing about the
2249
+ // entry is rewritten (gate MAJOR). Stripping a "workflows/" prefix from an entry
2250
+ // was how a subfolder literally named `workflows` came to impersonate the store
2251
+ // root: its file lists as "workflows/x.json", which stripped to "x.json" and
2252
+ // collided with the ROOT file of that name, so a bare request matched the nested
2253
+ // entry and then fetched the different root file.
2254
+ const storeKeyForListed = (listedKey) => `workflows/${listedKey}`;
2255
+ let listedButUnserved = null;
2256
+ let nearMisses = [];
2257
+ let listingUnreadable = false;
2258
+ if (outcome.kind === "absent") {
2259
+ const rawListed = await listUserdataWorkflowKeys();
2260
+ listingUnreadable = rawListed === null;
2261
+ // A listing entry is server-supplied data, so it gets the SAME escape guard as
2262
+ // caller input before it is echoed back as a request key. The split is
2263
+ // deliberately liberal about separators — that is a REJECTION rule, where being
2264
+ // over-inclusive can only refuse more, never resolve to another file.
2265
+ const listed = rawListed?.filter((k) => {
2266
+ const segs = k.split(/[\\/]+/);
2267
+ return !segs.includes("..") && !segs.some((s) => /^[A-Za-z]:/.test(s));
2268
+ });
2269
+ if (listed) {
2270
+ // Compared VERBATIM apart from Unicode normalization, which is what keeps
2271
+ // request depth matched to entry depth: a name with no separator can only
2272
+ // equal a depth-0 entry, and a name that includes a subfolder can only equal
2273
+ // that exact path. Letter case and separator flavour are deliberately not
2274
+ // folded — on a case-sensitive host "Foo.json" and "foo.json" are two
2275
+ // different files — so those are NAMED as near misses below, never served.
2276
+ const want = matchName(matchRel);
2277
+ const matches = listed.filter((k) => matchName(k) === want);
2278
+ if (matches.length > 1) {
2279
+ throw new Error(`"${p}" is ambiguous in the connected ComfyUI's workflow library — it matches ` +
2280
+ `${matches.length} saved workflows (${matches.map((k) => `"${k}"`).join(", ")}). ` +
2281
+ `Refusing to guess which one you meant: pass the exact name from list_workflows, ` +
2282
+ `or an absolute path.`);
2283
+ }
2284
+ if (matches.length === 1) {
2285
+ const retryKey = storeKeyForListed(matches[0]);
2286
+ // Only re-ask when the server's spelling actually differs from every key
2287
+ // already sent — otherwise this repeats a request that just failed.
2288
+ if (!attempted.includes(retryKey)) {
2289
+ await reask(retryKey, (d) => `the library lists "${matches[0]}", but re-reading it failed: ${d}`);
2290
+ }
2291
+ // Still absent after retrying the server's OWN entry: the library lists the
2292
+ // name but will not serve it. Say so — that is a server-side condition the
2293
+ // user must see, not a cue to go hunting for a local file.
2294
+ if (outcome.kind === "absent")
2295
+ listedButUnserved = matches[0];
2296
+ }
2297
+ else {
2298
+ // Reported only. A key that differs by separator flavour, letter case or
2299
+ // normalization is a DIFFERENT file on some filesystems, so it is never
2300
+ // substituted.
2301
+ nearMisses = listed.filter((k) => looseName(k) === looseName(matchRel));
2302
+ }
2303
+ }
2009
2304
  }
2010
2305
  if (outcome.kind === "found") {
2011
2306
  // A found-but-non-UI file must surface its own honest error, not silence.
2012
- return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library`);
2307
+ return assertUiWorkflow(outcome.parsed, `The workflow "${p}" from the ComfyUI userdata library (read as "${resolvedKey}")`);
2013
2308
  }
2014
2309
  if (outcome.kind === "malformed") {
2015
- throw new Error(`The workflow "${p}" in the ComfyUI userdata library is not valid JSON: ${outcome.detail}`);
2310
+ throw new Error(`The workflow "${p}" in the ComfyUI userdata library (read as "${resolvedKey}") is not ` +
2311
+ `valid JSON: ${outcome.detail}`);
2016
2312
  }
2017
2313
  if (outcome.kind === "refused") {
2018
2314
  // Server is reachable but did not serve the file — do NOT fall back to a
2019
- // possibly-stale local file; report the status honestly.
2315
+ // possibly-different local file; report the status honestly.
2020
2316
  throw new Error(`Could not read "${p}" from the connected ComfyUI: ${outcome.detail}. ` +
2021
- `Pass an absolute path, or a name shown by panel_list_workflows.`);
2317
+ `Pass an absolute path, or a name shown by list_workflows.`);
2022
2318
  }
2023
- // outcome.kind is "absent" (404) or "unreachable" — fall back to the
2024
- // orchestrator's guessed local workflows dirs (best-effort; only meaningful on
2025
- // a same-machine ComfyUI whose user-dir matches the default layout, or a file
2026
- // staged straight to disk).
2319
+ if (outcome.kind === "absent") {
2320
+ // AUTHORITATIVE absence. The connected ComfyUI resolved the name under its own
2321
+ // runtime `--user-directory` and said it has no such workflow — so any file the
2322
+ // orchestrator could still find by RECONSTRUCTING a default-layout path is, by
2323
+ // construction, a DIFFERENT file from the one the caller named. Loading it
2324
+ // would hand the agent the wrong graph to edit, which is worse than failing
2325
+ // (#202). Refuse, naming exactly what was tried.
2326
+ throw new Error(`No workflow named "${p}" — it ${outcome.detail}.` +
2327
+ (attempted.length > 1
2328
+ ? ` Asked for ${attempted.map((k) => `"${k}"`).join(" and then ")}.`
2329
+ : "") +
2330
+ (listingUnreadable
2331
+ ? ` Its workflow listing could not be read either, so no close match could be checked.`
2332
+ : "") +
2333
+ (listedButUnserved
2334
+ ? ` Its library DOES list "${listedButUnserved}", but the server would not serve that key —` +
2335
+ ` the file may have been removed or be unreadable on the ComfyUI machine.`
2336
+ : "") +
2337
+ (nearMisses.length
2338
+ ? ` The library does list ${nearMisses.map((k) => `"${k}"`).join(", ")}, which differs only` +
2339
+ ` in letter case, path separator, or Unicode normalization — a DIFFERENT file on some` +
2340
+ ` filesystems, so it was NOT substituted. Retype the name exactly if that is the one` +
2341
+ ` you meant.`
2342
+ : "") +
2343
+ ` The connected ComfyUI is the authority on its own user directory (it may have been started` +
2344
+ ` with --user-directory), so the orchestrator will NOT guess at a local path that could be a` +
2345
+ ` different file. Use a name exactly as shown by list_workflows (which reads the same library),` +
2346
+ ` or pass an absolute path.`);
2347
+ }
2348
+ // outcome.kind is "unreachable": the connected ComfyUI gave no answer at all, so
2349
+ // there is no authority to defer to. Only here does the orchestrator fall back to
2350
+ // the RECONSTRUCTED default-layout workflows dirs — best-effort, and only when
2351
+ // the name resolves to exactly ONE file (two hits mean two different candidate
2352
+ // graphs, which is refused rather than guessed at).
2027
2353
  // Each candidate is the store-relative name resolved UNDER a base dir. Only the
2028
2354
  // NORMALIZED `rel` is used (never the raw `workflows/…` key), so nothing nests a
2029
2355
  // second workflows/ (codex P1/P2). Belt-and-suspenders containment: an existing
2030
2356
  // candidate is only accepted if its REAL path (symlinks/junctions resolved)
2031
2357
  // stays beneath the base's REAL path — so a link under the workflows dir that
2032
- // targets an external directory can't be read on the 404/unreachable fallback
2033
- // (codex). Canonicalizing BOTH sides keeps a legitimately-symlinked workflows
2034
- // dir working (its base resolves too), while blocking a per-file escape.
2358
+ // targets an external directory can't be read on the fallback (codex).
2359
+ // Canonicalizing BOTH sides keeps a legitimately-symlinked workflows dir working
2360
+ // (its base resolves too), while blocking a per-file escape.
2361
+ //
2362
+ // KNOWN AND ACCEPTED: this check is check-then-open, so it does not survive a
2363
+ // concurrent swap of the checked file for a symlink between the realpath and the
2364
+ // read (codex MAJOR). Closing that needs an fd-based open + fstat, and the threat
2365
+ // it defends against is another process on the user's OWN machine racing their
2366
+ // own workflow load — outside this project's trust model, where the orchestrator,
2367
+ // the panel and the user are one trust domain. The check remains as a guard
2368
+ // against a statically mis-linked workflows dir, which is the accidental case
2369
+ // that actually happens.
2035
2370
  const realBaseUnder = (base, candidate) => {
2036
2371
  try {
2037
2372
  const rb = realpathSync(base);
@@ -2042,17 +2377,95 @@ async function readWorkflowFromPath(rawPath) {
2042
2377
  return false;
2043
2378
  }
2044
2379
  };
2045
- const localBases = [...comfyWorkflowsDirs(), process.cwd()];
2046
- const local = localBases
2380
+ /**
2381
+ * Is `name` the name the file on disk ACTUALLY has, segment for segment? When it
2382
+ * is not, report the on-disk spelling that came closest so the refusal can name
2383
+ * it.
2384
+ *
2385
+ * `resolve(dir, name)` answers in the FILESYSTEM's terms, not the store's (codex
2386
+ * MAJOR). It collapses repeated separators, so "recipes//foo.json" — a distinct
2387
+ * key everywhere else in this resolver — silently opens recipes/foo.json; and on
2388
+ * a case-insensitive volume `foo.json` opens an on-disk `Foo.json`, the very
2389
+ * substitution the server path refuses. That left the two branches applying
2390
+ * different rules to the same input depending only on whether ComfyUI answered.
2391
+ *
2392
+ * Walking the directory entries restores one rule: every segment must appear
2393
+ * VERBATIM in its parent listing. An empty segment (from a repeated separator)
2394
+ * can never match, and a case- or normalization-different on-disk name is refused
2395
+ * exactly as it would be against the server.
2396
+ *
2397
+ * The segmentation follows the PLATFORM, matching whatever `resolve()` just did
2398
+ * (codex MAJOR). On Windows a backslash is a separator — and a file literally
2399
+ * named `recipes\foo.json` cannot exist — so the folder reading is the only
2400
+ * reading. On POSIX a backslash is an ordinary filename character, `resolve()`
2401
+ * produces the LITERAL path, and this must too: splitting it there would bless a
2402
+ * folder reading that the server-first rule never authorised, letting an
2403
+ * unreachable server's `recipes/foo.json` stand in for a literal
2404
+ * `recipes\foo.json` that may well exist.
2405
+ */
2406
+ const platformSegments = (name) => name.split(sep === "\\" ? /[\\/]/ : /\//);
2407
+ const localSpelling = (base, name) => {
2408
+ let dir = base;
2409
+ const segs = platformSegments(name);
2410
+ for (let i = 0; i < segs.length; i++) {
2411
+ let entries;
2412
+ try {
2413
+ entries = readdirSync(dir);
2414
+ }
2415
+ catch {
2416
+ return { exact: false };
2417
+ }
2418
+ if (entries.includes(segs[i])) {
2419
+ dir = join(dir, segs[i]);
2420
+ continue;
2421
+ }
2422
+ // Not spelled the way it was asked for. Name the entry it would have been, so
2423
+ // the caller can retype it — reported only, never loaded.
2424
+ const near = entries.find((e) => looseName(e) === looseName(segs[i]));
2425
+ return { exact: false, onDisk: near ? join(dir, near, ...segs.slice(i + 1)) : undefined };
2426
+ }
2427
+ return { exact: true };
2428
+ };
2429
+ // Spellings that exist on disk but are NOT what was asked for — reported so the
2430
+ // refusal is actionable, never loaded.
2431
+ const misspelled = [];
2432
+ const hits = comfyWorkflowsDirs()
2047
2433
  .map((dir) => ({ dir, path: resolve(dir, rel) }))
2048
2434
  .filter(({ path }) => existsSync(path) && statSync(path).isFile())
2049
2435
  .filter(({ dir, path }) => realBaseUnder(dir, path))
2050
- .map(({ path }) => path)[0];
2051
- if (local)
2052
- return readLocal(local);
2053
- throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's workflows ` +
2054
- `dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}). ` +
2055
- `Pass an absolute path, or a name shown by panel_list_workflows.`);
2436
+ .filter(({ dir }) => {
2437
+ const spelling = localSpelling(dir, rel);
2438
+ if (spelling.exact)
2439
+ return true;
2440
+ if (spelling.onDisk)
2441
+ misspelled.push(spelling.onDisk);
2442
+ return false;
2443
+ })
2444
+ .map(({ path }) => path);
2445
+ // De-duplicate by REAL path: the two guessed layouts can be the same directory
2446
+ // (a symlink/junction), which is one candidate, not an ambiguity.
2447
+ const distinct = [...new Set(hits.map((h) => { try {
2448
+ return realpathSync(h);
2449
+ }
2450
+ catch {
2451
+ return h;
2452
+ } }))];
2453
+ if (distinct.length > 1) {
2454
+ throw new Error(`"${p}" is ambiguous: the connected ComfyUI could not be reached, and the name matches ` +
2455
+ `${distinct.length} different local files (${distinct.map((f) => `"${f}"`).join(", ")}). ` +
2456
+ `Refusing to guess which one you meant — pass an absolute path.`);
2457
+ }
2458
+ if (distinct.length === 1)
2459
+ return readLocal(distinct[0]);
2460
+ throw new Error(`No workflow file at "${p}". It ${outcome.detail}, and it is not under the orchestrator's ` +
2461
+ `reconstructed workflows dir (${comfyWorkflowsDirs().join(" or ") || "COMFYUI_PATH not set"}).` +
2462
+ (misspelled.length
2463
+ ? ` A file exists on disk as ${misspelled.map((f) => `"${f}"`).join(", ")}, which is` +
2464
+ ` not how you spelled it (letter case, repeated separator, or Unicode` +
2465
+ ` normalization), and with ComfyUI unreachable there is no authority to confirm they are` +
2466
+ ` the same file — so it was NOT loaded. Retype the name exactly, or pass an absolute path.`
2467
+ : "") +
2468
+ ` Pass an absolute path, or a name shown by list_workflows.`);
2056
2469
  }
2057
2470
  // IMPORTANT (Codex parity): use `z.array(z.number())` — NOT `z.tuple([...])` — for
2058
2471
  // fixed-length coordinate vectors. zod's `.tuple()` emits JSON-Schema draft-04
@@ -2307,6 +2720,17 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
2307
2720
  // preserving the raw cause. Keying on the TYPED flag (not error text) means a
2308
2721
  // POST-dispatch executor ok:false reply that merely quotes "no connected tab" is
2309
2722
  // never mis-wrapped as "nothing applied".
2723
+ // #709: a CAPABILITY refusal (the tab's panel does not enforce the workflow-stamp
2724
+ // contract) shares the dispatched:false flag with transient routing failures, but
2725
+ // the generic recovery below — "retry in a moment / rebind with
2726
+ // panel_set_workflow_target({mode:"current"})" — can NEVER clear it (the refusal's
2727
+ // own text says so: rebinding cannot add the missing capability). Appending it
2728
+ // sent agents into a futile retry/rebind loop that contradicted the embedded
2729
+ // guidance. Key on the TYPED marker and surface the cause verbatim instead; it
2730
+ // already names the real recovery (update + restart + browser hard-refresh).
2731
+ if (isCapabilityRefusal(err)) {
2732
+ return fail(err instanceof Error ? err.message : String(err));
2733
+ }
2310
2734
  if (dispatchOutcomeOf(err) === false) {
2311
2735
  const name = typeof cmd.cmd === "string" ? cmd.cmd : "panel command";
2312
2736
  // Neutral wording: a dispatched:false flag proves only that the command was NOT
@@ -2682,6 +3106,19 @@ let askTimingOverride = null;
2682
3106
  // total ask budget — applied even when env overrides ask for more, so a
2683
3107
  // misconfigured COMFYUI_PANEL_ASK_DEADLINE_S/GRACE_S can never recreate #486.
2684
3108
  const ASK_TOTAL_BUDGET_CAP_MS = 285_000;
3109
+ /**
3110
+ * The per-request timeout an INTERNAL MCP client must use when calling panel_*
3111
+ * tools (#325). Several panel tools are DESIGNED to block on a human well past
3112
+ * the MCP SDK's 60s default request timeout: panel_ask / the confirm / consent
3113
+ * cards wait up to ASK_TOTAL_BUDGET_CAP_MS (285s), and panel_request_secret
3114
+ * waits up to 300s on its masked input. A client that calls them with the SDK
3115
+ * default (the ollama-family backends' loopback panel client did) kills the
3116
+ * request at 60s with `MCP error -32001: Request timed out` — the user picks an
3117
+ * option minutes later, the server delivers it, and the model never sees it.
3118
+ * Sized above the LONGEST blocking card (the 300s secret card) with margin for
3119
+ * loopback transport + processing. Fast tools are unaffected: this is only an
3120
+ * upper bound, never a wait. */
3121
+ export const PANEL_TOOL_MCP_TIMEOUT_MS = 315_000;
2685
3122
  function getAskTiming() {
2686
3123
  if (askTimingOverride)
2687
3124
  return askTimingOverride;
@@ -3336,7 +3773,7 @@ export function buildPanelToolDefs() {
3336
3773
  path: z
3337
3774
  .string()
3338
3775
  .optional()
3339
- .describe("Path to a workflow .json on the ComfyUI machine's disk — absolute, or relative to the ComfyUI workflows folder (user/default/workflows). Read + parsed server-side and loaded onto the canvas (keeps a large JSON out of chat). Local ComfyUI only."),
3776
+ .describe("Path to a workflow .json — an ABSOLUTE path on the ComfyUI machine's disk, or a name from list_workflows, which is looked up in the connected ComfyUI's own saved-workflow library (so a custom --user-directory resolves correctly). Read + parsed server-side and loaded onto the canvas (keeps a large JSON out of chat). A name the library does not have is REFUSED rather than guessed at from a local path."),
3340
3777
  graph: z
3341
3778
  .union([z.string(), z.record(z.string(), z.unknown())])
3342
3779
  .optional()
@@ -3558,9 +3995,25 @@ export function buildPanelToolDefs() {
3558
3995
  return typeof pid === "string" && pid ? pid : null;
3559
3996
  })();
3560
3997
  QueueMonitor.markSelfQueued(queuedId);
3998
+ // #468 — open a run ticket so the render's completion can be CORRELATED
3999
+ // to this exact call by prompt id. This is what lets the orchestrator
4000
+ // journal an undelivered completion and replay it into the right run
4001
+ // instead of losing it while the agent works through a goal.
4002
+ const correlatable = RunCompletions.openRun(queuedId, {
4003
+ tabId: ctx.tabId,
4004
+ ...(typeof args.to_node_id === "number" ? { toNodeId: args.to_node_id } : {}),
4005
+ });
3561
4006
  // Append anti-poll guidance: the agent should go idle after queuing so the
3562
4007
  // executed event auto-injects the output image, rather than busy-polling.
3563
- const note = "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll queue (action:\"list\"), get_history, or list_output_images. Just end your turn now and wait for the result to be delivered to you.";
4008
+ //
4009
+ // HONEST WHEN WE CAN'T PROMISE IT (#468): the anti-poll instruction is only
4010
+ // safe because the completion is correlated by prompt id. Without one — the
4011
+ // panel forwarded no `prompt_id` — a later completion can only be reported as
4012
+ // UNDETERMINED, so telling the agent to idle and wait would park it on a
4013
+ // promise we can't keep. Say so and point at the verification path instead.
4014
+ const note = correlatable
4015
+ ? "\n\n[IMPORTANT] You will be notified automatically with the output image(s)/video when the render finishes — do NOT poll queue (action:\"list\"), get_history, or list_output_images. Just end your turn now and wait for the result to be delivered to you."
4016
+ : "\n\n[IMPORTANT] The run was queued, but the panel reported NO prompt id for it, so a completion event CANNOT be correlated back to this run — its outcome will be reported to you as UNDETERMINED. Do NOT simply idle and wait indefinitely: end your turn, and if nothing arrives, confirm the outcome with get_history before acting on it.";
3564
4017
  // Backpressure note. A backlog is only alarming when it's a job we did NOT
3565
4018
  // queue (possibly foreign/stuck). Deliberately batching renders — a sweep,
3566
4019
  // a multi-variant comparison — is a NORMAL workflow, so a queue made of our