diff --git a/Packages/MIES/MIES_ClaudeHelper.ipf b/Packages/MIES/MIES_ClaudeHelper.ipf new file mode 100644 index 0000000000..c8c46b5a50 --- /dev/null +++ b/Packages/MIES/MIES_ClaudeHelper.ipf @@ -0,0 +1,397 @@ +#pragma TextEncoding = "UTF-8" +#pragma rtGlobals = 3 // Use modern global access method and strict wave access. +#pragma rtFunctionErrors = 1 + +#ifdef AUTOMATED_TESTING +#pragma ModuleName = MIES_CH +#endif // AUTOMATED_TESTING + +/// @file MIES_ClaudeHelper.ipf +/// @brief Helper functions for the Igor Pro Bridge MCP server (tools/igor-mcp-bridge) + +#ifdef IGOR_PRO_BRIDGE + +// PE (Portable Executable) format constants used by the CH_PE* helpers below to +// parse a compiled .xop's headers and resource directory. Offsets/sizes are +// per the documented Microsoft PE/COFF format (IMAGE_DOS_HEADER, +// IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER64, IMAGE_SECTION_HEADER, +// IMAGE_RESOURCE_DIRECTORY(_ENTRY), IMAGE_RESOURCE_DATA_ENTRY) unless noted as +// XOP Toolkit-specific. +static Constant CH_PE_DOS_SIGNATURE = 0x5A4D // "MZ" -- IMAGE_DOS_HEADER.e_magic +static Constant CH_PE_SIGNATURE = 0x00004550 // "PE\0\0" +static Constant CH_PE_SIGNATURE_SIZE = 4 // sizeof(uint32), size of the "PE\0\0" signature itself +static Constant CH_PE_E_LFANEW_OFFSET = 0x3C // IMAGE_DOS_HEADER offset to e_lfanew (PE header file offset) +static Constant CH_PE_OPTIONAL_HEADER_MAGIC_PE32PLUS = 0x20B // IMAGE_OPTIONAL_HEADER64.Magic +static Constant CH_PE_FILE_HEADER_SIZE = 20 // sizeof(IMAGE_FILE_HEADER) +static Constant CH_PE_FILE_HEADER_NUM_SECTIONS_OFFSET = 2 // offset within IMAGE_FILE_HEADER +static Constant CH_PE_FILE_HEADER_SIZE_OF_OPT_HDR_OFFSET = 16 // offset within IMAGE_FILE_HEADER +static Constant CH_PE_OPT_HEADER_DATA_DIR_OFFSET = 112 // PE32+ Optional Header offset to the Data Directory array +static Constant CH_PE_DATA_DIRECTORY_ENTRY_SIZE = 8 // sizeof(IMAGE_DATA_DIRECTORY): uint32 RVA + uint32 Size +static Constant CH_PE_RESOURCE_TABLE_DIR_INDEX = 2 // index of the Resource Table entry within the Data Directory array +static Constant CH_PE_SECTION_HEADER_SIZE = 40 // sizeof(IMAGE_SECTION_HEADER) +static Constant CH_PE_SECTION_VIRTUAL_SIZE_OFFSET = 8 // offset within IMAGE_SECTION_HEADER +static Constant CH_PE_SECTION_VIRTUAL_ADDRESS_OFFSET = 12 // offset within IMAGE_SECTION_HEADER +static Constant CH_PE_SECTION_RAW_DATA_PTR_OFFSET = 20 // offset within IMAGE_SECTION_HEADER +static Constant CH_PE_RESDIR_NUM_NAMED_ENTRIES_OFFSET = 12 // offset within IMAGE_RESOURCE_DIRECTORY +static Constant CH_PE_RESDIR_NUM_ID_ENTRIES_OFFSET = 14 // offset within IMAGE_RESOURCE_DIRECTORY +static Constant CH_PE_RESDIR_ENTRIES_OFFSET = 16 // offset within IMAGE_RESOURCE_DIRECTORY to its entry array +static Constant CH_PE_RESDIR_ENTRY_SIZE = 8 // sizeof(IMAGE_RESOURCE_DIRECTORY_ENTRY) +static Constant CH_PE_RESDIR_ENTRY_OFFSET_FIELD_OFFSET = 4 // offset within IMAGE_RESOURCE_DIRECTORY_ENTRY to its Offset field +static Constant CH_PE_RESOURCE_HIGH_BIT_FLAG = 0x80000000 // marks a named Name field, or a subdirectory Offset field +static Constant CH_PE_RESOURCE_OFFSET_MASK = 0x7FFFFFFF // strips CH_PE_RESOURCE_HIGH_BIT_FLAG off a Name/Offset field +static Constant CH_PE_DATA_ENTRY_SIZE_FIELD_OFFSET = 4 // offset within IMAGE_RESOURCE_DATA_ENTRY to its Size field +static Constant CH_XOP_RESOURCE_ID = 1100 // resource ID the XOP Toolkit uses for XOPI/XOPC/XOPF (XOPMan8.pdf) +static Constant CH_PE_PAD_CHAR = 0x20 // ASCII space, used to pre-size a string buffer for FBinRead +static Constant CH_UINT16_BYTE_SIZE = 2 // bytes per uint16 field (also one UTF-16LE code unit) +static Constant CH_BYTE_SHIFT_8BIT = 256 // multiplier for the high byte of a little-endian uint16 +static Constant CH_CSTRING_SEARCH_INITIAL_CHUNK = 256 // initial read size when hunting for a null terminator +static Constant CH_CSTRING_SEARCH_MAX_CHUNK = 8192 // give up (return -1) once search size exceeds this many bytes +static Constant CH_CMPSTR_CASE_SENSITIVE = 1 // CmpStr's caseSensitive flag (1 = case-sensitive, per Igor Reference) + +/// CH_ListXOPExports(xopPath) reads the operations and functions a compiled .xop +/// file (a Windows DLL) adds to Igor, straight from the file's own bytes -- no +/// vendor documentation or source needed, and no live Igor Pro instance needs to +/// have that XOP loaded. +/// +/// Background (confirmed this session against WaveMetrics' own XOP Toolkit 8.01 +/// manual, XOPMan8.pdf, and cross-checked live against real, closed-source .xop +/// files including this repo's own XOPs-64bit/JSON-64.xop and .../ZeroMQ-64.xop, +/// whose decoded names matched FunctionList/OperationList's live results exactly): +/// every .xop declares the operations and functions it adds via two custom +/// resources, XOPC (operations) and XOPF (functions), each with resource ID 1100. +/// On Windows these are ordinary named Windows PE resources (resource TYPE name +/// literally "XOPC"/"XOPF", not a numeric type, and not a proprietary format) -- +/// the standard Windows resource compiler embeds them directly in the .xop/DLL's +/// own .rsrc section, the same mechanism used for any other named Win32 resource. +/// +/// Binary layout of the resource data itself (confirmed from the manual's Windows +/// .rc source examples for XOPC/XOPF): +/// XOPC: repeating {null-terminated name string; little-endian uint16 category +/// bitmask}, terminated by an empty-name (single 0x00 byte) record. +/// XOPF: repeating {null-terminated name string; uint16 category bitmask; uint16 +/// return-type code; uint16 parameter-type code * N; uint16 0 to terminate that +/// function's parameter list}, terminated the same way as XOPC. +/// This implementation only extracts the name lists (category/type bit values are +/// documented in the manual but not decoded here, since the name list is what's +/// actually useful for "what does this XOP add"). +/// +/// Only supports 64-bit (PE32+) XOPs -- true of every XOP actually in use in this +/// repo (all "-64.xop"/"64.xop" files) -- and aborts with a clear message for a +/// 32-bit (PE32) XOP rather than silently misreading it. +/// +/// Returns "operations:op1;op2;...\rfunctions:func1;func2;..." (either list may be +/// empty -- not every XOP adds both kinds). + +Function/S CH_ListXOPExports(string xopPath) + + string ops, funcs, result + + ops = CH_PEListXOPResource(xopPath, "XOPC") + funcs = CH_PEListXOPResource(xopPath, "XOPF") + + sprintf result, "operations:%s\rfunctions:%s", ops, funcs + + return result +End + +static Function CH_PEReadU16(variable refNum, variable pos) + + variable v + + FSetPos refNum, pos + FBinRead/F=2/U/B=3 refNum, v + + return v +End + +static Function CH_PEReadU32(variable refNum, variable pos) + + variable v + + FSetPos refNum, pos + FBinRead/F=3/U/B=3 refNum, v + + return v +End + +static Function/S CH_PEReadBytes(variable refNum, variable pos, variable numBytes) + + string s = PadString("", numBytes, CH_PE_PAD_CHAR) + + FSetPos refNum, pos + FBinRead refNum, s + + return s +End + +// Returns the length (not including the terminator) of a null-terminated string +// starting at pos, or -1 if no null byte turns up within a generous search cap +// (CH_CSTRING_SEARCH_MAX_CHUNK bytes -- far more than any real operation/function +// name). +static Function CH_PECStringLen(variable refNum, variable pos) + + string nul = num2char(0) + string chunk + variable chunkSize = CH_CSTRING_SEARCH_INITIAL_CHUNK + variable idx + + do + chunk = CH_PEReadBytes(refNum, pos, chunkSize) + idx = strsearch(chunk, nul, 0) + if(idx >= 0) + return idx + endif + chunkSize *= 2 + while(chunkSize <= CH_CSTRING_SEARCH_MAX_CHUNK) + + return -1 +End + +// Interprets two bytes at 0-based index offset within s as a little-endian uint16. +static Function CH_BytesToU16(string s, variable offset) + + return char2num(s[offset]) + char2num(s[offset + 1]) * CH_BYTE_SHIFT_8BIT +End + +// Reads a UTF-16LE resource name (as used for named PE resource directory +// entries, e.g. the "XOPC"/"XOPF" type names themselves) and returns it as a +// plain ASCII string -- sufficient here since every name this code looks for is +// pure ASCII. +static Function/S CH_PEReadUnicodeName(variable refNum, variable pos, variable numChars) + + string result = "" + variable i, code + + for(i = 0; i < numChars; i += 1) + code = CH_PEReadU16(refNum, pos + i * CH_UINT16_BYTE_SIZE) + result += num2char(code) + endfor + + return result +End + +// Converts an RVA (relative virtual address, relative to the module's load +// address) to a file offset, by finding which section contains it. Returns -1 if +// no section contains rva. +static Function CH_PERVAToFileOffset(variable refNum, variable sectionTableOffset, variable numSections, variable rva) + + variable i, secPos, secVA, secVirtSize, secRawPtr + + for(i = 0; i < numSections; i += 1) + secPos = sectionTableOffset + i * CH_PE_SECTION_HEADER_SIZE + secVirtSize = CH_PEReadU32(refNum, secPos + CH_PE_SECTION_VIRTUAL_SIZE_OFFSET) + secVA = CH_PEReadU32(refNum, secPos + CH_PE_SECTION_VIRTUAL_ADDRESS_OFFSET) + secRawPtr = CH_PEReadU32(refNum, secPos + CH_PE_SECTION_RAW_DATA_PTR_OFFSET) + if(rva >= secVA && rva < (secVA + secVirtSize)) + return secRawPtr + (rva - secVA) + endif + endfor + + return -1 +End + +// Walks the PE resource directory tree (Type -> ID -> Language, 3 levels) rooted +// at resourceSectionFileOffset, looking for a named type entry matching typeName +// containing a numeric-ID entry matching resourceID. All offsets inside the +// resource directory tree itself (including the string offsets for named +// entries) are relative to resourceSectionFileOffset -- confirmed from the PE +// format's own documented convention; only the leaf IMAGE_RESOURCE_DATA_ENTRY's +// own OffsetToData field is a true RVA (handled by the caller via +// CH_PERVAToFileOffset, not here). Returns the file offset of the matching +// IMAGE_RESOURCE_DATA_ENTRY structure, or -1 if typeName/resourceID isn't present +// (not every XOP has both XOPC and XOPF). +static Function CH_PEFindResourceOffset(variable refNum, variable resourceSectionFileOffset, string typeName, variable resourceID) + + variable numNamed, numIds, numEntries, i + variable entryPos, nameField, offsetField + variable subdirOffset, level2Base, level3SubdirOffset, level3Base + variable nameLen, strPos, dataEntryOffset + string entryName + + // --- Level 1: resource TYPE directory --- + numNamed = CH_PEReadU16(refNum, resourceSectionFileOffset + CH_PE_RESDIR_NUM_NAMED_ENTRIES_OFFSET) + numIds = CH_PEReadU16(refNum, resourceSectionFileOffset + CH_PE_RESDIR_NUM_ID_ENTRIES_OFFSET) + numEntries = numNamed + numIds + + subdirOffset = -1 + for(i = 0; i < numEntries; i += 1) + entryPos = resourceSectionFileOffset + CH_PE_RESDIR_ENTRIES_OFFSET + i * CH_PE_RESDIR_ENTRY_SIZE + nameField = CH_PEReadU32(refNum, entryPos) + offsetField = CH_PEReadU32(refNum, entryPos + CH_PE_RESDIR_ENTRY_OFFSET_FIELD_OFFSET) + + if(nameField & CH_PE_RESOURCE_HIGH_BIT_FLAG) + strPos = resourceSectionFileOffset + (nameField & CH_PE_RESOURCE_OFFSET_MASK) + nameLen = CH_PEReadU16(refNum, strPos) + entryName = CH_PEReadUnicodeName(refNum, strPos + CH_UINT16_BYTE_SIZE, nameLen) + if(CmpStr(entryName, typeName, CH_CMPSTR_CASE_SENSITIVE) == 0) + subdirOffset = offsetField + break + endif + endif + endfor + + if(subdirOffset < 0 || !(subdirOffset & CH_PE_RESOURCE_HIGH_BIT_FLAG)) + return -1 + endif + + // --- Level 2: resource ID directory --- + level2Base = resourceSectionFileOffset + (subdirOffset & CH_PE_RESOURCE_OFFSET_MASK) + numNamed = CH_PEReadU16(refNum, level2Base + CH_PE_RESDIR_NUM_NAMED_ENTRIES_OFFSET) + numIds = CH_PEReadU16(refNum, level2Base + CH_PE_RESDIR_NUM_ID_ENTRIES_OFFSET) + numEntries = numNamed + numIds + + level3SubdirOffset = -1 + for(i = 0; i < numEntries; i += 1) + entryPos = level2Base + CH_PE_RESDIR_ENTRIES_OFFSET + i * CH_PE_RESDIR_ENTRY_SIZE + nameField = CH_PEReadU32(refNum, entryPos) + offsetField = CH_PEReadU32(refNum, entryPos + CH_PE_RESDIR_ENTRY_OFFSET_FIELD_OFFSET) + + if(!(nameField & CH_PE_RESOURCE_HIGH_BIT_FLAG) && nameField == resourceID) + level3SubdirOffset = offsetField + break + endif + endfor + + if(level3SubdirOffset < 0 || !(level3SubdirOffset & CH_PE_RESOURCE_HIGH_BIT_FLAG)) + return -1 + endif + + // --- Level 3: language directory -- take the first (only, in every case seen + // so far) entry regardless of its language ID --- + level3Base = resourceSectionFileOffset + (level3SubdirOffset & CH_PE_RESOURCE_OFFSET_MASK) + numNamed = CH_PEReadU16(refNum, level3Base + CH_PE_RESDIR_NUM_NAMED_ENTRIES_OFFSET) + numIds = CH_PEReadU16(refNum, level3Base + CH_PE_RESDIR_NUM_ID_ENTRIES_OFFSET) + numEntries = numNamed + numIds + + if(numEntries == 0) + return -1 + endif + + entryPos = level3Base + CH_PE_RESDIR_ENTRIES_OFFSET + dataEntryOffset = CH_PEReadU32(refNum, entryPos + CH_PE_RESDIR_ENTRY_OFFSET_FIELD_OFFSET) + + return resourceSectionFileOffset + dataEntryOffset +End + +// Parses raw XOPC/XOPF resource bytes (already read into memory) into a +// semicolon-separated list of names, per the binary layout documented above +// CH_ListXOPExports. +static Function/S CH_ParseXOPResourceBlob(string raw, string resourceType) + + variable pos = 0 + variable len = strlen(raw) + string nul = num2char(0) + string names = "" + variable nameEnd, nameLen + string name + variable category, retType, paramType + + do + nameEnd = strsearch(raw, nul, pos) + if(nameEnd < 0) + break + endif + nameLen = nameEnd - pos + if(nameLen == 0) + break + endif + name = raw[pos, nameEnd - 1] + pos = nameEnd + 1 + + category = CH_BytesToU16(raw, pos) + pos += CH_UINT16_BYTE_SIZE + + if(CmpStr(resourceType, "XOPF", CH_CMPSTR_CASE_SENSITIVE) == 0) + retType = CH_BytesToU16(raw, pos) + pos += CH_UINT16_BYTE_SIZE + do + paramType = CH_BytesToU16(raw, pos) + pos += CH_UINT16_BYTE_SIZE + while(paramType != 0) + endif + + if(strlen(names) > 0) + names += ";" + endif + names += name + while(pos < len) + + return names +End + +// Opens xopPath, parses its PE headers, locates the named resourceType +// (CH_XOP_RESOURCE_ID) resource if present, and returns its decoded name list +// ("" if that XOP has no resource of that type). +static Function/S CH_PEListXOPResource(string xopPath, string resourceType) + + variable refNum + variable dosSig, peOffset, peSig + variable fileHeaderOffset, numSections, sizeOfOptHeader, optHeaderOffset, magic + variable resourceEntryOffset, resourceRVA, sectionTableOffset, resourceSectionFileOffset + variable dataEntryFileOffset, dataRVA, dataSize, dataFileOffset + string raw + + Open/R/Z refNum as xopPath + if(V_flag != 0) + Abort "CH_ListXOPExports: could not open file: " + xopPath + endif + + dosSig = CH_PEReadU16(refNum, 0) + if(dosSig != CH_PE_DOS_SIGNATURE) // "MZ" + Close refNum + Abort "CH_ListXOPExports: missing MZ/DOS signature, not a PE file: " + xopPath + endif + + peOffset = CH_PEReadU32(refNum, CH_PE_E_LFANEW_OFFSET) + peSig = CH_PEReadU32(refNum, peOffset) + if(peSig != CH_PE_SIGNATURE) // "PE\0\0" + Close refNum + Abort "CH_ListXOPExports: missing PE signature: " + xopPath + endif + + fileHeaderOffset = peOffset + CH_PE_SIGNATURE_SIZE + numSections = CH_PEReadU16(refNum, fileHeaderOffset + CH_PE_FILE_HEADER_NUM_SECTIONS_OFFSET) + sizeOfOptHeader = CH_PEReadU16(refNum, fileHeaderOffset + CH_PE_FILE_HEADER_SIZE_OF_OPT_HDR_OFFSET) + optHeaderOffset = fileHeaderOffset + CH_PE_FILE_HEADER_SIZE + magic = CH_PEReadU16(refNum, optHeaderOffset) + + if(magic != CH_PE_OPTIONAL_HEADER_MAGIC_PE32PLUS) + Close refNum + Abort "CH_ListXOPExports: only 64-bit (PE32+) XOPs are supported; magic=0x" + num2istr(magic) + " for " + xopPath + endif + + // PE32+ Data Directory array starts at offset CH_PE_OPT_HEADER_DATA_DIR_OFFSET + // within the Optional Header; CH_PE_RESOURCE_TABLE_DIR_INDEX (0-based) is the + // Resource Table entry -- confirmed from the PE format's own documented + // Optional Header layout. + resourceEntryOffset = optHeaderOffset + CH_PE_OPT_HEADER_DATA_DIR_OFFSET + CH_PE_RESOURCE_TABLE_DIR_INDEX * CH_PE_DATA_DIRECTORY_ENTRY_SIZE + resourceRVA = CH_PEReadU32(refNum, resourceEntryOffset) + + sectionTableOffset = optHeaderOffset + sizeOfOptHeader + resourceSectionFileOffset = CH_PERVAToFileOffset(refNum, sectionTableOffset, numSections, resourceRVA) + if(resourceSectionFileOffset < 0) + Close refNum + Abort "CH_ListXOPExports: could not locate the .rsrc section: " + xopPath + endif + + dataEntryFileOffset = CH_PEFindResourceOffset(refNum, resourceSectionFileOffset, resourceType, CH_XOP_RESOURCE_ID) + if(dataEntryFileOffset < 0) + Close refNum + return "" + endif + + dataRVA = CH_PEReadU32(refNum, dataEntryFileOffset) + dataSize = CH_PEReadU32(refNum, dataEntryFileOffset + CH_PE_DATA_ENTRY_SIZE_FIELD_OFFSET) + + dataFileOffset = CH_PERVAToFileOffset(refNum, sectionTableOffset, numSections, dataRVA) + if(dataFileOffset < 0) + Close refNum + Abort "CH_ListXOPExports: could not locate raw " + resourceType + " data: " + xopPath + endif + + raw = CH_PEReadBytes(refNum, dataFileOffset, dataSize) + Close refNum + + return CH_ParseXOPResourceBlob(raw, resourceType) +End +#endif // IGOR_PRO_BRIDGE diff --git a/Packages/MIES_Include.ipf b/Packages/MIES_Include.ipf index ac90388999..25ca02a47e 100644 --- a/Packages/MIES_Include.ipf +++ b/Packages/MIES_Include.ipf @@ -196,6 +196,7 @@ End #include "MIES_Cache" #include "MIES_CheckInstallation" +#include "MIES_ClaudeHelper" #include "MIES_Configuration" #include "MIES_ConversionConstants" #include "MIES_Constants" diff --git a/Packages/doc/igor-pro-bridge.rst b/Packages/doc/igor-pro-bridge.rst new file mode 100644 index 0000000000..cd77e40449 --- /dev/null +++ b/Packages/doc/igor-pro-bridge.rst @@ -0,0 +1,974 @@ +.. _igor_pro_bridge_doc: + +=============== +Igor Pro Bridge +=============== + +Description +----------- + +The Igor Pro Bridge lets an AI coding agent (Claude, via a local MCP server) control an +already-running Igor Pro instance directly: execute commands, read wave data, edit +``.ipf`` files on disk and recompile them, and inspect the live environment -- without a +human needing to click anything in Igor Pro for routine steps. + +The code lives in ``tools/igor-mcp-bridge/``: + +- ``server.py``: the MCP server implementation (Python, using ``pyzmq`` to talk to + Igor Pro's ZeroMQ-XOP, and ``pywin32`` for OS-level window handling and process + launching only -- see :ref:`igor_pro_bridge_v2_migration` below). +- ``igor-pro-bridge-*.mcpb``: packaged Claude Desktop Extension bundles built from + ``server.py`` via the `mcpb `__ CLI. +- ``requirements.txt``: pinned Python dependency versions (see :ref:`igor_pro_bridge_requirements`). +- ``install.ps1``: installs those pinned dependencies into the correct Python + environment and completes pywin32's post-install step -- see + :ref:`igor_pro_bridge_installation`. + +The companion procedure file ``Packages/MIES/ZMQ_BridgeHelpers.ipf`` (included from +``MIES_Include.ipf``, independent module ``ZBR``) provides the Igor-side functions the +bridge calls into, including an ``AfterCompiledHook`` used to get a more reliable +compile-success signal and to (re)bind the ZeroMQ server socket on every compile; see +:ref:`igor_pro_bridge_zbr_helpers` below. + +This is Windows-only tooling for MIES development, not something end users of MIES +interact with. + +.. _igor_pro_bridge_v2_migration: + +Architecture +------------ + +As of v2.0.0, the bridge talks to Igor Pro over the +`ZeroMQ-XOP `__'s ``CallFunction`` +JSON protocol, over a plain localhost TCP socket (``tcp://127.0.0.1:5680``) -- not +COM. Versions through 1.27.0 instead used Igor Pro's built-in ActiveX Automation +Server (``IgorPro.Application``, documented in ``Igor Pro Folder/Miscellaneous/Windows +Automation/Automation Server.ihf``): the bridge was a COM *client* process that +attached via ``win32com.client.GetActiveObject`` and issued commands through +``Execute2``. See :ref:`igor_pro_bridge_v1_history` for that transport's full design +history, now superseded. + +Why this changed: COM required the bridge's Python process and Igor Pro to run at the +*same* Windows privilege level (both elevated, or both not) -- an easy-to-miss +mismatch, e.g. Claude Desktop reopened normally after Igor Pro was left running +elevated from before. ZeroMQ is a plain TCP socket with no such requirement at all -- +elevation no longer matters in any way for this bridge, in either direction. + +Wire protocol, in brief (see ``server.py``'s own module docstring for the full +detail): the bridge sends one JSON request per round trip -- +``{"version": 1, "messageID": ..., "CallFunction": {"name": ..., "params": [...]}}`` +-- and receives ``{"errorCode": {"value": ..., "msg": ...}, "result": ...}`` back. A +fresh ZeroMQ REQ socket is created for every call (a REQ socket that times out cannot +send again without being recreated). Function names for anything in the ``ZBR`` +independent module must be ``#``-qualified (``"ZBR#ZBR_Ping"``) -- the XOP's own +README says to omit the ``#`` for independent-module functions; that is confirmed +empirically to be wrong. Wave return values carry the *entire* wave (dimensions, +units, note, complex/text/wave-ref support) natively serialized as JSON, and +multi-return Igor functions (``Function [a, b] Foo()``) are supported directly as a +JSON array of typed values. + +A central constraint carried over unchanged from the COM design: Igor's ``Execute`` +operation cannot run unqueued from inside a Function -- only ``Execute/P`` (deferred) +can. There is therefore no single-round-trip equivalent of COM's ``Execute2`` for +arbitrary free-form command text; ``execute_igor_command``/ +``execute_igor_command_unattended`` instead use a submit-then-poll pattern +(``ZBR_SubmitCommand``/``ZBR_SubmitCommandUnattended`` queue the command and return a +token immediately; ``ZBR_PollCommand``, called repeatedly, reports completion and the +captured text). Every other tool that doesn't need arbitrary free-form text is backed +by a small, purpose-built, directly-callable ``ZBR_*`` function instead and needs no +polling. + +The bridge checks the reply's ``errorCode.value`` itself and raises a Python +``RuntimeError`` subclass (``IgorZmqError``, or ``IgorZmqUnreachable`` if no reply +arrives at all) when appropriate -- a malformed or failing Igor-level call does not +otherwise surface as a Python exception on its own. + +.. _igor_pro_bridge_requirements: + +Requirements +------------ + +- Igor Pro 9.00 or later, running on Windows, with the ZeroMQ-XOP installed and + loaded. ``RELOAD CHANGED PROCS``, which ``reload_and_compile_procedures`` depends + on, was introduced in Igor Pro 9.00 and sets the actual minimum version. +- **``Packages/MIES/ZMQ_BridgeHelpers.ipf`` must be ``#include``-d and compiled into + whichever Igor Pro experiment the bridge talks to.** Unlike the old COM transport + (which worked against a stock Igor Pro installation with zero custom procedure + code), there is no bootstrap path over ZeroMQ itself -- if this file isn't + included/compiled, nothing is listening on the port at all, and every tool call + fails with ``IgorZmqUnreachable``. This repo's own ``Packages/MIES_Include.ipf`` + already does this permanently. For any *other* Igor Pro experiment: copy + ``ZMQ_BridgeHelpers.ipf`` onto its own procedure search path, add + ``#include "ZMQ_BridgeHelpers"`` to that experiment's own include list by hand, and + recompile -- a one-time, per-experiment setup step. See + :ref:`igor_pro_bridge_zbr_helpers` for what this file provides. +- Most tools require Igor Pro to already be running; the bridge connects to the + running instance's ZeroMQ socket. If needed, ``launch_igor_pro_unattended`` can + start Igor Pro itself (after ``configure_igor_launch``) -- see below. +- **No privilege-matching requirement of any kind.** This is the change v2.0.0 makes + over the old COM transport: Igor Pro and the bridge's Python process can each run + elevated or not, independently, with no effect on connectivity -- ZeroMQ is a plain + localhost TCP socket, not a Windows COM/RPC channel. +- Python 3.10 or later, accessible as ``python`` on ``PATH``, with the pinned packages + in ``requirements.txt`` (``mcp==1.29.0``, ``pyzmq==27.1.0``, ``pywin32==312``) + installed into that same environment -- see :ref:`igor_pro_bridge_installation` + below for how. The packaged extension does not vendor these. ``pywin32`` is still a + dependency in v2.0.0 -- it is no longer used to talk to Igor Pro, but is still used + for ``dismiss_compile_error_dialog``'s window enumeration and for launching the + Igor Pro process. + + ``mcp`` is pinned below its breaking v2.0.0 line (released 2026-07-27/28, protocol + revision 2026-07-28): v2 renamed ``FastMCP`` to ``MCPServer`` and moved it from + ``mcp.server.fastmcp`` to ``mcp.server.mcpserver``, among other changes, while + ``server.py`` still uses the v1 ``from mcp.server.fastmcp import FastMCP`` API. An + unpinned ``mcp`` dependency (or a plain ``pip install mcp`` today) resolves to v2 and + breaks the bridge outright (``ModuleNotFoundError``) -- confirmed directly from both + wheels' contents. Do not lift the ``<2`` upper bound without migrating ``server.py`` + to the v2 API first. + +.. _igor_pro_bridge_installation: + +Installation +------------ + +The bridge is distributed as a Claude Desktop Extension (``.mcpb``), not via manual +``claude_desktop_config.json`` editing (which does not work reliably for local MCP +servers in current Claude Desktop builds). + +- Build: ``mcpb pack tools/igor-mcp-bridge tools/igor-mcp-bridge/igor-pro-bridge-X.Y.Z.mcpb`` +- Install: Claude Desktop -> Settings -> Extensions -> Advanced settings -> Extension + Developer -> Install Extension, then select the ``.mcpb`` file. +- **Before first use (or whenever a Python dependency changes), run + ``tools/igor-mcp-bridge/install.ps1`` from an elevated PowerShell** to install the + pinned packages and complete pywin32's required post-install step + (``Scripts\pywin32_postinstall.py -install``, which a plain ``pip install`` does not + do). The script itself must run elevated only because that post-install step + registers COM-support DLLs into protected system locations -- pywin32 is still a + dependency for OS-level window handling and process launching (see + :ref:`igor_pro_bridge_requirements`), even though this bridge no longer uses COM to + talk to Igor Pro. This elevation requirement is purely install-time and unrelated to + how you run Claude Desktop or Igor Pro afterward -- neither needs to be elevated at + runtime. ``install.ps1`` resolves ``python.exe`` from the Machine/User ``PATH`` + registry values directly rather than trusting the invoking shell's own + possibly-customized ``$env:Path``, to mirror what a freshly launched Claude Desktop + process actually sees regardless of its own elevation state (not necessarily the + same interpreter an interactive console session would resolve, e.g. a + PowerShell-profile-only conda activation, or a Microsoft Store app-execution-alias + stub that behaves differently once elevated); pass ``-PythonPath`` to override this + if needed. See ``Get-Help ./install.ps1 -Full`` for the complete rationale and all + steps performed. +- Separately, ensure ``Packages/MIES/ZMQ_BridgeHelpers.ipf`` is ``#include``-d and + compiled into whatever Igor Pro experiment you intend to use with the bridge -- see + :ref:`igor_pro_bridge_requirements`. This repo's own experiments already have this + via ``MIES_Include.ipf``; any other experiment needs the one-time manual setup step + described there. +- After installing (or after running ``install.ps1``), fully restart Claude Desktop so + the updated server code/environment is actually picked up -- newly added tools, or a + freshly installed dependency, can otherwise lag behind what's on disk. +- Call ``get_bridge_version()`` afterward and confirm its ``python_executable`` field + matches the interpreter ``install.ps1`` installed into. If it doesn't, Claude Desktop + resolved a different Python than ``install.ps1`` guessed -- re-run ``install.ps1 + -PythonPath ``. + +Available tools +---------------- + +``execute_igor_command(command, timeout_seconds=30.0)`` + Runs a command string on Igor's command line. **Include a `print` call -- not + `fprintf 0, ...` -- to get data back** (confirmed live in v2.0.1: Igor's + ``CaptureHistory``, which this bridge's capture mechanism depends on, captures + ``print`` output but never captures ``fprintf``-to-history output at all, whether + directed at refnum 0, -1, or -2 -- the command runs without error either way, but an + ``fprintf``-only command silently returns empty ``"results"``/``"history"`` every + time). Returns a dict with ``"results"`` and ``"history"`` -- both now hold the + *same* captured text (see :ref:`igor_pro_bridge_v2_migration`: this transport + cannot isolate ``print``-only output from the full echoed history the way COM's + ``Execute2`` could isolate ``fprintf``-only output). Prefix ``command`` with + ``Silent 1;`` to suppress the echoed command text from the result. Implemented as + submit-then-poll + (``ZBR_SubmitCommand``/``ZBR_PollCommand``), since Igor's ``Execute`` cannot run + unqueued from inside a Function; ``timeout_seconds`` bounds how long this polls + before giving up. **Caution**: if ``command`` calls user-defined procedure code and + the Debugger is enabled, a breakpoint/runtime error/abort/stale-reference pause will + hang this call indefinitely (the poll loop keeps timing out and retrying, never + seeing it finish) -- there is no scriptable way to resume or dismiss the Debugger + window. Prefer ``execute_igor_command_unattended`` whenever nobody is watching who + could close that popup by hand. + +``execute_igor_command_unattended(command, timeout_seconds=30.0)`` + Same as ``execute_igor_command``, but automatically disables Igor's Debugger before + running the command and restores it afterward, even if the command raises. This is + the default choice for any unattended/automated call. + + **Neither of the two tools above is suitable for a command with an unknown or long + runtime** (more than roughly a minute) -- both block the entire MCP tool call while + polling, and the MCP transport itself has been observed to time out a single tool + call well under a minute regardless of ``timeout_seconds``. Use + ``submit_igor_command``/``poll_igor_command`` instead for anything long-running. + +``submit_igor_command(command)`` + Queues ``command`` for deferred execution (via ``ZBR_SubmitCommand``) and returns a + token immediately, without waiting for it to finish. **The tool to reach for when a + command's runtime is unknown or could be long -- minutes, hours, even weeks.** + Follow up with ``poll_igor_command(token)``, as many times as needed, spaced + however far apart in time is convenient. + + Reliable over arbitrarily long horizons because all of the actual state (a done + flag and the captured output text) lives entirely in Igor Pro's own data waves + (``root:Packages:ZBR:done``/``resultText``), not in this bridge's own Python + process -- polling later does not depend on this bridge process staying alive, on + Claude Desktop staying open, or on any particular amount of time having passed. The + one thing that *does* end the job is Igor Pro itself quitting, crashing, or + restarting -- at that point the underlying computation is gone regardless of + whether the token can still technically be looked up. + + **Caution**: same Debugger-pause risk as ``execute_igor_command``, but more + consequential here, since a long-running job is by definition likely to be + unattended -- a pause partway through leaves ``poll_igor_command`` reporting + "not done" forever, indistinguishable from the command still genuinely running. + **Use ``submit_igor_command_unattended`` instead for anything long-running.** + + **v2.2.0 reliability fix**: separately from the Debugger, ``command`` failing to + parse or hitting a genuine Igor-level runtime error partway through used to have + the exact same "hangs forever" effect, for a different reason -- confirmed live. + Fixed by queuing the command and the internal finish-callback as independent + ``Execute/P`` entries instead of one joined string (see SESSION_NOTES.md for the + full live-tested root cause). ``poll_igor_command`` is now guaranteed to + eventually report ``done: true`` regardless of whether ``command`` succeeded, + errored, or failed to parse. **Known residual gap**: there is still no generic + way to tell "ran and legitimately printed nothing" apart from "errored with no + output" -- both come back as an empty/short result with no error indication + (an attempted ``GetRTError()``-based fix was tried and confirmed live not to + work). Have ``command`` ``print`` an explicit sentinel if success needs to be + verifiable. + + **v2.2.3 fix**: the finish-callback (``ZBR_FinishToken``) captures its target row + index at submission time but runs later, in its own deferred entry -- if the + underlying storage waves are ever resized smaller in between (confirmed live + during this bridge's own maintenance/cleanup work), the callback's write used to + throw an uncaught "Index out of range" error and pop a modal dialog, exactly like + the v2.2.1 bug. Now bounds-checked: if the row no longer exists, the callback + silently does nothing, and a later ``poll_igor_command`` on that token correctly + reports ``"ERROR: unknown token ..."`` instead of hanging or crashing. + +``submit_igor_command_unattended(command)`` + Same as ``submit_igor_command``, but disables Igor's Debugger for the duration of + ``command`` and restores it afterward (via ``ZBR_SubmitCommandUnattended``). **The + recommended tool for anything long-running** -- without it, a Debugger pause has no + periodic signal distinguishing it from genuine progress, only silence. + +``poll_igor_command(token)`` + Checks whether a command submitted via ``submit_igor_command``/ + ``submit_igor_command_unattended`` has finished, and returns its captured output if + so. Returns ``{"done": false}`` while still pending -- call again later, with no + limit on how long to wait or how many times to poll. Returns + ``{"done": true, "results": , "history": }`` once finished (both keys + hold the same text, matching ``execute_igor_command``'s own return shape -- see its + entry above for why "results"/"history" can't be kept separate over this + transport, and why ``print``, not ``fprintf``, is what actually gets captured). + Raises if ``token`` isn't recognized -- e.g. a typo, or a token from an Igor Pro + instance that has since quit/restarted (tokens do not survive Igor Pro itself + restarting, only this bridge process or Claude Desktop restarting). For a job + expected to run over hours to weeks, consider a scheduled task that calls this + periodically rather than relying on the conversation staying open. Does **not** + raise just because the submitted command itself failed to parse or errored -- + see ``submit_igor_command``'s v2.2.0 note above. + +``read_session_history(stop=False)`` + Reads back everything sent to Igor's history area since this bridge's capture + started (via ``ZBR_ReadSessionHistory``, backed by Igor's built-in + ``CaptureHistoryStart()``/``CaptureHistory()`` functions) -- a capture starts + automatically, Igor-side, on first use. This can verify *past* executions + retroactively (e.g. to see everything printed across many separate calls at once). + Each call returns the full accumulated text since the capture started, so repeated + calls are always safe. ``stop=True`` ends the current capture and starts a fresh + one on next use. + +``get_wave(wave_path)`` + Returns an existing Igor wave's full data and metadata: ``type``, ``dim_size`` + (1 to 4 dimensions), ``data`` (nested Python lists matching the wave's own + dimensionality), ``unit``, ``note``, and per-dimension ``dimension`` info. Supports + any dimensionality, and real, complex, text, and wave-reference waves -- a + **capability expansion over v1.x**, which was limited to 1D real-valued waves read + one point at a time via COM. The entire wave comes back in a single round trip, + natively serialized by the ZeroMQ-XOP. + +``load_experiment(file_path, wait_for_ready_seconds=30.0, process_exit_timeout_seconds=30.0)`` + Loads an Igor Pro experiment file (``.pxp``), replacing whatever is currently open. + **Behavior change from v1.x**: COM's ``IApplication.LoadExperiment`` hot-swapped the + experiment inside the same running Igor Pro process; that method has no + procedure-language equivalent (confirmed: neither ``LoadExperiment`` nor + ``OpenFile`` appear anywhere in ``Igor Reference.ihf``, only in + ``Automation Server.ihf``), so this transport cannot replicate it. Instead, this + tool asks the running instance to quit (``Quit/N``, submitted via + ``ZBR_SubmitCommand``), waits for the underlying Igor64.exe **OS process** to fully + exit, then relaunches the configured Igor Pro executable (see + ``configure_igor_launch``) with ``/UNATTENDED`` plus the target file path as a + launch argument, and polls for the new instance to become reachable. This is a + genuine process restart, not an in-place swap -- **unsaved changes in the + currently-open experiment are lost**; call ``execute_igor_command('SaveExperiment')`` + first if that matters. Requires ``configure_igor_launch`` to have been called first. + Call ``get_environment_summary()`` afterward, since loading a different experiment + changes everything about the live environment. + + **v2.0.1 fix, root-caused from a live silent-failure report**: an earlier version of + this tool waited only for Igor Pro to stop answering ``ZBR_Ping`` over ZeroMQ as its + signal that the old process was gone, then immediately launched the replacement + ``Igor64.exe /UNATTENDED `` command line. This does not work: ZeroMQ goes + quiet well before the underlying OS process actually terminates (Igor can take + several seconds to fully exit after ``Quit/N`` runs), and launching the replacement + command line while the old process is still alive -- even mid-shutdown -- does not + spawn a new process at all. Windows/Igor's single-instance-per-user behavior instead + either (a) redirects the launch into the still-live old instance, which can pop an + unhandled "save changes?" dialog if it happens to have unsaved edits, or (b) if the + old instance is already mid-quit, silently drops the request altogether -- which + looks exactly like the relaunch had no effect whatsoever, with nothing to diagnose + (no error, no new process, `check_bridge_health()` just reports unreachable + indefinitely). Fixed by polling the actual Windows process list (matching the + configured executable's own file name, e.g. ``"Igor64.exe"``) until no such process + remains, up to ``process_exit_timeout_seconds``, before ever invoking the relaunch + command line; if that timeout elapses with the process still present, the tool now + raises rather than silently proceeding into the same failure mode -- the most likely + cause being a stuck "save changes?" dialog on the *old* instance, which needs a human + to resolve by hand. + + **v2.2.1 fix, root-caused from another live report**: reloading a saved experiment + via this tool (or a human manually reopening a ``.pxp``) could leave the whole + bridge unreachable, requiring a human to dismiss a modal Igor error dialog reading + "While executing CaptureHistory, the following error occurred: there is no open + file with this reference number". Root cause: this bridge's history-capture + mechanism stores its ``CaptureHistoryStart()`` reference number in a plain + ``Variable/G`` global, which Igor persists into a saved experiment like any other + global -- but the refnum is only meaningful within the OS process that created it, + so reloading brought back a stale-but-present value that the old code trusted + simply because it existed. Fixed Igor-side (``ZBR_EnsureCaptureStarted`` in + ``ZMQ_BridgeHelpers.ipf``): the stored refnum is now validated with a + ``try``/``catch``/``endtry`` block before being trusted, and silently replaced with + a fresh capture if it's stale, rather than ever surfacing this to the user. No + Python-side change was needed. See ``SESSION_NOTES.md`` for the full live-tested + root cause and the two Igor syntax mistakes caught and fixed along the way. + + **v2.2.2 refinement** (contributed by the repo owner after installing v2.2.1): the + stale-refnum probe call and its ``AbortOnRTE`` are kept on the SAME line rather + than split across two, because Igor's Debug on Error check happens at the end of + each *line*, not each statement -- on separate lines, a user with Debug on Error + enabled would get a Debugger popup right when the stale refnum's runtime error + occurred, before ``AbortOnRTE`` had a chance to convert it into a catchable abort. + Confirmed live (before and after) by temporarily enabling ``debug_on_error`` and + repeating the corrupted-refnum test: only the same-line version stays silent. + +``check_bridge_health()`` + Diagnoses whether the bridge can reach Igor Pro's ZeroMQ server right now. Unlike + the old COM-based version, this can no longer cleanly distinguish "Igor Pro isn't + running" from "``ZMQ_BridgeHelpers.ipf`` isn't included/compiled/bound" from "wrong + port/firewall" -- a ZeroMQ REQ socket that gets no reply at all looks the same in + all three cases; the ``"problem"`` field lists all three as things to check by + hand. Run this first whenever something doesn't work. + + **As of v2.3.2**, this (like every ZeroMQ-talking function) connects to whichever + endpoint is currently configured via ``configure_igor_launch(port=...)`` -- if a + custom port was set but the target Igor Pro instance wasn't actually launched with + that same port in effect, this reports unreachable even though Igor Pro itself may + be running fine on its default port. The ``"problem"`` message on failure includes + the exact endpoint that was tried. + +``get_bridge_version()`` + Returns the version of this Igor Pro Bridge build that is actually running in the + current Claude Desktop session, plus which Python interpreter/packages it's actually + running with:: + + { + "version": "2.1.0", + "python_executable": "C:\\Python312\\python.exe", + "python_version": "3.12.4", + "mcp_package_version": "1.29.0", + "pyzmq_version": "27.1.0" + } + + Useful before relying on a specific recent fix or behavior change, or to confirm + which ``.mcpb`` build ended up loaded after an install/restart. The + ``python_executable`` field is also the authoritative way to confirm which Python + environment Claude Desktop actually launched the bridge with, e.g. to cross-check + against what ``install.ps1`` installed into -- see + :ref:`igor_pro_bridge_installation`. + +``check_compilation_state()`` + Reports whether Igor's procedure code is currently compiled or uncompiled, via + ``ZBR_IsCompiled()`` (the same ``FunctionInfo``-based technique as + ``IsProcGlobalCompiled()`` in + ``Packages/igortest/procedures/igortest-test-compilation.ipf``). Since ``ZBR`` + compiles as its own independent module, a ``true`` result here specifically reflects + ProcGlobal's compile state -- reaching ``ZBR`` at all over ZeroMQ already implies + ``ZBR`` itself is compiled. **Fixed in v2.3.1**: the ``FunctionInfo()`` call was + previously unqualified, so it resolved against ``ZBR``'s own (always-compiled) + independent-module namespace instead of ProcGlobal's, meaning this could report + ``true`` even with a genuine ProcGlobal compile error. Fixed by qualifying it as + ``FunctionInfo("ProcGlobal#...")``, matching ``igortest``'s own reference + implementation. + +``reload_and_compile_procedures()`` + Forces Igor to reload changed ``.ipf`` files from disk (``RELOAD CHANGED PROCS``) and + attempt a fresh compilation (``COMPILEPROCEDURES``, via ``ZBR_SubmitReloadAndCompile``), + then reports the resulting compiled state. Use this after editing a ``.ipf`` file + directly on disk. Both commands go through Igor's operation queue rather than + running immediately (see "Operation Queue" in ``Advanced Topics.ihf``), so this + cross-checks two independent signals before trusting a "compiled" result -- see + :ref:`igor_pro_bridge_zbr_helpers` and :ref:`igor_pro_bridge_compile_dialog`. Poll + errors (the bridge briefly unable to reach Igor mid-recompile) are treated as "not + ready yet" rather than fatal. If compilation still isn't confirmed after the poll + times out, this automatically makes one attempt to dismiss a possible stuck + compile-error dialog (see ``dismiss_compile_error_dialog``). **Caution, carried + over from the COM-based version**: Igor Pro has been observed becoming + unreachable shortly after a reload/compile attempt on more than one occasion + during this bridge's development (crashed or was closed). As of **v2.3.0**, + crash-dump analysis (two ``.dmp`` files, parsed with Python's ``minidump`` + package) traced this to a genuine ``EXCEPTION_ACCESS_VIOLATION`` deep inside + ``Igor64.exe`` itself, not this bridge's own code, and a likely mechanism was + identified by comparing against this repo's own ``igortest-tracing.ipf`` + (whose ``CompileAndRestart()``/``AfterCompiledHook()`` pattern never crashes): + this bridge's ZeroMQ-XOP handler runs as a background thread that keeps + dispatching incoming ``CallFunction`` requests regardless of what Igor's main + thread is doing, so a request arriving while ``COMPILEPROCEDURES`` is + mid-rebuild of Igor's own internal function/symbol tables is a plausible + cross-thread race. v2.3.0 mitigates this by stopping the ZeroMQ handler + (``zeromq_handler_stop()``, via ``ZBR_StopHandlerBeforeRecompile``) before + ``RELOAD CHANGED PROCS``/``COMPILEPROCEDURES`` run, and restarting it only + after compilation finishes (the existing ``AfterCompiledHook`` -> + ``ZBR_EnsureZeroMQBound()`` call, unchanged). This is a well-reasoned + mitigation, not a proven fix -- ``Igor64.exe`` ships no public symbols, so the + exact fault can't be confirmed from here, and the crash was already + rare/nondeterministic. Confirmed live afterward, including three concurrent + ``reload_and_compile_procedures()`` calls as a stress test with no crash. If a + tool call after this one starts failing anyway, check + ``check_bridge_health()`` and be prepared for Igor Pro to need relaunching. + See ``SESSION_NOTES.md`` for the full investigation. + + **v2.3.1 fix -- the handler could stay stopped forever on a failed compile.** + v2.3.0's restart path was ``AfterCompiledHook`` alone, and Igor only calls that + hook after a *successful* compile. If the edited ``.ipf`` failed to compile, the + hook never fired, and the handler -- already stopped by + ``ZBR_StopHandlerBeforeRecompile`` -- stayed stopped permanently, killing the + bridge with no recovery short of relaunching Igor Pro. Found live by the repo + owner while intentionally testing a bad edit. Fixed by + ``ZBR_ArmRecompileWatchdog``/``ZBR_RecompileWatchdogTick`` (see + :ref:`igor_pro_bridge_zbr_helpers`): a named ``CtrlNamedBackground`` task, armed + immediately before the handler is stopped, that unconditionally restarts/rebinds + the handler regardless of whether the compile succeeds or fails, then + self-disarms the first time either it or ``AfterCompiledHook`` runs. Because the + background-task scheduler and the deferred operation queue (``Execute/P``) are + two independent Igor subsystems, live ``stopmstimer(-2)`` instrumentation (three + timestamped printouts: queue start, watchdog tick, ``AfterCompiledHook``) + confirmed they are **not** strictly ordered -- with only ``period=30`` set, the + watchdog ticked ~62ms after being armed, well before a real compile finished + (~414ms). The task is therefore registered with an explicit ``start=60`` (an + ~1-second floor before its first possible tick, independent of the ``period=30`` + interval governing later ticks), comfortably longer than any compile observed + this session. The property this protects is that the watchdog must not fire + before the operation queue has finished draining -- not that it must fire after + ``AfterCompiledHook`` specifically, which is meaningless on the failure path + since that hook never runs then; once the queue has drained, the relative order + of the two on the success path no longer matters, since both converge on the + same idempotent ``ZBR_EnsureZeroMQBound()`` call. This is a generous empirical + margin, not a mathematically airtight guarantee against an arbitrarily slow + future compile. As of v2.3.1, the bridge itself should stay reachable even after + a failed compile -- fix the ``.ipf`` and call ``reload_and_compile_procedures`` + again rather than needing to relaunch Igor Pro. + + A separate, unrelated issue also surfaced during this work: a pre-existing MIES + background *thread* (not task) left running during ``COMPILEPROCEDURES`` could + raise a blocking "Function Execution Module is still active" dialog, freezing + Igor's entire operation queue (direct ``CallFunction`` calls like + ``check_bridge_health`` kept working throughout, since they don't route through + the queue). Mitigated by a new ``BeforeUncompiledHook`` in + ``ZMQ_BridgeHelpers.ipf`` that calls ``ThreadGroupRelease(-2)`` to release + running thread groups before Igor uncompiles, added by the repo owner. + +``dismiss_compile_error_dialog()`` + Attempts to close a stuck Igor Pro dialog by posting a simulated Escape key press + directly to it (via ``PostMessage``), targeting a visible window owned by an Igor + Pro process whose title matches a known stuck-dialog title -- this does **not** + require or change OS focus/foreground state. **Confirmed live against both Igor + Pro 10.03 and Igor Pro 9.06**: the compile-error dialog is titled exactly + *"Function Compilation Error"* and is a Qt window (class ``"Qt693QWindowIcon"`` + on 10.03), not a native ``"#32770"`` dialog -- title matching is what actually + finds it on both major versions, and a *posted* (not real hardware) Escape + successfully closed it in both cases, with no focus/foreground change needed. An + earlier version also matched any generic native ``"#32770"`` dialog regardless of + title; removed after a Copilot PR review correctly flagged it as a real risk (this + is called automatically from ``reload_and_compile_procedures``, so it could have + Escape-dismissed an unrelated native dialog, e.g. a save-changes confirmation) and + it was never actually needed, since the real dialog isn't ``"#32770"`` anyway. + Does **not** recover the actual error message -- it only clears the dialog so work + can continue. See :ref:`igor_pro_bridge_compile_dialog`. + +``get_debugger_state()`` / ``set_debugger_enabled(enabled, ...)`` / ``restore_debugger_settings()`` + Read, change, and restore Igor's Debugger settings (``DebuggerOptions``). Use + ``get_debugger_state()`` to snapshot the current settings before a longer unattended + session, ``set_debugger_enabled(False)`` to disable the Debugger for the run, and + ``restore_debugger_settings()`` to put things back afterward. + +``get_environment_summary()`` + Summarizes the live instance: Igor version/build, the loaded experiment, loaded XOPs, + currently included procedure files (with a category breakdown), the contents of the + always-present "Procedure" window (which can carry experiment-specific + ``#include``/``#define`` directives not present in any on-disk ``.ipf`` file), the + top-level global data folder layout, and the current Debugger settings. + +``read_help_file(file_path, timeout_ms=30000)`` + Reads an Igor Pro help file (``.ihf``) as structured, formatted text -- e.g. to + confirm an operation's exact flags/behavior straight from Igor's own docs -- without + leaving any lasting change to Igor's help-window state. ``timeout_ms`` defaults to + 30s rather than this bridge's usual 5s (**fixed in v2.0.1** after a live timeout + reading the entire "Igor Reference.ihf" manual -- exporting a genuinely large help + file as HTML can take longer than 5s even though the export itself succeeds + Igor-side regardless; a timed-out client also leaves the ZeroMQ-XOP logging a + harmless but noisy "Host unreachable" error to history when it tries to reply to a + socket that already gave up, visible via ``read_session_history`` if this happens). + Pass a larger value still for unusually large help files. Better than an OS-level file + read for two reasons. First, Igor pre-registers every ``.ihf`` file in the Help Files + folder as an open help window (visible or hidden, ``WinList``'s ``WIN:512`` bit), and + a help-file view and a plain-notebook view of the same file are mutually exclusive + (``OpenNotebook/R`` fails with error 251 otherwise) -- this tool handles the required + ``CloseHelp/ALL`` -> ``OpenNotebook/R`` -> ``SaveNotebook`` export -> ``KillWindow/Z`` + -> ``OpenHelp`` restore dance, entirely in a ``finally`` block so a mid-sequence + failure still restores whatever help state existed beforehand. **Fixed in v2.3.2**: + the case where the expected new notebook window isn't found used to raise a plain + ``Abort ""``, which displays a real alert dialog the moment it executes -- + before the surrounding ``try``/``catch`` ever gets a chance to intervene, unlike an + ordinary runtime error. That would have hung unattended use exactly like every other + undismissable popup this bridge works around elsewhere. Fixed by setting the error + status directly instead of calling ``Abort`` at all in that branch. Second, and more + importantly: the returned ``"paragraphs"`` list (``[{"style": "Topic", "text": + "Debugging"}, ...]``) preserves the paragraph style name WaveMetrics' own help + authoring convention assigns to nearly every paragraph (e.g. ``"Topic"`` for a section + heading, ``"Code1"`` for a line of example code, ``"Steps"`` for a bullet item) -- + genuine content-block structure, not just flat prose. Not every XOP ships its own + help file this way -- some (e.g. the JSON XOP used elsewhere in this codebase) have + none at all and require external documentation instead; check + ``get_environment_summary()``'s ``loaded_xops`` field plus the global (``Igor + Application``) and user-specific (``Igor Pro User Files``) ``Igor Help Files`` + folders (both resolved via Igor's own ``SpecialDirPath`` function) before assuming a + given XOP has one. + +``configure_igor_launch(exe_path, port=None)`` + Records the full path to the Igor Pro executable to use for + ``launch_igor_pro_unattended``/``load_experiment``, for the rest of this bridge + process's session. There is no default or guessed path -- whatever agent is driving + the bridge should ask the user for this once, at the start of a session that might + need to launch Igor Pro, since the install location and version vary (this repo + alone has been tested against separately-named Igor Pro 9 and Igor Pro 10 installs). + Session-scoped: resets if the bridge process itself restarts. + + **``port``, added in v2.3.2** -- preparation for eventually talking to more than one + Igor Pro instance, not full simultaneous multi-instance support yet (this bridge + still only tracks one currently-configured endpoint at a time). Passing an integer + 1-65535 sets ``IGOR_PRO_BRIDGE_PORT`` in this bridge process's own environment; + ``launch_igor_pro_unattended``/``load_experiment`` inherit it when they start Igor + Pro, and the launched instance's ``ZBR_EnsureZeroMQBound`` (``ZMQ_BridgeHelpers.ipf``) + reads it back to decide which port to bind instead of its own default (5680). Every + ZeroMQ-talking function in this bridge also immediately starts targeting that same + port for its own connections (see ``check_bridge_health()`` above), not just the + next launch. **Omitting ``port`` (or passing ``None``) clears a previously-configured + custom port** -- it removes the environment variable entirely rather than leaving it + as-is, so calling this again to change just ``exe_path`` will also clear ``port`` + unless it's repeated on that same call. + +``launch_igor_pro_unattended(wait_for_ready_seconds=30.0)`` + Launches the configured executable with the ``/UNATTENDED`` command-line flag (see + :ref:`igor_pro_bridge_unattended_flag` below) and polls for it to become reachable + over ZeroMQ. Requires ``configure_igor_launch`` to have been called first in the + same session. Refuses to launch (returns ``"launched": false`` rather than raising) + if something already answers ``ZBR_Ping`` right now, since launching the executable + again with only ``/UNATTENDED`` (no ``/I``, ``/X``, ``/SN``, or file-path argument) + is documented to start a genuinely new instance rather than reuse the existing one + -- see "Calling Igor from Scripts" in ``Advanced Topics.ihf``. + + **Always launches as a plain child process** (``subprocess.Popen``), at whatever + privilege level the bridge's own Python process is running at -- no elevation + request, no UAC prompt, ever. This is the key v2.0.0 change: the old COM-based + version branched on whether this process was already elevated, using a direct + child-process launch (inheriting elevation with no prompt) when it was, or + ``ShellExecute``'s ``"runas"`` verb (triggering a UAC consent dialog, and leaving + this process itself still unelevated afterward -- see :ref:`igor_pro_bridge_v1_history`) + when it wasn't. Neither branch is needed anymore: ZeroMQ has no privilege-matching + requirement at all, so there is nothing left to branch on. + + Patches ``COMSPEC`` into the child's environment if this Python process's own + environment is missing it -- confirmed necessary during this bridge's development: + without it, MIES's own startup hook (``IgorStartOrNewHook`` -> ... -> + ``ExecuteGitForMIESVersion``, which shells out to git via ``ExecuteScriptText`` + using ``GetCmdPath()``/``COMSPEC`` to find ``cmd.exe``) asserted on every launch via + this path with *"We have git installed but could not regenerate version.txt"*, even + though a normal double-click/Start Menu launch never hits it (an interactive login + session always has ``COMSPEC`` set). See ``SESSION_NOTES.md`` for the full + diagnosis. + +.. _igor_pro_bridge_unattended: + +Unattended execution caveats +----------------------------- + +Two independent things can silently stall an automated Claude/Igor session. Neither +hangs the bridge's own ZeroMQ calls directly -- both instead leave Igor showing a GUI +element that only a human can dismiss. + +Debugger pauses +~~~~~~~~~~~~~~~~ + +If the Debugger is enabled and something trips it (a breakpoint, a runtime error with +"Debug on Error", a user abort, or a stale NVAR/SVAR/WAVE reference), Igor pauses and +opens the Debugger window. There is no documented operation to programmatically +resume, step, or dismiss that pause -- ``Debugger``/``DebuggerOptions`` are the only two +documented operations, and neither has a "continue" mode. The submitted command that +triggered the pause never reports as finished, so +``execute_igor_command``/``execute_igor_command_unattended``'s poll loop keeps timing +out and retrying forever. Other new ``CallFunction`` calls still get answered while +paused (Igor's command line stays reentrant), but the +original call, and anything waiting on it, is stuck for good. + +Mitigation: ``execute_igor_command_unattended`` disables the Debugger for the duration +of each call automatically. For a longer session, bracket it with +``get_debugger_state()`` / ``set_debugger_enabled(False)`` at the start and +``restore_debugger_settings()`` at the end instead. + +.. _igor_pro_bridge_compile_dialog: + +Compile-error dialogs +~~~~~~~~~~~~~~~~~~~~~~ + +Separately, a failed ``COMPILEPROCEDURES`` can leave a compile-error dialog open. This +does not hang the bridge's ZeroMQ calls (they keep returning normally), but it does +block Igor's operation queue from ever draining -- confirmed from ``Advanced +Topics.ihf``, "Operation Queue": "Igor services the operation queue when no +procedures are running and the command line is empty." A modal dialog means Igor is +never idle, so ``RELOAD CHANGED PROCS``/``COMPILEPROCEDURES`` queued by a later call +sit there without ever actually running -- ``reload_and_compile_procedures`` will keep +reporting "not compiled" even after the underlying ``.ipf`` file is genuinely fixed, +until a person closes that dialog by hand. + +There is no documented way to detect or dismiss this dialog via the ``CallFunction`` +protocol (Igor's own compiled code can't observe or interact with its own modal +dialogs), but Escape closes it. ``dismiss_compile_error_dialog()`` exploits that: it +enumerates top-level windows for a visible one owned by an Igor Pro process whose +title matches a known stuck-dialog title, then posts ``WM_KEYDOWN``/``WM_KEYUP`` for +Escape directly to it via ``PostMessage`` -- no foreground switch, no stolen focus. +This is pure OS-level window handling (``pywin32``), independent of whichever +transport talks to Igor Pro's procedure code. **One caveat carried over from the old +COM-based elevation requirement, now the other way around**: since v2.0.0 no longer +requires the bridge to run elevated, if a user chooses to run Igor Pro elevated for +some unrelated reason while the bridge itself is not, Windows' UIPI will block this +posted key press from reaching Igor Pro's window at all (simulated input from a +lower-privilege process cannot reach a higher-privilege one) -- in that specific case, +the bridge process itself would need to be run elevated too for this one tool to +keep working, even though nothing else about the ZeroMQ transport requires it. + +**Confirmed live against a real stuck dialog on both Igor Pro 10.03 and Igor Pro +9.06**: the original assumption that this dialog is an ordinary ``"#32770"`` +native dialog was wrong -- Igor Pro's UI (on both major versions tested) is +Qt-based, and the compile-error dialog is a Qt window titled exactly *"Function +Compilation Error"* on both (observed class on 10.03: ``"Qt693QWindowIcon"``, a +version-hash-looking string not worth matching on directly). Title matching is what +actually finds it, and a *posted* Escape (not a real hardware key press) was +confirmed to close it on both versions -- Qt's Windows platform layer reacts to the +posted message the same way it would a real key press. An earlier version also +matched any window with the generic native ``"#32770"`` dialog class regardless of +title; removed after a Copilot PR review correctly flagged it as a real risk, since +this is called automatically from ``reload_and_compile_procedures`` and could have +Escape-dismissed an unrelated native dialog (e.g. a save-changes confirmation) -- +and it was never actually needed, since the real dialog isn't ``"#32770"`` on +either version tested. If a future window's title doesn't match, dismissal safely +reports "not found" (along with a diagnostic list of +every window Igor currently owns) rather than doing something incorrect. The +trade-off either way: this recovers the ability to continue working, not the actual +error message -- check the ``.ipf`` file's syntax directly, or have a human read the +dialog text, if the exact message matters. ``reload_and_compile_procedures`` now +calls this automatically once, before falling back to asking a human. + +If the automatic attempt doesn't resolve it (or wasn't possible -- e.g. no matching +dialog window was found), ``reload_and_compile_procedures``'s result includes +``"auto_dismiss_attempted"`` (the full ``dismiss_compile_error_dialog()`` result, so +its ``"attempted"``/``"igor_windows_seen"`` fields can be inspected directly) plus a +``"note"`` explaining that this looks like a genuine compile error rather than a +timing artifact. Whatever is driving the bridge (e.g. an AI agent) should treat a +``"compiled": false`` result with ``"auto_dismiss_attempted": {"attempted": false, ...}`` +as a signal to ask the human operator to check for and close a stuck dialog by hand, +rather than silently retrying or only logging advisory text -- that distinction was +confirmed in practice to be what actually keeps an agent-driven/unattended workflow +moving. + +.. _igor_pro_bridge_unattended_flag: + +The ``/UNATTENDED`` launch flag and compile errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Igor Pro's own ``/UNATTENDED`` command-line flag (added in Igor Pro 9.00; see +"Calling Igor from Scripts" in ``Advanced Topics.ihf``) is documented only as +suppressing "certain interactions that are inconvenient for unattended +operations," with two concrete documented examples: the About Autosave dialog, and +(Igor Pro 10+) the license activation dialog. Nothing in Igor's help files ties it +to compile errors specifically. + +Empirically confirmed against a live Igor Pro 9.06 instance launched with +``/UNATTENDED``: it *also* suppresses the modal "Function Compilation Error" dialog +described above. A genuine syntax error introduced into an actually-loaded procedure +file produced ``reload_and_compile_procedures`` results of ``compiled: false`` (per +both the ``ZBR_ReadCompileCounter`` and ``ZBR_IsCompiled`` signals), while +``dismiss_compile_error_dialog``'s diagnostic window enumeration found no dialog +window at all -- only Igor's main window was visible. Instead, the compile error +appears as a plain line in Igor's history area, in the form +``::: error: `` (e.g. +``ZMQ_BridgeHelpers.ipf:46:7: error: expected terminating quote``), fully readable +via ``read_session_history``/the per-call ``history`` field. + +This makes an Igor Pro instance started with ``/UNATTENDED`` (e.g. via +``launch_igor_pro_unattended``) strictly better for this bridge's purposes than one +started normally: there is no dialog to dismiss at all, and the exact error message +is available programmatically, which the dialog-dismissal path never provided (it +only recovers the ability to continue, never the message itself). A bridge session +driving an ``/UNATTENDED`` Igor Pro instance should never need +``dismiss_compile_error_dialog`` in the first place. + +Methodological note: verify a target ``.ipf`` file is actually part of the +currently-loaded environment (``get_environment_summary()``'s +``included_procedure_files``) before editing it to test compile behavior. An +earlier attempt at this same test edited a file that turned out not to be included +in the loaded environment at all (no experiment file was open), so no compile ever +actually occurred -- which superficially looked like a real ``/UNATTENDED`` +behavior change but was really a no-op test. See ``SESSION_NOTES.md`` for the full +account. + +.. _igor_pro_bridge_runtime_errors: + +Igor's runtime error model (why a failure doesn't mean execution stopped) +--------------------------------------------------------------------------- + +With the Debugger disabled, an unhandled runtime error does not stop execution: it sets +Igor's internal runtime-error flag (readable via ``GetRTError(0)``, without clearing +it) and execution continues completely normally -- every subsequent line runs, +including side effects, all the way to the end of the function, unless something +explicitly checks the flag (see "Runtime Error / Abort Handling Conventions" in +:doc:`developers` for the project's ``AbortOnRTE``/``try``/``catch`` +conventions). If nothing ever checks it, the flag persists until execution unwinds all +the way back to the top-level command boundary -- i.e. the submitted command run via +``ZBR_SubmitCommand``/``ZBR_SubmitCommandUnattended`` -- which reports it as that +command's own failure, carrying the *original* error code and message. This boundary +check also clears the flag afterward, so a failure here never contaminates the next +command. + +The flag is "sticky": if two *different* unhandled runtime errors occur in sequence +with nothing checking/clearing in between, only the *first* one is ever visible -- +matching Igor's own documented caveat that ``GetErrMessage`` can be "incomplete" when +multiple errors occur. + +Practical consequence: a nonzero error code from ``execute_igor_command``/ +``execute_igor_command_unattended`` means at least one problem occurred and reports it, +but does **not** mean execution stopped there, and does **not** mean it was the only +problem. + +.. _igor_pro_bridge_zbr_helpers: + +ZMQ_BridgeHelpers.ipf and the ZBR module +------------------------------------------- + +``Packages/MIES/ZMQ_BridgeHelpers.ipf``, included from ``MIES_Include.ipf``, is no +longer a throwaway prototype -- it is the permanent Igor-side dependency of this +bridge as of v2.0.0, providing every ``ZBR_*`` function ``server.py`` calls via +``CallFunction``. Its own header comment documents the manual, one-time ``#include`` +delivery model (see :ref:`igor_pro_bridge_requirements`): this repo's own +``MIES_Include.ipf`` includes it permanently, but any other experiment needs its own +copy plus its own ``#include`` added by hand. + +It compiles as its own independent module (``#pragma IndependentModule = ZBR``), +which is why every ``CallFunction`` name for one of its functions must be +``#``-qualified (``"ZBR#ZBR_Ping"``, not ``"ZBR_Ping"``) -- see +:ref:`igor_pro_bridge_v2_migration`. This also means a compile error inside +``ZMQ_BridgeHelpers.ipf`` itself fails the *whole* experiment's compile (an +independent module is not somehow exempt from that), even though it compiles +separately from ProcGlobal. + +Compile-confirmation counter, migrated from MIES_ClaudeHelper.ipf +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``ZMQ_BridgeHelpers.ipf`` defines a static ``AfterCompiledHook`` that increments +``root:gClaudeHelperCompileCounter`` (the global name, and the counter's role, carried +over unchanged from this repo's older ``MIES_ClaudeHelper.ipf``, which no longer +exists as a separate file): + +.. code-block:: igorpro + + static Function AfterCompiledHook() + Variable modifiedBefore + Variable/G root:gClaudeHelperCompileCounter + NVAR gClaudeHelperCompileCounter = root:gClaudeHelperCompileCounter + + modifiedBefore = GetDataFolderDF(...)... // ExperimentModified state captured here + + ZBR_EnsureZeroMQBound() + + gClaudeHelperCompileCounter += 1 + + return 0 + End + +``AfterCompiledHook`` is a predefined Igor hook that Igor calls only after *all* +procedure windows have compiled successfully. Unlike polling ``FunctionInfo()`` for a +non-existing function (which can read stale state before Igor's operation queue has +actually drained -- see :ref:`igor_pro_bridge_compile_dialog`), this counter only ever +changes at the exact moment Igor itself confirms a successful compile, so it is a +race-free confirmation signal, read back via ``ZBR_ReadCompileCounter()``. +``reload_and_compile_procedures`` reads a baseline before issuing +``RELOAD CHANGED PROCS``/``COMPILEPROCEDURES`` and treats any increase as immediate, +trustworthy success, falling back to the ``ZBR_IsCompiled()``-based poll when the +counter is unavailable. There is no equivalent hook for a *failed* compile -- that +gap is exactly why v2.3.1 added the ``ZBR_ArmRecompileWatchdog``/ +``ZBR_RecompileWatchdogTick`` background-task restart path described in the +``reload_and_compile_procedures()`` reference entry above, since ``AfterCompiledHook`` +by itself cannot recover the ZeroMQ handler on the failure path. Declared +``static`` so it coexists with any other file's own static ``AfterCompiledHook`` +without colliding. + +A companion ``BeforeUncompiledHook`` (also added in v2.3.1) calls +``ThreadGroupRelease(-2)`` before Igor uncompiles, to release any running Igor +thread groups and avoid a blocking "Function Execution Module is still active" +dialog that a pre-existing MIES background thread could otherwise raise during +``COMPILEPROCEDURES``. + +Auto-binding the ZeroMQ server socket on every compile +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The same hook also calls ``ZBR_EnsureZeroMQBound()``, which (re)binds the ZeroMQ-XOP's +server socket to ``ZBR_ZEROMQ_ENDPOINT`` (``tcp://127.0.0.1:5680``) and starts its +handler every time Igor finishes a successful compile: + +.. code-block:: igorpro + + static Function ZBR_EnsureZeroMQBound() + Variable err + zeromq_server_bind(ZBR_ZEROMQ_ENDPOINT); err = GetRTError(1) + zeromq_handler_start(); err = GetRTError(1) + return 0 + End + +Two corrections were made to the first draft of this function, both confirmed to +matter in practice: it does **not** call ``zeromq_stop()`` first (that would tear down +and corrupt any *other* ZeroMQ binds already active in the same experiment on every +recompile -- e.g. MIES's own, currently short-circuited, ZeroMQ subsystem on a +different port); and each XOP call is followed by ``; err = GetRTError(1)`` on the +*same line* specifically to suppress a theoretical Debugger popup, since "Debug on +Error" only checks for a pending runtime-error state at the end of a line, not +mid-statement. ``ZBR_EnsureZeroMQBound()`` is called from ``AfterCompiledHook`` +immediately after the pre-compile ``ExperimentModified`` state is captured (needed so +that binding a socket -- itself an experiment-modifying action, from Igor's +perspective -- doesn't get misattributed as user-driven unsaved-changes state). + +Why this replaces the old ``#ifdef IGOR_PRO_BRIDGE`` convention +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The v1.x COM-based bridge's ``MIES_ClaudeHelper.ipf`` gated its entire function body +behind ``#ifdef IGOR_PRO_BRIDGE``, activated either by hand-editing the experiment's +Procedure window or via the (now-retired) ``ensure_igor_pro_bridge_defined`` tool -- +see :ref:`igor_pro_bridge_v1_history`. ``ZMQ_BridgeHelpers.ipf``'s independent-module +architecture has no equivalent gating at all: everything in the ``ZBR`` module +compiles unconditionally whenever the file is included, by design (the whole point of +an independent module is that its own compilation doesn't depend on ProcGlobal's +``#define`` state). This is strictly simpler for this bridge's own purposes, but it is +also *why* the one-time manual ``#include`` step in +:ref:`igor_pro_bridge_requirements` can't be skipped programmatically the way the old +``#define`` could -- there is no bootstrap tool call over ZeroMQ that could add an +``#include`` directive to a `.ipf` file on disk and trigger Igor to notice it; that +step is inherently a one-time, human, on-disk action. + +Known limitations +------------------ + +- No scriptable way to resume a Debugger pause -- this requires a human, as described + above. A compile-error dialog can usually be auto-dismissed via a posted Escape key + press (see ``dismiss_compile_error_dialog``, confirmed live), but that recovers the + ability to continue, not the error message itself; if a genuinely new/different + Igor popup shows up (not the known "Function Compilation Error" title), dismissal + safely reports "not found" and a human is still needed -- title matching only, + deliberately, since matching any generic native dialog risked dismissing an + unrelated one (e.g. a save-changes confirmation). +- ``execute_igor_command``/``execute_igor_command_unattended`` can no longer separate + ``print``-only output from the full echoed history the way COM's ``Execute2`` could + isolate ``fprintf``-only output -- both ``"results"`` and ``"history"`` now hold the + same captured text (see :ref:`igor_pro_bridge_v2_migration`). Also, + **use `print`, never `fprintf 0/-1/-2, ...`, to get data back** -- confirmed live + (v2.0.1) that ``fprintf``-to-history output is never captured at all by this + transport's ``CaptureHistory``-based mechanism, regardless of which refnum it + targets. +- ``load_experiment`` needs the OLD Igor Pro process to fully exit (not just stop + answering over ZeroMQ) before relaunching with the new file -- fixed in v2.0.1 after + a live silent-failure report; see that tool's reference entry above for the full + mechanism. +- ``load_experiment`` is a real process restart (quit + relaunch with a file-path + argument), not an in-place experiment swap -- unsaved changes in the previously-open + experiment are lost. See that tool's own reference entry above. +- The ZeroMQ-XOP's Router (server) socket documents a default + ``ZMQ_MAXMSGSIZE`` of 1024 bytes; whether this caps incoming requests only, or also + outgoing replies, has not been confirmed. Treated as a risk specifically for + ``read_help_file``'s underlying ``ZBR_ReadHelpFile``, which sidesteps the question + entirely by writing the exported HTML to a local temp file (both Igor Pro and the + bridge run on the same machine) and reading it back off disk, rather than returning + the HTML content through the ``CallFunction`` reply itself. No other tool in this + bridge returns a payload large enough for this to plausibly matter, but it hasn't + been stress-tested. +- This is a *local* MCP server (stdio transport): it only works from a Claude Desktop + session running on the same Windows machine as Igor Pro, not from a cloud/sandboxed + session. +- **Observed on more than one occasion during this bridge's development (both under + the v1.x COM transport and v2.0.0's ZeroMQ transport)**: Igor Pro became unreachable + (crashed or was closed) shortly after a ``reload_and_compile_procedures`` call. As + of **v2.3.0**, this was traced via crash-dump analysis to a genuine + ``EXCEPTION_ACCESS_VIOLATION`` inside ``Igor64.exe`` itself, with a likely + mechanism identified (confirmed correct by the repo owner's comparison against + ``igortest-tracing.ipf``'s crash-free ``CompileAndRestart()``/``AfterCompiledHook()`` + pattern): the ZeroMQ-XOP's background message-handler thread dispatching a + ``CallFunction`` request while Igor's main thread is mid-``COMPILEPROCEDURES``, + racing on Igor's own internal function/symbol tables. v2.3.0 mitigates this by + stopping the handler before reload/compile and restarting it only after + compilation finishes -- see the ``reload_and_compile_procedures()`` reference entry + above for the full mechanism and the caveat that this is a mitigation, not a + proven fix. Treat any unreachability after a compile attempt as a signal to check + ``check_bridge_health()`` and be prepared to relaunch Igor Pro. See + ``SESSION_NOTES.md`` for the full investigation. +- **v2.3.0's restart path had its own concept flaw, fixed in v2.3.1**: it relied + solely on ``AfterCompiledHook``, which Igor never calls if the compile itself + fails -- so a bad edit left the ZeroMQ handler stopped permanently, with no + recovery short of relaunching Igor Pro. Fixed by an unconditional background-task + watchdog (armed with an explicit ``start=60`` floor, after live timing data showed + it could otherwise fire before the operation queue finished draining) -- see the + ``reload_and_compile_procedures()`` reference entry above for the full mechanism. + As of v2.3.1 the bridge should stay reachable even after a failed compile. + +.. _igor_pro_bridge_v1_history: + +Version history (v1.x, COM-based transport) +---------------------------------------------- + +Versions through 1.27.0 used Igor Pro's COM Automation Server instead of ZeroMQ (see +:ref:`igor_pro_bridge_v2_migration` for why this changed). Key milestones, newest +first, kept here for reference since the design decisions behind them (Igor's runtime +error model, the Debugger/compile-dialog caveats, the submit/poll necessity) all +carried forward unchanged into v2.0.0: + +- **v1.27.0**: corrected an overstated elevation requirement -- confirmed empirically + that COM only required client and server to run at the *same* privilege level, not + that elevation itself was mandatory. +- **v1.26.0**: added ``ensure_igor_pro_bridge_defined`` (retired in v2.0.0 -- see + :ref:`igor_pro_bridge_zbr_helpers`), managing the ``IGOR_PRO_BRIDGE`` + conditional-compilation symbol via ``SetIgorOption poundDefine``. +- **v1.25.0**: pinned Python dependencies via ``requirements.txt`` and added + ``install.ps1``; discovered and worked around the MCP Python SDK's breaking v2.0.0 + line (unrelated to this bridge's own v2.0.0 -- a naming coincidence between the + ``mcp`` package's major version and this project's own). +- **v1.24.0**: added ``read_help_file``. +- **v1.22.0**: added ``get_bridge_version``. +- **v1.17.0**: added ``load_experiment`` (via the COM-only ``IApplication.LoadExperiment`` + method, superseded in v2.0.0 by a relaunch-based implementation -- see that tool's + current reference entry above). +- **v1.15.0 and earlier**: initial ``execute_igor_command``/``get_wave``/ + ``check_compilation_state``/``reload_and_compile_procedures``/ + ``dismiss_compile_error_dialog``/Debugger-control/``launch_igor_pro_unattended`` + tools, all built directly on ``win32com.client.GetActiveObject("IgorPro.Application")`` + and ``Execute2``. diff --git a/Packages/doc/index.rst b/Packages/doc/index.rst index 82cfeb940d..913324d1bc 100644 --- a/Packages/doc/index.rst +++ b/Packages/doc/index.rst @@ -7,6 +7,7 @@ Table of Contents user installation developers + igor-pro-bridge reportingbugs releasenotes grouplist diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md new file mode 100644 index 0000000000..dadb2e5cf7 --- /dev/null +++ b/SESSION_NOTES.md @@ -0,0 +1,2426 @@ +## Purpose + +Facts, corrections, and findings from an extended Claude/Igor Pro working session on this +repository. Kept here so both the user and Claude can recall them accurately in later +sessions rather than re-deriving or re-arguing them from scratch. Entries are grouped by +topic, not chronology. + +## Standing instruction + +Verify findings carefully before reporting an error — read the documentation, trace the +actual code, or find corroborating evidence in the existing codebase, rather than asserting +Igor Pro semantics from memory. Several entries below exist because that wasn't done +carefully enough the first time. + +**The user usually says when they've switched git branches, but may occasionally forget. +If something inconsistent turns up after a new user message** (a function/file that should +exist doesn't, a line number or piece of code referenced earlier in the conversation no +longer matches what's on disk, an unexpected compile/test result, etc.) **— check `git +branch --show-current` (and/or `git log -1`) before assuming a code regression or a mistake +on Claude's part.** Concretely confirmed once already this session: mid-conversation +assumptions about a file's content/line numbers (carried forward from earlier turns) turned +out to be stale after the user switched branches without an explicit callout, since the same +physical working directory is shared between the bash tool and the Read/Edit/Grep file +tools — a branch change changes what both see identically and immediately. + +**Whenever a branch switch is confirmed or discovered (see above), check +`Packages/MIES/MIES_ClaudeScrapCode.ipf` and clean it up if needed.** This file is +intentionally never committed (untracked, `#include`d from `MIES_Include.ipf` purely for +interactive Claude Desktop sessions -- see the file's own header comment). Scratch helper +functions written for one branch's task frequently call `static`/module-qualified functions +or reference window/structure names from the main MIES codebase that may not exist, or may +behave differently, on the next branch checked out -- so old scratch code can silently fail +to compile, or "succeed" while doing something meaningless, after a switch. No fixed +required approach: remove the stale functions entirely, or adapt them to the new branch's +actual code, whichever fits -- judge case by case; some scratch helpers may simply no longer +have a meaningful purpose once the branch changes and are better deleted than patched. + +**`read_session_history()` should always be saved straight to a file, not read inline.** +The user has seen Igor's history area grow past 10,000 lines in normal use, especially +during test runs with heavy log output — inline reads reliably exceed the context window +past a certain point in any long session. Default to writing the result to a file first +(e.g. via the bash tool) and `grep`/search it for the specific markers needed (`error:`, +`Finished with no errors`, a specific test-case name, etc.), the same technique already +worked out and written up further below, rather than waiting to hit the token-limit error +first. + +**Reach for `ipt`/`ipt.exe` (the Igor Programming Tool -- `tools/ipt` (Linux), +`tools/ipt.exe` (Windows), `tools/run-ipt.sh` picks the right one) proactively in any Igor +Pro related workflow where it can plausibly sharpen understanding, not only when explicitly +asked for a parse/AST** — use it as a standing habit alongside direct reading of the source, +not as a replacement for it. Concretely, per subcommand (see the "`tools/ipt`" section below +for the full write-up of each, including confirmed gaps/bugs): +- `ipt check [--print-ast] ` — confirm a file/edit actually parses, or get an + authoritative AST (node types, precise line:column spans) for understanding a function's + real structure without needing a live Igor Pro instance. +- `ipt lint ` — catch known real bug/style patterns (e.g. + `BugproneReservedKeywordsAsIdentifier`) before or instead of relying on manual review for + those specific cases; know its confirmed gap (no built-in-*function*-name-shadowing + detection, since it has no symbol-resolution semantics for that). +- `ipt rename --print-symbol-table ` (plus a full `-f`/`-l`/`-c`/`-n` target to avoid + the no-target crash bug noted below) — get a genuine cross-referenced symbol table + (declarations, every read/write/definition point with exact spans, function signatures) + for a file when tracing how a variable/function is actually used matters more than just + seeing the parse tree; also usable for the rename itself (preview without `-y`, apply + with it). +- `ipt analyze` — available but not yet evaluated this deeply in this repo; worth trying + when a task's shape matches its evident purpose (broader rule-based analysis) rather than + assuming it's irrelevant. +- `ipt format` — confirmed in real use this session: the user ran it directly against + `MIES_ClaudeHelper.ipf` right after moving a `static Constant` block to a new location by + hand. The resulting file was consistently formatted throughout (aligned `=` signs across + runs of consecutive assignment/declaration lines, a blank line separating a function's + local-variable declarations from its first statement), not just around the lines that had + just been edited by hand -- worth reaching for after any manual restructuring, not only + after small line-level edits. +- **`ipt.exe` only ever knows about the file(s) explicitly passed via `files`/`-f`** — it + does not resolve `#include`s itself, so pass every file actually relevant to the question + at hand rather than assuming cross-file context comes for free. **In practice this is a + non-issue when a live Igor Pro instance is available through the bridge, and should not + be treated as a reason to skip `ipt` or reach for it half-heartedly**: `get_environment_ + summary()`'s `included_procedure_files` field already reports the complete, authoritative + list of every procedure file actually included in the running instance right now (derived + from `WinList(..., "WIN:128")`) -- exactly the context `ipt` needs, obtained without + guessing or grepping for `#include` lines by hand. The one extra step: that field returns + bare file names (e.g. `MIES_ForeignFunctionInterface.ipf`), not filesystem paths, so + resolve each name to its actual on-disk path (a repo-wide filename search/glob; names are + effectively unique across this codebase) before passing the list to `ipt` as its `files` + argument. Combine the two rather than treating them as unrelated tools. + +**When Igor's own `.ihf` help files are relevant to a question (e.g. confirming an +operation's exact flags/behavior), prefer reading them as Igor notebooks through the bridge +over an OS-level file read** -- snapshot currently-open help windows (`WinList("*", ";", +"WIN:512")`), `CloseHelp/ALL`, `OpenNotebook/R ""`, `SaveNotebook/O/S=5/H=...` to +export as HTML (surfaces genuine content-block structure via named paragraph classes like +`Topic`/`Code1`/`Steps`, not just flat text), `KillWindow/Z` the temporary notebook, then +restore each snapshotted file via `OpenHelp/V=.../INT=0` -- see the dedicated section below +for the full non-destructive, repeatable workflow. + +**Commands sent to Igor Pro through the bridge's COM interface (`execute_igor_command(_unattended)`) +run the same way as Igor's own command line: as *interpreted* code, not compiled procedure +code.** Several Igor language features only work inside compiled functions and are rejected +(often with an unhelpful generic error) when attempted directly this way -- confirmed this +session: `Make/FREE ...` (free waves have no valid scope outside a function), `WAVE ref = +SomeFunc()` (assigning a wave reference from a function call), and calling a `static` +function by its bare name (it's scoped to its file's `#pragma ModuleName` and needs +`ModuleName#FunctionName` from outside that module). **Workaround: create/extend a small +compiled scratch procedure file (e.g. `Packages/MIES/MIES_ClaudeScrapCode.ipf`), `#include` +it from `Packages/MIES_Include.ipf`, `reload_and_compile_procedures`, then call a single +compiled helper function from that file via the command line/Execute2** -- everything inside +the function body runs as compiled code, so none of the above restrictions apply. (A +differently-named prior-art scratch file, `MIES_ClaudeHelper.ipf`, was used the same way on +an earlier branch for a compile-confirmation hook -- same pattern, different specific +filename/purpose; there is no single fixed required name, just the `#include`-a-scratch-file +approach.) See the bridge section below for the concrete Analysis Browser example. + +**Whenever writing or editing code in ANY Igor Pro procedure file (`.ipf`) in this repo -- +not just Claude-authored scratch/bridge-helper files like `MIES_ClaudeHelper.ipf`, but +existing production MIES code too -- apply these three style conventions as standard +practice, not just when a specific function happens to prompt them (confirmed as the user's +explicit, general preference, applying to any `.ipf` file; first applied to +`CH_ListXOPExports`/the `CH_PE*` helpers in `MIES_ClaudeHelper.ipf`, see that section below +for the worked example):** +1. Use lowercase type keywords (`variable`, `string`, not `Variable`/`String`), and declare + all of a function's local variables at the very top of its body, before the first + statement -- matching Igor's own function-level (not block-level) scoping. +2. Use Igor 7+ inline parameter-type declarations in the function signature itself + (`Function/S Foo(string bar, variable baz)`), not the old two-part style + (`Function/S Foo(bar, baz)` followed by separate `string bar` / `variable baz` lines). +3. Replace unexplained numeric *and* string literals with named `static Constant`s / + `static StrConstant`s declared at the top of the file, rather than leaving "magic + numbers" (or magic strings) inline -- one constant per distinct meaning, with a trailing + comment explaining what it represents when that isn't obvious from the name alone. This + covers both kinds of literal equally; don't treat strings as exempt just because their + meaning often looks self-evident in context. + +**Always run `ipt format` (see the `tools/ipt` section below) immediately after editing any +`.ipf` file in this repo, every time, not as an optional habit** -- the user relies on its +canonical formatting to make the diff easier to review on their end. Re-verify behavior is +unchanged afterward (recompile, re-run whatever live test previously established +correctness) before considering the edit done. + +## Igor Pro language facts (confirmed this session) + +- **Reference-typed locals have function-level scope, not block scope.** `WAVE`, `NVAR`, + `SVAR`, `DFREF`, `FUNCREF` locals are recognized by the compiler across the whole + function body (e.g. a `WAVE test1 = data` inside an `if` block is still a valid, + in-scope local after the `endif`), and default to a null/non-existent reference at + function entry if the assigning line is never reached. This is *not* an error condition — + referencing such a variable later without `/Z` only fails if the `WAVE` statement itself + executes and its right-hand side fails to resolve, not merely because the statement was + skipped by control flow. +- **A bare `String` defaults to a null string, not `""`.** These are distinct states, + distinguishable via `strlen()`: `NaN` for a null string, `0` for `""`. +- **`Make` without `/N` defaults to a 1D wave with 128 points**, not 0. If an initializer + list is given instead (`Make wv = {1, 2, 3}`), the wave is sized from the list, not + defaulted to 128. Curly-brace initializer lists always require at least one operand — + `Make wv = {}` is not valid syntax. +- **`Concatenate` (e.g. with `/NP=dim`) always leaves the destination wave existing**, even + if the source has zero rows, even repeated across every iteration of a loop — the + destination ends up as a valid 0-row wave, never an unbound/null reference. This does + *not* apply if the outer wave-reference-wave being iterated (e.g. `sources` in + `for(WAVE/T src : sources) ... endfor`) itself has zero rows: then the loop body never + runs, `Concatenate` is never called, and the destination stays a null/non-existent + reference per the default-initialization rule above. +- **Auto-indexing in a waveform assignment (e.g. `Make/WAVE/N=(n) w = SomeFunc(p)`) runs + strictly in increasing index order when `Multithread` is *not* used.** With `Multithread`, + execution order for a given index is not guaranteed, and the right-hand side must be + threadsafe. This matters when the called function has order-dependent side effects. +- **The Igor compiler disallows a `WAVE name = expr` *declaration* statement where `name` + also appears inside `expr`** (e.g. as a function argument), even if `name` was already + declared earlier in the function. Workaround: introduce a second reference to the same + wave under a different name (`WAVE/Z tmp = name; WAVE name = Func(tmp)`). This is + different from a destructuring *reassignment* like `[out, outT] = Func(out, outT)`, which + is legal because it updates already-declared references rather than re-declaring them. +- **`FindValue /TXOP` bit flags**: `4` = case-insensitive whole-cell text match (the + pervasive default throughout this codebase), `5` = `4 | 1` = case-sensitive. Confirmed via + existing code: `GetDecimalMultiplierValue` (`IPNWB_Utils.ipf`) uses `TXOP=(1+4)` for SI + unit-prefix matching specifically because case matters there (`m` vs `M`). +- **`ListToTextWave` never returns a null wave.** An empty `listStr` input produces a + 0-row text wave, not a 1-row wave containing a single empty string. +- **`Make/N=(...)`: an explicit dimension size of `0` means "this dimension does not + exist"** (same convention documented for `Redimension`), while an explicit `1` creates a + real, if trivial, additional dimension. `Make/N=(n, 1, 1, 1)` is *not* equivalent to a + true 1D wave — `DimSize(wv, COLS)` is `1`, not `0`. This codebase has an established + convention that wave-of-waves values must be strictly 1D (e.g. `GetSetIntersectionWaves` + asserts `DimSize(wv, COLS) == 0`), so creators of such waves must pass `0`-equivalent + (or simply omit trailing dimensions) rather than `1`. +- **`Variable/G name = value` (with an explicit initializer) overwrites the global's value + every time that line executes, even if the global already existed** — confirmed from Igor + Reference.ihf: "/G Creates a variable with global scope and overwrites any existing + variable," and "The variable is initialized when it is created if you supply the initial + value." Bare `Variable/G name` (no initializer) is the safe, standard idiom to call + unconditionally on every invocation instead: it creates the global at `0` only if missing, + and leaves an existing value untouched otherwise — no `NVAR_Exists`-style guard needed. + Caught in `MIES_ClaudeHelper.ipf`'s `AfterCompiledHook()`, which originally used a guarded + `Variable/G root:gClaudeHelperCompileCounter = 0` and was simplified to the bare form. +- **`#define` symbols meant to control cross-file conditional compilation (`#ifdef`/ + `#ifndef`) must be set in the experiment's special "Procedure" window, not in a regular + included `.ipf` file.** Confirmed from Programming.ihf: "Although it is difficult to + determine the order in which procedure files are compiled, the main procedure window is + always first." Since the Procedure window compiles before every other included file, a + `#define` placed there (e.g. this experiment's existing `#define AUTOMATED_TESTING`) is + reliably visible to every file's `#ifdef` checks; a `#define` in an ordinary `.ipf` file + has no such guarantee and should not be relied on for this purpose. +- **The Igor compiler does not stop a local variable/string/`WAVE` reference from + being named the same as a built-in Igor function or reserved keyword** (e.g. + `string log` shadows the built-in `log()` function; the same applies to names + like `return` or other keywords/function names). This compiles without error but + is a real footgun: within that variable's scope, every reference to the name + resolves to the local variable instead of the built-in function, silently + breaking any code in that scope that expected to call the actual function/keyword + behavior. **Rule: never name a variable, string, or `WAVE` reference after an + Igor built-in function or reserved keyword**, even though the compiler allows it. +- **`NewPath`/`PathInfo`'s `S_path` always returns Igor's colon-separated native path + notation, even on Windows** -- confirmed live: normalizing `"C:\Projects\mies_data\ + ivscc_apfrequency"` via `NewPath` + `PathInfo $symbPath; S_path` produced + `"C:Projects:mies_data:ivscc_apfrequency:"` (colon-delimited, trailing colon), not a + backslash Windows path. This is the same normalized form MIES's own Analysis Browser + stores internally (e.g. in its folder-list wave), so anything comparing against or + displaying that value should expect colon notation regardless of host OS, not assume + Windows paths stay backslash-separated after a round-trip through a symbolic path. +- **Variables declared on the Igor command line via `execute_igor_command(_unattended)` + (e.g. `String win`) persist as global command-line variables across separate Execute2 + calls within the same Igor session** -- they are not scoped to a single bridge tool call. + Re-declaring the same name in a later call fails with `"the name already exists as a + variable"`; just assign to it directly (skip the `String`/`Variable` declaration) in + follow-up calls, or expect this persistence when debugging multi-call command-line + sequences. +- **Igor's command line does not support multi-line control-flow blocks (`if`/`else`/ + `endif`, `for`/`endfor`) the way compiled functions do.** A command sent via Execute2 + containing such a block fails as a whole (every line reported `NOT EXECUTED`, with a + generic `expected wave name, variable name, or operation` error), even though each + individual line would be valid inside a real function. This is a second, independent + reason (beyond free waves/`WAVE ref = func()`/`static` scoping) to move any nontrivial + logic into a compiled scratch-file helper function rather than a raw multi-statement + Execute2 command -- see the standing-instruction note above. + +## MIES wave-versioning convention + +Located in `MIES_WaveDataFolderGetters.ipf`: `WAVE_NOTE_LAYOUT_KEY = "WAVE_LAYOUT_VERSION"`, +with helpers `GetWaveVersion`, `SetWaveVersion`, `WaveVersionIsAtLeast`, `WaveVersionIsSmaller`, +`IsWaveVersioned`, `ExistsWithCorrectLayoutVersion`. `WaveVersionIsSmaller(wv, N)` returns +true if the wave is unversioned (`NaN`) or its version is `< N`. Correct migration idiom is a +sequence of independent `if(WaveVersionIsSmaller(wv, N))` blocks (N increasing), each +performing exactly the upgrade needed for that step — *not* an exclusive `if/elseif` chain, +which can skip needed migration steps for very old wave versions. + +**Open bug, not yet fixed as of last check**: `GetAnalysisBrowserMap()` in +`MIES_WaveDataFolderGetters.ipf` (branch `feature/2737-prepare2_ivscc_apfrequency`) writes to +column index 3 (`wv[][3] = ANALYSISBROWSER_FILE_TYPE_IGOR`) inside its +`WaveVersionIsSmaller(wv, 1)` block *before* the wave is ever redimensioned beyond its +original 3 columns (widening to 5 columns only happens later, in the +`WaveVersionIsSmaller(wv, 4)` block). For a genuinely unversioned pre-2016 `experimentMap` +wave (confirmed via git history to have exactly 3 columns: +`ExperimentDiscLocation`/`ExperimentName`/`ExperimentFolder`), this throws an +index-out-of-range runtime error instead of migrating, because Igor bounds-checks wave +assignments. `GetSweepBrowserMap()` and `GetExperimentBrowserGUIList()` in the same diff +both redimension correctly before/with their writes — `GetAnalysisBrowserMap()` is the +outlier and needs the same treatment (redimension to at least 4 columns before writing +column 3). + +**Fixed correctly in later commits on that branch** (for reference, not action items): +`GetSweepBrowserMap()` now uses the `WaveVersionIsSmaller`-gated pattern with +`SetWaveVersion`; the `SweepFormula.rst` doc wording around `seltag("")` matching all +sweeps in DataBrowser context was corrected to be precisely scoped and now matches the code; +`seltag`'s `SFH_CheckArgumentCount` minArgs was fixed from 0 to 1. + +## SweepFormula dataset/datatype architecture + +- Every SweepFormula operation result is a "dataset": a single-element `WAVE/WAVE` + container, typically created via `SFH_CreateSFRefWave`, with an `SF_META_DATATYPE` JSON + wave note (`JWN_SetStringInWaveNote`/`JWN_GetStringFromWaveNote`) identifying its kind + (`SF_DATATYPE_SELECTCOMP`, `SF_DATATYPE_SELECTTAG`, etc.). +- `SFH_GetOutputForExecutorSingle(data, ..., dataType=X)` wraps whatever `data` it's given + in a *new* single-element `WAVE/WAVE`, setting the note on that new wrapper — it does not + tag `data` itself. Operations that call this directly on their own final payload (most + `select*` filter operations) get one level of wrapping, note on the outside. +- `select()` itself is the counter-example: `SFOS_OperationSelect` builds its own composite + (`GetSFSelectDataComp`), sets `SF_META_DATATYPE = SF_DATATYPE_SELECTCOMP` directly on it, + and returns it via `SFH_GetOutputForExecutor(output, ...)` directly — skipping + `SFH_GetOutputForExecutorSingle` entirely, so there's no extra wrapper for the note to get + lost behind. +- `seltag` needs *two* levels of wrapping around its `tags` text wave specifically to stop + the array-literal executor from treating a multi-tag `seltag([a, b])` result as a plain + text wave and array-expanding its elements (see below). The `SF_META_DATATYPE` note must + be set on the *inner* wrapper (the one that becomes `genericElement[0]` when the call + appears inside an array literal), not only on the outer one — otherwise the note is lost + the moment `seltag(...)` appears inside `[...]`. + +## SweepFormula `and`/`with` keywords are a plotter-targeting concern, not an executor one + +Clarified by the user: a SweepFormula expression itself may not contain line +breaks, so `and`/`with` (which must each stand alone on their own line) can +never appear *inside* an expression parsed/run by `SFE_ExecuteFormula`/ +`SFE_ExecuteVariableAssignments` -- that's why those two functions' doc +comments say they don't support `and`/`with`, and why the JSON-based executor +(`SFE_FormulaExecutor`) never has to know about them at all. `and`/`with` are +recognized in an earlier, separate post-processing step that splits the SF +notebook text into individual single-expression formulas, and they solely +control *where the plotter puts each expression's result* -- `with` targets +the same plot sub-window as the previous expression, `and` targets a new one. +The executor always just returns the result of one already-isolated +expression; the plotter is what reads `and`/`with` to decide placement. So +there is no way to feed `and`/`with` through `SFE_ExecuteFormula` even +indirectly (e.g. via a nested/generated formula string) -- it would need to +go through the notebook-level splitting step first, which these two +executor-only entry points never invoke. + +## SweepFormula executor position trackers are not restored on nested-call return + +Working out `TestAssertDataStack3OP` (see the assert-data-stack test +consolidation above) raised whether two nested (`newFrame = 1`) calls made +sequentially from the same outer/dispatched operation instance could ever +freeze the outer frame's `LOCMSG` with two different, correct texts (one per +nested call). Traced through the code: `SFE_FormulaExecutor` unconditionally +overwrites the global `GetSweepFormulaJSONPathTracker` (and similarly the +`SRCLOCID`/`STEP` fields via `SFH_StoreAssertInfoExecutor`) on every call, and +nothing restores the outer frame's own former tracker value after a nested +call returns -- the tracker is simply left holding whatever the nested call +last set. Consequence: a single dispatched operation making two sequential +nested calls has no way to get the outer frame's *own* position re-frozen +correctly a second time (it would still reflect the first nested call's +position) -- the only way the outer frame's position genuinely changes +between two freezes is if something *external* to the operation (e.g. the +`SFE_ExecuteVariableAssignments` assignment loop moving to the next +assignment) re-stamps it via `SFH_StoreAssertInfoParser`/`Executor` in +between. The user confirmed there's currently no use case needing two +different frozen texts from the same outer frame (the final SFH_ASSERT +message is a one-shot terminal event), but flagged this as something to +revisit if a non-terminal message type (e.g. warnings that must be kept +correct across multiple points) is ever introduced. + +## Igor Pro Universal Testing Framework: `IUTF_TD_GENERATOR`/`UTF_TD_GENERATOR` tag scanning + +The advanced.rst docs are ambiguous/contradictory about how far above a +multi-data test case's `Function` line the tag comment can be (one place says +"within four lines", another says "all lines above `Function` up to the +previous `Function`"). Checked the actual implementation, +`GetFunctionTagWave` in `Packages/igortest/procedures/igortest-functiontags.ipf`: +it uses `ProcedureText(funcName, -1, ...)` minus `ProcedureText(funcName, 0, +...)` to isolate every comment line between the previous function's `End` and +this function's `Function` line, then scans *all* of those lines (looping +backwards), trying every known tag pattern against each non-empty line and +silently skipping (no error) any line that doesn't match one. There is no +four-line cutoff in the code -- the "all lines up to the previous Function" +description is the accurate one. This means ordinary `///` doc-comment lines +can be freely mixed in above a `// IUTF_TD_GENERATOR ...` tag line (they just +won't match any tag pattern and are skipped), so there was no need for the +earlier caution of keeping the tag as the only comment line directly above +`Function`. + +## SweepFormula executor: array-literal handling of dataset elements + +This session added support, in `SFE_FormulaExecutor`'s `JSON_ARRAY` branch +(`MIES_SweepFormula_Executor.ipf`), for array literals whose elements are datasets (e.g. +`[seltag(a), seltag(b)]`), where previously any non-text/non-numeric array element was +encoded as a stringified `wRefPath` marker (via `SFH_GetOutputForExecutor`) and placed into +a plain text accumulator — which silently discarded each element's own `SF_META_DATATYPE` +note, since the note lived on a wrapper level that got peeled away and never re-attached to +the marker. + +Fix, in outline: + +1. Introduce a genuine `WAVE/WAVE` accumulator (`outW`), alongside the existing numeric + (`out`) and textual (`outT`) ones, used specifically for dataset array elements. Each + element is stored as a direct wave reference (`outW[index] = subArray`) — never a + stringified marker — so it keeps its own note natively; no marker-resolution helper is + needed by consumers. +2. New helper `SFE_ExecutorCreateOrCheckWaveRef(WAVE/Z/WAVE outW, variable size0)` — + deliberately takes only one size parameter, since `outW` should always stay strictly 1D + (datasets are never spread across the outer array's other dimensions; see the `Make/N` + dimension-size fact above for why `0`/omitted, not `1`, matters here). +3. `SFE_PlaceSubArrayAt` gained a `WAVE/WAVE` branch that assigns `outW[index] = subArray` + directly — no `Multithread`, no elementwise copy, since a dataset occupies exactly one + opaque slot regardless of its own internal shape. +4. The dimension-widening logic (`effectiveArrayDimCount` bump, `topArraySize[1,*] = + max(...)`) must be guarded with `if(!WaveExists(outW))` — a dataset's own internal + dimensionality must never influence the outer array's shape. This was an actual bug + caught by testing: `[dataset(1,"abcd"), dataset(2,"cdef")]` produced a `(2,2)`-shaped + `outW` instead of a flat 2-element one, because `dataset(...)`'s own multi-row payload + leaked into `topArraySize` before this guard was added. +5. To allow *mixed* arrays like `["text", dataset(2, "cdef")]` (previously a hard + `"mixed array types"` assertion failure): the loop was restructured into a prescan that + resolves every element exactly once via `SF_ResolveDatasetFromJSON` (stored once, reused + by both possible downstream branches — resolving twice was flagged as potentially + unsafe, since resolution can execute arbitrary operations with side effects), determines + whether *any* element is dataset-kind, and only then decides the accumulation strategy: + if any dataset is present, the whole array is promoted to a uniform wave-of-datasets, + with plain text/numeric elements individually wrapped into their own single-element + `"PromotedArrayElement"` dataset (no `SF_META_DATATYPE` note attached to that wrapper). + Otherwise, it falls through to the original `out`/`outT` accumulation logic, still reusing + the already-resolved elements rather than re-resolving from JSON. +6. `SFH_GetArgumentSelect` (`MIES_SweepFormula_Helpers.ipf`) needs a matching update: check + `IsWaveRefWave(array)` instead of `IsTextWave(array)`, and use + `Duplicate/FREE/WAVE array, selectArray` directly instead of resolving each element via + `SFH_AttemptDatasetResolve(WaveText(array, row = p), ...)` — array elements are now real + wave references, not stringified markers, so there's nothing left to string-parse. + +**Follow-up cleanup, not yet done** (tracked as session TODO items, not written to disk): +the fallback (`containsDataset == 0`) loop still carries the full original per-element +dispatch logic, including now-unreachable "mixed array types" asserts and the dataset/`else` +branch — harmless (dead code, since `containsDataset` is guaranteed false there) but worth +trimming down to just the text/numeric paths, reusing `IsTextWave(preResolved[i])` / +`IsNumericWave(preResolved[i])` directly instead of re-deriving `subArray` and re-running +`SFE_ConvertNonFiniteElements` a second time. + +## SweepFormula operation pattern: backup/restore the variable storage to run +## nested formula code in a scratch environment + +`ivscc_apfrequency()` (`SFO_OperationIVSCCApFrequency`/`Impl2` in +`MIES_SweepFormula_Operations.ipf`) implements itself partly by composing +*other* SweepFormula operations (`select`, `merge`, `prepareFit`, `fit2`, ...) +rather than reimplementing their logic directly, using a +backup/mutate/restore pattern on the per-graph SweepFormula variable storage +(`GetSFVarStorage(exd.graph)`, a `WAVE/WAVE` keyed by variable name, populated +by `$varName`-style references between formula lines): + +1. `SFO_OperationIVSCCApFrequencyPrepareVariables` takes `WAVE/WAVE varStorage + = GetSFVarStorage(exd.graph)` and makes a `Duplicate/FREE` copy, + `varBackup`, preserving the caller's existing variables untouched. +2. It then builds an ordinary SweepFormula source string on the fly (e.g. + `"sel = select(selsweeps(), selstimset(...), selvis(all), + selivsccsweepqc(passed))\r"` plus per-experiment/avg-plot expressions, + assembled via `SF_AddExpressionToFormula`) and runs it for real through + `SFE_ExecuteVariableAssignments(exd.graph, formula, allowEmptyCode = 1)` -- + i.e. it re-enters the actual formula executor with dynamically generated + code, exactly as if the user had typed those lines into the SweepFormula + notebook themselves. This mutates the *live* `varStorage` in place with all + of that scratch computation's intermediate variables (`sel`, + `ivsccavg_merged`, per-experiment `freqNorm`/`currentNormMerged`, + etc.). +3. `SFO_OperationIVSCCApFrequencyImpl2` reads whatever it needs back out of + that now-mutated `varStorage` (by name, e.g. `varStorage[%ivsccavg_norm_y]`) + to build the actual plot trace data. +4. Before returning, it restores the original state with `Duplicate/O + varBackup, varStorage` -- wiping out all of its own scratch/intermediate + variables -- and only *afterward* re-adds the specific outputs it actually + wants to persist for the user (`SFH_AddVariableToStorage(exd.graph, + "ivscc_apfrequency_explist_" + tagSuffix, ...)`, + `"ivscc_apfrequency_fit_" + tagSuffix`, one set per tag group). + +Net effect: the operation gets to reuse the real formula executor and other +real operations as implementation building blocks, using the shared variable +storage as a scratch workspace, without leaking any of its own internal +temporary variable names into the user's persistent SweepFormula environment +once it's done -- only the deliberately-named, explicitly re-added outputs +survive. Confirmed directly from source (`MIES_SweepFormula_Operations.ipf` +lines ~3339-3365 and ~3390-3509), per the user's own explanation of the +approach. + +**Two identified architectural gaps in generalizing this pattern (user's own +analysis, verified against source):** + +1. ~~**No exception safety.**~~ **Reassessed by the user: this is a non-issue, + not a gap.** Originally flagged: neither `SFO_OperationIVSCCApFrequencyPrepareVariables` + nor anything above it in the call chain (`Impl2`, the operation dispatch, + all the way up) wraps the nested `SFE_ExecuteVariableAssignments` call in a + `try`/`catch` -- the *only* `try`/`catch` in the whole path is the one in + `SF_button_sweepFormula_display` itself -- so if the scratch formula aborts + via `SFH_ASSERT`, that level's own `Duplicate/O varBackup, varStorage` + restore step never runs, leaving `varStorage` in its mutated/scratch state. + **The user's correction**: this is fully OK given how SweepFormula's + execution model actually works. A failed evaluation simply means there is + no valid result -- SweepFormula has no concept of updating an + already-displayed plot in place, so there is no code path that could ever + observe or render the stale `varStorage` contents between the abort and + the next run. And as already noted above, `SFE_ExecuteVariableAssignments` + unconditionally wipes `varStorage` back to 0 rows at the start of its own + next invocation regardless, so the leftover state doesn't linger or + accumulate either. No fix needed here; not pursuing this further. + +2. **No per-call-level source-location tracking, so nested-operation errors + get misattributed to the wrong place in the notebook.** Traced precisely: + `GetSFAssertData()` (`MIES_WaveDataFolderGetters.ipf`) is a single flat, + *non-stacked* per-graph text wave (`SFAssertData`, 8 fields: `JSONID`, + `SRCLOCID`, `JSONPATH`, `STEP`, `LINE`, `OFFSET`, `FORMULA`, + `INFORMULAOFFSET`), written in place by `SFH_StoreAssertInfoParser`/ + `SFH_StoreAssertInfoExecutor` (`MIES_SweepFormula_Helpers.ipf`) every time + *any* formula gets parsed/executed -- including a nested + `SFE_ExecuteVariableAssignments` call like the one inside + `SFO_OperationIVSCCApFrequencyPrepareVariables`. When that inner call + parses its own dynamically-built scratch formula (e.g. `"sel = + select(...)"`), it overwrites the *same* global `LINE`/`OFFSET`/`FORMULA` + fields with values relative to *that* internal string, clobbering + whatever the outer (real, user-visible) formula's own values were. + Crucially, `SF_CalculateErrorLocationInNotebook` (`MIES_SweepFormula.ipf`) + *always* re-reads the real, on-screen SF notebook's text + (`GetNotebookText(BSP_GetSFFormula(win), mode = 2)`) and blindly indexes + into it using whatever `info[%LINE]`/`info[%OFFSET]` currently hold -- + with no way to know those numbers actually describe a position inside an + entirely different, invisible string (`ivscc_apfrequency`'s own generated + formula) rather than the real notebook. The result: an assert inside a + nested operation call resolves to some essentially coincidental position + in the *outer*, user-visible formula (in this repo's typical case, the + `ivscc_apfrequency()` call site itself, since the inner formula's line 0 + gets misread as notebook line 0) -- not the actual failing sub-expression, + which was never textually present in the notebook at all. + + **User's proposed fix**: replace the single-frame `SFAssertData` wave with + a wave-reference wave used as a LIFO stack -- each nested formula-execution + episode pushes its own independent frame (mirroring today's 8 fields) on + entry, and error-message construction needs extending to walk *every* + frame on the stack (not just the current/top one) so a nested failure's + message can show the full call chain, e.g. innermost failing sub-formula + plus which outer operation (`ivscc_apfrequency()`) invoked it and at what + notebook location. **Noted interaction with gap 1**: the aggregated + error message must be built by walking the stack *at the moment + `SFH_ASSERT` fires*, before any unwinding -- relying on a normal + push-on-entry/pop-on-return discipline alone would never populate a + correct message on the failure path itself (that's precisely the path + where "pop" never executes, per gap 1), and conversely, if the stack isn't + explicitly reset somewhere (e.g. alongside `SF_ClearSFOutputState()`), + stale frames from a previous aborted run would corrupt the *next* run's + tracking. Also worth covering when implementing: `SRCLOCID` is a JSON id + requiring `JSON_Release` (currently released once, in + `SFH_GetAssertLocationMessage`/`SF_MarkErrorLocationInNotebook`'s cleanup) + -- with multiple stacked frames, every frame's JSON id needs releasing + during error handling/reset, not just the top one. + + Not yet implemented -- discussed/designed only so far in this session; the + user has not yet asked for code changes. + +### Gap 2 fix implemented: LIFO assert-data stack + +Implemented per the design above, with one refinement discovered while +implementing: the *global* execution-position trackers +(`GetSweepFormulaJSONPathTracker()`/`GetSweepFormulaBufferOffsetTracker()`, +`MIES_GlobalStringAndVariableAccess.ipf`) only ever reflect whatever is +executing *right now* -- they're updated on every recursion level/token, +unconditionally, not scoped per formula-execution episode. So a naive +"walk the stack and read the live trackers for each frame" would give every +frame the *innermost* (currently-failing) position, not its own. Fix: freeze +each frame's rendered location message into a new `LOCMSG` field on that frame +at the moment a *deeper* frame gets pushed on top of it (i.e. while the live +trackers still reflect its position) -- see `SFH_PushAssertDataFrame`. + +Files changed: +- `MIES_WaveDataFolderGetters.ipf`: `GetSFAssertDataStack()` (new, `WAVE/WAVE` + LIFO, lazily created), `GetSFAssertData()` rewritten to return the + top-of-stack frame (auto-pushing a base frame if the stack is empty). + `SF_ASSERTDATA_NUMFIELDS` bumped 8 -> 9 for the new `LOCMSG` field. +- `MIES_SweepFormula_Helpers.ipf`: `SFH_PushAssertDataFrame()` (freezes the + outer frame's `LOCMSG` first, then pushes a blank frame), + `SFH_PopAssertDataFrame()` (asserts against popping the base frame; + deliberately does *not* release JSON ids -- a normal return already released + them via the ordinary executor success path, so releasing again here would + double-release), `SFH_GetOutermostAssertDataFrame()` (stack[0], for + notebook-position lookups), `SFH_ResetAssertDataStack()` (releases every + remaining frame's `JSONID`/`SRCLOCID` via `JSON_Release(..., ignoreErr=1)`, + then empties the stack). `SFH_GetAssertLocationMessage` refactored: the old + per-frame logic moved unchanged into `SFH_GetAssertLocationMessageForFrame` + (returns the frozen `LOCMSG` if present, otherwise computes fresh from the + live trackers -- exactly right for whichever frame is currently on top), and + the public function now walks the stack top-to-bottom, joining more than one + non-empty frame message with `"\rCalled from:"`. +- `MIES_SweepFormula.ipf`: `SF_CalculateErrorLocationInNotebook` now reads + `SFH_GetOutermostAssertDataFrame()` instead of `GetSFAssertData()` (only the + outermost frame's `LINE`/`OFFSET` are ever real notebook positions); + `SF_ClearSFOutputState()` now also calls `SFH_ResetAssertDataStack()`. + `SF_MarkErrorLocationInNotebook`/`SF_IsExecutionErrorInVariable` needed **no + change** -- both correctly keep top-of-stack semantics via the unchanged + `GetSFAssertData()`. +- `MIES_SweepFormula_Operations.ipf`: the nested + `SFE_ExecuteVariableAssignments` call inside + `SFO_OperationIVSCCApFrequencyPrepareVariables` is now wrapped with + `SFH_PushAssertDataFrame()`/`SFH_PopAssertDataFrame()`. No `try`/`catch` -- + on an abort, the pop is simply skipped, deliberately leaving that frame's + data on the stack for the aggregate error message (gap 1, the exception + -safety issue, is still open/unaddressed; this only fixes gap 2). + +**Verified compiling and working live** (Igor Pro 9.06 Nightly, via the +bridge): added a temporary `ClaudeScrap_TestAssertStack()` smoke test to +`MIES_ClaudeScrapCode.ipf` simulating the exact scenario -- outer formula +reaches an operation call site (line 5, offset 2) -> operation pushes a frame +-> nested formula hits its own parser error and `SFH_ASSERT` fires (caught +here instead of propagating). Confirmed: the outer frame's `LOCMSG` freezes on +push; the outer frame's own `LINE`/`OFFSET` (5/2) are untouched by the nested +frame's write (0/3) -- the actual bug being fixed; the pushed frame is left on +the stack after the simulated abort (pop correctly skipped); the final +aggregated message is `"Nested op failed\r 1 +\rCalled from:\r +ivscc_apfrequency()"` -- both levels present, joined as designed; and +`SFH_ResetAssertDataStack()` empties the stack back to 0. This test function +is left in `MIES_ClaudeScrapCode.ipf` (harmless, no window/GUI interaction) in +case it's useful again. + +**Pitfall hit and fixed while getting a clean compile, unrelated to this +task**: `MIES_ClaudeScrapCode.ipf` (the scratch file from earlier in this +session) wouldn't compile for two separate, unrelated reasons, both now fixed: +1. `Make/FREE/T/N=1 wFolder = {folder}` -- turned out to be a red herring; the + idiom itself is valid (confirmed against `DAP_GetRadioButtonCoupling` in + `MIES_DAEphys.ipf`), the real error was #2 below, just cascading to a + nearby line. Split into `Make/FREE/T/N=1 wFolder` + `wFolder[0] = folder` + anyway (harmless simplification). +2. **The actual cause, identified by the user**: every `MIES_AB#...`/ + `MIES_SF#...` module-qualified call in the scratch file relies on + `#pragma ModuleName = MIES_AB`/`MIES_SF` etc., which are themselves gated + behind `#ifdef AUTOMATED_TESTING` in each source file (e.g. + `MIES_AnalysisBrowser.ipf` lines 4-6). `AUTOMATED_TESTING` is a test-only + define (unlocks otherwise-`static`/private functions for test code) and is + **not** defined in a regular MIES session -- so those module namespaces + don't exist at all right now, and any `ModuleName#Function(...)` call + referencing them is simply unresolvable. Fixed by stripping the reliance + per-call: `AB_GetExperimentsIndices()`'s one-line body + (`FindIndizes(expBrowserSel, col = 0, var = LISTBOX_TREEVIEW, prop = + PROP_MATCHES_VAR_BIT_MASK)`) was reproduced inline in + `ClaudeScrap_TagExperimentsViaGUI`/`ClaudeScrap_DumpExperimentTags` (its + dependencies are non-static/non-gated); the `AB_UpdateColors()`/ + `AB_UpdateTagList()` calls were dropped entirely after confirming from + source they're purely cosmetic (folder-list background highlighting and + the tag-list summary panel respectively) and already re-triggered by the + real button-click handler where it matters; + `ClaudeScrap_AddAnalysisBrowserFolder`/`ClaudeScrap_GetSFPlotWindowInfo` + were stubbed out (disabled, with an explanatory note) since their + remaining dependencies (`AB_AddExperimentEntries`/`AB_CollapseAll`/ + `SF_GetDataDisplayWindowName`, the latter pulling in several more gated + helpers/constants) weren't worth reproducing inline for disposable scratch + code unrelated to the current task. + +**Separate live-session pitfall hit while testing** (not a code bug): calling +`SFE_ExecuteFormula(formula, "test", ...)` with a made-up, nonexistent window +name triggers `DoAbortNow("The main panel is too old to be usable...")` from a +panel-version check deep in `MIES_BrowserSettingsPanel.ipf`/ +`MIES_AnalysisBrowser_SweepBrowser.ipf`. Unlike a normal `Abort`, `DoAbortNow` +shows its alert dialog *synchronously before* unwinding, so it is not +suppressed by a `try`/`catch` around the call, and it blocks Igor's operation +queue the same way a stuck compile-error dialog does -- the bridge has no +auto-dismiss logic for this dialog title (only for "Function Compilation +Error"), so it required the user to close it by hand twice before this was +understood and the offending test function was deleted rather than retried. +Lesson: never pass a fabricated window name to SF/BSP-layer entry points in +this bridge; use a real, currently-open panel or avoid the panel-version- +checked code paths entirely (as `ClaudeScrap_TestAssertStack()` does, by +exercising `SFH_*`/`GetSFAssertData*` directly instead of going through +`SFE_ExecuteFormula`). + +### Gap 2 fix: real UTF tests added, reviewed, and iterated on with the user + +The user added their own real tests in `UTF_SweepFormula.ipf` (`TestAssertDataStack`/ +`TestAssertDataStackOP`, then `TestAssertDataStack2`/`TestAssertDataStack2OP`), +using the test-only `testop(...)` SweepFormula operation +(`SF_OP_TESTOP`/`SFO_OperationTestop`, `#ifdef AUTOMATED_TESTING`-gated, with its +implementation swapped in per-test via the `GetSFTestopName(graph)` SVAR/`FUNCREF` +indirection) to exercise a **real 4-level-deep recursive** nested-call chain +(`testop(0)` -> `testop(1)` -> `testop(2)` -> `testop(3)`, failing at level 3), +rather than the single hand-simulated level in `ClaudeScrap_TestAssertStack()`. +Both now pass. Findings and fixes along the way: + +- **How to actually run a single UTF test case via the bridge**: not by calling + the (static, module-scoped) test function directly -- that bypasses the + igortest framework's fixture setup/teardown and is unreliable (see the next + bullet). The correct invocation, per the user: `RunWithOpts(testcase="")`. +- **Pitfall hit calling a test function directly** (bypassing `RunWithOpts`): + got `RTE 27 "MoveWave...the name already exists"` from + `CreateEmptyUnlockedDataBrowserWindow()`/`CreateFakeSweepData()`, traced to + leftover `DB_ITC16_Dev_0`/`DB_ITC16_Dev_02` DataBrowser windows already open in + the session from earlier manual work -- an artifact of skipping the test + runner's normal per-test cleanup, not a bug in the test itself. Resolved by + using `RunWithOpts` instead, which passed cleanly. +- **Missing cleanup bug (found by review, fixed by user)**: `TestAssertDataStack()` + originally never called `SFH_ResetAssertDataStack()` after its check. Since + the assert-data stack lives at `GetSweepFormulaPath()` -- a single *global* + path (`root:MIES:SweepFormula`), not per-graph -- leftover frames from an + intentionally-aborted nested test like this one would persist for the rest of + the Igor session and could corrupt any *later* test that also inspects + `SFH_GetAssertLocationMessage()`'s output (stale frames' frozen `LOCMSG` + would get walked and appended as spurious "Called from:" segments). Fixed by + adding `SFH_ResetAssertDataStack()` + a `DimSize(...)==0` check at the end of + the test; both `TestAssertDataStack`/`TestAssertDataStack2` now do this. +- **Wiring bug (found by review, fixed by user)**: `TestAssertDataStack2()` + initially set `funcName = "UTF_SWEEPFORMULA#TestAssertDataStackOP"` (the + *original*, `SFE_ExecuteVariableAssignments`-based operation) instead of + `TestAssertDataStack2OP` (the new `SFE_ExecuteFormula`-based one) -- silently + testing the same code path twice rather than the new one. One-line fix. +- **Trailing-space bug (diagnosed, fixed by user)**: after fixing the wiring + bug, `TestAssertDataStack2` failed with two extra trailing spaces in the + innermost `"testop(3)"` line of the message. Root cause: + `formula = SF_AddExpressionToFormula("", expr)` appends a trailing + `SF_CHAR_CR` (`return formula + expr + SF_CHAR_CR`) -- fine for the + assignment-extraction path (`SF_GetVariableAssignments` pulls lines via + `StringFromList`, which strips the separator, so the CR never survives into + the stored formula text), but fatal for a bare-expression string fed straight + into `SFE_ExecuteFormula`: since `"testop(3)\r"` contains no `=`, + `SF_GetVariableAssignments` finds zero assignments and takes its early-return + path, `return [$"", preProcCode]`, handing back the *whole string unchanged, + CR intact*. That CR rides into `SFP_ParseFormulaToJSON`, ends up baked into + the source-location JSON's stored formula text, and + `SFH_FormatSourceLocationError`'s `ReplaceString("\r", formula, " ")` turns it + into a trailing space when rendering the error message. Fixed by dropping + `SF_AddExpressionToFormula` entirely in `TestAssertDataStack2OP` and + `sprintf`-ing straight into the formula string passed to `SFE_ExecuteFormula` + (which doesn't need/want a trailing CR -- other call sites in this file pass + it bare strings like `"testop()"`). + +### Design refactor by the user: push/pop centralized into `SFE_ExecuteVariableAssignments`/`SFE_ExecuteFormula` via a new `newFrame` flag + +Rather than every caller manually bracketing its own nested-execution call with +`SFH_PushAssertDataFrame()`/`SFH_PopAssertDataFrame()` (the original design, +easy to forget), the user added an optional `newFrame` parameter (default 0) to +both `SFE_ExecuteVariableAssignments` and `SFE_ExecuteFormula` +(`MIES_SweepFormula_Executor.ipf`): when set, the function pushes a frame on +entry and pops it on every normal-return path itself. `SFO_OperationIVSCCApFrequencyPrepareVariables`, +`TestAssertDataStackOP`, and `TestAssertDataStack2OP` were all updated to just +pass `newFrame = 1` instead of the manual push/pop wrapper. + +Reviewed this in detail and confirmed it's correct: the push happens before +anything that could consume the live execution-position trackers (and, in +`SFE_ExecuteVariableAssignments`'s case, after the harmless +`SF_GetVariableAssignments` parse, which never touches those trackers); pop +happens on every normal-return branch in both functions, including the +`singleResult` branch of `SFE_ExecuteFormula`; on abort, the pop is correctly +skipped everywhere (frame deliberately left for the aggregate message, matching +the original design intent); and `SFE_ExecuteFormula`'s own internal call to +`SFE_ExecuteVariableAssignments(graph, formula)` (inside its `preProcess` +block) correctly omits `newFrame`, since the outer push already covers that +inner call -- no double-push. + +**Bug found (since fixed)**: right after this refactor, `TestAssertDataStack2OP` +still had a leftover manual `SFH_PopAssertDataFrame()` call immediately after +`SFE_ExecuteFormula(formula, exd.graph, newFrame = 1)` -- a copy-paste artifact +from before push/pop was centralized. Since `SFE_ExecuteFormula(..., newFrame=1)` +now pops its own frame internally on normal return, this extra call would have +double-popped (removing a frame belonging to a *different*, outer level) on any +non-aborting run. It was invisible in this specific test only because every +recursive call always ends in an abort at `result=3`, which skips straight past +it. Removed. + +### Code style / convention fixes from the user + +- Constants must be `static` "when possible" (i.e. whenever not needed + cross-file) and, by convention, declared at the *top* of the procedure file, + not inline near their first use. `SF_ASSERTDATA_NUMFIELDS` + (`MIES_WaveDataFolderGetters.ipf`) was moved from an inline declaration next + to `GetSFAssertDataStack()` up to the file's top-of-file constants block, and + changed from a bare `Constant` to `static Constant` (confirmed it's only used + within this one file). Non-static "global" constants are by convention only + supposed to live in `MIES_Constants.ipf` -- audited every file touched by + this task's diff and confirmed no other new global constants were + introduced anywhere else. +- The new cross-file functions (`GetSFAssertDataStack`, `GetNewSFAssertDataFrame`, + `SFH_PushAssertDataFrame`, `SFH_PopAssertDataFrame`, + `SFH_GetOutermostAssertDataFrame`, `SFH_ResetAssertDataStack`) were checked + against the same "static when possible" rule -- all of them are genuinely + called across multiple files (`MIES_SweepFormula_Executor.ipf`, + `MIES_SweepFormula.ipf`, `MIES_SweepFormula_Operations.ipf`, the UTF test + file), so none can be made `static` without breaking those call sites. The + one truly file-local helper, `SFH_GetAssertLocationMessageForFrame`, is + already `static`. +- **Redundant getter-call pattern, caught by the user, fixed in two places**: + both `GetSFAssertData()` and `SFH_GetOutermostAssertDataFrame()` originally + re-called `GetSFAssertDataStack()` a second time *inside* the + `if(DimSize(...)==0) SFH_PushAssertDataFrame() ... endif` block, apparently + to "refresh" the reference after the push. This is unnecessary: + `SFH_PushAssertDataFrame()` grows the *same* underlying wave via + `Redimension`, it does not replace/recreate it, so the `WAVE/WAVE` reference + obtained *before* the `if` already reflects the new size afterward -- Igor + wave references stay valid across `Redimension` of the same wave. Removed + the redundant second call in both functions. + **Standing lesson to avoid repeating this**: before re-calling a getter a + second time just to "pick up" a change made by an intervening function call, + check what that intervening call actually does to the wave/data structure. + If it only mutates or resizes the *same* object (`Redimension`, in-place + wave-note edits, etc.) rather than replacing it (a fresh `Make` under the + same name, or swapping in a different wave reference), the original + reference obtained from the first getter call is still valid and current -- + a second call is dead weight and, worse, invites the reader to wonder if it + matters (or to copy the pattern elsewhere believing it's necessary). Only + re-fetch when the intervening call could plausibly have replaced the + underlying object, not merely resized/mutated it. +- **Standing convention: unconditional `SFH_ASSERT(0, ...)` calls must use + `SFH_FATAL_ERROR(...)` instead.** `SFH_FATAL_ERROR(message, [jsonId])` is + exactly `SFH_ASSERT(0, message) // NOLINT` under the hood (see + `MIES_SweepFormula_Helpers.ipf`), but its name and the (linter-suppressed) + `SFH_ASSERT(0, ...)` inside make the "this always aborts, there is no + condition to evaluate" intent explicit at the call site, rather than + requiring the reader to notice a literal `0` first argument. Caught in + `UTF_SweepFormula.ipf`'s `TestAssertDataStack3OP` test-op, which had + `SFH_ASSERT(0, "TestOP result threshold reached")` in its unconditional + failure branch -- changed to `SFH_FATAL_ERROR("TestOP result threshold + reached")`. Apply this whenever writing a new unconditional abort in + SweepFormula code, test or production. + +## Igor Pro COM Automation Server bridge (this session's later work) + +Goal: let Claude control a running Igor Pro instance directly, from a local MCP server +(`tools/igor-mcp-bridge/server.py`) acting as a COM client on Windows. + +- **Ruled out**: Igor 10's built-in Python bridge (`igorpro` module, `Python`/`PythonFile` + operations) is documented by WaveMetrics as usable only *from within* Igor Pro itself -- + it cannot be used by an external process to control a running Igor instance. +- **Viable mechanism**: Igor's separate ActiveX/COM Automation Server (Windows-only). Igor + can act as a COM *server*; it cannot act as a COM *client*. All details below were + extracted directly from the local `Igor Pro Folder\Miscellaneous\Windows + Automation\Automation Server.ihf` file (not secondhand/forum info). +- ProgID: `"IgorPro.Application"`. Connect to an already-running instance with + `win32com.client.GetActiveObject("IgorPro.Application")` (Python equivalent of the + documented VB `GetObject(, "IgorPro.Application")`). Using `Dispatch()` instead would + launch a new instance and require handling Igor's post-launch initialization delay. +- `Execute2(int flags, int codePage, BSTR cmds, int* pIgorErrorCode, BSTR* errorMsg, BSTR* + history, BSTR* results)`: does not raise a COM error on Igor-level command failure -- + check `pIgorErrorCode` (0 = success). `codePage` ignored since Igor 7 (pass 0). To get + data back, put `fprintf 0, "..."` inside `cmds` and read it from `results` (WaveMetrics' + own documented example: `WaveStats/Q jack; fprintf 0, "%g", V_avg`). +- `IApplication.DataFolder(nameOrPath)` -> `IDataFolder`; `IDataFolder.Wave(waveNameOrPath)` + -> `IWave`. `waveNameOrPath` accepts an absolute path directly, so `root:` can be used as + a fixed anchor and any full path passed straight into `.Wave(...)`. +- `IWave.GetDimensions(IgorProDataType* pDataType, long* pNumRows, long* pNumColumns, long* + pNumLayers, long* pNumChunks)`. +- `IgorProDataType` enum (confirmed exact values): `ipDataTypeText = 0`, + `ipDataTypeComplex = 0x01` (OR'd combination flag), `ipDataTypeFloat = 0x02`, + `ipDataTypeDouble = 0x04`, `ipDataTypeSignedByte = 0x08`, `ipDataTypeSignedShort = 0x10`, + `ipDataTypeSignedLong = 0x20`, `ipDataTypeUnsignedByte = 0x48`, + `ipDataTypeUnsignedShort = 0x50`, `ipDataTypeUnsignedLong = 0x60`. So `dataType == 0` + means text; anything else is some real numeric subtype (or has the complex flag set). +- `IWave.GetNumericWavePointValue(long index, double* pValue)` and + `IWave.GetTextWavePointValue(long index, int codePage, BSTR* pValue)`: single-point + reads, 1D waves only, real data only for the numeric one. The docs also document + whole-wave SAFEARRAY methods (`GetNumericWaveDataAsDouble`, `GetRawTextWaveData`) but + explicitly recommend the point-value methods "for most uses" -- and the point methods + avoid SAFEARRAY marshaling questions entirely, so the bridge uses those for now (a + whole-wave SAFEARRAY path could be added later for speed on large waves). +- **Critical setup requirement (verbatim from the docs)**: "The Windows operating system + requires that you run the client and server (Igor) as administrator." Both Igor Pro + and the Python client process must run elevated on Windows 10+, or the COM connection + fails. +- **Not verifiable from this session (no Windows/Igor available here to run it)**: the + exact Python-side tuple-unpacking shape pywin32's dynamic dispatch produces for + multi-`[out]`-parameter methods like `Execute2`. The implementation assumes the standard + IDispatch/pywin32 convention (`[out]`-only params come back as a tuple appended to the + return value, e.g. `errorCode, errorMsg, history, results = igor.Execute2(0, 0, cmd)`) -- + this is well-established pywin32 behavior generally, but has not been run against the + real Igor COM server yet. This is the one thing to verify first when testing + `tools/igor-mcp-bridge/server.py` for real. +- `tools/igor-mcp-bridge/server.py` now has a real (not placeholder) implementation of + `execute_igor_command` (via `Execute2`) and `get_wave` (via `DataFolder`/`Wave`/ + `GetDimensions`/point-value methods, 1D real waves only for now). It has grown + substantially since: `execute_igor_command_unattended` (auto-disables/restores the + Debugger around a call — a Debugger pause has no scriptable resume and hangs the + triggering call forever otherwise), `check_bridge_health`, `check_compilation_state` / + `reload_and_compile_procedures` (requires two consecutive "compiled" reads before + trusting one, since `RELOAD CHANGED PROCS`/`COMPILEPROCEDURES` only run once Igor's + operation queue drains — see Advanced Topics.ihf, "Operation Queue" section — and a + single immediate check can race ahead of that), `get_debugger_state`/ + `set_debugger_enabled`/`restore_debugger_settings`, and `get_environment_summary`. + Confirmed live: a leftover compile-error dialog from a failed compile blocks the + operation queue from ever draining (so a later, genuinely-fixed reload/compile keeps + reporting "not compiled") without hanging the bridge's own COM calls directly — there is + no *documented* (COM-level) way to detect or dismiss that dialog. However, it's an + ordinary modal Windows dialog that closes on a real Escape key press, and since this + bridge's Python process and Igor Pro are both required to run elevated anyway (see + above), Windows' UIPI doesn't block a simulated Escape key press from this process + reaching Igor Pro's window (unlike the usual low-to-high-privilege case). v1.10.0 first + added `dismiss_compile_error_dialog` using a hardware-level simulated key press + (`keybd_event`) sent to whatever window was currently in the OS foreground — requiring + a `SetForegroundWindow` call first, i.e. stealing focus. **v1.11.0 replaced this**, per + the user's suggestion, with a `PostMessage(WM_KEYDOWN/WM_KEYUP, VK_ESCAPE)` sent + directly to Igor's dialog window, found by enumerating top-level windows for one with + class `"#32770"` (the standard Windows dialog class) owned by an Igor Pro process — no + foreground/focus change needed at all. + **Live-tested end to end against a real Igor Pro 10.03 instance (v1.12.0), and it + worked.** First finding: the `"#32770"` assumption was wrong — `dismiss_compile_error_dialog` + correctly and safely reported "not found" the first time, with no crash or bad + side effect, and its diagnostic `"igor_windows_seen"` fallback (added specifically + for this) revealed the real window: titled exactly `"Function Compilation Error"`, + class `"Qt693QWindowIcon"` — Igor Pro 10's UI is Qt-based, not native Win32 dialogs. + Switched targeting (v1.12.0) to match by that title (keeping `"#32770"` as a second, + OR'd condition for any genuinely native dialog). Retested: `dismiss_compile_error_dialog` + found the Qt window and posted Escape to it — **user confirmed the dialog actually + closed on screen**. Confirms a *posted* (not real hardware) key event is enough for + Qt's Windows platform layer to react the same as a real key press, with zero + foreground/focus disruption. `reload_and_compile_procedures` calls this automatically + once before giving up and asking a human. + **Separately, twice during this same testing session, Igor Pro became unreachable + via COM (crashed or was closed) shortly after a `reload_and_compile_procedures` + call** — once with broken code present, once right after fixing it back. No root + cause confirmed (no Windows crash logs accessible from here); not established + whether this is related to the bridge's own actions (e.g. the new dismiss logic) + or a pre-existing Igor Pro stability issue independent of it. Documented as a + caution in the tool's docstring and the RST docs. Worth keeping an eye on in + future sessions — if it recurs a third time with a clearer trigger, that would be + worth isolating further. + **Cross-version retest (user's request): closed Igor Pro 10 and opened Igor Pro + 9.06 (build 56685) instead, then repeated the entire scenario from scratch** — + broke `test()`, `reload_and_compile_procedures`, confirmed the same + `"Function Compilation Error"` Qt dialog title, ran `dismiss_compile_error_dialog` + (found it, posted Escape, **user confirmed it closed** — same result as on 10.03), + fixed the code, `reload_and_compile_procedures` succeeded via the + `AfterCompiledHook` counter with **no crash this time**, and `test()` executed + correctly (`"Hello World"` printed, user-confirmed). So both the dialog-title/Qt + behavior and the PostMessage-Escape mechanism are now confirmed across both major + Igor Pro versions (9.06 and 10.03) — updated `server.py`'s docstrings/comments and + `igor-pro-bridge.rst` accordingly (including softening the crash note to note the + 9.06 retest didn't reproduce it, without claiming that rules anything out). +- **`MIES_ClaudeHelper.ipf`** (new file, included from `MIES_Include.ipf`) holds a `static + Function AfterCompiledHook()` that increments `root:gClaudeHelperCompileCounter` on every + successful compile — a compile-confirmation signal driven by Igor itself, as a more + reliable alternative to polling `FunctionInfo()` for a non-existing function. The whole + function body is gated behind `#ifdef IGOR_PRO_BRIDGE ... #endif`, so it compiles out + entirely for a normal end-user build; a developer wanting it active must add + `#define IGOR_PRO_BRIDGE` to the experiment's "Procedure" window (see the `#define` + ordering fact above for why it has to go there specifically, not in the `.ipf` file + itself). Current implementation (as of the v1.18.0 fix below) also captures + `modifiedBefore` via `ExperimentModified`/`V_flag` before touching the counter and + restores unmodified state (`ExperimentModified 0`) afterward if it wasn't modified + before — matching the sibling `AfterCompiledHook` in `MIES_IgorHooks.ipf`, so this + hook never spuriously flips an otherwise-unmodified experiment to "modified" (see the + v1.18.0 Copilot-review entry further down for why this matters specifically for this + bridge). +- **History readback (v1.13.0)**: `execute_igor_command`/`execute_igor_command_unattended` + now return `{"results": ..., "history": ...}` instead of a plain results string -- + `history` is Execute2's own `history` out-parameter ("any text sent to Igor's history + area by the commands", confirmed from `Automation Server.ihf`), so a `print` + statement's output can be verified directly from the return value instead of asking + the user to look at Igor's screen. Also added `read_session_history(stop=False)`, + backed by Igor's built-in `CaptureHistoryStart()`/`CaptureHistory()` functions + (confirmed from `Igor Reference.ihf`) -- a capture starts automatically the first + time `_execute2` runs in the bridge process's lifetime, and each read returns the + full accumulated text since then. Live-tested against Igor Pro 9.06: confirmed + `history` correctly showed `test()`'s "Hello World" output, and separately used both + mechanisms to verify a full `RunWithOpts(testsuite="UTF_Utils_Algorithm")` MIES test + suite run completed with no real failures (distinguishing the suite's own deliberate + fail-path test cases, which print `"!!! ... assertion FAILED !!!"` as *expected* + output, from an actual suite failure -- the suite's own closing "Finished with no + errors" / "Test finished with no errors" lines are the authoritative signal). +- **PR #2754 opened** (`AllenInstitute/MIES` on GitHub) for this bridge. GitHub + Copilot's automated PR review caught several real issues, all fixed (v1.14.0): + (1) the module crashed with a raw ImportError on non-Windows platforms instead of + failing clearly -- added an early `sys.platform != "win32"` check with an actionable + message; (2) `set_debugger_enabled`'s optional sub-flags (`debug_on_error`/ + `debug_on_abort`/`nvar_svar_wave_checking`) were documented as "leave unchanged if + omitted" but `bool(None)` silently forced them to `False` whenever the debugger was + enabled -- fixed to read Igor's current setting and fall back to that instead of + `False`; (3) `get_wave`'s docstring claimed every COM call was individually + reconnect-protected, but the initial post-`GetDimensions` `_get_wave_ref` call + wasn't actually wrapped in `_run_with_reconnect` -- fixed to match the claim; (4) the + module docstring's "Registering with Claude Desktop" section still described editing + `claude_desktop_config.json` directly, contradicting `igor-pro-bridge.rst`'s + documented (and correct) `.mcpb`-install process -- updated to match; (5) a + duplicated-word typo in `MIES_ClaudeHelper.ipf` ("Igor Pro Bridge bridge"). Two + other Copilot comments (both about a "Make sure Igor Pro 10 (or later)..." message, + in `_get_igor` and `check_bridge_health`) were already fixed earlier in this session + when the Igor Pro 9 minimum-version requirement was confirmed -- verified those two + specific strings already said "Igor Pro 9.00" before concluding no further change was + needed. Note: GitHub's PR page loads inline review comment bodies via JavaScript: a + plain `WebFetch`/`api.github.com` fetch only returned the file/line ranges, not the + actual comment text, and the Claude in Chrome extension wasn't connected to render + it -- the user pasted each comment's text manually instead. + +- **`Quit/N` via `Execute2` logs `NOT EXECUTED: Quit/N` to history but Igor quits anyway.** + Confirmed live: `execute_igor_command_unattended("Quit/N")` returned history text + `" NOT EXECUTED: Quit/N\r"`, and a subsequent `check_bridge_health()` call confirmed no + COM object was reachable -- Igor had genuinely quit. Per `Automation Server.ihf`, `Quit` + is exposed as its own dedicated `IApplication.Quit()` method, distinct from + `Execute`/`Execute2`'s command-string interface -- consistent with Igor deferring the + actual quit until after the in-flight `Execute2` RPC call returns (it can't tear down the + process from inside the call servicing it), and logging the deferred line as + "NOT EXECUTED" from the perspective of the synchronous command interpreter, even though + the quit still happens moments later. Practical upshot: don't treat a `NOT EXECUTED:` line + in `history` as proof a command had no effect for operations like `Quit` that are + legitimately deferred/special-cased -- verify with an independent check + (`check_bridge_health`) rather than trusting the history text alone. The bridge has no + dedicated `quit_igor_pro()` tool wrapping the real `IApplication.Quit()` COM method; + `execute_igor_command_unattended("Quit/N")` is sufficient in practice and no new tool was + added for this. + +- **`/UNATTENDED` suppresses the modal "Function Compilation Error" dialog entirely and + reports the error via history instead.** Confirmed live against Igor Pro 9.06 + launched with `/UNATTENDED`: introducing a genuine syntax error into an actually-loaded + file (`MIES_ClaudeHelper.ipf`) and running `reload_and_compile_procedures` gave + `compiled: false`, `raw_function_info: "Procedures Not Compiled"`, and + `dismiss_compile_error_dialog` found no dialog window at all (only Igor's main window + was visible) -- unlike the interactive/non-`/UNATTENDED` case, where that same dialog + reliably appears (confirmed earlier this session on both Igor Pro 10.03 and 9.06). The + exact compile error is readable directly from history via `CaptureHistory`: + `MIES_ClaudeHelper.ipf:46:7: error: expected terminating quote` (format + `::: error: `). This is strictly better for the bridge than + the dialog path: nothing to dismiss, and the real error text is available + programmatically, which the dialog-dismissal path never provided. Not documented + anywhere in Igor's help files (the `/UNATTENDED` flag's own doc entry only mentions the + About Autosave dialog and, as of Igor Pro 10, skipping license activation) -- + this compile-error behavior was inferred and confirmed empirically, not from docs. + +- **Methodology error, caught by the user: verify a target .ipf file is actually loaded + before editing it to test a bridge behavior.** While testing how Igor Pro's + `/UNATTENDED` command-line flag affects the compile-error case, a syntax error was + deliberately introduced into `Packages/tests/Basic/UTF_Basic_Includes.ipf` (the file + used for this in earlier sessions), but this Igor Pro 9 instance had been started + without loading `Basic.pxp` -- so that file was never `#include`d by anything + actually loaded, and `get_environment_summary()`'s `included_procedure_files` list + (fetched earlier in the same session) did not contain it. `RELOAD CHANGED + PROCS`/`COMPILEPROCEDURES` therefore never touched the file at all: no compile error + ever occurred, which is why no dialog appeared, no error text showed up in history, + and `test()` merely failed as "not a recognized command" rather than "broken + function." All of this looked superficially like a real `/UNATTENDED` behavior + change but was actually a no-op test. **Lesson: before editing any procedure file + to probe or reproduce bridge/compile behavior, cross-check the file's name against + the current `included_procedure_files` list from `get_environment_summary()` -- + do not assume a file on disk is part of the live compiled environment just because + it exists in the repo or was used successfully in a previous session (a different + experiment, or no experiment at all, may be loaded now).** Redone correctly on + `MIES_ClaudeHelper.ipf` (confirmed present in `included_procedure_files`), which + gave the real, useful result -- see the `/UNATTENDED` entry above. + +- **v1.15.0: added `configure_igor_launch(exe_path)` / `launch_igor_pro_unattended(...)`**, + letting the bridge start Igor Pro itself with `/UNATTENDED` rather than requiring a + human to do it. `configure_igor_launch` deliberately has no default/guessed + executable path -- the calling agent must ask the user for it once per session (this + repo alone has been tested against two differently-located Igor Pro installs), and + the setting is session-scoped like the history-capture refnum (resets if the bridge + process restarts). `launch_igor_pro_unattended` refuses to launch a second instance + if one is already reachable via COM (launching with only `/UNATTENDED`, no `/I`/`/X`/ + `/SN`/file argument, is documented to start a genuinely new instance rather than + reuse an existing one), and handles elevation two ways: if this Python process is + already elevated, Igor launches as a direct child process (inherits elevation, no + prompt); if not, it launches via `ShellExecute`'s `"runas"` verb (triggers a UAC + consent dialog) -- but the bridge process itself remains unelevated either way in + that second case, so COM calls will keep failing until Claude Desktop is itself + relaunched as Administrator. Live-tested end-to-end the same session -- see the + next entry. + +- **`launch_igor_pro_unattended` (v1.15.0) confirmed working end-to-end**, live-tested + against the Igor Pro 9 nightly install (`...\Igor Pro 9 Folder Nightly\ + IgorBinaries_x64\Igor64.exe`): with the bridge process already elevated, + `configure_igor_launch` + `launch_igor_pro_unattended` launched Igor Pro as a direct + child process with no UAC prompt (exactly as `configure_igor_launch`'s + `"elevation_plan"` predicted), and `check_bridge_health` confirmed COM reachable + afterward. + **New finding from this test**: the initial readiness poll (30s) timed out even + though the launch itself worked, because Igor Pro's Debugger popped up during its + own startup and blocked the COM Automation Server from responding until the user + manually closed it. The Debugger's enabled state is a persistent Igor Pro + preference (confirmed: `get_environment_summary()` showed + `debugger_settings.enable: true` immediately after this fresh launch, with no + experiment loaded) -- it is not reset by `/UNATTENDED` and carries over from + whatever it was left at in a previous Igor Pro session. So a fresh `/UNATTENDED` + launch can still hit the already-documented "Debugger pauses" failure mode (no + scriptable way to dismiss it) during Igor's own startup, before the bridge ever + gets a chance to call `set_debugger_enabled(False)` -- only a human closing it + manually unblocks the COM connection at that point. Disabled the Debugger + afterward via `set_debugger_enabled(False)` for the rest of this session. + **Not yet implemented**: having `launch_igor_pro_unattended` automatically call + `set_debugger_enabled(False)` right after a successful COM connection, to prevent + this recurring on the *next* launch (it can't help the *current* launch, since the + Debugger pause happens before a connection exists to call it through) -- suggested + to the user, not yet actioned. + +- **Confirmed the Debugger-enable preference is genuinely persistent across a full + quit/relaunch cycle, not just within one running instance.** After disabling it + (`set_debugger_enabled(False)`, see entry above), quit Igor Pro via + `execute_igor_command_unattended("Quit/N")` (again showed the misleading + `NOT EXECUTED: Quit/N` history line, again actually quit -- see the earlier `Quit/N` + entry) and relaunched fresh via `launch_igor_pro_unattended`. This time + `com_ready: true` came back in 25 poll attempts (~25s) with no manual intervention + needed -- no Debugger popup -- and `get_environment_summary()` confirmed + `debugger_settings.enable: false` on the freshly-launched instance. So the fix + from the previous entry wasn't a one-time fluke of that running instance; it holds + across restarts, as expected for a genuine Igor Pro preference rather than + per-session state. + +- **Diagnosed and fixed (v1.16.0): `launch_igor_pro_unattended`'s direct-child-process + path triggered a real MIES startup assertion, "We have git installed but could not + regenerate version.txt", that never happens on a normal user launch.** Full chain, + confirmed by reading the actual code (not guessed) and querying the live instance + directly: + - The assertion's stacktrace pointed to `IgorStartOrNewHook` (`MIES_IgorHooks.ipf`, + runs on every Igor Pro launch) -> `GetMiesVersion` -> `CreateMiesVersion` -> + `CreateMiesVersionNoCache` -> `ExecuteGitForMIESVersion` + (`MIES_GlobalStringAndVariableAccess.ipf`). + - `ExecuteGitForMIESVersion` shells out to git via `ExecuteScriptText/B/Z`, + building the command as ` /C " -C describe ... > + version.txt"`, where `shellPath = GetCmdPath()` (`MIES_Utilities_File.ipf`) is + just `GetEnvironmentVariable("COMSPEC")`. `ASSERT(!V_flag, "We have git + installed but could not regenerate version.txt")` follows each + `ExecuteScriptText` call. + - Queried the live bridge-launched instance directly: + `GetEnvironmentVariable("COMSPEC")` came back **empty**, while `PATH` was intact + (including a working git-for-Windows install) -- ruling out a missing/ + unfindable git and pointing specifically at `COMSPEC`. + - Root cause: `launch_igor_pro_unattended`'s direct-child-process path used + `subprocess.Popen([exe_path, "/UNATTENDED"])` with no explicit `env`, so the + child inherits this Python process's own environment -- which, inherited in + turn from whatever launched Claude Desktop, apparently never had `COMSPEC` set. + Windows normally sets `COMSPEC` automatically for every interactive login + session, so a normal double-click/Start Menu launch of Igor Pro never hits + this; it only surfaced via this bridge's non-interactive launch path. + - Fix: added `_build_igor_launch_env()`, which copies `os.environ` and patches in + `COMSPEC` (falling back to `\System32\cmd.exe`) if missing, passed + as `env=` to the `subprocess.Popen` call. Only patches this one confirmed-missing + variable, not a full environment rebuild. + - **Re-tested live after the fix (v1.16.0 installed): confirmed working.** Quit + Igor Pro, relaunched via `launch_igor_pro_unattended` -- `com_ready` in 12 poll + attempts, no Debugger popup, and `GetEnvironmentVariable("COMSPEC")` queried + directly on the fresh instance now returns `C:\Windows\System32\cmd.exe` + (previously empty). User confirmed no assertion appeared on screen this time. + Note: history-based verification still can't retroactively prove the assertion + text is absent (the capture only starts once this bridge process first talks to + a fresh instance, which is necessarily after its startup hook already ran) -- + the fix is confirmed at the root-cause level (COMSPEC populated) plus the + user's direct visual confirmation, not via history text. + - Separately observed mid-test: a `configure_igor_launch` tool call failed with + "Tool permission stream closed before response received", and Claude Desktop + itself relaunched (not Igor Pro) shortly after -- cause not established, but + unrelated to the COMSPEC fix itself (this bridge process's own session state, + e.g. the configured exe path, was simply reset by the restart, same as any + other Claude Desktop restart; re-ran configure_igor_launch and proceeded + normally afterward). + - The `ShellExecute`/`"runas"` path (used when this process isn't elevated) was + not touched -- `ShellExecute` goes through the shell (similar to a normal + double-click), so it's expected to already inherit a proper interactive-session + environment including `COMSPEC`; this was not independently verified, though. + +- **v1.17.0: added `load_experiment(file_path)`** to open a `.pxp` experiment (e.g. + MIES's `Basic.pxp`) into the running instance. Like `Quit` earlier this session, + `LoadExperiment` turned out to exist only as a COM Automation method + (`IApplication.LoadExperiment(flags, loadType, symbolicPathName, filePath)`, + confirmed from `Automation Server.ihf`) -- confirmed absent from `Igor + Reference.ihf` (neither `LoadExperiment` nor `OpenFile` appear there at all), so + it cannot be run as an `Execute2` command string the way most other tools in this + bridge work. Implemented by calling the COM method directly (same pattern as + `get_wave`'s direct `DataFolder`/`Wave` calls), using `loadType=ipLoadTypeOpen` + (2). Per the docs, this does not prompt to save the previously-open experiment's + changes -- left to the caller to do explicitly via + `execute_igor_command('SaveExperiment')` first if needed. Wrapped with the same + Debugger disable/restore bracket as `execute_igor_command_unattended`, since + loading an experiment runs its recreation procedures and MIES's + `IgorStartOrNewHook` startup hook, and this call bypasses `_execute2` entirely so + it wouldn't otherwise get that protection. **Live-tested successfully**: loaded + `Packages/tests/Basic/Basic.pxp`, confirmed via `get_environment_summary()` + (`experiment_file_name: "Basic.pxp"`, 252 procedure files included, Debugger + stayed disabled, no COM reconnect needed) and again later with + `Packages/tests/HistoricData/HistoricData.pxp` to run the + `UTF_HistoricSweepBrowser` test suite (passed -- "Finished with no errors"). + +- **v1.18.0: fixed 2 real issues from a fresh Copilot PR review on #2754**, triggered + by a third commit (`3eca418`, "MCP: Added two new functions") that had been pushed + to the PR branch independently of this session's own (still-uncommitted) local + edits. Same pattern as the first review: user pasted each comment, each was + verified against the actual current code before fixing, both turned out real. + 1. `_is_stuck_dialog_window` (`server.py`) unconditionally treated ANY window with + the generic native Windows dialog class `"#32770"` as safe to dismiss, + regardless of title. Since `dismiss_compile_error_dialog` is called + automatically from `reload_and_compile_procedures`, this could have + Escape-dismissed an unrelated native dialog (e.g. a save-changes + confirmation) -- and it was never actually needed, since the real + compile-error dialog (confirmed live on both Igor Pro 10.03 and 9.06) is a Qt + window, not `"#32770"` at all. Fixed by removing the class-based branch + entirely -- title matching alone (already confirmed sufficient) is what's + used now. Removed the now-unused `_DIALOG_WINDOW_CLASS` constant and updated + every docstring/comment that described the old OR'd-class behavior + (`_attempt_dismiss_compile_error_dialog`'s "reason" message, + `dismiss_compile_error_dialog`'s docstring, the module-level comment block). + 2. `MIES_ClaudeHelper.ipf`'s `AfterCompiledHook` incremented a global variable + without capturing/restoring `ExperimentModified` state first, unlike the + sibling `AfterCompiledHook` in `MIES_IgorHooks.ipf` which already does exactly + this. Left as-is, this could flip an otherwise-unmodified experiment to + "modified," risking a "Save changes?" prompt later -- particularly bad for + this bridge specifically, since that's exactly the kind of dialog it has no + way to dismiss remotely (unlike the compile-error dialog). Fixed to match the + established convention: capture `modifiedBefore` via `ExperimentModified`/ + `V_flag` before the increment, restore to unmodified afterward if it wasn't + modified before. + - Packaged and delivered as v1.18.0. User then committed and force-pushed + directly (outside this session's own git actions) -- PR branch confirmed via + the PR page to now be at commit `af09a1f` (3 commits: `8ae3cf7`, `357ca75`, + `af09a1f`), closing the previously-tracked gap between local fixes and the + GitHub branch. Both of this entry's fixed comments now show "Show resolved" on + the PR page. A new Copilot review was triggered by this push but had not + produced visible results yet as of the last check -- worth checking back for + new comments. + - **Follow-up Copilot comment on this same push, also real**: `igor-pro-bridge.rst` + still described the old, now-removed class-based `"#32770"` matching in the + `dismiss_compile_error_dialog()` tool entry -- the `server.py` code and its + docstrings were updated when the fix was made, but this RST doc was missed. + Fixed all three stale mentions (the tool entry, the "Compile-error dialogs" + narrative section, and the "Known limitations" bullet) to describe title-only + matching, with the removed class-check kept only as explanatory history. Doc-only + change, no new `.mcpb` package needed -- just needs committing alongside the code. + - **Next Copilot comment on the same push, also real**: the RST "Requirements" + section still said "Igor Pro must already be running before a tool call is made + ... it does not launch Igor," predating the v1.15.0 launch tools entirely. Fixed + to note most tools require an already-running instance, with + `launch_igor_pro_unattended` (after `configure_igor_launch`) as the exception. + Checked `server.py`'s own module docstring for the same stale claim -- not + present there, so this was the only spot. Doc-only, no new package needed. + - **v1.19.0: fixed a real Copilot comment on `configure_igor_launch`'s + `elevation_plan` text.** `_is_current_process_elevated()` can return `True`, + `False`, or `None` (undetermined), but the plain `if elevated ... else ...` + ternary treated `None` the same as `False`, reporting "NOT currently elevated" + as a confirmed fact when it was actually unknown. Fixed with explicit + three-way branching (`is True` / `is False` / else-unknown), the unknown case + explaining that `launch_igor_pro_unattended` conservatively treats undetermined + the same as not-elevated (safer than risking a silently unelevated direct + launch). `launch_igor_pro_unattended`'s own launch-path selection was left + unchanged initially -- that fallback behavior is a deliberate, safe default, not + a documentation-accuracy bug. + - **v1.20.0: follow-up Copilot comment, also acted on**: `launch_igor_pro_unattended`'s + own `elevated else ...` ternary and `if elevated:` check had the same + None-treated-as-False pattern. Behaviorally identical either way (None was + already falsy), but changed to explicit `is True` checks anyway so the + deliberate unknown-treated-as-not-elevated choice is unambiguous in the code, + not just documented in prose. Packaged and delivered as v1.20.0, sha256-verified + between build output and the repo copy, same as every prior version. + - **Note**: this copy of SESSION_NOTES.md was carried over by the user from a + different branch's working tree partway through a later session; entries for + bridge v1.21.0 (relaxed reload/compile timing + the `is True` elevation fix), + v1.22.0 (`get_bridge_version()`/`close_data_browser()`), and a clean 25-step + Igor Pro crash stress test (0 crashes) exist in that other branch's copy but are + not reflected here. Not re-added on this branch since they describe bridge/tool + work rather than anything specific to this branch's code. + - **Analysis Browser: programmatically added a folder to the source list** + (`C:\Projects\mies_data\ivscc_apfrequency`), reproducing what the "Add Folder" + button does after its (non-scriptable, OS-native) folder-picker dialog returns a + path -- `AB_ButtonProc_AddFolder` in `MIES_AnalysisBrowser.ipf` calls + `AB_AddElementToSourceList(folder)` then `AB_AddExperimentEntries(win, wFolder)` + then `AB_CollapseAll()`. Hit exactly the three interpreted-vs-compiled + restrictions noted above in immediate succession while trying to do this as raw + Execute2 command-line statements: `Make/FREE/T wFolder = {folder}` failed + (`/FREE` outside a function), `WAVE/T folderList = GetAnalysisBrowserGUIFolderList()` + failed as a command-line assignment, and `AB_AddExperimentEntries`/`AB_CollapseAll` + both failed by bare name because they're `static` and scoped to + `#pragma ModuleName = MIES_AB` (active because this experiment's Procedure window + has `#define AUTOMATED_TESTING`) -- calling them from outside that module needs + `MIES_AB#AB_AddExperimentEntries(...)`/`MIES_AB#AB_CollapseAll()`. Resolved per + the user's direct instruction: created `Packages/MIES/MIES_ClaudeScrapCode.ipf` + (`#include`d from `Packages/MIES_Include.ipf`) holding one compiled function, + `ClaudeScrap_AddAnalysisBrowserFolder(nativeFolderPath)`, that does the whole + sequence internally (including the module-qualified calls) and returns + `"|"`; called via `execute_igor_command_unattended` + after a `reload_and_compile_procedures`. Confirmed working: folder list ended at + exactly 1 entry (no duplicates from the earlier partial command-line attempts, + thanks to a `FindValue` dedup check before adding), `get_environment_summary()` + showed `MIES_ClaudeScrapCode.ipf` in `included_procedure_files` (252 total, up + from 251) with a clean compile. Also had to clean up a stray global `wFolder` + wave left behind at `root:` by an earlier failed command-line attempt (a + `KillWaves/Z` line that never ran because the same command errored on an + earlier statement) -- `top_level_waves` in `get_environment_summary()` is a good + place to spot this kind of debris after an interrupted multi-statement + Execute2 command. + - **Analysis Browser: tagging experiments -- redone via actual GUI controls, + not the internal static function, per explicit user correction.** First + attempt tagged experiments by calling `MIES_AB#AB_AddTagToRow(idx, tag)` + directly for each target row -- this produced the correct result but the + user pointed out it "is not the way a human user would interact with the + AnalysisBrowser Panel" and asked for it to be redone driving the panel's + actual GUI controls via `MIES_ProgrammaticGUIControl.ipf` + (`PGC_SetAndActivateControl`), after manually clearing the tags added the + first way. Redone as `ClaudeScrap_TagExperimentsViaGUI` in + `MIES_ClaudeScrapCode.ipf`: (1) click `button_show_tagcontrol` via PGC to + reveal the Tag Control subpanel (`AnalysisBrowser#TagControl`, hidden by + default on panel open), (2) type each tag into `setvar_tagcontrol_tagname` + and click `button_tagcontrol_addtag` via PGC (both real controls -- this is + exactly what `AB_ButtonProc_AddTagControl`/`AB_SetVarProc_TagNameControl` + do, reached this time through genuine control interaction rather than by + calling `AB_AddTagToSelectedExperiments`/`AB_AddTagToRow` directly). + **Confirmed empirically, and user-confirmed as a known, correct limitation**: + `list_experiment_contents` is a "mode=9" (treeview + checkbox-style) + ListBox, and its multi-row *selection* cannot be driven through + `PGC_SetAndActivateControl(win, control, val=row)` or the raw + `ListBox ..., selRow=row` command at all -- both were tested directly + (writing a small debug dump of the selWave's column-0 bits before/after) + and neither changed the selection bit for this control style; there is no + synthetic-mouse-click primitive available for this kind of listbox through + the bridge. **`MIES_ProgrammaticGUIControl.ipf` does not support every + possible GUI interaction -- ListBox row selection is a confirmed, known gap + in `PGC_*`, not a bug in how it was called.** Consistent with the existing + `ListBoxSelectAll` (Ctrl+A handler) convention already in this codebase: + multi-select state for this listbox style is managed by writing + `LISTBOX_SELECT_OR_SHIFT_SELECTION` directly into the selWave, not through + any higher-level control API. So the final approach sets that selection bit + directly for the target rows (the same mechanism `ListBoxSelectAll` uses), + then performs the actual tag-adding step entirely through the real + SetVariable/Button controls via PGC -- selection state is the one piece not + driven through a GUI-control function, because no such function exists for + it yet. Verified end-to-end: rows 0-1 tagged "a", rows 2-4 tagged "b", + `hideState` for `button_show_tagcontrol` read back as `0` (subpanel + genuinely shown, not just internally flagged). + - **Select all + Load Sweeps into a new SweepBrowser + enable SweepFormula + + execute a formula, all via real GUI controls/documented APIs.** Selection: + `ListBoxSelectAll(GetExperimentBrowserGUISel())` -- the exact function + `AB_ListBoxProc_ExpBrowser`'s own Ctrl+A handler calls, confirmed still + present (non-static) on this branch too. Loading: set + `popup_SweepBrowserSelect` to `"New"` and click `button_load_sweeps` via + PGC -- `AB_ButtonProc_LoadSweeps` opens a new `SweepBrowser` window and + loads sweeps for every selected+expanded experiment + (`AB_GetExpandedIndices` starts from the same selection bit). The new + window was identified by diffing `WinList(SWEEPBROWSER_WINDOW_NAME+"*", ";", + "WIN:1")` before/after the click (loop-based list diff -- no ready-made + "list difference" utility found). SweepFormula: enable via PGC on + `check_BrowserSettings_SF` in `BSP_GetPanel(win)` -- confirmed correct + against this codebase's own `TestDefaultFormula` test, which uses the + identical control/value. Formula text: **`SF_SetFormula(win, formula)`** + (non-static, in `MIES_SweepFormula.ipf`) is the documented, intended way to + set the SweepFormula notebook's contents (`ReplaceNotebookText` under the + hood) -- notebooks aren't one of PGC's supported control types, so this + isn't a PGC call, but it's a real public API, not a bypass of anything; + used once with `""` to clear, once with the real formula. Execute: click + `button_sweepFormula_display` via PGC (matches `TestDefaultFormula` again). + - **`GetNotebookText(win, mode=N)` mode pitfall, caught via a false alarm**: + initially verified the notebook's content using `mode=4` (copied from + unrelated `BSP_*` help-notebook code elsewhere in `MIES_BrowserSettingsPanel.ipf`) + and got an empty string back even though `SF_SetFormula` had just set real + text -- looked like `SF_SetFormula` was silently failing. **It wasn't**: + `SF_GetCode` (`MIES_SweepFormula.ipf`), the function the Display button + itself uses to read the notebook, explicitly calls + `GetNotebookText(formula_nb, mode = 2)`. Reading back with `mode=2` + correctly showed the real text every time. Lesson: don't assume a + `getData`/similar mode number from one call site transfers to a different + notebook/purpose -- check the specific reader the real code path uses. + - **`ivscc_apfrequency()` executed with no error and DID produce real plotted + output -- initial verification methodology was wrong, corrected by the + user.** `GetSweepFormulaOutputSeverity()`/`GetSweepFormulaOutputMessage()` + correctly showed a clean run (`SF_MSG_OK`, no error). But the "no visible + output" conclusion drawn at the time was wrong, for two compounding + reasons: (1) **SweepFormula plots into a separate, dedicated plotter + panel, not into the SweepBrowser's own graph** -- so checking + `TraceNameList("SweepBrowser", ...)` for new traces was checking the wrong + window entirely (user explicitly corrected this: "The sweepformula + plotter does not change the traces in the SweepBrowser graph but creates + a new panel with graph or table subwindows"). The actual window is named + `SweepFormula_plotsweepBrowser_graph`, and its traces live in a *child + subwindow* (`ChildWindowList(...)` -> `graph0`), not at the panel's own + top level -- `TraceNameList("SweepFormula_plotsweepBrowser_graph", ...)` + alone is also empty; the real call is + `TraceNameList("SweepFormula_plotsweepBrowser_graph#graph0", ";", 1)`. + (2) The before/after `WinList("*", ";", "WIN:65535")` window-diff was run + too late (in a separate debug call after the plotter window had already + been created by the first real run), so by the time that diff ran, the + window already existed in both the "before" and "after" snapshots and + correctly showed as "not new" -- a false negative caused by diffing at + the wrong point in time, not by the operation failing. + **Confirmed working correctly**: `TraceNameList("SweepFormula_plotsweepBrowser_graph#graph0", ";", 1)` + returned 13 real traces, including per-experiment traces + (`T000000d0_a__Scn1a_R613X_B6_825669_02_09_02_nwb`, etc.) split into `_a__`/`_b__` + groups matching the "a"/"b" tags applied earlier in this session (via + `ClaudeScrap_TagExperimentsViaGUI`, still in effect -- confirming + `ivscc_apfrequency()` auto-groups by existing experiment tags when no + explicit `seltag` argument is given), plus computed + `ivscc_apfrequency_concat`/`_DAScale`/`_DAScale_Avg`/`_avg_bins` traces per + group. **Lesson for future verification of SweepFormula operations: check + the dedicated `SweepFormula_plot*` panel and its child graph/table + subwindows (via `ChildWindowList`), not the host DataBrowser/SweepBrowser's + own graph** -- and take a window-list snapshot immediately before the + triggering action, not in a later, separate diagnostic call. + - **Better yet, per the user's follow-up explanation: don't discover the + SweepFormula plot window via `WinList`/diffing at all -- derive its name + deterministically.** MIES allows multiple simultaneous SweepBrowsers, each + accepting its own SweepFormula input and creating its own independently- + named SweepFormula plot window, precisely so they don't collide -- the + naming is generated from the specific SweepBrowser/DataBrowser `graph` + argument, not a global counter. Chain: `SF_FormulaPlotter` -> + `SF_CreateDataDisplayWindow` -> `SF_GetDataDisplayWindowName`/ + `SF_NewSweepFormulaBaseWindow` (all `static`, module `MIES_SF` under + `#pragma ModuleName`, since `MIES_SweepFormula.ipf` also gets that pragma + when `AUTOMATED_TESTING` is defined -- same pattern as `MIES_AB` elsewhere + in this file). Added `ClaudeScrap_GetSFPlotWindowInfo(graph)` to + `MIES_ClaudeScrapCode.ipf`, calling + `MIES_SF#SF_GetDataDisplayWindowName(graph, SF_DISPLAYTYPE_GRAPH, SF_DM_SUBWINDOWS, 0)` + directly (module-qualified, all-global constants) -- confirmed it returns + exactly `SweepFormula_plotsweepBrowser_graph#graph0` for `graph="SweepBrowser"`, + matching the real window found manually, with the same 13 real traces. + Note the `SF_DM_NORMAL`-mode variant (no `idx` suffix child window) is a + *different*, non-existent name for this case (`WindowExists` false) -- + `SF_DM_SUBWINDOWS` is the mode actually used by the real plotter code, and + already returns the fully-qualified `host#graph0` reference in one call + (no need to manually append `"#" + SF_WINNAME_SUFFIX_GRAPH + "0"` on top + of it -- that would double up the suffix). This is now the correct, + robust way to locate a specific SweepBrowser's SweepFormula plot output, + including when more than one SweepBrowser/plot is open at once. + +## `tools/ipt` (Igor Programming Tool) evaluated for AST/code understanding + +The repo ships `tools/ipt` (Linux ELF, statically linked), `tools/ipt.exe` (Windows), and +`tools/run-ipt.sh` (a git-root-relative wrapper picking the right binary by `uname`). Docs at +docs.byte-physics.de/ipt. Investigated whether it genuinely improves understanding of Igor +Pro source beyond manual reading -- conclusion: **yes, with real value and one confirmed +gap**, based on live runs against this repo (not assumed from the docs alone). + +- **`ipt check --print-ast ` parses actual Igor Pro source into a real AST** (node + types like `Function`, `Declaration`, `Assignment`, `OperationStatement`, each with + precise line:column spans) -- confirmed by running it against + `Packages/MIES/MIES_GlobalStringAndVariableAccess.ipf` (the exact file investigated + earlier this session for the COMSPEC/git bug): it parses cleanly with **zero errors**, + including the tricky nested-quote `sprintf`/`ExecuteScriptText` command-building lines, + confirming the parser handles real, non-trivial MIES code correctly, not just toy + examples. +- **`--print-symbol-table` lives under `ipt rename`, not `ipt check`** (corrected after + actually running it -- `ipt check --help` has no such flag; `ipt rename --help` does, + alongside its own `--print-ast`, which per its help text prints "each AST after symbol + table creation"). **`ipt.exe` only ever knows about the procedure file(s) explicitly passed + via the `files`/`-f` arguments** -- it does not resolve `#include`s or otherwise pull in + the rest of the codebase itself, so a symbol table (or an AST) requested for one file only + reflects that file's own top-level declarations, not anything defined in files it + `#include`s or that `#include` it. Pass every file actually needed for a complete picture + explicitly. + - **Verified the output format is genuinely parseable**, live, against a small throwaway + test file (`Function IPTSymTestAdd(variable a, variable b)` with a local `result`, plus + a `static Function/S IPTSymTestGreet`, plus one global `Constant`). `ipt rename + --print-symbol-table ` prints a structured cross-reference table, not a flat list: + a top-level `files: [...]` block per input file (module name(s), and reference-only + lists of its constants/structs/functions), followed by global `structures:`/`constants: + `/`functions:`/`variables:` sections holding the *actual* records, each tagged with an + `id: [address/counter]` (an ephemeral, process-memory-derived identifier -- confirmed by + running twice and seeing different numbers both times; **not** stable across runs, so + don't rely on it for anything beyond within-a-single-invocation cross-referencing). + Records elsewhere just hold a `-> [id] kind: name` pointer back to the real one. Each + function record carries its full signature (required/optional args, return type, `multi + return types` for the `[a, b] = Func()` destructuring style) and a `variables:` list of + pointers to every one of its params/locals. Each variable record carries `write points`/ + `read points`/`definition points` -- each a `statement: [line:col - line:col]` + span -- distinguishing where a variable is declared/assigned versus merely read. + - **Cross-checked this understanding against real rename behavior**: the test file's + `result` local (`variable result = a + b`) showed exactly one `write points`/ + `definition points` entry at its `Declaration` statement (line 9) and one `read points` + entry at its `ReturnStatementNormal` (line 10). Running an actual `ipt rename -f + -l 9 -c 11 -n resultRenamed ` (targeting that same declaration) previewed renames + at exactly `9:11` and `10:9` -- matching the symbol table's own write/read points + precisely, confirming the table was read correctly rather than just superficially. + - **Gotcha hit while testing**: `ipt rename --print-symbol-table ` with *no* rename + target (`-f`/`-l`/`-c`/`-n` all omitted) prints the full symbol table correctly, then + **crashes** (`Bug: target file was not parsed`, `terminate called without an active + exception`, exit code 134/SIGABRT) instead of exiting cleanly -- a real bug in `ipt` + itself (`rename` apparently always expects a valid target even when only the debug + printout is wanted). The printed symbol table content before the crash is complete and + trustworthy regardless; just don't rely on the process's exit code in that no-target + case, and prefer always supplying a valid target if a clean exit matters. +- **Whole-codebase check**: ran `ipt check` (no `--print-ast`, batched to fit the shell's + per-call time budget) over all 487 `.ipf` files under `Packages/`. Result: **484 parse with + zero errors; the only 3 parsing errors are the same deliberately-malformed fixture file** + (`test-input-function-params.ipf`, a doxygen-filter test input, vendored/duplicated under + `doc/`, `igortest/docu/`, and `unit-testing/docu/`) -- not real MIES source bugs. So `ipt` + is practically usable across this entire real codebase, not just isolated files. +- **Directly relevant to the shadowing rule just added above**: `ipt` ships a lint rule + named exactly for this, `BugproneReservedKeywordsAsIdentifier` (confirmed via `ipt lint + --list`). Live-tested its actual scope with three throwaway test files: it correctly + flags a variable named after a genuine reserved **keyword/type name** (e.g. `variable + wave` -> "Use of reserved keyword as identifier. Please rename it."), **but it does + NOT flag a variable/string named after a built-in **function** name** (`variable abs`, + `string print`, `string log` all passed both `ipt check` and `ipt lint` -- including + `--include BugproneReservedKeywordsAsIdentifier` explicitly -- with zero warnings). This + is a real, confirmed gap: `ipt`'s existing tooling would not have caught the user's + original `string log` example, which is exactly why that rule was worth writing down by + hand in this file rather than assuming `ipt lint` already covers it. +- **The AST itself has no built-in-name-resolution semantics** -- confirmed from the + printed tree for a `string log` test case: the declaration, assignment target, `print` + argument, and `return` value all show up as plain `(Id \`log\` ...)` nodes with no + annotation distinguishing "shadows a built-in" from "an ordinary local name." This is + consistent with `ipt` being a syntax-level tool (parser + lints operating on the parse + tree), not a full semantic/symbol-resolution engine against Igor's built-in function + table -- explains why the lint gap above exists rather than being an oversight. +- **Practical takeaway for future sessions**: `ipt check`/`ipt check --print-ast` is a fast, + reliable way to get an authoritative parse of a `.ipf` file's structure (function + signatures, statement nesting, operation-argument shape) without needing a live Igor Pro + instance, and is trustworthy against this codebase (near-100% clean parse rate). `ipt + lint` catches genuine keyword-as-identifier misuse and a range of other real style/bug + patterns (see `ipt lint --list` / the docs' rule list), but does not catch built-in + *function*-name shadowing -- that class of bug still needs to be caught by review/manual + attention (or a new custom rule), not by relying on existing `ipt` output. +- Performance note: parsing this repo's ~487 `.ipf` files is not uniformly fast -- most + batches process in the range of tens-of-milliseconds-per-file, but at least one file in + the tree parses roughly 10x slower than the rest (bisection pinned it to a ~125-file + slice without identifying the specific file); worth keeping invocations chunked/batched + rather than assuming a single whole-codebase call finishes quickly. + +## Reading Igor Pro `.ihf` help files as formatted notebooks (better than an OS-level file read) + +The user pointed out a second way to read Igor's own `.ihf` help files, beyond just reading +the raw file bytes at the OS level: `.ihf` files are themselves Igor formatted-text +notebooks, and any such notebook can be opened directly via `OpenNotebook`, then read back +through Igor's own notebook operations via the bridge -- confirmed live end-to-end against +`Igor Pro 9 Folder Nightly:Igor Help Files:Debugging.ihf`. + +- **Igor pre-registers `.ihf` files as "open as a help file" via ordinary (often hidden) + help windows -- `WIN:512` is the correct `WinList` bit for these, confirmed against + `WinList` operation's own bit table** (1=graphs, 2=tables, 4=layouts, 16=notebooks, + 64=panels, 128=procedure windows, **512=help windows**, ...). An earlier pass through this + investigation guessed `WIN:1024` for help windows and got an empty result, which was + wrongly read as "the file is registered with no window object at all" -- corrected after + actually checking `WinList`'s documented bit values: `1024` isn't a defined window type at + all, so that query was meaningless, not evidence of anything. Re-tested with the correct + bit: `WinList("*", ";", "WIN:512")` reliably lists every currently-open help file + (including invisible ones), while adding `,VISIBLE:1` restricts to only the visible ones + -- e.g. `Igor Reference.ihf` showed up in the plain `WIN:512` list but not the + `VISIBLE:1`-qualified one, i.e. it was open as a hidden help window (Igor appears to open + it in the background on its own, e.g. for command-line help lookups, independent of + anything this session did explicitly). `OpenNotebook/R ""` fails + with error 251 ("The file ... is already open but as a help file") whenever the target + file's help window (hidden or not) is currently open -- a help-file view and a + plain-notebook view of the same file are mutually exclusive. +- **The user's own manual technique**: hold Alt (Option on Mac) and click a help window's + close button to close and unregister it, then reopen the same file via File > Open > + Notebook. **Programmatic equivalent, found in `Igor Reference.ihf`**: the `CloseHelp` + operation (added in Igor Pro 7.00). `CloseHelp/ALL` (closes every registered help window) + confirmed working live -- immediately unblocks `OpenNotebook/R` on any `.ihf` file + afterward. `CloseHelp/FILE=""` (meant to close just one specific file) instead threw + `error 140: expected window title` when tried against a full HFS-style path -- not yet + resolved why; `/ALL` is the confirmed-working option and is harmless to use even when only + one file matters, since Igor's help windows are cheap to reopen on demand. +- **Full plain-text readback**: after `OpenNotebook/R ""` succeeds, find the resulting + window's actual name via `WinList("*", ";", "WIN:16")` (Igor auto-assigns + `Notebook0`/`Notebook1`/... unless `/N=name` was given to `OpenNotebook`), then: + `Notebook selection={startOfFile, endOfFile}` followed by `GetSelection notebook, + , 2` sets `S_Selection` to the notebook's entire text in one call (paragraph breaks + come through as `\r`). Confirmed against `Debugging.ihf`: 20,554 characters, first ~400 + matched the file's actual visible content exactly. +- **Formatted-text export reveals genuine content-block structure, not just prose (the + user's key hint)**: `.ihf` files are *formatted* notebooks, and WaveMetrics' own help + authoring convention uses named paragraph styles for different content roles, not just ad + hoc bold/italic/font-size choices. Exporting via `SaveNotebook/O/S=5/H={"UTF-8", + writeParagraphProperties, writeCharacterProperties, PNGOrJPEG, quality, bitDepth} as + ".html"` (saveType 5 = HTML export; confirmed with + `writeParagraphProperties=3`/`writeCharacterProperties=7`, `SaveNotebook` V-823) produces a + `

` tag on every single paragraph, and the class name itself directly + identifies the paragraph's semantic role -- confirmed live against `Debugging.ihf`'s + export: + - `Topic` -- a section heading (e.g. `

Debugging

`). + - `Subtopic` / `Subtopic-Indented` -- a sub-heading. + - `TopicBody1` / `TopicBody1a` -- ordinary body prose. + - `Steps` / `ListNumbered` -- bullet/numbered list items (e.g. `

• Using + print statements

`). + - `Code1` / `Code1a` / `Code-Indented1` -- a line of example code (e.g. `

Function Test(w, num, str)

`). + - `SeeAlso`, `NOTE`, `Table2Col`, `Table3Col`, `RelatedTopics` -- self-explanatory by name. + This is strictly better than inferring structure from raw font weight/size, since the + class names already encode the author's intended block type -- heading vs. body vs. code + vs. list item vs. note -- with no guessing required. +- **Non-destructive, repeatable workflow (user-specified, live-verified end-to-end)**: the + first pass through this technique left `CloseHelp/ALL` in effect permanently and never put + the previously-open help file(s) back -- a real gap, since Igor may have had help windows + open (visibly or in the background) before this workflow ever touched anything, and those + shouldn't be lost as a side effect of reading a different file. Corrected workflow: + 1. **Snapshot what's currently registered as open help**, before touching anything: + `String helpAll = WinList("*", ";", "WIN:512")` and `String helpVisible = WinList("*", + ";", "WIN:512,VISIBLE:1")` (the latter needed to restore visible ones as visible, not + just hidden). + 2. `CloseHelp/ALL`. + 3. `OpenNotebook/R ""`; find the resulting window's actual name + via `WinList("*", ";", "WIN:16")` (Igor auto-assigns `Notebook0`/`Notebook1`/... unless + `/N=name` was given). + 4. `SaveNotebook/O/S=5/H={"UTF-8", 3, 7, 0, 0.9, 32} as ".html"` (path + must be reachable by whatever reads it back -- e.g. somewhere under the bash-mounted + repo), then read/parse the exported HTML for the `

` per-paragraph + structure. + 5. `KillWindow/Z ` to close the temporary notebook. + 6. Repeat steps 3-5 for any other `.ihf` files that need reading in the same session -- + no need to re-run `CloseHelp/ALL` again since it's already in effect. + 7. **Restore**: for each file name captured in step 1, `OpenHelp/V=(1 if it was in + helpVisible else 0)/INT=0 ""` (`/INT=0` suppresses any + recompile-confirmation dialog; `WinList` only returns bare file names for help/procedure + windows, so resolve each back to a full path -- e.g. by prefixing the known Help Files + folder path, or matching against an `IndexedFile` listing of that folder). + - Live-verified full round trip: before touching anything, `helpAll = "Igor + Reference.ihf;"` (not in `helpVisible`, i.e. open hidden in the background). `CloseHelp/ + ALL` -> `OpenNotebook/R` on `Igor Shortcuts.ihf` (a file not touched earlier this + session) opened as `Notebook1` -> HTML export showed new paragraph classes specific to + this file's content (`ShortcutHow`, `ShortcutHow-7`, `ShortcutTo`, + `SeeAlsoIndented`, alongside the already-known `Topic`/`TopicBody1`/`TopicBody1a`) -- + confirming the class-naming convention is genuinely per-content-role, not just a fixed + set from one file. `KillWindow/Z Notebook1` closed it cleanly. `OpenHelp/V=0/INT=0 + "...Igor Reference.ihf"` restored it: returned `V_Flag=0` (success) and + `WinList("*", ";", "WIN:512")` immediately showed `Igor Reference.ihf;` again -- state + fully restored. Scratch HTML export files deleted after each read; they have no lasting + purpose once parsed. + - **Caveat: expect drift between the snapshot and later checks.** `Igor Reference.ihf` + reappeared in the `WIN:512` list at one point in this session even though nothing in this + workflow had explicitly reopened it -- ordinary Igor Pro activity (e.g. command-line + help lookups) can silently open/reopen certain help files in the background outside of + any explicit `OpenHelp` call. Always take the snapshot (step 1) immediately before + intervening, rather than trusting an earlier snapshot or assuming the set of open help + files is static. +- **This entire technique only applies to XOPs that ship their own `.ihf` help file -- + not every XOP does.** Some XOPs that extend Igor Pro's built-in pool of + functions/operations bundle a compiled `.ihf` help file of their own (readable via this + same `CloseHelp`/`OpenNotebook`/`SaveNotebook` workflow, same as any of WaveMetrics' own + help files) -- e.g. National Instruments' `DAQmx_*` operations. **Other XOPs ship no help + file at all** -- per the user, the JSON XOP (used elsewhere in this codebase) is one of + these; its documentation is only available externally, at + . +- **Igor Pro loads XOPs/help files/fonts/procedure files from two places, joined together + at startup into one environment (per the user, matching the "Igor Pro User Files" help + topic)**: a **global** location, subfolders of the Igor Pro program folder itself (e.g. + `...Igor Pro 9 Folder Nightly:Igor Extensions (64-bit):`), and a **user-specific** + location, `:WaveMetrics:Igor Pro User Files:` (confirmed live: + `C:Users:enigm:Documents:WaveMetrics:Igor Pro 9 User Files:` exists and, via + `IndexedDir`, mirrors the exact same subfolder set as the global program folder -- + `Igor Extensions`, `Igor Extensions (64-bit)`, `Igor Fonts`, `Igor Help Files`, `Igor + Procedures`, `User Procedures`). This resolves the open question from earlier this + session about where `DAQmx`'s own help file actually lives, and turned up two more + concrete, useful facts from the user's own installation (`IndexedFile(..., "????")` to + list all files regardless of type, since `.ihf`-only filtering misses shortcuts): + - `Igor Extensions (64-bit):` (user-specific) contains `NIDAQmx64.XOP - Shortcut.lnk` + (a second reference to the same DAQmx XOP already found in the global folder) and + `Debug JSONXOP- Shortcut.lnk.dis`. **Corrected by the user**: the `.dis` suffix here + is *not* Igor's disable-an-extension convention (initial guess, wrong) -- it's the + user's own unrelated helper file for switching which JSON XOP build (release vs. + debug) gets included. Lesson: don't assume a plausible-sounding Igor convention + without checking -- a `.dis`-suffixed file sitting in an XOP folder isn't + self-explanatory and can just as easily be project-specific tooling. + - `Igor Help Files:` (user-specific) contains `NIDAQ Tools MX Help - Shortcut.lnk` (a + shortcut to DAQmx's real help file -- confirming it does ship one, as expected) and + `ZeroMQ.ihf` (a real, non-shortcut `.ihf` file) -- a second concrete, directly-readable + example of an XOP-supplied help file via this same technique, alongside DAQmx. + **Practical upshot**: before assuming an XOP operation/function can be looked up via this + `.ihf`-reading workflow, check both the global and user-specific `Igor Help Files:` + folders for it (`IndexedFile`/`IndexedDir`, `"????"` wildcard to include shortcuts), or + just try `OpenNotebook`/`OpenHelp` and see if it resolves -- if neither location has one, + fall back to that XOP's own external documentation instead of assuming no help exists at + all. +- **`FunctionList`/`OperationList` (per the user) enumerate every function/operation + actually available in the running instance, including ones added by XOPs -- a + complementary way to know the live environment's real capabilities, independent of + whether any given one happens to have a help file.** Confirmed live: + `FunctionList("*", ";", "KIND:4")` (KIND:4 = "external functions, defined by an XOP") + returned 244 functions this session, including the `fDAQmx_*` family (e.g. + `fDAQmx_ReadChan`, `fDAQmx_ScanStart`); `OperationList("*", ";", "external")` returned 80 + operations, including the `DAQmx_*` family (e.g. `DAQmx_AI_SetupReader`, + `DAQmx_CTR_CountEdges`). Cross-checked against the ZeroMQ XOP-help finding above and it + matched exactly as expected: `FunctionList("*ZeroMQ*", ";", "KIND:4")` returned 21 real + `zeromq_*` functions, consistent with `ZeroMQ.ihf` actually being present and loadable. + **Corrected by the user on the JSON XOP check specifically**: the JSON XOP adds + *operations*, not functions -- `FunctionList("*JSON*", ";", "KIND:4")` returning nothing + was simply the wrong list to check, not evidence the XOP wasn't loaded (see the `.dis` + correction above -- that file was never actually a disable marker in the first place). + `OperationList("*JSON*", ";", "external")` is the correct check and returns 13 real, + currently-loaded `JSONXOP_*` operations (`JSONXOP_Parse`, `JSONXOP_GetValue`, + `JSONXOP_Dump`, `JSONXOP_AddTree`, etc.) -- the JSON XOP is loaded and fully functional in + this instance; the earlier "not loaded" conclusion was wrong on two independent counts at + once. **Practical use, corrected**: when unsure whether a given function/operation is + actually available in the current live instance, check `FunctionList` for XOP-added + *functions* (`KIND:4`) and `OperationList("*", ";", "external")` for XOP-added + *operations* -- an XOP can contribute either or both, so check both list types rather + than assuming from one empty result that an XOP contributes nothing at all. +- **Even more direct and conclusive, per the user: if currently-compiled code calls an + XOP's operation/function and the instance is in compiled state, the XOP must be loaded -- + no separate enumeration needed at all.** `Packages/MIES/json_functions.ipf` (confirmed + present in `included_procedure_files`) calls `JSONXOP_Parse`, `JSONXOP_Dump`, + `JSONXOP_New`, `JSONXOP_Release`, `JSONXOP_Remove`, etc. directly (e.g. line 85: + `JSONXOP_Parse/Z=1/Q=(JSON_QFLAG_DEFAULT) jsonStr`); Igor cannot compile a call to an + operation that doesn't exist, so a clean `check_compilation_state()` (`compiled: true`) + together with this file being included is already airtight proof the JSON XOP is loaded + -- stronger and simpler than inferring it from an `OperationList` scan. **Even simpler + still, and the one to reach for first**: `get_environment_summary()` already has a + dedicated `loaded_xops` field for exactly this question -- confirmed live it lists + `"JSON-64"` (and `"NIDAQmx64"`, `"ZeroMQ-64"`, etc.) directly by name. No need for + `FunctionList`/`OperationList` scans, or the compiled-code inference above, when the + question is simply "is XOP X loaded right now" -- `loaded_xops` answers that in one call. + **Corrected by the user: `FunctionList`/`OperationList` do *not* actually solve the other + question either (which specific entries a given XOP contributes) -- overclaimed above.** + Both return a flat name list with no per-entry attribution back to the XOP that defined + it (confirmed: neither operation's documented output includes an owning-XOP field, and no + `IgorMan.md` search turned up any dedicated name-to-XOP lookup). In practice, attributing + a specific function/operation name to a specific XOP relies entirely on already knowing + that XOP's naming convention (e.g. `JSONXOP_*`, `DAQmx_*`, `zeromq_*` -- all human + knowledge, not queryable from Igor). Diffing the list before/after loading only the XOP in + question isn't a practical workaround either: no `IgorMan.md` search found any operation + for loading/unloading a specific XOP at runtime, so XOPs are apparently fixed for an + entire Igor Pro session (set only via which files sit in the Extensions folders at + startup) -- there's no in-session way to toggle just one and diff. **Bottom line, per the + user: with no documentation or source available for a given XOP, there is no simple way + to determine which operations/functions it specifically contributes** -- `loaded_xops` + and `FunctionList`/`OperationList` only answer "is this XOP loaded" and "what's available + in total," not "which of these came from XOP X." + +## Solved: extracting an XOP's operations/functions from its compiled binary, no source/docs needed + +Follow-up to the "no simple way" conclusion just above. The user explained the actual mechanism: +an XOP is a DLL with a specific structure, and the list of operations/functions it adds to Igor is +encoded in that DLL's resources -- and gave read access to WaveMetrics' own XOP Toolkit 8.01 +(`c:\download\XOP8.01\`, containing `XOPMan8.pdf` plus buildable sample-XOP source). Working +through the toolkit manual (`pdftotext -layout` extraction, `poppler-utils`'s `pdftotext` already +available in the sandbox) confirmed this in full, and a live test against real, closed-source, +already-compiled `.xop` files proved it works with zero vendor documentation or source needed: + +- **The relevant resources are `XOPI` 1100 (required, general XOP info), `XOPC` 1100 (operations + the XOP adds), and `XOPF` 1100 (functions it adds)** -- confirmed from the manual's "XOP + Resources" chapter (`XOPMan8.pdf`, "There are three types of resources... XOP-specific + ...WaveMetrics..."). On Windows, unlike the Macintosh `.r`/Rez-resource-fork mechanism, these + are compiled by the **standard Windows resource compiler** from a `.rc` file (e.g. + `WaveAccessWinCustom.rc`) directly into the `.xop`/DLL's own PE resource section, using the + literal strings `"XOPC"`/`"XOPF"`/`"XOPI"` as the resource *type* name (not a numeric type) and + `1100` as the resource ID -- confirmed directly from the manual's own words: "Igor examines + these custom resources to determine what operations, functions and menus the XOP adds." This + means any XOP's `.xop` file, being an ordinary PE/DLL, can have these extracted by any generic + PE resource reader -- no proprietary format, no vendor cooperation needed. +- **Exact binary layout, confirmed from the manual's Chapter 5/6 (`XOPC`/`XOPF` Windows `.rc` + source examples)**: + - `XOPC` (operations): repeating `{null-terminated name string; int16 little-endian category + bitmask}` records, terminated by a record whose name is the empty string (a single `0x00` + byte) with no trailing bitmask after it. + - `XOPF` (functions): repeating `{null-terminated name string; int16 category bitmask; int16 + return-type code; int16 parameter-type code}*N; int16 `0` to terminate that function's + parameter list}` records, the whole resource terminated the same way as `XOPC` (an empty-name + record). + - Category/type bit meanings (`XOPOp`/`ioOp`/`compilableOp`/... for `XOPC`; `NT_FP64`/ + `WAVE_TYPE`/`HSTRING_TYPE`/... for `XOPF` return/parameter types; `F_UTIL`/`F_EXTERNAL`/... + for `XOPF`'s own category bitmask) are all documented in the manual with their exact decimal + values -- not needed just to get the name list, but available for full interpretation. +- **Live-verified against real, compiled, closed-source `.xop` files already present in this + machine's global Igor Pro 10 install** (`More Extensions (64-bit)/.../*.xop` -- no need to build + anything from the toolkit's own sample sources): using Python's `pefile` library (pure Python, + `pip install pefile`, works even in this Linux sandbox against a Windows PE file) to walk the PE + resource directory for a type entry named `"XOPC"`/`"XOPF"`, then the above byte-layout parser + (implemented and run directly, not just theorized): + - `NIGPIB2-64.xop`'s `XOPC` resource decoded to exactly the 10 operations already known from its + `.r` source seen earlier in the XOP Toolkit (`NI4882`, `GPIB2`, `GPIBRead2`, `GPIBWrite2`, + `GPIBReadWave2`, `GPIBWriteWave2`, `GPIBReadBinary2`, `GPIBWriteBinary2`, + `GPIBReadBinaryWave2`, `GPIBWriteBinaryWave2`), each with category `0x1060` -- exactly + `XOPOp(0x20) | ioOp(0x1000) | compilableOp(0x40)`, matching the source's `XOPOp | ioOp | + compilableOp` declaration bit-for-bit. + - `VISA64.xop`'s `XOPF` resource decoded to all 57 `vi*` functions (`viOpenDefaultRM`, `viRead`, + `viWrite`, `viClose`, ...) with full parameter-type lists; `TDM64.xop` decoded to 63 `TDM*` + functions; `SQL64.xop` to 79 `SQL*` functions; `AxonTelegraph64.xop` to 8 + `AxonTelegraph*`/`AxonTelegraphA*` functions -- all with sensible, correctly-decoded return + and parameter type codes matching each function's evident purpose (e.g. `HSTRING_TYPE` + (`0x2000`) return type on `*GetDataString`, `WAVE_TYPE`-flavored params on wave-taking + functions). +- **This fully resolves the "no simple way" conclusion above, with one caveat**: it requires + actual read access to the compiled `.xop` file's bytes (trivial for XOPs sitting in the global + or user Extensions folders on the same machine, as confirmed this session), not just a live + Igor Pro instance talking COM -- the Igor Pro Bridge itself has no channel for reading arbitrary + files' raw bytes off the host disk today. Turning this into a proper bridge tool (e.g. + `list_xop_exports(xop_path)`, using `pywin32`'s own resource APIs or bundling `pefile`) was + not yet done this session -- flagged as a natural next step, not yet actioned. +- **Cross-validated against this repo's own production XOPs (`XOPs-64bit/*.xop`), including the + exact two (`JSON-64.xop`, `ZeroMQ-64.xop`) whose loaded-function/operation lists were already + independently confirmed earlier this session via live `FunctionList`/`OperationList` calls -- + and the static extraction matched the live runtime introspection exactly, both directions**: + - `JSON-64.xop`'s `XOPC` decoded to precisely the same 13 `JSONXOP_*` operations already seen + live via `OperationList("*JSON*", ";", "external")` (`JSONXOP_AddTree`, `JSONXOP_AddValue`, + `JSONXOP_Dump`, `JSONXOP_GetArraySize`, `JSONXOP_GetKeys`, `JSONXOP_GetMaxArraySize`, + `JSONXOP_GetType`, `JSONXOP_GetValue`, `JSONXOP_New`, `JSONXOP_Parse`, `JSONXOP_Release`, + `JSONXOP_Remove`, `JSONXOP_Version`) -- same 13, no more, no fewer. + - `ZeroMQ-64.xop`'s `XOPF` decoded to precisely the same 21 `zeromq_*` functions already seen + live via `FunctionList("*ZeroMQ*", ";", "KIND:4")`. + - Also decoded every other XOP in that folder, several directly relevant to this repo's own + hardware-interface work this session: `MultiClamp700xCommander64.xop` (1 operation, + `MCC_FindServers`, plus 65 `MCC_*` functions -- the amplifier-control layer behind + `MIES_ForeignFunctionInterface.ipf`'s `FFI_*` wrappers this session added hardware tests + for), `itcXOP2-64.xop` (32 `ITC*2` DAQ-hardware operations), `SutterXOP_Win-64.xop` (2 + operations, 69 functions), `TUF-64.xop` (7 `TUFXOP_*` operations -- this repo's own test + framework support XOP), `MIESUtils-64.xop` (3 functions, `MU_GetFreeDiskSpace`/ + `MU_RunningInMainThread`/`MU_WaveModCount` -- this repo's own small utility XOP), + `mies-nwb2-compound-XOP-64.xop` (2 operations, `IPNWB_WriteCompound`/`IPNWB_ReadCompound`). + - **This means the technique isn't just a WaveMetrics-sample-XOP party trick -- it works + identically on this specific codebase's real, in-use, already-compiled dependencies**, + including ones with no external documentation at all (`MIESUtils-64.xop` appears to be + built in-house for this repo specifically). + +### Implemented as a pure Igor Pro procedure function instead of a bridge tool + +Per the user's judgment call (this would be a rarely-used capability, not worth a permanent +bridge Python tool), reimplemented the whole PE-resource extraction from scratch as ordinary +Igor Pro procedure code -- `Function/S CH_ListXOPExports(string xopPath)` plus a dozen +`static` helper functions (`CH_PEReadU16`/`CH_PEReadU32`/`CH_PEReadBytes`/`CH_PECStringLen`/ +`CH_BytesToU16`/`CH_PEReadUnicodeName`/`CH_PERVAToFileOffset`/`CH_PEFindResourceOffset`/ +`CH_ParseXOPResourceBlob`/`CH_PEListXOPResource`) -- added to `MIES_ClaudeHelper.ipf`, inside +the existing `#ifdef IGOR_PRO_BRIDGE ... #endif` block alongside `AfterCompiledHook`. Uses +only `Open/R`, `FSetPos`, `FBinRead` (with `/F=2` or `/F=3`, `/U`, `/B=3` for little-endian +unsigned 16-/32-bit reads) and ordinary string operations (`strsearch`, substring indexing, +`char2num`/`num2char`) to walk the PE header, section table, and 3-level resource directory +tree (Type -> ID -> Language) entirely by hand, find the named `"XOPC"`/`"XOPF"` resource +type's `CH_XOP_RESOURCE_ID` (1100) entry, and decode it per the binary layout documented above +`CH_ListXOPExports` in the file itself. Only supports 64-bit (PE32+) XOPs (every XOP actually +in use in this repo) -- aborts clearly for a 32-bit one rather than misreading it. Returns +`"operations:op1;op2;...\rfunctions:func1;func2;..."`. + +**Three style passes applied after the initial working implementation, each re-verified +end-to-end against `JSON-64.xop`/`ZeroMQ-64.xop` (identical output every time -- no +regressions from any of these):** +1. Lowercased the `Variable`/`String` type keywords to `variable`/`string`, and moved every + function's local-variable declarations to the very top of its body (matching Igor's own + function-level, not block-level, scoping -- see the language-facts note above), per the + user's explicit style preference. +2. Converted every function signature from the old two-part style + (`Function/S Foo(paramName)` + a separate `string paramName` line) to Igor 7+'s inline + parameter-type declarations (`Function/S Foo(string paramName)`), removing the + now-redundant standalone type lines, per the user's explicit request. +3. Replaced every unexplained numeric literal in the PE-parsing code with a named + `static Constant` -- PE/COFF structure signatures and offsets (`CH_PE_DOS_SIGNATURE`, + `CH_PE_SIGNATURE`, `CH_PE_E_LFANEW_OFFSET`, `CH_PE_OPTIONAL_HEADER_MAGIC_PE32PLUS`, the + `IMAGE_FILE_HEADER`/`IMAGE_SECTION_HEADER`/`IMAGE_RESOURCE_DIRECTORY(_ENTRY)`/ + `IMAGE_RESOURCE_DATA_ENTRY` field offsets and struct sizes, the + `CH_PE_RESOURCE_HIGH_BIT_FLAG`/`CH_PE_RESOURCE_OFFSET_MASK` name/subdirectory bit + convention), the XOP Toolkit's own `CH_XOP_RESOURCE_ID` (1100), plus a few + implementation-detail constants (`CH_PE_PAD_CHAR`, `CH_UINT16_BYTE_SIZE`, + `CH_BYTE_SHIFT_8BIT`, `CH_CSTRING_SEARCH_INITIAL_CHUNK`/`_MAX_CHUNK`, + `CH_CMPSTR_CASE_SENSITIVE`) -- per the user's explicit request. Initially added this + `static Constant` block directly above the `CH_PE*` helpers that use it; the user + subsequently moved the whole block to the top of the `#ifdef IGOR_PRO_BRIDGE` section + (before `AfterCompiledHook`) to match this repo's own convention of declaring module-level + constants at the top of a file, then ran `ipt format` (see the `tools/ipt` section above) + over the whole file to reapply canonical formatting after the manual restructuring. + +**Live-verified end-to-end, exact match against the already-validated Python reference, for +every XOP tested**: `JSON-64.xop` -> 13 operations (`JSONXOP_AddValue;JSONXOP_GetValue;...`), +0 functions; `ZeroMQ-64.xop` -> 0 operations, 21 functions (`zeromq_client_connect;...`); +`MultiClamp700xCommander64.xop` -> 1 operation (`MCC_FindServers`), 65 `MCC_*` functions; +`itcXOP2-64.xop` -> 32 `ITC*2` operations, 0 functions -- every single name, in the same +order, for all four. + +**Two real gotchas hit and resolved while testing this, both worth remembering generally, not +just for this function**: +1. **The very first test call failed with `FunctionInfo("CH_ListXOPExports")` returning an + empty string** (function not found at all), even right after a `reload_and_compile_procedures` + that reported `"compiled": true`. Root cause: the experiment's "Procedure" window + (`ProcedureText("", 0, "Procedure")`) had no `#define IGOR_PRO_BRIDGE` in it at all this + session -- so the entire `#ifdef IGOR_PRO_BRIDGE ... #endif` block in + `MIES_ClaudeHelper.ipf` compiled out silently, including the *pre-existing* + `AfterCompiledHook`, not just the newly-added function (confirmed by + `reload_and_compile_procedures`'s own `"confirmed_via"` field saying "AfterCompiledHook + counter unavailable or unchanged" -- a real, self-diagnosing signal that was almost missed). + The user added the `#define` and a subsequent `reload_and_compile_procedures` confirmed via + the `AfterCompiledHook` counter again, and the function became visible. + **Lesson: after adding new code to a `#ifdef`-gated file, don't just check `compiled: true` + -- confirm the specific new function actually exists (`FunctionInfo`/`FunctionList`) before + debugging anything else**, since a successful compile says nothing about which conditional + branches were actually included. +2. **`String result = CH_ListXOPExports(...)` and even a bare + `fprintf 0, "%s", CH_ListXOPExports(...)` both initially failed** with `"expected string + variable or string function"` / `"got ... instead of a string variable or string function + name"` -- but this turned out to be a symptom of gotcha 1 above (the function genuinely + didn't exist yet at that point), not a separate interpreted-vs-compiled restriction. Once + the `#define` was added and recompiled, the exact same `fprintf 0, "%s", + CH_ListXOPExports(...)` form worked without any change -- confirming user-defined + `Function/S` calls work perfectly normally from the command line via `_execute2`, same as + any other function call documented elsewhere in this file. + +Since `MIES_ClaudeHelper.ipf` is the never-committed, per-branch-emptied scratch file (see the +standing instruction near the top of this file), this implementation is session/branch-scoped +like everything else in it -- it will be gone after the next branch switch unless copied +somewhere permanent first, which was not requested this session. + +## Hardware test environment: MultiClamp Commander must run elevated + +Added `FFIGetCurrentClampStateWorks`/`FFIGetCurrentClampStateWorks_REENTRY` to +`Packages/tests/HardwareBasic/UTF_ForeignFunctionInterfaceWithHardware.ipf`, covering the new +`FFI_GetCurrentClampState` (part of the FFI clamp-control PR on +`feature/2559-mh_add_ffi_clamp_control`), following the exact same pattern as +`HardwareSelectionWorks`: a `DeviceNameGeneratorMD1`-driven multi-data test case that configures +one headstage via `InitDAQSettingsFromString`/`AcquireData_NG` (no actual DAQ/TP start needed -- +`_TP0_DAQ0` in the settings string), then a `_REENTRY` function that checks the active +headstage's returned clamp-state wave (`%ClampMode`, a few IC-mode dimension labels via +`IsFinite`), that an inactive headstage returns a null wave, and that an invalid headstage index +aborts (`try`/`FAIL()`/`catch CHECK_NO_RTE()`). + +First run failed immediately in `EnsureMCCIsOpen` (`REQUIRE_EQUAL_VAR(DimSize(ampMCC, ROWS), 2)` +-- found 0), before the new test's own body ever ran. Confirmed this was an environment issue, +not a bug in the new test, by running the pre-existing `StartingStoppingTestPulseWorks` (same +`AcquireData_NG` settings string, same default `s.amp = 1`) and seeing the identical failure. +Root cause (per the user): MultiClamp Commander -- the process MIES actually talks to for +amplifier control -- needs to be running **elevated** on this machine for MIES to see its +amplifier channels at all; it wasn't. Once started elevated, both tests passed. Worth checking +first (rather than assuming a test-code bug) whenever a hardware test fails specifically inside +`EnsureMCCIsOpen`/`REQUIRE_EQUAL_VAR(DimSize(ampMCC, ROWS), ...)`. + +Extended `FFIGetCurrentClampStateWorks`/`_REENTRY` to cover all three clamp modes (VC, IC, I=0), +parameterized via a new `FFI_ClampModeCases()` data generator in `Packages/tests/UTF_DataGenerators.ipf` +(one `WAVE/T` per mode: `_CM` token for `InitDAQSettingsFromString`, expected `ClampMode` value, +representative dimension labels to check `IsFinite` on). All three subcases' actual assertions +(clamp state wave contents, inactive-headstage null check, invalid-headstage abort) passed. + +## `CHECK_EMPTY_FOLDER()` false-positive on the first hardware test case run interactively + +Running any hardware test case via `RunWithOpts(testcase=...)` from the interactive command +line/bridge -- as opposed to the normal CI harness -- makes the *first* test case of that run fail +its `TestCaseEndCommon` teardown with `Assertion "CHECK_EMPTY_FOLDER()" failed`, reporting stray +root-level variables `V_enable;V_debugOnError;V_NVAR_SVAR_WAVE_Checking;V_debugOnAbort` (sometimes +also `V_Flag`/`interactiveMode`) as the folder's "contents". This looks like a test bug but isn't. + +**First theory (wrong, corrected below after a live A/B test)**: traced into `igortest`'s own +source (`Packages/igortest/procedures/igortest-debug.ipf`) and initially blamed `RunTest`/ +`RunWithOpts`'s unconditional `SetDebugger(debugMode)` setup call (which chains into +`SetIgorDebugger()` -> `GetCurrentDebuggerState()`, whose body is a bare, argument-less +`DebuggerOptions` call) for leaving `V_enable`/`V_debugOnError`/`V_debugOnAbort`/ +`V_NVAR_SVAR_WAVE_Checking` in `root:`. This didn't actually explain why the user's own manual +Igor-command-line usage never sees this residue, since that same igortest setup chain runs +identically either way. + +**Corrected root cause**, found by a controlled A/B test (clean `root:`, running the identical +`RunWithOpts(testcase=...)` call two different ways): the residue comes from the **Igor Pro +Bridge's own `execute_igor_command_unattended` tool**, not from anything in igortest. That tool +documents itself as disabling the Debugger for the duration of the call and restoring it after -- +which means it issues its own `DebuggerOptions enable=0` call *immediately before* running the +user's actual command. That bare-argument-style `DebuggerOptions` invocation is exactly what +leaves the four/six stray variables in whatever data folder is current (`root:`, for an +interactive/bridge call) -- confirmed live: identical `RunWithOpts(...)` calls, from a freshly +cleaned `root:`, **failed** via `execute_igor_command_unattended` and **passed** ("Finished with no +errors") via the plain `execute_igor_command` (with the Debugger separately confirmed off +beforehand via `get_debugger_state()`), reproduced for `FFIGetCurrentClampStateWorks`, +`HardwareSelectionWorks`, and other test cases across the session. Igor's own `DebuggerOptions` +operation, called without arguments (or to set a value), sets those output variables **in whatever +the current data folder is at that moment** -- so this is a genuine, confirmed side effect of the +bridge's implementation, not of igortest or of any test's own code. + +Only the first test case in a run shows the `CHECK_EMPTY_FOLDER()` failure; subsequent +subcases/test cases in the same run pass clean (IUTF appears to only count/report the first +occurrence of an identical failure message per run -- `"Failed with 1 errors"` regardless of how +many subcases hit it). + +**Workaround used for the remainder of the affected part of this session (no longer needed as of +bridge v1.23.0, see below)**: use plain `execute_igor_command` (never `_unattended`) for +`RunWithOpts(...)` test invocations, after confirming the Debugger is off via +`get_debugger_state()`, and run `KillVariables/Z root:V_Flag, root:V_enable, root:V_debugOnError, +root:V_debugOnAbort, root:V_NVAR_SVAR_WAVE_Checking, root:interactiveMode` before/after each run +for hygiene (harmless if some don't exist, since `/Z` suppresses the error). This avoided the +false positive entirely rather than just tolerating/ignoring it, while the bridge itself still +had the bug. + +**Fixed properly in Igor Pro Bridge v1.23.0** -- see the "Igor Pro Bridge v1.23.0" section below +for the implementation, and the live confirmation that `RunWithOpts(testcase= +"HardwareSelectionWorks")` run through `execute_igor_command_unattended` now finishes with +`"Finished with no errors"`, no `CHECK_EMPTY_FOLDER()` failure. **The `execute_igor_command` +-only workaround above is no longer necessary** -- `execute_igor_command_unattended` is safe to +use directly for test runs again. + +## Igor Pro Bridge v1.23.0: stray debugger-globals fix + `close_data_browser` removal + +Two changes made on branch `feature/2754-add-basic-igor-pro-mcp-server` (`tools/igor-mcp-bridge/` +in this repo, a separate codebase from the MIES procedures themselves, packaged as its own +`.mcpb` Claude Desktop extension) after the corrected `CHECK_EMPTY_FOLDER()` root-cause analysis +above pinned the actual bug on this bridge's own code. + +**Fix**: every tool that touches Igor's Debugger settings funnels through exactly two shared +helpers in `server.py` -- `_read_debugger_options()` (a read-only query, used by +`get_debugger_state`, `set_debugger_enabled`, `restore_debugger_settings`, +`execute_igor_command_unattended`, `load_experiment`, `get_environment_summary`) and +`_apply_debugger_options(state)` (the write/set command, used by +`execute_igor_command_unattended`, `load_experiment`, `set_debugger_enabled`, +`restore_debugger_settings`). Both build an Igor command string containing a bare/argument +`DebuggerOptions` invocation -- confirmed (see the `CHECK_EMPTY_FOLDER()` section above) that +this operation *always* creates `V_enable`/`V_debugOnError`/`V_debugOnAbort`/ +`V_NVAR_SVAR_WAVE_Checking` as output variables in whatever data folder is current, purely as a +side effect of being called, regardless of arguments. Fix: both helpers now append +`; KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking` onto the +*same* command string (one Execute2 round-trip, not a separate call) -- for the read-only query, +this runs after the `fprintf` that captures the values into `results`, so nothing is lost by +cleaning up immediately. Fixing it at this shared-helper level (rather than only inside +`execute_igor_command_unattended`, as the original TODO was phrased) means every one of the six +tools listed above gets the fix, not just one. + +**`close_data_browser` removed** (per explicit user request, no longer wanted): deleted the tool +function from `server.py` and its entry from `Packages/doc/igor-pro-bridge.rst`. It was a +precautionary tool (close Igor's built-in Data Browser before a reload/compile cycle, added +after Igor Pro was reported to sometimes crash with one open) that turned out to not be needed +in practice. + +**Packaged as v1.23.0 using the official `mcpb` CLI** (`@anthropic-ai/mcpb`, available via +`npx mcpb`), replacing an earlier ad hoc/undocumented packaging process -- no build script or +`manifest.json` existed anywhere in this repo before this session; both were reconstructed by +unpacking the prior `igor-pro-bridge-1.22.0.mcpb` (a plain zip of `manifest.json` + +`pyproject.toml` + `src/server.py`) with `unzip`, editing in place, and repacking with +`npx mcpb pack . .mcpb`. Kept `_BRIDGE_VERSION` (`server.py`), `manifest.json`'s +`"version"`, and `pyproject.toml`'s `version` all in sync at `1.23.0` (found and fixed a +pre-existing drift while at it: `pyproject.toml` had been stuck at `"1.19.0"` since at least +v1.22.0). Also found and fixed a pre-existing gap in `manifest.json`'s `"tools"` array: the +`get_bridge_version` tool has existed in `server.py` since v1.22.0 but was never actually listed +there -- added it. `npx mcpb validate manifest.json` and a `python -m py_compile` of the packaged +`src/server.py` both passed before delivering the bundle. **Gotcha**: the first `npx mcpb pack` +attempt swept a stray `src/__pycache__/*.pyc` (left over from an earlier local `py_compile` check +in the same build directory) into the archive -- deleted it and repacked; always check `npx mcpb +pack`'s own "Archive Contents" listing for anything unexpected like this before shipping. + +**Live-verified after the user installed the new build and restarted Claude Desktop**: +`get_bridge_version()` confirmed `{"version": "1.23.0"}` actually loaded; `check_bridge_health()` +returned `"status": "OK"`. Confirmed the fix directly: ran a trivial +`execute_igor_command_unattended('print "hello from unattended"')` from a freshly-cleaned `root:` +and checked `VariableList("*", ";", 4)` immediately after -- empty, no stray globals, where the +old (pre-fix) bridge would have left the same four variables behind on every such call. Then ran +the actual regression case: `RunWithOpts(testcase="HardwareSelectionWorks")` via +`execute_igor_command_unattended` (the exact call shape that reliably failed with +`CHECK_EMPTY_FOLDER()` before the fix) -- result: `"Finished with no errors"` / +`"Test finished with no errors"`, no assertion failure. Confirms the fix is real and the +`execute_igor_command`-only workaround from the section above can be retired. + +## `ListBoxSelectAll` test coverage added, live-verified + +`MIES_Utilities_GUI.ipf`'s `ListBoxSelectAll(WAVE selWave)` had no test coverage. Added +`TestListBoxSelectAll` and `TestListBoxSelectAllOnPlainSelectionWave` to +`Packages/tests/Basic/UTF_Utils_GUI.ipf`, designed from actually reading the function body +(`selWave[][0][0] = selWave[p][0][0] | LISTBOX_SELECT_OR_SHIFT_SELECTION`) plus corroborating +evidence from Igor's own `Igor Reference.ihf` `ListBox` operation docs (`selWave` is "a +numeric wave with the same dimensions as listWave," bit 0 = selected, "additional dimensions +are used for color info," "in modes 3 and 4 bit 0 is set only in column zero") and from how +MIES itself builds a real selWave (`GetAnalysisBrowserGUIFolderSelection`: `Make/N=(1,1,3)`, +layer 0 = selection, layers 1/2 dim-labeled `foreColors`/`backColors`) -- so the first test's +3-layer shape mirrors production usage rather than being an arbitrary shape. + +- **Live-tested via the Igor Pro Bridge** (launched Igor Pro 9 nightly with + `launch_igor_pro_unattended`, loaded `Basic.pxp`, ran `RunWithOpts(testsuite= + "UTF_Utils_GUI")`). First run caught a real bug in the second test, not in + `ListBoxSelectAll` itself: `TestListBoxSelectAllOnPlainSelectionWave` built `selWave` as + `Make/FREE/N=(numRows, 1)` (2D) but `expected` as `Make/FREE/N=(numRows)` (1D) -- + `CHECK_EQUAL_WAVES` failed on `DIMENSION_SIZES`/`DIMENSION_LABELS` even though the actual + data values matched. Fixed by making `expected` explicitly `(numRows, 1)` too. Re-ran after + fixing and reloading/recompiling: **"Test finished with no errors."** +- **`RunWithOpts` also accepts a single `testcase=` name** (in addition to `testsuite=`), to + run one specific test function without the rest of its suite -- confirmed live: `RunWithOpts + (testcase="TestListBoxSelectAll")` ran only that one case ("Entering test case + \"TestListBoxSelectAll\"" / "Finished with no errors"), still reporting "Entering test suite + \"UTF_Utils_GUI.ipf\"" around it (the suite file is still scanned to locate the named case, + but only that case actually runs). Useful for iterating on a single new/failing test without + re-running an entire suite. +- **Fuller option reference for `RunWithOpts`**, read directly from its own source + (`Packages/tests/UTF_HelperFunctions.ipf`) and the underlying `RunTest` it calls + (`Packages/igortest/procedures/igortest-basics.ipf` -- this experiment's compiled + environment uses the `igortest` framework, not the older, also-present `unit-testing` + package; confirmed by `included_procedure_files` listing `igortest-basics.ipf` but not + `unit-testing-basics.ipf`). `RunWithOpts` is a thin MIES-specific wrapper: `testsuite` + defaults to `GetDefaultTestSuitesForExperiment()` if omitted, `traceWinList` defaults to + `"MIES_.*\.ipf"` (only used if `instru=1`), and it otherwise forwards straight to `RunTest`. + Named parameters, all optional: + - `testsuite` -- semicolon-separated list of procedure files to treat as test suites (e.g. + `"UTF_Utils_GUI"` -- `RunWithOpts` appends `.ipf` automatically unless `enableRegExp=1`). + Defaults to this experiment's full default suite list if omitted entirely. + - `testcase` -- semicolon-separated list of test-case function names to run within + `testsuite` (default: all). Confirmed live above for a single name. + - `enableRegExp` -- when `1`, both `testsuite` and `testcase` are matched as (anchored, + case-insensitive) regular expressions instead of literal/list names, and `testsuite` is + matched against the full file name **including** `.ipf` (confirmed live: `testsuite= + "UTF_Utils_GUI"` with `enableRegExp=1` failed with "A procedure window matching the + pattern \"^(?i)UTF_Utils_GUI$\" could not be found" -- needed `testsuite= + "UTF_Utils_GUI.ipf"`). Combining both let one `RunWithOpts(testsuite="UTF_Utils_GUI.ipf", + testcase="TestListBoxSelectAll.*", enableRegExp=1)` call run both new `ListBoxSelectAll` + tests together without the rest of the suite or a semicolon-joined exact-name list -- + confirmed live, both passed. + - `allowDebug` -- leave Igor's Debugger in whatever state it's already in for the run + (normally overridden off); ignored if `debugMode` is also given. Not relevant to this + bridge's own calls, since `execute_igor_command_unattended`/`load_experiment` already + force the Debugger off for the duration of the call regardless. + - `instru` -- turns on execution tracing/coverage instrumentation (RTF + optionally + Cobertura output) over `traceWinList` (defaults to all `MIES_*.ipf` files); off by + default. Unrelated to pass/fail reporting -- a coverage feature, not needed just to + check correctness. + - `ITCXOP2Debug` -- hardware (ITC) XOP debug mode passthrough via `HW_ITC_DebugMode`; not + relevant without real DAQ hardware attached. + - `keepDataFolder` -- don't clean up each test case's temporary data folder afterward, to + allow inspecting produced data by hand; off by default. + - `enableJU` -- write a JUnit-compatible XML report at the end; defaults to on only when + `IsRunningInCI()` is true, off in an interactive/bridge-driven run like this session's. + - All of the above are also documented with more nuance directly on `RunTest` itself + (`igortest-basics.ipf` around line 1490), including two options `RunWithOpts` doesn't + expose at all: `shuffle` (randomize suite/test-case execution order, useful for catching + order-dependent test bugs) and `retry`/`retryMaxCount` (rerun flaky tests tagged + `IUTF_RETRY_FAILED` up to N times) -- call `RunTest` directly instead of `RunWithOpts` if + either of those is needed. +- **Note on `TestRemoveAllColumnsFromTable`'s console output**: this pre-existing, + unmodified test deliberately prints two `"!!! Assertion FAILED !!!"` lines (from + `RemoveAllColumnsFromTable`'s own internal `ASSERT` firing inside a `try/catch` the test + sets up on purpose, to confirm the function rejects a non-table window) -- this is expected + output, not a real failure, and correctly does not appear in the suite's final failure list + (consistent with the fail-path-test convention already noted elsewhere in this file for + UTF test suites generally). +- `ipt check`/`ipt lint` were run against the edited test file both before and after the fix + (per the new standing `ipt` rule above) and reported zero errors/warnings each time -- a + reminder that a clean `ipt` parse does not guarantee the test's *assertions* are actually + correct (that dimension-mismatch bug parsed and linted cleanly); only the live Igor Pro run + caught it. + +## Git note + +`.git/packed-refs` was observed truncated (trailing NUL bytes, "unterminated line" error +blocking all git commands) partway through an earlier session. **Resolved/non-issue as of +this session**: `git status`/`git log` ran cleanly (checked while investigating whether +this session's local fixes had reached the PR branch), confirming it was a transient +artifact of the folder mount rather than lasting repo damage. + +## FFI hardware test coverage: remaining write functions (branch `feature/2559-mh_add_ffi_clamp_control`) + +Added tests for every remaining `FFI_Set*`/`FFI_TriggerAutoClampControl` write function in +`Packages/tests/HardwareBasic/UTF_ForeignFunctionInterfaceWithHardware.ipf`, each following the +established setup/`_REENTRY` two-function pattern (`InitDAQSettingsFromString`/`AcquireData_NG` in +setup, assertions in `_REENTRY`), all live-verified via the bridge ("Finished with no errors" for +each), in order added: + +- **`FFIGetClampStateWorks`/`_REENTRY`**: covers `FFI_GetClampState` (the *unfiltered* clamp state, + containing both VC and IC fields regardless of active mode -- unlike `FFI_GetCurrentClampState`, + which is filtered to the active mode only), plus a `GetWaveDimensionality(clampState) == ROWS` + check confirming the returned wave is 1D (`GetWaveDimensionality`, `MIES_Utilities_WaveHandling.ipf` + line 142, returns the highest dimension index with `DimSize > 1`, or `ROWS` if none -- the + established MIES idiom for a 1D-wave assertion). +- **`FFISetGetHeadstageActiveWorks`/`_REENTRY`**: `FFI_SetHeadstageActive`/`FFI_GetHeadstageActive` + round-trip on HS1 (enable, verify, disable, verify), plus invalid-headstage abort for both. +- **`FFISetClampModeWorks`/`_REENTRY`**: cycles HS0 through VC -> I=0 -> back to IC via + `FFI_SetClampMode`, verifying `FFI_GetCurrentClampState(...)[%ClampMode]` after each; invalid + clamp mode (`-1`) and invalid headstage both abort. Refactored (user request) to introduce a + `variable headstage = 0` local instead of repeating the literal `0` at every call site. +- **`FFISetHoldingPotentialWorks`/`_REENTRY`**: sets/verifies/disables a VC holding potential + (`CHECK_CLOSE_VAR` for the float value, `tol = 1e-6`); NaN potential aborts; calling it while not + in VC aborts (`"Attempt to set holding potential but current clamp mode is not VC !"`); invalid + headstage aborts. Also given the `headstage` variable refactor. +- **`FFISetBiasCurrentWorks`/`_REENTRY`**: same shape as holding-potential, for IC/bias current + (`"Attempt to set bias current but current clamp mode is not IC !"`). +- **`FFISetAutoBiasWorks`/`_REENTRY`**: same shape again, for `FFI_SetAutoBias`'s target + potential/enable. Clamp-state field names for this one are **`AutoBiasVcom`**/`AutoBiasEnable` + (not e.g. "AutoBiasPotential") -- confirmed from `AI_MapFunctionConstantToName`'s + `MCC_NO_AUTOBIAS_V_FUNC`/`MCC_NO_AUTOBIAS_ENABLE_FUNC` cases in `MIES_AmplifierInteraction.ipf`. + Note: unlike `FFI_SetHoldingPotential`/`FFI_SetBiasCurrent`, `FFI_SetAutoBias`'s source has **no + `IsNaN` guard** on its `potential` argument -- so this test intentionally has no NaN-abort case + (there is nothing to assert there); the positive-path enable/disable/verify assertions cover it + instead. +- **`FFITriggerAutoClampControlWorks`/`_REENTRY`**: exercises all three `FFI_TriggerAutoClampControl` + auto-control kinds -- auto pipette offset (works in either clamp mode), auto bridge balance + (IC-only, `"MCC_AUTOBRIDGEBALANCE_FUNC works only in IC clampMode"` if not), auto capacitance + (VC-only, `"MCC_AUTOWHOLECELLCOMP_FUNC works only in VC clampMode"` if not) -- plus an unknown + auto-control value (`"Unknown auto clamp control"`, via `FATAL_ERROR`) and the usual invalid + -headstage abort. `AUTO_PIPETTE`/`AUTO_CAPACITANCE`/`AUTO_BRIDGEBALANCE` are `static Constant`s + private to `MIES_ForeignFunctionInterface.ipf` (values `1`/`2`/`3`), so the test uses the numeric + literals directly with an explanatory comment rather than referencing the (inaccessible-from-here) + named constants. + +**Gotcha hit and fixed once (`FFISetHoldingPotentialWorks`)**: an early version tried to force HS1 +into IC (to test the "wrong mode" abort path) via `FFI_SetClampMode(device, 1, I_CLAMP_MODE)`. This +doesn't abort -- it just prints `"(Dev1) Could not switch the clamp mode to I_CLAMP_MODE as no DA +and/or AD channels are associated with headstage 1."` and returns normally, since HS1 has no DA/AD +channels associated in this suite's standard single-headstage setup (only HS0 does) -- +`DAP_SetClampMode` requires associated channels to actually perform the switch and just logs and +returns otherwise, it does not `ASSERT`/abort. This made the subsequent `try +FFI_SetHoldingPotential(device, 1, 0, 1); FAIL()` block spuriously fail, since HS1's mode never +actually changed. **Fix, and standing pattern for every later "wrong mode" test in this group**: use +HS0 itself for the mode-mismatch check (switch HS0 to the wrong mode via `FFI_SetClampMode`, which +does work on HS0, run the abort-expecting `try`/`catch`, then switch HS0 back) -- never rely on HS1 +for anything that requires an actual clamp-mode change. + +## Rebase verification (`feature/2559-mh_add_ffi_clamp_control` onto latest `main`) + +After the user rebased this branch onto the latest `origin/main`, verified the rebase was resolved +correctly rather than just trusting a clean `git status`: + +- No conflict markers (`<<<<<<<`/`>>>>>>>`) anywhere in the repo; no `.git/rebase-merge`/ + `rebase-apply` in progress -- the rebase had genuinely completed. +- `git merge-base HEAD origin/main` equals `origin/main`'s own tip exactly, confirming the 3 + feature commits (`DAP: Refactor...`, `AI: Add range check...`, `FFI: Add functions for clamp and + headstage control`) sit directly on latest `main` with nothing missing or duplicated. +- `git diff --stat ..origin/main` showed upstream only touched + `MIES_SweepFormula_Parser.ipf` and its tests/docs in the interim -- completely disjoint from + every file this branch touches (`MIES_DAEphys.ipf`, `MIES_AmplifierInteraction.ipf`, + `MIES_ForeignFunctionInterface.ipf`, the FFI hardware test file, `UTF_DataGenerators.ipf`). So + there was essentially no real content to conflict on in the first place. +- `DAP_SetClampMode` (`MIES_DAEphys.ipf`) picked up an added `AI_AssertOnInvalidClampMode(mode)` + call at its top -- redundant with (but harmless alongside) `FFI_SetClampMode`'s own + `ASSERT(AI_IsValidClampMode(...))` check one level up; confirmed harmless since the FFI-level + assert still fires first with its own more specific message ("Invalid clamp mode: -1"), verified + by the still-passing `FFISetClampModeWorks` test. +- Working tree was clean for every FFI-related file (no diff vs. `HEAD`) -- all 16 FFI test + functions this session added were confirmed present and byte-identical to what had been tested. +- **Transient, self-resolved compile error observed mid-verification, not a real problem**: one + `read_session_history()` dump showed `UTF_ForeignFunctionInterfaceWithHardware.ipf:35:8: error: + No such structure exists.` sandwiched between two full-suite runs that both completed with + "Finished with no errors." `check_compilation_state()` immediately after reported clean, and two + subsequent full-suite runs both passed cleanly -- the error did not recur and was very likely a + transient artifact of file-on-disk churn during the rebase (e.g. Igor's file-watcher catching a + momentarily-inconsistent file state), not a lasting defect. Lesson: don't treat one compile-error + line found in a long, cumulative history dump as necessarily current -- corroborate with a fresh + `check_compilation_state()` and/or another clean run before concluding something is actually + broken. +- Two pieces of uncommitted, unrelated-to-the-rebase local state, initially just flagged for the + user -- **now clarified by the user as permanent, intentional, and never to be committed**: + - `Packages/tests/Basic/UTF_Basic_Includes.ipf`'s commented-out `example-stimulus-set-api` + include: the file is only present in CI, not locally, so this line must stay commented out + for local test runs to work at all -- but must equally never be committed that way, since CI + needs it active. Leave this uncommitted local diff alone; don't "fix" it by committing either + state. + - `Packages/MIES_Include.ipf`'s `#include "MIES_ClaudeScrapCode"` line and + `Packages/MIES/MIES_ClaudeScrapCode.ipf` itself: **never commit**, purely a scratch file used + only during interactive Claude Desktop sessions (see the dedicated section below for the + required per-branch-switch cleanup step this implies). +- Practical technique for reading a very large `read_session_history()` dump without exceeding the + context window: save it to a file, convert the JSON-escaped `\r` sequences to real newlines + (`sed 's/\\r/\n/g'`, **not** `tr '\r' '\n'` -- the saved file contains the literal two-character + escape sequence, not an actual carriage-return byte, since it's JSON text written verbatim), then + `grep -n` for just the markers of interest (`error:`, `Finished with no errors`, `RunWithOpts(`) + instead of reading the whole thing. + +## `tools/check-code.sh`'s "trailing semicolon" check has a comment-detection blind spot + +The check (`git grep --perl-regexp '^[[:space:]]*[^\/].*;$' ...`) is meant to flag lines of actual +Igor code with a stray trailing `;` (Igor doesn't need semicolons to terminate statements, only to +separate multiple statements on one line), while skipping `//`-comment lines via the `[^\/]` +right after the leading whitespace. **This comment-exclusion doesn't reliably work for indented +comments**: `[^\/]` matches any single character that isn't a literal `/`, including whitespace +itself. Since `[[:space:]]*` is greedy but backtracks on failure, the regex engine can satisfy +`[^\/]` by consuming the leading tab/space instead of requiring `[[:space:]]*` to do it -- so an +indented `//` comment line still matches the overall pattern as long as it happens to end in a +literal `;`. Net effect: only comments with *zero* leading indentation are reliably excluded; +virtually every real comment in this codebase is indented and so is not actually protected by this +guard. + +Hit this for real: three new comments in `UTF_ForeignFunctionInterfaceWithHardware.ipf` used a +semicolon as a prose separator ("... aborts; use HS0 itself for this ...", written as two +sentences split across lines with the first ending in `;`) and were flagged as "trailing +semicolon" hits even though there was no actual code semicolon anywhere nearby -- confirmed by +inspecting the flagged lines directly, all three were `//`-comments. Fixed by rewording (no longer +ending any comment in `;`); `tools/check-code.sh` reported no more trailing-semicolon failures +afterward. + +**Standing rule (user's instruction) going forward**: never end a comment with a semicolon. +`tools/check-code.sh` runs as part of this repo's pre-push git hook, and a "trailing semicolon" +hit blocks `git push` outright -- so this isn't just a style nit, it's a hard gate. Prefer a +period, comma, or just restructuring the sentence instead of `;` at a comment's line-end. + +## Igor Pro Bridge v1.25.0: pinned `install.ps1`/`requirements.txt`, and the MCP Python SDK's +## breaking v1 -> v2 transition + +**Dated finding, confirmed via web search this session (2026-08-03) and by directly inspecting +both wheels' contents**: the MCP Python SDK's v2 line (`mcp` on PyPI) went stable at `2.0.0`, +released 2026-07-27/28 alongside MCP protocol revision `2026-07-28` -- a deliberate breaking +rework, not a routine minor bump. Most relevant to this bridge: `FastMCP` was renamed to +`MCPServer` and moved from `mcp.server.fastmcp` to `mcp.server.mcpserver`. `server.py` still +uses the v1 API (`from mcp.server.fastmcp import FastMCP`), confirmed still present by +unzipping the `mcp==1.29.0` wheel directly (`mcp/server/fastmcp/server.py` exists; the `2.0.0` +wheel does not have that path at all). **This means the bridge's previous unpinned dependency +spec (`mcp>=1.0.0` in `pyproject.toml`, and the manifest's own documented `pip install mcp +pywin32` instruction) was a live, undiscovered bug as of this finding**: running either command +today resolves to `2.0.0` and breaks the bridge outright with a `ModuleNotFoundError` on +import, with no warning beforehand. Fixed by pinning `mcp==1.29.0` (the last 1.x release, +confirmed via `pip index versions mcp`) in a new `requirements.txt`, and tightening +`pyproject.toml`'s spec to `mcp>=1.29.0,<2` so a future `pip install -e .`-style install can't +silently repeat the same mistake. `pywin32` pinned to `312` (confirmed latest via web search) +in the same file for the same "don't drift silently" reason, even though it wasn't at similar +risk of a breaking rename. + +**The user's request that prompted this**: an installation script for the bridge that (a) +installs pinned versions from a `requirements.txt`, (b) installs specifically into the Python +environment Claude Desktop itself uses when run elevated -- explicitly *not* assumed to be the +same as whatever Python an elevated console resolves by default -- and (c) runs pywin32's +required post-install step. Delivered as `tools/igor-mcp-bridge/install.ps1`. + +**Design rationale for (b), the "which Python does Claude Desktop actually use" problem**: +Claude Desktop's `manifest.json` invokes the bridge as the bare command `"python"`, resolved by +Claude Desktop's own (elevated) process via whatever `PATH` its environment has at launch time. +This is a different resolution mechanism than an interactive elevated PowerShell/cmd session, +which can have extra `PATH` entries injected only for that session (a PowerShell profile +script activating a conda environment, pyenv-win shims, etc.) that a plain elevated GUI-app +launch never picks up -- so trusting an elevated console's own `$env:Path` to decide where to +`pip install` can silently target the wrong interpreter entirely. There's also a known Windows +elevation-specific quirk with per-user Microsoft Store "app execution alias" stubs (a +placeholder `python.exe` that just opens the Store) behaving differently once elevated. +`install.ps1` avoids all of this by reading `[Environment]::GetEnvironmentVariable('Path', +'Machine')` and `...('Path', 'User')` directly (the same two registry-backed sources, in the +same order, Windows composes into a freshly created process's environment block) instead of +the invoking shell's own `$env:Path`, and explicitly rejects a Microsoft Store app-execution- +alias stub found that way (detected by path containing `\WindowsApps\` and a small file size). +An `-PythonPath` parameter bypasses this resolution entirely for a known-correct interpreter. + +**Because auto-resolution is still a best-effort guess, not a guarantee**, also added ground- +truth verification: `get_bridge_version()` now additionally reports `python_executable` +(`sys.executable`), `python_version`, `mcp_package_version`, and `pywin32_build` (all via +`importlib.metadata.version(...)`, since the `mcp` package itself has no `__version__` +attribute -- confirmed by inspecting its `__init__.py`). The documented workflow: run +`install.ps1`, restart Claude Desktop (elevated), then call `get_bridge_version()` and confirm +`python_executable` matches what `install.ps1` installed into; if not, re-run `install.ps1 +-PythonPath `. This closes the loop with an authoritative answer from inside the +actual process Claude Desktop launched, rather than relying on `install.ps1`'s guess alone. + +Repackaged as `igor-pro-bridge-1.25.0.mcpb` (bumped from 1.24.0), now including +`requirements.txt` and `install.ps1` alongside `server.py` in the bundle. Also bumped +`requires-python`/the manifest's `compatibility.runtimes.python` from `>=3.9` to `>=3.10` to +match `mcp==1.29.0`'s own floor (confirmed via that wheel's `METADATA`: `Requires-Python: +>=3.10`). Verified: `python3 -m py_compile` on `server.py` inside the repacked bundle, and a +byte-for-byte diff against the repo's own copy, both clean. Could not test-run `install.ps1` +itself end-to-end (no Windows/PowerShell available in this session's sandbox; downloading a +portable `pwsh` build failed -- GitHub's release-asset CDN host was unreachable from here, +unlike `github.com` itself) -- reviewed by hand instead (brace/paren/bracket/here-string +balance checked programmatically, backtick-escape usage traced line by line). **Should be +smoke-tested on a real elevated Windows session before being treated as fully proven.** + +## Igor Pro Bridge `requirements.txt`: added pip hash-pinning (no version bump, per user request) + +Follow-up to the above: the user asked to additionally pin every package in `requirements.txt` +to its cryptographic hash, explicitly **without** bumping the bridge version or repackaging the +`.mcpb` -- so this only touched `tools/igor-mcp-bridge/requirements.txt` and `install.ps1` +(added `--require-hashes` to the latter's `pip install` call for a clear failure instead of a +silent unverified install if the hashes are ever accidentally stripped later). **The already- +packaged `igor-pro-bridge-1.25.0.mcpb` still contains the old, unhashed `requirements.txt`** -- +that's a deliberate consequence of not repackaging, not an oversight; only the working-tree copy +(which is what `install.ps1` actually reads via `$PSScriptRoot`, whether run from the repo or +copied out of an already-installed extension folder) is updated. Bundling the hashed version +requires a future repackage. + +**Why this is more involved than pinning just the two direct dependencies**: pip's hash-checking +mode (triggered automatically the instant any requirement has a `--hash`) requires **every** +package that would actually be installed -- not just the top-level ones -- to be pinned to an +exact version with a hash, including the entire transitive dependency tree. Since `mcp==1.29.0` +alone pulls in roughly two dozen packages (`anyio`, `httpx`/`httpcore`/`h11`, `pydantic`/ +`pydantic-core`, `jsonschema` and its own tree, `pyjwt`+`cryptography`+`cffi`+`pycparser`, +`starlette`/`sse-starlette`/`uvicorn`, etc.), the whole tree had to be resolved and hashed, not +just `mcp`/`pywin32` themselves. + +**Resolving a Windows dependency tree from this session's Linux sandbox**: used +`pip download --platform win_amd64 --python-version --implementation cp --abi cp +--only-binary=:all: -r requirements.in -d

`, which lets pip's real resolver fetch metadata +and wheels for a *different* target platform/Python version than the host is actually running, +entirely through wheel-tag matching (no code execution/building needed, since every package in +this tree ships wheels for Windows). Ran this once per Python version (310/311/312/313, the +range this bridge's `pyproject.toml` currently declares, `>=3.10`) to catch any per-version +divergence, then `sha256sum` on every downloaded wheel file directly (not via PyPI's JSON API -- +see the gotcha below) to get the hash values themselves. + +**Real, non-obvious finding surfaced by doing this per-Python-version rather than just once**: +`rpds-py` (a transitive dependency of `jsonschema`/`referencing`) resolved to a **different +version**, not just a different wheel file, depending on target Python version -- `0.30.0` for +Python 3.10, `2026.6.3` for 3.11+ -- because the current `rpds-py` release has dropped Python +3.10 support entirely (no cp310 wheel published for it), so pip's resolver falls back to the +newest version that still has one. This is a real version-vs-version divergence, not just a +platform/ABI-tag difference (contrast with `cryptography`, which stays at one version, `50.0.0`, +but ships two different abi3 wheels -- `cp39-abi3` and `cp311-abi3` -- covering the same version +across the whole 3.10-3.13 range with two hashes on one pinned line). Handled by splitting +`rpds-py` into two separate pinned+hashed lines gated by `python_version < '3.11'` / +`>= '3.11'` markers in the requirements file -- valid, standard pip syntax, confirmed working +(see verification below). + +**Gotcha hit and worth remembering generally**: plain `curl`/Python `urllib` requests to +`pypi.org`'s JSON API (`https://pypi.org/pypi///json`) failed outright from this +sandbox (TLS handshake reset partway through) even though `pip download` itself worked fine -- +this environment's outbound network evidently only allow-lists pip's own configured index +traffic, not arbitrary HTTPS to `pypi.org`. Worked around it by computing `sha256sum` directly on +the wheel files `pip download` had already fetched, which is equally correct (identical bytes, +identical hash) and doesn't depend on being able to query PyPI's API directly. + +**Second gotcha, more subtle and specifically relevant to how this was verified**: pip's +`--platform`/`--python-version`/`--implementation`/`--abi` flags (as used with `download`) +**only affect wheel-tag matching, not PEP 508 environment-marker evaluation** -- markers like +`python_version` and `sys_platform` are always evaluated against the *actual, real* running +interpreter, never the target flags. First noticed when a `pip download --require-hashes` +verification pass targeting `cp312` (from this sandbox's real Python 3.10) tried to install +`rpds-py==0.30.0` (the marker-selected, `python_version < '3.11'` line -- true for the *real* +host interpreter, 3.10) while simultaneously asking for a `cp312`-tagged wheel of it -- a +self-inflicted, impossible-in-real-life combination (a real Python 3.12 install would never +evaluate that marker true) that only arises from mixing this sandbox's real 3.10 interpreter +with cross-platform *download* target overrides. **This is a testing-methodology artifact only, +not a bug in the requirements.txt itself** -- on a real target machine, there is no +cross-compilation happening at all; pip runs natively on the real interpreter, so markers and +wheel-tag selection are automatically consistent. Verified correctly instead by: (1) a full, +un-confounded `pip download --require-hashes` round-trip for the complete dependency tree, +restricted to `--python-version 310 --abi cp310` (an exact match for this sandbox's real +interpreter -- zero host/target mismatch), which succeeded end-to-end with zero hash or +"missing pin" errors across all ~30 packages; (2) isolated single-package hash checks (each +package alone in a throwaway requirements file, no markers) for `pywin32` (cp310), `rpds-py` +(cp311, the *other* branch, confirming its hash independently of marker evaluation), and a +`pydantic-core` cp313 wheel. All passed. The cp311/cp312/cp313-specific wheel hashes for +`pydantic-core`/`cffi`/`cryptography`/`pywin32` that couldn't be round-tripped this way (no +3.11+ interpreter available in this sandbox, and installing one would have required root/apt +access this sandbox doesn't have) were still computed via direct `sha256sum` on the actual +downloaded wheel bytes, which is the authoritative hash regardless of pip's marker-evaluation +quirks -- just not independently re-verified through pip's own hash checker for those specific +combinations. **Should still be smoke-tested with a real `pip install --require-hashes -r +requirements.txt` on an actual elevated Windows machine with each supported Python version, the +same caveat as install.ps1 itself above.** + +**That real-machine smoke test happened, and it failed exactly the way the caveat above +predicted it could.** The user ran `install.ps1` for real (elevated PowerShell): auto-resolution +correctly found their actual Python (`C:\Users\enigm\AppData\Local\Programs\Python\Python314\ +python.exe`, i.e. **Python 3.14.0**), pip upgraded cleanly, then failed with +`THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE` on `pywin32==312` -- +because this session's resolution pass had only covered cp310/311/312/313, never cp314, so +there was no hash for it at all yet Python 3.14 exists and was in real, current use. Exactly the +gap called out in the requirements.txt regeneration comment: `pyproject.toml` declares an +open-ended `>=3.10` floor, so a newly released Python version can reach real users before its +wheels are covered here -- this isn't a hypothetical, it happened on the very first real test. + +Fixed by re-running the same `pip download --platform win_amd64 --python-version 314 +--implementation cp --abi cp314 --only-binary=:all:` resolution pass and adding the resulting +hashes for the four version-specific compiled packages needing them (`pywin32`, `pydantic-core`, +`cffi`, `rpds-py`'s 2026.6.3 branch) to the existing lines -- confirmed all four packages still +resolve to the exact same *versions* already pinned (no new divergence beyond the pre-existing +rpds-py 3.10 split), `cryptography`'s existing `cp311-abi3` wheel already covers 3.14 (stable +ABI, no new file needed, confirmed no new cryptography download occurred for the cp314 target). +The `pywin32==312` cp314 hash added +(`a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e`) was cross-checked against +the exact "Got" value in the user's own error message -- an exact match, confirming both that +the file pip fetched really is the genuine, untampered PyPI artifact and that the fix is +correct. Verified the regenerated file with the same methodology as before: a full +`pip download --require-hashes` round-trip for the complete tree at `--python-version 310` +(unchanged, zero errors), plus isolated single-package hash checks for the four new cp314 +hashes at `--python-version 314` (three passed to completion; the `cffi`/`pydantic-core` +isolated checks correctly got past the hash check itself -- reaching "Using cached ...whl" before +failing only on their own *unpinned* transitive deps in the deliberately minimal, single-package +test file, which the real requirements.txt already pins). + +**Lesson for future maintenance**: an unbounded `requires-python` floor (`>=3.10`, no upper +bound) means this hash-pinned file needs updating **every time a new CPython minor version is +released and gains adoption**, not just when a dependency version is deliberately bumped -- +there's no automatic detection of this short of someone actually hitting the gap, as happened +here. Worth checking for a new Python release's wheel availability periodically rather than only +reactively. + +## `install.ps1`: PowerShell mangles a multi-line, quote-containing argument passed to a native exe + +After the cp314 hash fix above, the user's real elevated run got all the way through -- +`pip install --require-hashes` succeeded (all packages installed/upgraded cleanly, including +correctly *skipping* the `rpds-py==0.30.0; python_version < '3.11'` line on their real Python +3.14, exactly as designed) and the `pywin32_postinstall.py -install` step also completed +successfully (DLLs copied to `system32`, COM registrations done) -- **only the final +verification step failed**, with a Python `SyntaxError` on a line that should have read +`print(f"python_executable: {sys.executable}")` but arrived as `print(fpython_executable:` -- +every embedded `"` character had vanished, and the embedded newlines had become literal raw +newlines inside what pip/Python received as a single command-line argument. + +**Root cause**: `install.ps1`'s verification step built a multi-line Python snippet (containing +several f-strings, i.e. embedded double quotes) as a single PowerShell string and passed it as +one argument to `python.exe -c` via `& $Exe @Arguments` (the script's own `Invoke-Checked` +helper). PowerShell's argument-to-native-command-line conversion is a known weak point exactly +for this combination -- a single argument containing both embedded double quotes *and* +newlines -- and mangled the quotes when re-serializing the argument array into the actual Win32 +process command-line string. This is a genuine, confirmed-in-practice limitation, not a +one-off typo: the other `Invoke-Checked` call sites in this same script (`pip install ...`, +`pywin32_postinstall.py -install`) never hit this because none of their arguments contain both +quotes and newlines together -- only the verification step's inline multi-line snippet did. + +**Fix**: stopped passing the Python snippet via `-c` entirely. Instead, `Set-Content` writes it +to a temp file (`Join-Path ([System.IO.Path]::GetTempPath()) "igor-bridge-verify-.py"`, +via `[guid]::NewGuid()` for a collision-free name) inside a `try`/`finally`, then +`python .py` is run as an ordinary script argument (just a path -- no quotes, no +newlines, no PowerShell native-argument-quoting risk at all), with the temp file always removed +afterward regardless of success/failure. This is the standard, robust pattern for handing a +non-trivial script to a subprocess from PowerShell -- avoid command-line argument quoting +entirely for anything beyond simple flags/paths, and use a temp file instead. + +**Confirmed this really was the whole remaining problem**: everything upstream of the +verification step (Python resolution, elevation check, `pip install --require-hashes` against +the newly-fixed hashed `requirements.txt`, `pywin32_postinstall.py -install`) succeeded cleanly +on this real Python 3.14/Windows run with zero other issues -- a good sign that the rest of the +script's design (registry-PATH-based Python resolution, hash-pinned installs, elevation +handling) is sound in practice, not just in this session's own sandboxed review. Could not +re-run the fixed version against the same real machine this session (no live access) -- the fix +itself (write-to-temp-file instead of inline `-c` argument) is a well-established pattern for +this exact class of problem, but it should still be re-confirmed on a real elevated Windows +session before being treated as fully proven, same standing caveat as the rest of this script. diff --git a/tools/igor-mcp-bridge/ZMQ_BridgeHelpers.ipf b/tools/igor-mcp-bridge/ZMQ_BridgeHelpers.ipf new file mode 100644 index 0000000000..358524bb72 --- /dev/null +++ b/tools/igor-mcp-bridge/ZMQ_BridgeHelpers.ipf @@ -0,0 +1,599 @@ +#pragma rtFunctionErrors = 1 +#pragma TextEncoding = "UTF-8" +#pragma rtGlobals = 3 +#pragma IndependentModule = ZBR +#pragma version = 1.00 + +// ZMQ_BridgeHelpers.ipf -- Igor Pro-side helper functions for the Igor Pro Bridge +// (tools/igor-mcp-bridge/), which talks to Igor over the ZeroMQ-XOP's CallFunction JSON +// protocol. #include-d from Packages/MIES_Include.ipf; see igor-pro-bridge.rst for setup +// in other experiments, and SESSION_NOTES.md for full design rationale/history. +// +// Compiles as its own independent module (#pragma IndependentModule=ZBR) so it stays +// reachable via CallFunction even if the rest of the experiment has a compile error. +// Execute cannot run unqueued from inside a Function, so most operations here use a +// submit (Execute/P) + poll pattern instead of one blocking call -- see +// ZBR_SubmitCommand/ZBR_PollCommand. + +// --- Constants ------------------------------------------------------------------------- + +/// Reported by ZBR_Ping. +static StrConstant ZBR_VERSION_STR = "1.00" + +/// ZeroMQ ROUTER (server) socket endpoint -- distinct from MIES's own ZeroMQ port +/// (MIES_MiesUtilities_ZeroMQ.ipf) to avoid collisions. +static StrConstant ZBR_ZEROMQ_ENDPOINT = "tcp://127.0.0.1" +static Constant ZBR_ZEROMQ_DEFAULT_PORT = 5680 +static StrConstant ZBR_ZEROMQ_ENV_PORT = "IGOR_PRO_BRIDGE_PORT" + +static StrConstant ZBR_RECOMPILE_WATCHDOG_TASK = "ZBR_RecompileWatchdog" + +// --- Storage ------------------------------------------------------------------------- + +/// Parallel-array storage for in-flight ZBR_SubmitCommand() calls, indexed by row. A +/// row's index (as a string) is the token handed back to the caller. +static Function ZBR_EnsureStorage() + + NewDataFolder/O root:Packages + NewDataFolder/O root:Packages:ZBR + DFREF dfr = root:Packages:ZBR + + if(!WaveExists(dfr:done)) + Make/N=0/O dfr:done // 0 = pending, 1 = done + Make/N=0/O/T dfr:resultText + Make/N=0/O dfr:historyStart + endif +End + +/// Ensures a valid CaptureHistoryStart() refnum is active, recovering if the stored one +/// is stale (e.g. after reloading a saved experiment). See SESSION_NOTES.md for why the +/// probe call and AbortOnRTE must stay on the same line. +static Function ZBR_EnsureCaptureStarted() + + string dummy + variable err + + NewDataFolder/O root:Packages + NewDataFolder/O root:Packages:ZBR + + NVAR/Z refnum = root:Packages:ZBR:captureRefNum + if(!NVAR_Exists(refnum)) + variable/G root:Packages:ZBR:captureRefNum = CaptureHistoryStart() + else + NVAR refnumRW = root:Packages:ZBR:captureRefNum + + try + dummy = CaptureHistory(refnumRW, 0); AbortOnRTE + catch + err = GetRTError(1) + refnumRW = CaptureHistoryStart() + endtry + endif +End + +static Function ZBR_CaptureRefNum() + + ZBR_EnsureCaptureStarted() + NVAR refnum = root:Packages:ZBR:captureRefNum + + return refnum +End + +static Function ZBR_HistoryLength() + + return strlen(CaptureHistory(ZBR_CaptureRefNum(), 0)) +End + +static Function/S ZBR_HistorySince(variable startLen) + + string full = CaptureHistory(ZBR_CaptureRefNum(), 0) + + if(strlen(full) <= startLen) + return "" + endif + + return full[startLen, Inf] +End + +// --- Generic command execution (submit/poll) ------------------------------------------ + +/// Allocates a new token/storage row, shared by ZBR_SubmitCommand and +/// ZBR_SubmitReloadAndCompile. +static Function/S ZBR_AllocateToken() + + variable n + + ZBR_EnsureStorage() + DFREF dfr = root:Packages:ZBR + WAVE done = dfr:done + WAVE/T resultText = dfr:resultText + WAVE historyStart = dfr:historyStart + + n = DimSize(done, 0) + Redimension/N=(n + 1) done, resultText, historyStart + done[n] = 0 + resultText[n] = "" + historyStart[n] = ZBR_HistoryLength() + + return num2istr(n) +End + +/// Queues `cmd` for deferred execution and returns a token to poll via ZBR_PollCommand(). +/// `cmd` and the finish-callback are queued as separate Execute/P entries so the +/// callback still runs even if `cmd` fails to parse or errors -- see SESSION_NOTES.md. +Function/S ZBR_SubmitCommand(string cmd) + + string token, finishCall + + token = ZBR_AllocateToken() + sprintf finishCall, "ZBR#ZBR_FinishToken(%s)", token + Execute/P/Q/Z cmd + Execute/P/Q/Z finishCall + + return token +End + +/// Same as ZBR_SubmitCommand, but disables Igor's Debugger for `cmd`'s duration and +/// restores it afterward via persistent globals (plain locals don't survive the +/// boundary between separate Execute/P entries). +Function/S ZBR_SubmitCommandUnattended(string cmd) + + string token, finishCall, restore + + DebuggerOptions + variable/G root:Packages:ZBR:savedDebugEnable = V_enable + variable/G root:Packages:ZBR:savedDebugOnError = V_debugOnError + variable/G root:Packages:ZBR:savedDebugOnAbort = V_debugOnAbort + variable/G root:Packages:ZBR:savedDebugNvarCheck = V_NVAR_SVAR_WAVE_Checking + KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking + + restore = "DebuggerOptions enable=root:Packages:ZBR:savedDebugEnable, " + restore += "debugOnError=root:Packages:ZBR:savedDebugOnError, " + restore += "debugOnAbort=root:Packages:ZBR:savedDebugOnAbort, " + restore += "NVAR_SVAR_WAVE_Checking=root:Packages:ZBR:savedDebugNvarCheck; " + restore += "KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking, " + restore += "root:Packages:ZBR:savedDebugEnable, root:Packages:ZBR:savedDebugOnError, " + restore += "root:Packages:ZBR:savedDebugOnAbort, root:Packages:ZBR:savedDebugNvarCheck" + + token = ZBR_AllocateToken() + sprintf finishCall, "ZBR#ZBR_FinishToken(%s)", token + + Execute/P/Q/Z "DebuggerOptions enable=0" + Execute/P/Q/Z cmd + Execute/P/Q/Z restore + Execute/P/Q/Z finishCall + + return token +End + +/// Deferred callback that finalizes a submitted command's result row. Bounds-checks idx +/// before writing since storage may have been resized since submission. See +/// SESSION_NOTES.md for why GetRTError(1) here can't reliably detect that `cmd` errored. +Function ZBR_FinishToken(variable idx) + + variable err + string errMsg + + DFREF dfr = root:Packages:ZBR + WAVE done = dfr:done + WAVE/T resultText = dfr:resultText + WAVE historyStart = dfr:historyStart + + if(idx >= 0 && idx < DimSize(done, 0)) + err = GetRTError(1) + if(err) + errMsg = "ERROR: " + GetErrMessage(err) + "\r" + else + errMsg = "" + endif + + resultText[idx] = errMsg + ZBR_HistorySince(historyStart[idx]) + done[idx] = 1 + endif +End + +/// Polls a token from ZBR_SubmitCommand/ZBR_SubmitCommandUnattended. isDone=0 while +/// pending; once isDone=1, result holds everything printed while the command ran (see +/// SESSION_NOTES.md for the "ran fine vs. errored silently" ambiguity). +Function [variable isDone, string result] ZBR_PollCommand(string token) + + variable idx + + DFREF dfr = root:Packages:ZBR + WAVE done = dfr:done + WAVE/T resultText = dfr:resultText + + idx = str2num(token) + if(NumType(idx) != 0 || idx < 0 || idx >= DimSize(done, 0)) + return [1, "ERROR: unknown token " + token] + endif + + if(!done[idx]) + return [0, ""] + endif + + return [1, resultText[idx]] +End + +// --- Wave access ----------------------------------------------------------------------- + +/// Returns a wave by its full data-folder path. +Function/WAVE ZBR_GetWaveGeneric(string wavePath) + + WAVE/Z w = $wavePath + return w +End + +// --- Compilation state ------------------------------------------------------------- + +/// True if ProcGlobal is compiled. Must qualify the FunctionInfo() probe with +/// "ProcGlobal#" -- an unqualified name resolves against this (always-compiled) +/// independent module instead. See SESSION_NOTES.md. +Function ZBR_IsCompiled() + + return strlen(FunctionInfo("ProcGlobal#ZBR_DefinitelyNotARealFunctionName_8f3a1c")) == 0 +End + +/// Reads root:gClaudeHelperCompileCounter (bumped by AfterCompiledHook on every +/// successful compile) without creating it. Returns -1 if not yet created. +Function ZBR_ReadCompileCounter() + + return NumVarOrDefault("root:gClaudeHelperCompileCounter", -1) +End + +/// Queues ZBR_StopHandlerBeforeRecompile, RELOAD CHANGED PROCS, and COMPILEPROCEDURES as +/// three separate Execute/P entries (each needs its own trailing space). Poll +/// ZBR_IsCompiled()/ZBR_ReadCompileCounter() rather than a finish-callback -- anything +/// queued behind COMPILEPROCEDURES is discarded by the recompile. See SESSION_NOTES.md +/// for the crash mitigation this exists for. +Function ZBR_SubmitReloadAndCompile() + + Execute/P/Q/Z "ZBR#ZBR_StopHandlerBeforeRecompile()" + Execute/P/Q/Z "RELOAD CHANGED PROCS " + Execute/P/Q/Z "COMPILEPROCEDURES " + + return 0 +End + +/// Stops the ZeroMQ handler and arms the recompile watchdog before RELOAD CHANGED +/// PROCS/COMPILEPROCEDURES run. +Function ZBR_StopHandlerBeforeRecompile() + + variable err + + zeromq_handler_stop(); err = GetRTError(1) + ZBR_ArmRecompileWatchdog() + + return 0 +End + +/// Restarts the ZeroMQ handler only -- does not rebind the socket. See +/// ZBR_EnsureZeroMQBound's docstring for why binding now lives elsewhere. +Function ZBR_StartHandlerAfterRecompile() + + variable err + + zeromq_handler_start(); err = GetRTError(1) + + return 0 +End + +/// Arms a named background task that unconditionally restarts the ZeroMQ handler after a +/// reload/compile attempt, whether it succeeded or failed (AfterCompiledHook alone can't +/// cover the failure case). `start=60` sets an explicit ~1s floor before the first +/// possible tick, since background tasks and the deferred operation queue are not +/// strictly ordered -- see SESSION_NOTES.md for the timing data behind this value. +static Function ZBR_ArmRecompileWatchdog() + + variable err + + CtrlNamedBackground $ZBR_RECOMPILE_WATCHDOG_TASK, period=30, proc=ZBR_RecompileWatchdogTick, start=60 + err = GetRTError(1) + + return 0 +End + +/// Restarts the ZeroMQ handler via ZBR_StartHandlerAfterRecompile() and self-disarms. +/// Public (non-static): CtrlNamedBackground's proc= target must be resolvable from +/// outside this function's own immediate caller. +Function ZBR_RecompileWatchdogTick(STRUCT WMBackgroundStruct &s) + + ZBR_StartHandlerAfterRecompile() + CtrlNamedBackground $ZBR_RECOMPILE_WATCHDOG_TASK, stop + + return 1 +End + +// --- Debugger control ---------------------------------------------------------------- + +Function [variable enable, variable debugOnError, variable debugOnAbort, variable nvarChecking] ZBR_GetDebuggerState() + + DebuggerOptions + variable e = V_enable + variable doe = V_debugOnError + variable doa = V_debugOnAbort + variable nv = V_NVAR_SVAR_WAVE_Checking + KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking + + return [e, doe, doa, nv] +End + +Function ZBR_SetDebuggerEnabled(variable enable) + + DebuggerOptions enable=(enable != 0) + KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking + + return 0 +End + +Function ZBR_RestoreDebuggerSettings(variable enable, variable debugOnError, variable debugOnAbort, variable nvarChecking) + + DebuggerOptions enable=enable, debugOnError=debugOnError, debugOnAbort=debugOnAbort, NVAR_SVAR_WAVE_Checking=nvarChecking + KillVariables/Z V_enable, V_debugOnError, V_debugOnAbort, V_NVAR_SVAR_WAVE_Checking + + return 0 +End + +// --- Direct built-in introspection wrappers ------------------------------------------- +// +// Thin, generic passthroughs to read-only Igor built-ins -- structuring/parsing the +// returned raw strings into a proper dict happens client-side (Python); see +// get_environment_summary in server.py for where these get assembled. + +/// IgorInfo(n) passthrough -- see Igor Reference.ihf for the index table. +Function/S ZBR_IgorInfo(variable n) + + return IgorInfo(n) +End + +/// WinList(matchStr, ";", options) passthrough. +Function/S ZBR_WinList(string matchStr, string options) + + return WinList(matchStr, ";", options) +End + +/// ProcedureText(funcName, flags, winTitle) passthrough. Pass funcName="" and winTitle=a +/// window name to retrieve that window's whole contents -- winTitle is the third +/// argument, not the first (passing it first silently returns ""). +Function/S ZBR_ProcedureText(string funcName, variable flags, string winTitle) + + return ProcedureText(funcName, flags, winTitle) +End + +/// DataFolderDir(bits) for the current data folder. Callers wanting a specific folder +/// should set it first via ZBR_SubmitCommand("SetDataFolder ..."). +Function/S ZBR_DataFolderDir(variable bits) + + return DataFolderDir(bits) +End + +/// FunctionInfo(name) passthrough. ZBR_IsCompiled() is this called with a bogus name. +Function/S ZBR_FunctionInfo(string name) + + return FunctionInfo(name) +End + +// --- Environment introspection ------------------------------------------------------- + +/// Minimal identity/diagnostic summary; get_environment_summary composes its full +/// picture client-side from the granular wrappers above instead. +Function/S ZBR_GetEnvironmentSummary() + + string summary + sprintf summary, "igorInfo0=%s;experiment=%s;dataFolder=%s;compiled=%d", IgorInfo(0), IgorInfo(1), GetDataFolder(1), ZBR_IsCompiled() + + return summary +End + +/// Reads back everything sent to history since the capture started. stop=1 also kills +/// the stored refnum; the next call starts a fresh capture. +Function/S ZBR_ReadSessionHistory(variable stop) + + string text + variable refnum = ZBR_CaptureRefNum() + + text = CaptureHistory(refnum, stop) + + if(stop) + KillVariables/Z root:Packages:ZBR:captureRefNum + endif + + return text +End + +// --- Health / identity ----------------------------------------------------------------- + +/// Confirms this module specifically is loaded and reachable. +Function/S ZBR_Ping() + + string info + sprintf info, "ZBR_ALIVE|version=%s|experiment=%s|dateTime=%.0f", ZBR_VERSION_STR, IgorInfo(1), DateTime + + return info +End + +// --- Help file reading ------------------------------------------------------------------ +// +// Synchronous equivalent of the COM bridge's read_help_file -- none of these operations +// are subject to the Execute-only-from-top-level restriction COMPILEPROCEDURES needs, so +// this runs as one direct CallFunction round trip, no submit/poll needed. + +/// Returns the first entry in `afterList` not present in `beforeList`, or "" if none. +static Function/S ZBR_FirstNewListEntry(string afterList, string beforeList) + + variable i, n + string name + + n = ItemsInList(afterList) + for(i = 0; i < n; i += 1) + name = StringFromList(i, afterList) + if(WhichListItem(name, beforeList) == -1) + return name + endif + endfor + + return "" +End + +/// Resolves a bare help-file name (as returned by WinList's WIN:512 bit) to a full path, +/// or "" if not found in Igor's standard Help Files folders. +static Function/S ZBR_ResolveHelpFilePath(string bareName) + + string specialDirs = "Igor Application;Igor Pro User Files" + string base, candidate + variable i, n + + n = ItemsInList(specialDirs) + for(i = 0; i < n; i += 1) + base = SpecialDirPath(StringFromList(i, specialDirs), 0, 1, 0) + if(strlen(base) == 0) + continue + endif + candidate = base + "Igor Help Files:" + bareName + GetFileFolderInfo/Q/Z candidate + if(V_flag == 0) + return candidate + endif + endfor + + return "" +End + +/// Exports filePath (an .ihf help file) as HTML to tmpHtmlPath, restoring whatever help +/// windows were open beforehand. Returns "OK|" or +/// "ERROR||". See SESSION_NOTES.md for the full sequence and +/// the Abort-dialog pitfall this avoids. +Function/S ZBR_ReadHelpFile(string filePath, string tmpHtmlPath) + + string helpAll, helpVisible, notebooksBefore, newName, restoreFailures + string name, resolvedPath, statusStr + variable i, numHelpWin, visibleFlag, err + + helpAll = WinList("*", ";", "WIN:512") + helpVisible = WinList("*", ";", "WIN:512,VISIBLE:1") + notebooksBefore = WinList("*", ";", "WIN:16") + newName = "" + statusStr = "OK" + + try + CloseHelp/ALL; AbortOnRTE + + OpenNotebook/R filePath; AbortOnRTE + + newName = ZBR_FirstNewListEntry(WinList("*", ";", "WIN:16"), notebooksBefore) + if(strlen(newName) == 0) + // Not Abort "" -- pops a dialog before catch runs. See SESSION_NOTES.md. + statusStr = "ERROR|OpenNotebook/R succeeded but no new notebook window was found" + else + SaveNotebook/O/S=5/H={"UTF-8", 3, 7, 0, 0.9, 32} $newName as tmpHtmlPath; AbortOnRTE + endif + catch + err = GetRTError(1) + statusStr = "ERROR|" + GetErrMessage(err) + endtry + + if(strlen(newName) > 0) + KillWindow/Z $newName + endif + + restoreFailures = "" + numHelpWin = ItemsInList(helpAll) + for(i = 0; i < numHelpWin; i += 1) + name = StringFromList(i, helpAll) + resolvedPath = ZBR_ResolveHelpFilePath(name) + if(strlen(resolvedPath) == 0) + restoreFailures = AddListItem(name, restoreFailures, ";", Inf) + continue + endif + visibleFlag = (WhichListItem(name, helpVisible) != -1) ? 1 : 0 + OpenHelp/V=(visibleFlag)/INT=0/Z=1 resolvedPath; err = GetRTError(1) + if(err) + restoreFailures = AddListItem(name, restoreFailures, ";", Inf) + endif + endfor + + return statusStr + "|" + restoreFailures +End + +// --- ZeroMQ server bind ---------------------------------------------------------------- + +/// (Re-)binds this module's ZeroMQ ROUTER socket and starts its handler. Does not call +/// zeromq_stop() first, so it won't tear down any other ZeroMQ binds in the same +/// experiment (e.g. MIES's own). Safe to call repeatedly -- an already-bound error is +/// caught, not propagated. Called only from IgorStartOrNewHook; recompiles instead just +/// stop/restart the handler via ZBR_StopHandlerBeforeRecompile/ +/// ZBR_StartHandlerAfterRecompile, not a full rebind. See SESSION_NOTES.md. +static Function ZBR_EnsureZeroMQBound() + + variable err, port + string bindURL, envPort + + envPort = GetEnvironmentVariable(ZBR_ZEROMQ_ENV_PORT) + if(!strlen(envPort)) + port = ZBR_ZEROMQ_DEFAULT_PORT + else + port = str2num(envPort); err = GetRTError(1) + if(numType(port) == 2) + printf "Could not parse port number from %s: %s\rUsing default port %d\r", ZBR_ZEROMQ_ENV_PORT, envPort, ZBR_ZEROMQ_DEFAULT_PORT + port = ZBR_ZEROMQ_DEFAULT_PORT + endif + endif + + sprintf bindURL, "%s:%d", ZBR_ZEROMQ_ENDPOINT, port + zeromq_server_bind(bindURL); err = GetRTError(1) + if(!err) + printf "Igor Pro Bridge MCP bound through ZMQ at port %d\r", port + endif + zeromq_handler_start(); err = GetRTError(1) + + return 0 +End + +/// Releases running thread groups before Igor uncompiles, preventing a blocking +/// "Function Execution Module is still active" dialog from a stray MIES background +/// thread during COMPILEPROCEDURES. See SESSION_NOTES.md. +static Function BeforeUncompiledHook(variable changeCode, string procedureWindowTitleStr, string textChangeStr) + + variable err + + err = ThreadGroupRelease(-2) +End + +/// Fires on Igor launch and on creating a new experiment. Binds/starts the ZeroMQ +/// handler here (once per process) rather than in AfterCompiledHook. +static Function IgorStartOrNewHook(string igorApplicationNameStr) + + variable modifiedBefore + + ExperimentModified + modifiedBefore = V_flag + + ZBR_EnsureZeroMQBound() + + if(!modifiedBefore) + ExperimentModified 0 + endif + + return 0 +End + +/// Bumps the compile-confirmation counter on every successful compile. Does not touch +/// the ZeroMQ socket/handler -- see IgorStartOrNewHook. +static Function AfterCompiledHook() + + variable modifiedBefore + + ExperimentModified + modifiedBefore = V_flag + + variable/G root:gClaudeHelperCompileCounter + NVAR gClaudeHelperCompileCounter = root:gClaudeHelperCompileCounter + + gClaudeHelperCompileCounter += 1 + + if(!modifiedBefore) + ExperimentModified 0 + endif + + return 0 +End diff --git a/tools/igor-mcp-bridge/igor-pro-bridge-2.3.2.mcpb b/tools/igor-mcp-bridge/igor-pro-bridge-2.3.2.mcpb new file mode 100644 index 0000000000..e4ab58c629 Binary files /dev/null and b/tools/igor-mcp-bridge/igor-pro-bridge-2.3.2.mcpb differ diff --git a/tools/igor-mcp-bridge/install.ps1 b/tools/igor-mcp-bridge/install.ps1 new file mode 100644 index 0000000000..58c4452122 --- /dev/null +++ b/tools/igor-mcp-bridge/install.ps1 @@ -0,0 +1,278 @@ +<# +.SYNOPSIS + Installs the pinned Python dependencies for the Igor Pro Bridge MCP server + (tools/igor-mcp-bridge/server.py) into the same Python environment Claude Desktop + itself resolves when it launches the bridge. + +.DESCRIPTION + Claude Desktop's manifest.json invokes the bridge as the bare command "python", + resolved via whatever PATH Claude Desktop's own process environment has at launch + time. That is NOT guaranteed to be the same Python an interactive console session + resolves -- e.g. a PowerShell profile script activating a conda environment only + for that session, or a per-user Microsoft Store "app execution alias" stub (a + placeholder python.exe that just opens the Store) which is known to behave + differently once elevated. Installing packages into whatever Python a + manually-opened console happens to find can therefore silently install into the + wrong environment. + + This script instead resolves python.exe from the Machine and then User PATH + registry values directly (via [Environment]::GetEnvironmentVariable(..., target)), + the same two sources and order Windows composes into a freshly created process's + environment block regardless of that process's elevation state -- deliberately + ignoring this session's own possibly-customized $env:Path, to mirror what a + freshly launched Claude Desktop process actually sees, whether or not it happens + to be elevated. Pass -PythonPath explicitly to skip this resolution entirely if + you already know the right interpreter (e.g. from a prior get_bridge_version() + call -- see below). + + Steps performed, in order: + 1. Confirm this script itself is running elevated. This is required only for + step 5 below (pywin32's post-install step, which registers COM-support DLLs + into protected system locations, even though this bridge no longer uses COM + itself as of v2.0.0 -- see below) -- it is NOT because Claude Desktop or Igor + Pro themselves need to be elevated at runtime. As of v2.0.0, the bridge talks + to Igor Pro over a plain localhost ZeroMQ socket, which has NO privilege- + matching requirement at all (unlike the old COM transport, which needed + Claude Desktop and Igor Pro to run at the same privilege level) -- this + script needing elevation is purely a one-time, install-time requirement of + its own (pywin32's DLL registration), unrelated to how you run the bridge or + Igor Pro afterward. + 2. Resolve python.exe (or use -PythonPath). + 3. ` -m pip install --upgrade pip` + 4. ` -m pip install --require-hashes -r requirements.txt` (pinned, + hash-verified versions -- see that file's own comments, notably why "mcp" is + pinned below its new v2 line, and why every transitive dependency is listed + and hashed too, not just mcp/pyzmq/pywin32 themselves). + 5. Run pywin32's required post-install step + (Scripts\pywin32_postinstall.py -install), which `pip install pywin32` alone + does not do -- it registers pywin32's COM-support DLLs. This bridge itself no + longer uses COM (v2.0.0+), but pywin32 is still a dependency for + dismiss_compile_error_dialog's window enumeration and for process launching, + and skipping this step can still leave the install in a broken state. + 6. Import-check mcp, zmq, and win32api/win32gui with the same interpreter, and + print its full path/version (plus pyzmq's version) for you to cross-check. + + After this script finishes, fully restart Claude Desktop, then call the bridge's + get_bridge_version tool from a conversation -- its "python_executable" field is + the authoritative answer for which interpreter Claude Desktop actually launched. + If it doesn't match what this script installed into, re-run this script with + -PythonPath pointing at that reported path. + +.PARAMETER PythonPath + Full path to a specific python.exe to install into, bypassing auto-resolution + entirely. Use this if auto-resolution picks the wrong interpreter, or if you + already know the right one (e.g. from get_bridge_version()'s "python_executable"). + +.PARAMETER RequirementsFile + Path to the requirements.txt to install from. Defaults to requirements.txt next to + this script. + +.EXAMPLE + .\install.ps1 + Auto-resolve python.exe and install. + +.EXAMPLE + .\install.ps1 -PythonPath 'C:\Python312\python.exe' + Install into a specific, already-known-correct interpreter. +#> + +[CmdletBinding()] +param( + [string]$PythonPath, + [string]$RequirementsFile = (Join-Path $PSScriptRoot 'requirements.txt') +) + +$ErrorActionPreference = 'Stop' + +function Test-IsElevated { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-IsWindowsAppsStub { + <# + Detects the Microsoft Store "app execution alias" placeholder for python.exe + (typically under %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe): a small + reparse-point stub that, when run without a real Store Python install behind + it, just opens the Store instead of running anything. A real install (Store + Python or otherwise) landing in that same folder is not rejected -- only the + tiny placeholder is, distinguished here by file size. + #> + param([string]$Path) + + if ($Path -notlike '*\WindowsApps\*') { + return $false + } + + $item = Get-Item -LiteralPath $Path -ErrorAction SilentlyContinue + return ($item -and $item.Length -lt 100KB) +} + +function Resolve-ClaudeDesktopPython { + <# + Mirrors how Claude Desktop's own process resolves the bare command "python" + from its manifest.json (regardless of whether that process happens to be + elevated or not -- Machine/User PATH registry values are the same either way), + without trusting this interactive PowerShell session's own $env:Path -- see + the script's top-level comment-based help for the full rationale. Returns the + first matching python.exe found by + searching the Machine PATH, then the User PATH, in that order (the same + composition order Windows uses to build a fresh process's environment block), + or $null if none is found. + #> + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $combined = @($machinePath, $userPath) -join ';' + $dirs = $combined -split ';' | Where-Object { $_ } + + foreach ($dir in $dirs) { + $candidate = Join-Path $dir 'python.exe' + if ((Test-Path -LiteralPath $candidate) -and -not (Test-IsWindowsAppsStub $candidate)) { + return (Resolve-Path -LiteralPath $candidate).ProviderPath + } + } + + return $null +} + +function Invoke-Checked { + <# + Runs an external executable and throws if it exits nonzero. Needed because + PowerShell's $ErrorActionPreference does not apply to native command exit + codes -- only to PowerShell-native errors -- so failures here would otherwise + be silently ignored and the script would carry on as if each step succeeded. + #> + param( + [Parameter(Mandatory)][string]$Exe, + [Parameter(Mandatory)][string[]]$Arguments + ) + + & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed (exit code $LASTEXITCODE): `"$Exe`" $($Arguments -join ' ')" + } +} + +# --- 1. Elevation check ----------------------------------------------------------- + +if (-not (Test-IsElevated)) { + Write-Error ( + "This script must run elevated (as Administrator) because the pywin32 " + + "post-install step below registers COM-support DLLs into protected system " + + "locations, which requires admin rights regardless of how you plan to run " + + "Claude Desktop/Igor Pro afterward. This is a one-time, install-time " + + "requirement only -- as of v2.0.0 the bridge talks to Igor Pro over " + + "ZeroMQ, which has no privilege-matching requirement at all, so neither " + + "Claude Desktop nor Igor Pro need to be elevated at runtime (see " + + "igor-pro-bridge.rst, 'Requirements'). Re-run this script from an " + + "elevated PowerShell (right-click PowerShell -> Run as administrator)." + ) + exit 1 +} + +# --- 2. Resolve the target python.exe ---------------------------------------------- + +if ($PythonPath) { + if (-not (Test-Path -LiteralPath $PythonPath)) { + throw "Specified -PythonPath does not exist: $PythonPath" + } + $python = (Resolve-Path -LiteralPath $PythonPath).ProviderPath + Write-Host "Using explicitly specified python: $python" +} else { + $python = Resolve-ClaudeDesktopPython + if (-not $python) { + throw ( + "Could not find python.exe on either the Machine or User PATH " + + "(registry-level, not this session's own `$env:Path). Install Python " + + "first, or pass -PythonPath explicitly." + ) + } + Write-Host "Auto-resolved python (Machine/User PATH): $python" + Write-Host ( + "If Claude Desktop actually uses a different interpreter (e.g. one only " + + "available via a shell profile/conda environment, not the registry PATH " + + "searched here), re-run this script with -PythonPath after confirming the " + + "real one via the bridge's get_bridge_version tool (its " + + "'python_executable' field)." + ) +} + +& $python --version +if ($LASTEXITCODE -ne 0) { + throw "`"$python`" --version failed (exit code $LASTEXITCODE) -- is this a valid Python interpreter?" +} + +# --- 3./4. Install pinned requirements ---------------------------------------------- + +if (-not (Test-Path -LiteralPath $RequirementsFile)) { + throw "requirements.txt not found at: $RequirementsFile" +} + +Write-Host "`nUpgrading pip..." +Invoke-Checked -Exe $python -Arguments @('-m', 'pip', 'install', '--upgrade', 'pip') + +Write-Host "`nInstalling pinned, hash-verified packages from $RequirementsFile ..." +# --require-hashes is passed explicitly (pip would already enter hash-checking mode +# automatically the moment any requirement has a --hash) so that if requirements.txt +# is ever accidentally edited down to unhashed entries, this fails loudly with a clear +# pip error instead of silently installing an unverified package. +Invoke-Checked -Exe $python -Arguments @('-m', 'pip', 'install', '--require-hashes', '-r', $RequirementsFile) + +# --- 5. pywin32 post-install --------------------------------------------------------- + +# Scripts\ is a sibling of python.exe's own directory for both a full Python install +# and a virtual environment -- the standard Windows layout pywin32's installer targets. +$scriptsDir = Join-Path (Split-Path -Parent $python) 'Scripts' +$postInstall = Join-Path $scriptsDir 'pywin32_postinstall.py' + +if (-not (Test-Path -LiteralPath $postInstall)) { + throw ( + "pywin32_postinstall.py not found at expected path: $postInstall -- the " + + "pywin32 install above may have failed, or landed in an unexpected layout " + + "for this Python installation." + ) +} + +Write-Host "`nRunning pywin32 post-install step ($postInstall -install)..." +Invoke-Checked -Exe $python -Arguments @($postInstall, '-install') + +# --- 6. Verify ------------------------------------------------------------------------- + +Write-Host "`nVerifying the installed packages import correctly..." +# Written to a temp .py file and run as a script, rather than passed via `python -c +# `: PowerShell's argument-quoting for native executables is unreliable for a +# single argument that itself contains both embedded double quotes and newlines (a +# multi-line Python snippet with f-strings hits both) -- confirmed for real, this +# mangled the double quotes around the f-strings below into a Python SyntaxError when +# first tried as `-c $verifyScript`. A temp file sidesteps command-line +# quoting/escaping entirely. +$verifyScript = @' +import importlib.metadata +import sys +import mcp +import zmq +import win32api +import win32gui + +print(f"python_executable: {sys.executable}") +print(f"python_version: {sys.version.split()[0]}") +print(f"mcp_package_version: {importlib.metadata.version('mcp')}") +print(f"pyzmq_version: {importlib.metadata.version('pyzmq')}") +print(f"pywin32_build: {importlib.metadata.version('pywin32')}") +print("zmq / win32api / win32gui imports OK") +'@ +$verifyScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "igor-bridge-verify-$([guid]::NewGuid()).py" +try { + Set-Content -LiteralPath $verifyScriptPath -Value $verifyScript -Encoding utf8 + Invoke-Checked -Exe $python -Arguments @($verifyScriptPath) +} finally { + Remove-Item -LiteralPath $verifyScriptPath -ErrorAction SilentlyContinue +} + +Write-Host ( + "`nDone. Fully restart Claude Desktop, then call the bridge's get_bridge_version " + + "tool and confirm its 'python_executable' field matches: $python`n" + + "If it does not match, re-run this script with -PythonPath set to the path " + + "get_bridge_version() actually reports." +) diff --git a/tools/igor-mcp-bridge/manifest.json b/tools/igor-mcp-bridge/manifest.json new file mode 100644 index 0000000000..5149679e81 --- /dev/null +++ b/tools/igor-mcp-bridge/manifest.json @@ -0,0 +1,120 @@ +{ + "manifest_version": "0.4", + "name": "igor-pro-bridge", + "display_name": "Igor Pro Bridge", + "version": "2.3.2", + "description": "Control a running Igor Pro instance via the ZeroMQ-XOP's CallFunction interface: run commands, read wave data, manage compilation, load experiments, and launch Igor Pro itself, directly from Claude.", + "long_description": "v2.3.2: preparation for eventually talking to more than one Igor Pro instance, plus a comment-reduction pass and one more unattended-use dialog fix. On the Igor side (`ZMQ_BridgeHelpers.ipf`), `ZBR_EnsureZeroMQBound` now reads an `IGOR_PRO_BRIDGE_PORT` environment variable (`ZBR_ZEROMQ_ENV_PORT`) at bind time and binds to that port instead of its own default (5680, `ZBR_ZEROMQ_DEFAULT_PORT`) when it's set. The ZeroMQ bind/handler-start was also restructured: binding (`zeromq_server_bind`) now happens once, in a new `IgorStartOrNewHook` (fires on Igor launch and on creating a new experiment), rather than on every compile via `AfterCompiledHook` as in v2.3.0/v2.3.1 -- `AfterCompiledHook` now only bumps the compile-confirmation counter, and the recompile watchdog/`ZBR_StopHandlerBeforeRecompile` path only stops/restarts the message *handler* (`ZBR_StartHandlerAfterRecompile`, `zeromq_handler_stop`/`zeromq_handler_start`) rather than attempting a fresh bind on every recompile, since the bind is a property of the ZeroMQ-XOP itself and doesn't need repeating just because Igor recompiled a procedure file. On the Python side (`server.py`), `configure_igor_launch` gained an optional `port` parameter: setting it writes `IGOR_PRO_BRIDGE_PORT` into this bridge process's own environment (inherited by the launched Igor Pro child process, which reads it via the above), and every ZeroMQ-talking function (`call_function`, `check_bridge_health`, and everything built on them) now resolves its endpoint through one `_igor_zmq_endpoint()` helper instead of a fixed constant, so setting or clearing the port immediately retargets all of them together, not just the next launch. Omitting `port` (or passing `None`) explicitly clears a previously-configured custom port, removing the environment variable rather than merely not re-setting it -- calling `configure_igor_launch` to update just the exe path without repeating `port=` will also clear any previously-set port, documented prominently since Python can't distinguish an omitted argument from an explicit `None`. This bridge still only tracks one currently-configured endpoint at a time; talking to two Igor Pro instances simultaneously isn't supported yet. Also fixed in `ZBR_ReadHelpFile`: an `Abort \"\"` used when the expected new notebook window wasn't found displayed a real alert dialog at the moment `Abort` executed, BEFORE the enclosing `try`/`catch` ever got a chance to intervene -- wrapping it in `try`/`catch` did not suppress the popup the way it does for an ordinary runtime error, which would have hung unattended use. Fixed by setting the error status string directly and skipping `SaveNotebook` in that branch instead of calling `Abort` at all. Finally, `ZMQ_BridgeHelpers.ipf`'s in-code comments (which had grown to extensive historical narratives per function) were cut down to short one/two-line docstrings; the full design rationale and bug histories they contained were moved to a new reference section in `SESSION_NOTES.md` instead, so the reasoning is preserved without cluttering the source file. No behavior change from the comment reduction itself.\n\nv2.3.1: fixed a concept flaw in v2.3.0's crash mitigation, found live by the repo owner: v2.3.0 stops the ZeroMQ handler before `RELOAD CHANGED PROCS`/`COMPILEPROCEDURES` run and relies on `AfterCompiledHook` to restart it afterward -- but Igor only calls `AfterCompiledHook` after a *successful* compile. If the edited `.ipf` failed to compile, the hook never fired, the handler stayed stopped forever, and the bridge was permanently dead with no recovery short of restarting Igor Pro. Fixed by adding `ZBR_ArmRecompileWatchdog`/`ZBR_RecompileWatchdogTick` (`ZMQ_BridgeHelpers.ipf`): a named background task, armed immediately before the handler is stopped, that unconditionally restarts/rebinds the handler regardless of whether the compile succeeds or fails, then self-disarms. Because Igor's background-task scheduler and its deferred operation queue (`Execute/P`) are two independent subsystems, the repo owner raised a sharp concern: could the watchdog fire before `RELOAD CHANGED PROCS`/`COMPILEPROCEDURES` even finish, reintroducing the exact cross-thread race v2.3.0 exists to prevent? Live timing instrumentation (three `stopmstimer(-2)` printouts: queue start, watchdog tick, `AfterCompiledHook`) confirmed the concern was valid -- with only `period=30` set, the watchdog could and did tick (~62ms after arming) well before a real compile finished (~414ms, per `AfterCompiledHook`'s own timestamp). Fixed by registering the task with an explicit `start=60` (an ~1-second floor before its first possible tick, decoupled from the `period=30` interval between later ticks), comfortably longer than any compile observed this session. The correctness property this protects is that the watchdog must not fire before the operation queue has finished draining -- not that it must fire after `AfterCompiledHook` specifically, which is meaningless on the failure path since that hook never runs then; once the queue has drained, the relative order of the watchdog and `AfterCompiledHook` on the success path no longer matters, since both converge on the same idempotent `ZBR_EnsureZeroMQBound()` restart. This is a generous empirical margin based on observed compile times, not a mathematically airtight guarantee against an arbitrarily slow future compile. A second, independent bug was found and diagnosed live by the repo owner while testing the above: `ZBR_IsCompiled()`'s `FunctionInfo()` call was unqualified, so it resolved against ZBR's own independent-module namespace (always fine) instead of ProcGlobal's -- meaning `check_compilation_state()` could silently report `true` even while ProcGlobal itself had a genuine compile error. Fixed by qualifying the call as `FunctionInfo(\"ProcGlobal#...\")`, confirmed against Igor's own docs and this repo's `igortest-test-compilation.ipf` reference implementation. A third, unrelated issue surfaced during live testing: a pre-existing MIES background thread (not task) left running during `COMPILEPROCEDURES` could raise a blocking \"Function Execution Module is still active\" dialog, freezing Igor's entire operation queue (though direct `CallFunction` calls like `check_bridge_health` kept working throughout, since they don't route through the queue). Mitigated by a new `BeforeUncompiledHook` in `ZMQ_BridgeHelpers.ipf` that calls `ThreadGroupRelease(-2)` to release running thread groups before Igor uncompiles, added by the repo owner and confirmed to prevent recurrence in subsequent testing. No Python-side (`server.py`) logic changes were needed beyond updated docstrings describing these fixes; all of the actual fixes are within `ZMQ_BridgeHelpers.ipf`.\n\nv2.3.0: investigated the long-standing, previously-unexplained crash pattern where Igor Pro itself (not this bridge) would sometimes become unreachable shortly after `reload_and_compile_procedures`. Two Windows crash dumps captured during this bridge's development were analyzed with Python's `minidump` package (Igor's own crash reporter provides no stack trace), confirming both were genuine `EXCEPTION_ACCESS_VIOLATION`s deep inside `Igor64.exe` itself, not in this bridge's code or any XOP. A likely mechanism was then identified, based directly on a comparison the repo owner suggested against this repo's own `igortest-tracing.ipf`, whose `CompileAndRestart()`/`AfterCompiledHook()` pattern reloads and compiles procedures reliably with no crash: unlike that reference pattern, this bridge's ZeroMQ-XOP handler runs as a background thread (documented in `HelpFiles/ZeroMQ.ihf` as \"a threaded message handler\") that keeps dispatching incoming `CallFunction` requests regardless of what Igor's main thread is doing -- so a request arriving while `COMPILEPROCEDURES` is mid-rebuild of Igor's own internal function/symbol tables is a plausible cross-thread race, distinct from anything happening inside `AfterCompiledHook` itself (which runs safely afterward, exactly like `igortest`'s hook). Fixed by adding `ZBR_StopHandlerBeforeRecompile()` (calls `zeromq_handler_stop()`) and queuing it as the FIRST deferred `Execute/P` entry in `ZBR_SubmitReloadAndCompile()`, ahead of `RELOAD CHANGED PROCS`/`COMPILEPROCEDURES`; the handler is restarted afterward via the existing, unchanged `AfterCompiledHook` -> `ZBR_EnsureZeroMQBound()` synchronous call. Confirmed live: recompiled successfully afterward, then ran three CONCURRENT `reload_and_compile_procedures()` calls as a stress test -- all three returned `compiled: true` cleanly with no crash, and `check_bridge_health()`/`execute_igor_command()` both worked correctly afterward. Honest caveat: this is a well-reasoned mitigation, not a proven fix -- `Igor64.exe` ships no public symbols, so the exact fault can't be confirmed from here, and the crash was already rare/nondeterministic before this fix. No Python-side (`server.py`) logic changes were needed beyond an updated docstring; the fix is entirely within `ZMQ_BridgeHelpers.ipf`.\n\nv2.2.3: fixed another uncaught-runtime-error-pops-a-dialog bug, this time in `ZBR_FinishToken`, found while cleaning up leftover test-token rows from this bridge's own `root:Packages:ZBR` storage waves. `ZBR_AllocateToken` captures a token's row index (`idx`) at submission time, but `ZBR_FinishToken` -- the callback that actually writes the result and flips the done flag -- runs later, in its own separate deferred `Execute/P` entry (per the v2.2.0 fix). If the `done`/`resultText`/`historyStart` waves are resized smaller in between (e.g. maintenance code clearing out old tokens, exactly as happened live during this session's own cleanup), `idx` can end up pointing past the end of the now-shorter waves. `ZBR_FinishToken` had no bounds check, so writing to `resultText[idx]` in that state threw an uncaught \"Index out of range for wave...\" runtime error -- which, like the v2.2.1 bug, pops a real modal dialog and blocks Igor's entire main thread until a human dismisses it. Fixed by bounds-checking `idx` against `DimSize(done, 0)` before writing anything; if the row no longer exists, the callback now just silently does nothing (there is nothing useful left to recover), and `ZBR_PollCommand`'s own existing bounds check already reports a clean `\"ERROR: unknown token\"` response to the caller instead of a hang. Confirmed live: reproduced the exact original crash (resizing the storage waves to 0 rows while a submitted command's own token was still in flight, which had just thrown the dialog moments earlier during this session's testing), then repeated it after the fix -- this time it returned `\"ERROR: unknown token ...\"` cleanly with no dialog and no hang, and `check_bridge_health()` stayed `OK` throughout. No Python-side changes were needed.\n\nv2.2.2: minor robustness refinement to the v2.2.1 fix, contributed directly by the repo owner after installing it. `ZBR_EnsureCaptureStarted`'s stale-refnum probe (`CaptureHistory(refnumRW, 0)`) and its `AbortOnRTE` were originally written on separate lines; moved onto the SAME line. Reason: Igor's Debug on Error check happens at the END of each line, not each statement -- if the two were on separate lines, a user with Debug on Error enabled (unusual, but this bridge doesn't force the Debugger off during a raw `execute_igor_command`/`submit_igor_command` call the way the `_unattended` variants do) would get a Debugger popup right when the stale refnum's runtime error occurred, before `AbortOnRTE` ever got a chance to convert it into a catchable abort -- defeating the whole point of the v2.2.1 fix in that one specific configuration. Confirmed live both before accepting this change (by temporarily enabling `debug_on_error` via `set_debugger_enabled` and repeating the deliberately-corrupted-refnum test) and after: with the two statements on one line, the corrupted-refnum scenario completes cleanly with no Debugger popup and no hang even with Debug on Error active, and the refnum still self-heals correctly. No other behavior change.\n\nv2.2.1: fixed a bug where `load_experiment` (or a user manually reopening a saved .pxp) could leave the bridge completely unreachable, requiring a human to dismiss a modal Igor error dialog. Root cause: this bridge's history-capture mechanism stores its `CaptureHistoryStart()` reference number in a plain `Variable/G` global (`root:Packages:ZBR:captureRefNum`), which Igor persists into a saved experiment like any other global -- but that refnum is only meaningful within the OS process that created it. Reloading a saved experiment brings the OLD numeric value back even though the process is brand new; the prior code only checked that the global *existed*, not whether its value was still a live capture, so it trusted the stale refnum. Using it then threw a genuine Igor runtime error (\"there is no open file with this reference number\") from a plain top-level `Execute/P` entry -- not caught anywhere -- which popped a real modal dialog and blocked Igor's entire main thread (and therefore every ZeroMQ reply) until a human dismissed it. Confirmed live by saving and reloading an experiment via `load_experiment` and then calling `execute_igor_command`, which reproduced the dialog exactly. Fixed in `ZBR_EnsureCaptureStarted` (`ZMQ_BridgeHelpers.ipf`): the stored refnum is now validated with a `try`/`catch`/`endtry`+`AbortOnRTE` block before being trusted, and silently replaced with a fresh `CaptureHistoryStart()` call if it's stale -- confirmed live afterward, both by deliberately corrupting the refnum to a bogus value and by repeating the exact save-then-`load_experiment`-then-`execute_igor_command` sequence that originally triggered the dialog; neither reproduced the dialog and both recovered silently. (Two syntax mistakes were made and caught along the way while implementing this: a runtime error inside a `try` block does not by itself jump to `catch` without an explicit `AbortOnRTE` immediately after the risky call, and a bare `return` with no value is invalid inside a plain, implicit-`Variable`-returning `Function` -- both confirmed from Igor's own bundled help, \"Flow Control for Aborts\" and \"The Return Statement\" in `Programming.ihf`.) No Python-side (`server.py`) changes were needed for this fix -- it is entirely within the Igor-side `ZMQ_BridgeHelpers.ipf` helper file.\n\nv2.2.0: fixed a serious reliability bug in the v2.1.0 submit/poll primitives, found via live testing of the exact scenario they exist for (a command whose runtime is unknown and could be very long). `ZBR_SubmitCommand`/`ZBR_SubmitCommandUnattended` (Igor-side, in `ZMQ_BridgeHelpers.ipf`) previously joined the submitted command and the internal finish-callback into ONE compound `Execute/P` string (`cmd + \"; \" + finishCall`). Confirmed live: if `cmd` failed to parse, OR hit a genuine Igor-level runtime error partway through (not just a Debugger pause), the ENTIRE joined string aborted, so the appended finish-callback never ran -- leaving `poll_igor_command` reporting `\"done\": false` forever, indistinguishable from a job still genuinely in progress. This directly undermines the reliability guarantee these tools exist to provide. Root-caused via two live tests: (1) queuing an invalid command and a valid one as two SEPARATE `Execute/P` entries showed the second still ran fine despite the first failing, proving Igor's deferred-operation queue processes independently-queued entries regardless of a prior one's failure; (2) the same joined-string command that had a genuine runtime error (not just an invalid command name) also silently swallowed its own appended print statement, confirming the bug applies to runtime errors too, not just parse errors. Fixed by queuing `cmd` and the finish-callback (and, for the `_unattended` variant, a Debugger-disable step and a Debugger-restore step, with the saved settings carried across via persistent globals) as fully independent `Execute/P` entries instead of one joined string -- verified live afterward with an invalid command, a genuine runtime-erroring command, and a normal command, all three now correctly reaching `\"done\": true` instead of hanging. **Known residual limitation, also confirmed live:** there is currently no generic way to tell \"the command ran and legitimately printed nothing\" apart from \"the command errored out with no output\" -- both now come back as `\"done\": true` with an empty/short result and no error indication (an attempted fix using `GetRTError()` in the finish-callback was tested and found not to work: each `Execute/P` entry is dispatched as its own independent top-level execution, so Igor clears any pending runtime-error state before the next queued entry runs). If a submitted command's success needs to be verifiable, have it `print` an explicit sentinel/result value itself.\n\nv2.1.0: added first-class support for commands with an unknown or very long runtime (hours to weeks). `execute_igor_command`/`execute_igor_command_unattended` block the whole MCP tool call while polling, which cannot work for a genuinely long calculation -- the MCP transport itself has been observed to time out a single tool call well under a minute, regardless of the requested `timeout_seconds`. Three new tools expose the existing submit/poll primitives directly: `submit_igor_command`/`submit_igor_command_unattended` queue a command and return a token immediately with no waiting at all, and `poll_igor_command(token)` performs one cheap, instant check for completion, callable any number of times spaced arbitrarily far apart. This is reliable no matter how long the job runs or how many times this bridge process or Claude Desktop itself restarts in between, because all of the actual state (a done flag and the captured text) lives entirely in Igor Pro's own data waves (`root:Packages:ZBR`), not in this bridge's Python process -- the only thing that actually ends the job is Igor Pro itself quitting, crashing, or restarting. As with the existing `_unattended` tools, use `submit_igor_command_unattended` for anything long-running: a Debugger pause partway through leaves `poll_igor_command` reporting \"not done\" forever, indistinguishable from the command still genuinely running.\n\nv2.0.1: three bug fixes found during live v2.0.0 testing. (1) `execute_igor_command`/`execute_igor_command_unattended`: the documented pattern of using `fprintf 0, ...` to get data back does not actually work over this transport -- confirmed live that `CaptureHistory` (which this bridge's capture mechanism relies on) captures `print` output but never captures `fprintf`-to-history output at all (refnum 0, -1, or -2 all silently produce nothing, even though the command itself runs without error). Use `print` instead; all docs/docstrings updated accordingly. (2) `load_experiment`: fixed a silent-failure bug where relaunching Igor Pro with a new experiment file could do nothing at all, with no error reported. Root cause (diagnosed live): the tool was waiting for Igor Pro to stop answering over ZeroMQ as its signal that the old process had quit, but ZeroMQ goes quiet well before the underlying Igor64.exe process actually terminates -- launching the replacement `Igor64.exe /UNATTENDED ` command line while the old process is still alive (even mid-shutdown) doesn't spawn a new process at all; Windows/Igor's single-instance-per-user behavior instead either redirects the load into the still-live old instance (risking an unhandled \"save changes?\" dialog) or silently drops the request if that instance is already mid-quit. Fixed by polling the actual OS process list for the configured executable's own process to fully exit before ever launching the replacement, raising a clear error instead of proceeding if it doesn't exit in time. (3) `read_help_file`: added a `timeout_ms` parameter (default raised from this bridge's usual 5s to 30s) -- confirmed live that exporting a genuinely large help file (e.g. the full \"Igor Reference.ihf\") can take longer than 5 seconds, which previously timed out the call even though the export eventually succeeded Igor-side anyway.\n\nv2.0.0: BREAKING transport change. Replaces the COM Automation Server transport (win32com.client, ProgID `IgorPro.Application`) with the ZeroMQ-XOP's `CallFunction` JSON protocol over a plain localhost TCP socket (tcp://127.0.0.1:5680), talking to a small set of Igor-side helper functions in `Packages/MIES/ZMQ_BridgeHelpers.ipf` (the `ZBR` independent module).\n\nWhy: COM required this bridge process and Igor Pro to run at the SAME Windows privilege level (both elevated, or both not) -- an easy-to-miss mismatch (e.g. Claude Desktop reopened normally after Igor Pro was left running elevated from before). ZeroMQ is a plain TCP socket with no such requirement at all -- elevation no longer matters in any way. `launch_igor_pro_unattended` no longer has an elevation-branching code path: it always launches Igor Pro as a plain, non-elevated child process, at whatever privilege level this bridge process itself runs at.\n\n**New setup requirement**: unlike the COM transport (which worked against a stock Igor Pro installation with zero custom procedure code), this transport requires `Packages/MIES/ZMQ_BridgeHelpers.ipf` to be `#include`-d and compiled into whichever Igor Pro experiment this bridge talks to -- there is no bootstrap path over ZeroMQ itself. This repo's own `Packages/MIES_Include.ipf` already does this permanently. Any OTHER Igor Pro experiment needs `ZMQ_BridgeHelpers.ipf` copied onto its own procedure search path with a matching `#include` added by hand, then a recompile -- see `Packages/doc/igor-pro-bridge.rst` (\"Installation\") for the exact one-time steps.\n\n**Capability improvements**: `get_wave` now supports any wave dimensionality (up to 4D), real and complex numeric waves, text waves, and wave-reference waves -- the entire wave comes back from one round trip, natively serialized (the old COM version was limited to 1D real-valued waves, read one point at a time).\n\n**Behavior changes to be aware of**: `execute_igor_command`/`execute_igor_command_unattended` can no longer separate fprintf-only output from the full echoed history the way COM's `Execute2` could -- both \"results\" and \"history\" now hold the same captured text; prefix a command with `Silent 1;` to suppress the echo. `load_experiment` no longer hot-swaps the open experiment in place (that was a COM-only method with no procedure-language equivalent) -- it now quits the running instance and relaunches Igor Pro with the target file path as a launch argument, a real process restart; unsaved changes in the previously-open experiment are lost. `ensure_igor_pro_bridge_defined` is retired -- it existed to manage a `#ifdef IGOR_PRO_BRIDGE` gating convention that ZBR's always-on independent-module architecture doesn't use.\n\nSee `Packages/doc/igor-pro-bridge.rst` and `SESSION_NOTES.md` in the MIES repository for the full protocol research, design decisions, and confirmed-live test results behind this rewrite.\n\nTools:\n- `execute_igor_command` / `execute_igor_command_unattended`: run a command string on Igor's command line (submitted via `ZBR_SubmitCommand`/`ZBR_SubmitCommandUnattended`, polled via `ZBR_PollCommand`); include a `print` call (NOT `fprintf 0, ...` -- confirmed not captured, see v2.0.1 changelog above) to get data back. Both return `{results, history}`. The `_unattended` variant automatically disables/restores Igor's Debugger around the call.\n- `read_session_history`: read back everything sent to Igor's history area since this bridge's capture started.\n- `get_wave`: read any Igor wave's full data and metadata back (any dimensionality, numeric/complex/text/wave-reference).\n- `load_experiment`: quit the running instance and relaunch Igor Pro with a .pxp file path as a launch argument.\n- `check_bridge_health`: diagnose whether this bridge can reach Igor Pro's ZeroMQ server right now.\n- `check_compilation_state` / `reload_and_compile_procedures`: check and refresh Igor's compiled state after editing a `.ipf` file on disk.\n- `dismiss_compile_error_dialog`: close a stuck \"Function Compilation Error\" dialog via a posted Escape key, without needing OS focus.\n- `get_debugger_state` / `set_debugger_enabled` / `restore_debugger_settings`: read, change, and restore Igor's Debugger settings.\n- `get_environment_summary`: summarize the live instance (version, loaded experiment, XOPs, included procedure files, data folders, Debugger settings).\n- `read_help_file`: read an Igor Pro help file (.ihf) as structured, formatted text.\n- `get_bridge_version`: report the running bridge version and Python/package versions.\n- `configure_igor_launch` / `launch_igor_pro_unattended`: record the Igor Pro executable path once per session, then launch it with the `/UNATTENDED` flag and wait for it to become reachable over ZeroMQ.\n\n**Requirements:**\n- Igor Pro 9.00 or later, running on Windows, with the ZeroMQ-XOP installed and loaded.\n- `Packages/MIES/ZMQ_BridgeHelpers.ipf` (or a copy of it) `#include`-d and compiled into the target experiment -- see `Packages/doc/igor-pro-bridge.rst` (\"Installation\") for the one-time setup steps for experiments other than this repo's own.\n- Python (accessible as `python` on PATH), with the pinned packages in `requirements.txt` installed into that same environment. Run `install.ps1` to do this correctly and also complete pywin32's required post-install step (still needed for window-handling/process-launch helpers, even though this bridge no longer uses COM) -- see that script's own help (`Get-Help ./install.ps1 -Full`).\n- No elevation/privilege-matching requirement of any kind -- this is the change v2.0.0 makes.\n\nPrior COM-based history (v1.x): see `Packages/doc/igor-pro-bridge.rst` for the full v1.x changelog, retained there for reference.", + "author": { + "name": "Michael Huth" + }, + "server": { + "type": "python", + "entry_point": "src/server.py", + "mcp_config": { + "command": "python", + "args": [ + "${__dirname}/src/server.py" + ], + "env": {} + } + }, + "compatibility": { + "claude_desktop": ">=1.0.0", + "platforms": [ + "win32" + ], + "runtimes": { + "python": ">=3.10" + } + }, + "tools": [ + { + "name": "execute_igor_command", + "description": "Execute a single Igor Pro command string in the running Igor instance (submit/poll over ZeroMQ). Include a print call (not fprintf 0, ...) to get data back. Returns {results, history}." + }, + { + "name": "execute_igor_command_unattended", + "description": "Same as execute_igor_command, but disables Igor's Debugger for the duration of the call and restores it afterward." + }, + { + "name": "submit_igor_command", + "description": "Queue a command for deferred execution and return a token immediately, without waiting for it to finish. Use for commands with an unknown or long (hours to weeks) runtime instead of execute_igor_command. Poll completion with poll_igor_command. Guaranteed (as of v2.2.0) to always reach done=true eventually, even if the command fails to parse or errors partway through." + }, + { + "name": "submit_igor_command_unattended", + "description": "Same as submit_igor_command, but disables Igor's Debugger for the duration of the command and restores it afterward. Recommended for anything long-running." + }, + { + "name": "poll_igor_command", + "description": "Check whether a command submitted via submit_igor_command/submit_igor_command_unattended has finished yet, and retrieve its captured output if so. Safe to call any number of times, spaced arbitrarily far apart." + }, + { + "name": "read_session_history", + "description": "Read back everything sent to Igor's history area since this bridge's capture started." + }, + { + "name": "get_wave", + "description": "Return an existing Igor wave's full data and metadata (any dimensionality; numeric, complex, text, or wave-reference)." + }, + { + "name": "load_experiment", + "description": "Quit the running Igor Pro instance and relaunch it with a .pxp experiment file path as a launch argument." + }, + { + "name": "check_bridge_health", + "description": "Diagnose whether this bridge can reach Igor Pro's ZeroMQ server right now." + }, + { + "name": "check_compilation_state", + "description": "Report whether Igor's procedure code is currently compiled or uncompiled." + }, + { + "name": "reload_and_compile_procedures", + "description": "Reload changed .ipf files from disk and attempt a fresh compilation, reporting the resulting compiled state." + }, + { + "name": "dismiss_compile_error_dialog", + "description": "Attempt to close a stuck Igor Pro compile-error dialog by posting a simulated Escape key press to it." + }, + { + "name": "get_debugger_state", + "description": "Read Igor's current Debugger settings (enable/debugOnError/debugOnAbort/NVAR_SVAR_WAVE_Checking)." + }, + { + "name": "set_debugger_enabled", + "description": "Enable or disable Igor's Debugger, optionally changing individual sub-settings." + }, + { + "name": "restore_debugger_settings", + "description": "Restore Igor's Debugger settings to a previously-saved snapshot." + }, + { + "name": "get_environment_summary", + "description": "Summarize the live Igor Pro instance: version, loaded experiment, XOPs, included procedure files, data folders, Debugger settings." + }, + { + "name": "read_help_file", + "description": "Read an Igor Pro help file (.ihf) as structured, formatted text (paragraph style names preserved, e.g. Topic/Code1/Steps), without leaving any lasting change to Igor's help-window state." + }, + { + "name": "get_bridge_version", + "description": "Return the version of this Igor Pro Bridge build that is actually running right now, plus the Python interpreter/package versions it's running with." + }, + { + "name": "configure_igor_launch", + "description": "Record the full path to the Igor Pro executable to use for launch_igor_pro_unattended/load_experiment, for this bridge session. Optionally set (or clear) a custom ZeroMQ port for the next launched instance to bind to and for this bridge itself to connect on." + }, + { + "name": "launch_igor_pro_unattended", + "description": "Launch the configured Igor Pro executable with the /UNATTENDED flag, always as a plain non-elevated child process, and wait for it to become reachable over ZeroMQ." + } + ], + "keywords": [ + "igor pro", + "wavemetrics", + "zeromq", + "scientific computing" + ], + "license": "MIT" +} diff --git a/tools/igor-mcp-bridge/pyproject.toml b/tools/igor-mcp-bridge/pyproject.toml new file mode 100644 index 0000000000..f07891855c --- /dev/null +++ b/tools/igor-mcp-bridge/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "igor-pro-bridge" +version = "2.3.2" +description = "MCP bridge for controlling Igor Pro via its ZeroMQ-XOP CallFunction interface" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.29.0,<2", + "pyzmq==27.1.0", + "pywin32==312; sys_platform == 'win32'", +] diff --git a/tools/igor-mcp-bridge/requirements.txt b/tools/igor-mcp-bridge/requirements.txt new file mode 100644 index 0000000000..7bdc50720b --- /dev/null +++ b/tools/igor-mcp-bridge/requirements.txt @@ -0,0 +1,214 @@ +# Pinned dependencies for the Igor Pro Bridge MCP server (tools/igor-mcp-bridge/server.py). +# Install with install.ps1 (recommended -- also runs pywin32's required post-install +# step), or manually via: +# -m pip install --require-hashes -r requirements.txt +# \pywin32_postinstall.py -install +# +# Every package below -- direct AND transitive -- is pinned to an exact version with +# one or more --hash=sha256:... values. This is pip's "hash-checking mode": it is +# triggered automatically the moment any requirement has a --hash, and once active, +# EVERY package pip would install (the whole dependency tree, not just mcp/pywin32 +# themselves) must appear here, pinned and hashed, or the install is refused outright. +# This is a supply-chain-integrity measure (protects against a compromised/tampered +# package on PyPI, or a MITM'd download) on top of the version pins already protecting +# against unwanted upgrades (see the mcp v1/v2 note below). +# +# Regenerating this file (e.g. after deliberately bumping a version, or to add a newly +# released Python version): resolve the full dependency tree for the target platform/ +# Python versions with `pip download --platform win_amd64 --python-version +# --implementation cp --abi cp --only-binary=:all: -d -r requirements.in`, +# where is each Python minor version actually in use (currently 310/311/312/313/ +# 314 -- pyproject.toml declares an open-ended `>=3.10` floor, so a *new* Python release +# can reach this bridge before its wheels are covered here; if install.ps1/pip reports a +# hash mismatch or "no matching distribution", that's the likely cause -- add that +# version the same way, don't just widen/drop the hash pin). Repeat per Python version to +# catch any per-version wheel/version divergence -- rpds-py below is a real example of a +# package whose resolved *version*, not just its wheel file, differs between Python 3.10 +# and 3.11+ -- then sha256sum every downloaded wheel. Verify with +# `pip download --require-hashes -r requirements.txt ...` (same flags, run under an +# actual interpreter of that target version -- environment markers like python_version/ +# sys_platform are evaluated against the REAL running interpreter, not these download +# target flags, which only affect wheel-tag selection) before committing -- this +# round-trips both the hash values and pip's ability to parse the file. +# +# mcp: pinned to the last 1.x release. The MCP Python SDK's v2 line (2.0.0, released +# 2026-07-27/28 alongside MCP protocol revision 2026-07-28) is a deliberate breaking +# rework: FastMCP was renamed to MCPServer and moved from mcp.server.fastmcp to +# mcp.server.mcpserver, among other changes. server.py still uses the v1 API +# (`from mcp.server.fastmcp import FastMCP`), so an unpinned or `>=1.0.0` requirement +# would silently resolve to 2.x today and break the bridge outright (ModuleNotFoundError +# on import) -- confirmed by inspecting both the 1.29.0 and 2.0.0 wheels directly. +# Do not remove the exact pin without migrating server.py to the v2 API first. + +mcp==1.29.0 \ + --hash=sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7 + +# pywin32: as of v2.0.0, no longer used for talking to Igor Pro itself (that's pyzmq, +# below) -- only for win32api/win32con/win32gui/win32process, used by +# dismiss_compile_error_dialog's window enumeration and by launch_igor_pro_unattended/ +# load_experiment's process launching. Still requires the separate post-install step +# (Scripts\pywin32_postinstall.py -install) to register its COM-support DLLs even +# though this bridge no longer uses COM itself; both install.ps1 and a plain +# `pip install` alone are not sufficient without that step. +# Hashes cover the cp310/cp311/cp312/cp313/cp314 win_amd64 wheels. +pywin32==312; sys_platform == "win32" \ + --hash=sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db \ + --hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \ + --hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \ + --hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \ + --hash=sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a + +# pyzmq: provides the `zmq` module server.py uses to talk to Igor Pro's ZeroMQ-XOP +# (new in v2.0.0 -- replaces the COM transport that pywin32.win32com.client used to +# provide; pywin32 is still needed above, but now only for dismiss_compile_error_ +# dialog's window enumeration and for launching the Igor Pro process). No transitive +# dependencies of its own -- the Windows wheels statically bundle libzmq. The +# cp312-abi3 wheel covers 312/313/314; 310 and 311 need their own wheel each (pyzmq +# does not build an abi3 wheel back that far). +pyzmq==27.1.0 \ + --hash=sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f \ + --hash=sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97 \ + --hash=sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf + +# --- Transitive dependencies (mcp's own dependency tree) ------------------------- +# Required by pip's hash-checking mode (see the top comment) -- pip resolves these +# via mcp's own pyproject.toml at install time regardless, this just makes each one +# an explicit, hash-verified pin instead of an unverified floating resolution. + +# async I/O abstraction -- required by mcp, httpx, starlette, sse-starlette +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 + +# anyio's exceptiongroup backport (Python < 3.11 only, installed unconditionally here +# for simplicity -- harmless no-op on newer Python) +exceptiongroup==1.3.1 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + +# required by anyio and httpx +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 + +# HTTP client -- required by mcp +httpx==0.28.1 \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + +# required by httpx +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 + +# HTTP/1.1 protocol implementation -- required by httpcore +h11==0.16.0 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + +# Server-Sent Events support for httpx -- required by mcp +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc + +# default CA bundle -- required by httpx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 + +# data validation -- required by mcp, pydantic-settings +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba + +# pydantic's compiled (Rust) backend -- per-Python-version wheels (cp310/311/312/313/314) +pydantic-core==2.46.4 \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 + +# required by pydantic +annotated-types==0.8.0 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + +# required by pydantic (unconditionally) and starlette (Python < 3.13) +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 + +# required by pydantic and pydantic-settings +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 + +# required by mcp +pydantic-settings==2.14.2 \ + --hash=sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440 + +# required by pydantic-settings +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a + +# validates tool input schemas -- required by mcp +jsonschema==4.26.0 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + +# required by jsonschema +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 + +# required by jsonschema +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 + +# required by referencing/jsonschema +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe + +# referencing/jsonschema's Rust-backed persistent data structures. Split into two +# version-pinned lines because the current release (2026.6.3, below) dropped Python +# 3.10 support -- pip resolves an older version on 3.10 instead, confirmed by +# resolving this exact dependency tree separately for each target Python version. +rpds-py==0.30.0; python_version < '3.11' \ + --hash=sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169 + +# same package, for Python 3.11+ (cp311/312/313/314 wheels) +rpds-py==2026.6.3; python_version >= '3.11' \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 + +# required by mcp (with the [crypto] extra) +pyjwt==2.13.0 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + +# required by pyjwt's [crypto] extra -- two hashes because this release ships two +# abi3 wheels with different minimum-ABI baselines (cp39-abi3 and cp311-abi3) +cryptography==50.0.0 \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba + +# required by cryptography -- per-Python-version wheels (cp310/311/312/313/314) +cffi==2.1.0 \ + --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \ + --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \ + --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \ + --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \ + --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da + +# required by cffi +pycparser==3.0 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + +# required by mcp +python-multipart==0.0.32 \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + +# Server-Sent Events transport support -- required by mcp +sse-starlette==3.4.6 \ + --hash=sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6 + +# required by mcp and sse-starlette +starlette==1.3.1 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 + +# ASGI server -- required by mcp (unused by this bridge's stdio transport, but still +# an unconditional mcp dependency) +uvicorn==0.52.1 \ + --hash=sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a + +# required by uvicorn +click==8.4.2 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 diff --git a/tools/igor-mcp-bridge/server.py b/tools/igor-mcp-bridge/server.py new file mode 100644 index 0000000000..df77d0e437 --- /dev/null +++ b/tools/igor-mcp-bridge/server.py @@ -0,0 +1,1773 @@ +""" +Igor Pro MCP bridge server +========================== + +Exposes a running Igor Pro instance to Claude (or any MCP client) as a set of MCP tools. + +**v2.0.0: transport rewritten from COM to ZeroMQ.** Versions through 1.27.0 talked to +Igor Pro as a COM Automation *client* (win32com, `IgorPro.Application`, `Execute2`). +From 2.0.0 on, this bridge instead talks to Igor Pro's ZeroMQ-XOP +(https://github.com/AllenInstitute/ZeroMQ-XOP) over a plain TCP socket, sending +`CallFunction` JSON requests and calling into Igor-side helper functions in +`Packages/MIES/ZMQ_BridgeHelpers.ipf` (the `ZBR` independent module). See +SESSION_NOTES.md for the full COM-vs-ZeroMQ evaluation that led here, and +Packages/doc/igor-pro-bridge.rst for the up-to-date setup steps. + +Why this changed: COM requires this Python process and Igor Pro to run at the SAME +Windows privilege level (both elevated, or both not) -- a mismatch is a real, +easy-to-miss failure mode (e.g. Claude Desktop reopened normally after Igor Pro was +left running elevated from before). ZeroMQ is a plain localhost TCP socket with no +such requirement at all -- **this bridge no longer cares about elevation in any way**. +That is also why `launch_igor_pro_unattended` no longer has an elevation-branching +code path (see its docstring): it always launches Igor Pro as a plain, non-elevated +child process, at whatever privilege level this bridge process itself is running at. + +**Setup requirement, new in v2.0.0**: unlike the COM transport (which worked against +a completely stock Igor Pro installation with zero custom procedure code), this +transport requires `Packages/MIES/ZMQ_BridgeHelpers.ipf` to be `#include`-d and +compiled into whatever Igor Pro experiment this bridge talks to -- there is no +bootstrap path over ZeroMQ itself (if that file isn't loaded, there is nothing +listening on the port at all). This repo's own `Packages/MIES_Include.ipf` already +does this permanently. Any OTHER Igor Pro experiment that wants to use this bridge +needs `ZMQ_BridgeHelpers.ipf` copied onto its own procedure search path with a +matching `#include` added by hand, then a recompile -- see +Packages/doc/igor-pro-bridge.rst for the exact steps. This is a one-time, +per-experiment setup cost that didn't exist before; it is the price of everything +else this transport buys (see SESSION_NOTES.md's ZeroMQ-evaluation section for the +full trade-off discussion). + +Protocol summary (confirmed empirically this session against a live Igor Pro 9.06 +instance, and against https://github.com/AllenInstitute/ZeroMQ-XOP's own README): + +- Endpoint: `tcp://127.0.0.1:5680` by default (`_igor_zmq_endpoint()` below) -- + matches `ZBR_ZEROMQ_ENDPOINT`/`ZBR_ZEROMQ_DEFAULT_PORT` in ZMQ_BridgeHelpers.ipf, + deliberately NOT MIES's own `ZEROMQ_BIND_REP_PORT` (5670) so this can coexist with + MIES's own (currently short-circuited) ZeroMQ subsystem in the same experiment. + configure_igor_launch(port=...) can override the port this bridge itself connects + to (every ZMQ-talking function goes through `_igor_zmq_endpoint()`, not a fixed + constant, so they all pick up a configured custom port together). +- One JSON `CallFunction` request per ZeroMQ REQ-socket round trip: send + `{"version": 1, "messageID": ..., "CallFunction": {"name": ..., "params": [...]}}`, + receive `{"errorCode": {"value": ..., "msg": ...}, "result": ...}`. A NEW REQ socket + is created for every single call (see `call_function` below) rather than one + reused across calls -- a REQ socket that times out waiting for a reply is left in a + state where it cannot send again without being recreated (confirmed empirically + this session), so per-call sockets sidestep that fragility entirely at negligible + cost for a local TCP connection. +- **Confirmed documentation bug in the XOP's own README/help**: it says + `CallFunction.name` should be "a ProcGlobal function without module and/or + independent module specification, i.e. without `#`" -- empirically confirmed this + session that this is simply wrong for independent-module functions (like + everything in the `ZBR` module): the qualified form (`"ZBR#ZBR_Ping"`) is what + actually works; the unqualified form fails with `errorCode.value=101` ("Unknown + function"). Every ZBR call in this file uses the qualified form. +- Multi-return Igor functions (`Function [a, b] Foo()`, Igor 8+) are fully supported + over this protocol -- `result` becomes a JSON array of typed values in declaration + order. `call_function` below decodes this into a plain Python list automatically. +- Wave return values carry the ENTIRE wave (dimensions, units, note, complex/text/ + wave-ref support) natively-serialized as JSON -- see `_decode_wave` below. This + replaces the old COM bridge's per-point `GetNumericWavePointValue` loop entirely, + and is why the new `get_wave` supports far more than the old "1D real waves only" + limitation. +- **Central architectural constraint, unchanged from the COM-vs-ZeroMQ evaluation**: + Igor's `Execute` operation (used to run arbitrary free-form command text) cannot be + called unqueued from inside a Function -- only `Execute/P` (deferred: queued to run + only after the calling function returns) is legal there. This means a single + CallFunction round trip cannot synchronously "run this arbitrary command string and + hand back what it printed" -- there is no direct equivalent of the COM bridge's + `Execute2`. `execute_igor_command`/`execute_igor_command_unattended` below instead + use a submit-then-poll pattern (`ZBR_SubmitCommand`/`ZBR_SubmitCommandUnattended` + queue the command and return a token immediately; `ZBR_PollCommand`, called via a + LATER separate request, reports completion and the captured output). Every OTHER + tool below that doesn't need to run arbitrary free-form text (get_wave, + check_compilation_state, get_debugger_state, get_environment_summary's underlying + queries, read_help_file, etc.) is backed by a small, purpose-built, directly-callable + Igor function instead, and is a single synchronous round trip -- no polling needed. +- **Commands with an unknown or long runtime (v2.1.0+)**: `execute_igor_command`/ + `execute_igor_command_unattended` block the whole MCP tool call while polling, up to + their own `timeout_seconds` -- workable for anything expected to finish in seconds, + but not for a calculation that might run for hours or weeks, since the underlying + MCP transport itself has been observed to time out a single tool call well under a + minute regardless of what `timeout_seconds` requests. `submit_igor_command`/ + `submit_igor_command_unattended` expose the same submit step as its own tool + (returns a token immediately, no waiting at all), and `poll_igor_command(token)` + exposes the same poll step as its own tool (one cheap, instant check, callable any + number of times, spaced arbitrarily far apart). Because all of the actual state + (done flag, captured text) lives entirely in Igor Pro's own data waves + (`root:Packages:ZBR`), not in this bridge's Python process, polling stays reliable + no matter how long the job runs or how many times this bridge process/Claude + Desktop itself restarts in the meantime -- the only thing that actually ends the + job is Igor Pro itself quitting, crashing, or restarting. + +**Behavior changes from the COM version worth knowing about**: + +- `execute_igor_command`/`execute_igor_command_unattended` no longer return a clean, + separately-captured "results" (fprintf-only output) distinct from "history" (full + history including the echoed command) -- COM's `Execute2` had special handling to + split these; this transport cannot replicate that, since the submit/poll mechanism + only has Igor's own history-diffing (`CaptureHistory`) to go on. Both keys are now + populated with the same text (everything printed to history while the command ran, + including its own echo). Prefix a command with `Silent 1;` if you want the echo + suppressed from the returned text. +- **Use `print`, not `fprintf 0, ...`, to get data back.** Confirmed live this + session: `CaptureHistory` (which the submit/poll mechanism above relies on) + captures `print` output but does NOT capture `fprintf`-to-history-refnum output at + all, whether directed at refnum 0 (history), -1, or -2 -- a command consisting of + only `fprintf 0, "..."` runs without error but returns empty "results"/"history" + every time, even though the exact same value printed via `print` is captured + correctly. This is a real behavior change from the COM version, whose `Execute2` + had its own dedicated mechanism for capturing `fprintf(0,...)` output specifically + (unrelated to `CaptureHistory`), which is why that was the documented pattern + before. `fprintf` remains fine (and necessary) for anything that ISN'T about + getting data back through this bridge, e.g. writing to a wave or a real file. +- `load_experiment` no longer hot-swaps the open experiment in the running instance + (that was `IApplication.LoadExperiment`, a COM-only method -- confirmed neither + "LoadExperiment" nor "OpenFile" appear anywhere in Igor Reference.ihf, only in + Automation Server.ihf, and there is no procedure-language equivalent). It instead + asks the running instance to quit, then relaunches the configured Igor Pro + executable with the target file path as a launch argument -- a real process + restart, not an in-place swap. **Any unsaved changes in the currently-open + experiment are lost** (the same as before -- COM's LoadExperiment never auto-saved + either -- but now there is also no "hot" instance left to save from afterward if + you forgot). Call `execute_igor_command('SaveExperiment')` first if that matters. +- `ensure_igor_pro_bridge_defined` is retired. It existed to make sure a + `#ifdef IGOR_PRO_BRIDGE`-gated procedure file's optional code got compiled in + without a human hand-editing the experiment's Procedure window. `ZBR`'s own + functions have no such gating (the whole point of an independent module is that it + compiles on its own regardless of ProcGlobal's `#define` state), so this bridge no + longer needs it for its own purposes. +- `check_bridge_health`/`check_compilation_state` can no longer distinguish "Igor Pro + isn't running" from "Igor Pro is running but ZMQ_BridgeHelpers.ipf isn't + included/compiled/bound" from "wrong port" as cleanly as COM's `GetActiveObject` + could (a clean binary "is there a registered COM object" signal) -- a ZeroMQ REQ + socket that gets no reply at all looks the same in all three cases. See + `check_bridge_health`'s docstring for what to check by hand if this happens. + +Setup +----- + pip install mcp pyzmq pywin32 + +(pywin32 is still needed for `dismiss_compile_error_dialog`'s window enumeration and +for launching the Igor Pro process -- see below. It is no longer needed for, or +involved in, talking to Igor Pro itself.) + +Registering with Claude Desktop +-------------------------------- +Do NOT register this by manually editing claude_desktop_config.json -- that does not +work reliably for local MCP servers in current Claude Desktop builds. Instead, package +this directory as a Claude Desktop Extension (.mcpb) and install it via Settings -> +Extensions -> Advanced settings -> Extension Developer -> Install Extension: + + mcpb pack tools/igor-mcp-bridge tools/igor-mcp-bridge/igor-pro-bridge-X.Y.Z.mcpb + +See Packages/doc/igor-pro-bridge.rst ("Installation") for the full, up-to-date +installation steps, including the one-time ZMQ_BridgeHelpers.ipf setup step for any +experiment other than this repo's own. + +This is a *local* MCP server (stdio transport) -- it only works from a Claude Desktop +session running on the same Windows machine as Igor Pro, not from a cloud/Cowork sandbox. +""" + +import html.parser +import importlib.metadata +import json +import os +import subprocess +import sys +import tempfile +import time +import uuid +from typing import Optional + +if sys.platform != "win32": + raise RuntimeError( + "tools/igor-mcp-bridge/server.py is Windows-only (requires pywin32 for " + "dismiss_compile_error_dialog's window enumeration and for launching the " + "configured Igor Pro executable). It cannot run on this platform " + f"({sys.platform!r}) -- e.g. running it by accident on a non-Windows dev " + "machine or in CI. The ZeroMQ transport itself is cross-platform; this " + "restriction is only about this file's OS-level helper tools." + ) + +import win32api +import win32con +import win32gui +import win32process + +import zmq + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("igor-pro") + +# --- ZeroMQ transport ---------------------------------------------------------------- + +# Matches ZBR_ZEROMQ_ENDPOINT/ZBR_ZEROMQ_DEFAULT_PORT in +# Packages/MIES/ZMQ_BridgeHelpers.ipf. +IGOR_ZMQ_HOST = "tcp://127.0.0.1" +IGOR_ZMQ_DEFAULT_PORT = 5680 +_ZMQ_DEFAULT_RECV_TIMEOUT_MS = 5000 +_ZMQ_SEND_TIMEOUT_MS = 2000 +_ZMQ_LINGER_MS = 0 + +# Set by configure_igor_launch(port=...) -- see that tool's docstring. None means +# "use IGOR_ZMQ_DEFAULT_PORT". +_configured_igor_port = None + +_zmq_context = None + + +def _get_zmq_context(): + global _zmq_context + if _zmq_context is None: + _zmq_context = zmq.Context() + return _zmq_context + + +def _igor_zmq_endpoint(): + """The ZeroMQ endpoint this bridge currently connects to. Every function that + talks to Igor Pro over ZeroMQ must go through this (not a fixed string) so they + all consistently follow whatever custom port configure_igor_launch(port=...) set, + instead of some functions silently still targeting the default port.""" + port = ( + _configured_igor_port + if _configured_igor_port is not None + else IGOR_ZMQ_DEFAULT_PORT + ) + return f"{IGOR_ZMQ_HOST}:{port}" + + +class IgorZmqError(RuntimeError): + """Igor Pro replied, but reported errorCode.value != 0 for the CallFunction call.""" + + +class IgorZmqUnreachable(RuntimeError): + """No reply was received at all within the timeout. Could mean: Igor Pro isn't + running, ZMQ_BridgeHelpers.ipf isn't #include-d/compiled in the running instance, + its ZeroMQ server socket isn't bound to this bridge's currently configured + endpoint (see _igor_zmq_endpoint), or the reply is simply slow (e.g. a + long-running command; pass a longer timeout_ms).""" + + +def _decode_number(value): + """Non-normal numbers (NaN/Inf/-Inf) are encoded as strings per the ZeroMQ-XOP's + own spec ("Messages consist of JSON ... NaN, Inf and -Inf are not supported by + JSON, so we encode these non-normal numbers as strings"). Decode those back to + real Python floats; pass anything else through unchanged.""" + if isinstance(value, str): + low = value.strip().lower() + if low == "nan": + return float("nan") + if low in ("inf", "+inf"): + return float("inf") + if low == "-inf": + return float("-inf") + return value + + +def _reshape_column_major(flat, dim_size): + """Reshape a flat, column-major list per dim_size (1 to 4 entries, per the + ZeroMQ-XOP's wave serialization spec) into nested Python lists indexed + data[row][col][layer][chunk] -- matches the spec's own worked example + (`np.array(raw).reshape(dim_size, order='F')`) without requiring numpy.""" + if not dim_size or list(dim_size) == [0]: + return [] + + dims = list(dim_size) + [1] * (4 - len(dim_size)) + rows, cols, layers, chunks = dims[:4] + if rows == 0: + return [] + + def at(r, c=0, l=0, k=0): + return flat[r + rows * (c + cols * (l + layers * k))] + + ndims = len(dim_size) + if ndims <= 1: + return [at(r) for r in range(rows)] + if ndims == 2: + return [[at(r, c) for c in range(cols)] for r in range(rows)] + if ndims == 3: + return [ + [[at(r, c, l) for l in range(layers)] for c in range(cols)] + for r in range(rows) + ] + return [ + [ + [[at(r, c, l, k) for k in range(chunks)] for l in range(layers)] + for c in range(cols) + ] + for r in range(rows) + ] + + +def _decode_wave(value): + """value is None (an invalid/free wave reference, `$""`) or the wave-serialization + object documented in https://github.com/AllenInstitute/ZeroMQ-XOP's README ("Wave + serialization format"). Returns None, or a dict with the wave's type, shape, data + (reshaped into nested Python lists matching the wave's own dimensionality), unit, + and note. Supports numeric (real and complex), text, and wave-reference waves.""" + if value is None: + return None + + wave_type = value.get("type", "") + dimension = value.get("dimension", {}) or {} + dim_size = dimension.get("size", []) + data = value.get("data", {}) or {} + raw = data.get("raw", []) + + if wave_type == "WAVE_TYPE": + decoded_data = [ + _decode_wave(item) if item is not None else None for item in raw + ] + elif isinstance(raw, dict) and "real" in raw: + # Complex wave: raw = {"real": [...], "imag": [...]}. + real = [_decode_number(x) for x in raw.get("real", [])] + imag = [_decode_number(x) for x in raw.get("imag", [])] + decoded_data = [complex(r, i) for r, i in zip(real, imag)] + else: + decoded_data = [_decode_number(x) for x in raw] + + return { + "type": wave_type, + "dim_size": dim_size, + "data": _reshape_column_major(decoded_data, dim_size), + "unit": data.get("unit"), + "note": value.get("note", ""), + "dimension": dimension, + } + + +def _decode_typed(typed): + """typed is {"type": "variable"|"string"|"wave"|"dfref", "value": ...} -- return + the corresponding plain Python value (numbers get NaN/Inf decoded, waves get fully + decoded via _decode_wave, everything else passes through as-is).""" + if not isinstance(typed, dict): + return typed + kind = typed.get("type") + value = typed.get("value") + if kind == "variable": + return _decode_number(value) + if kind == "wave": + return _decode_wave(value) + return value + + +def call_function(name, params=None, timeout_ms=_ZMQ_DEFAULT_RECV_TIMEOUT_MS): + """Send one CallFunction request to Igor Pro and return its decoded result. + + name must be the FULLY QUALIFIED function name for anything in the ZBR + independent module, e.g. "ZBR#ZBR_Ping" -- see the module docstring's + documentation-bug note for why (the XOP's own docs say to omit the "#"; that is + wrong for independent-module functions, confirmed empirically). + + Returns a plain Python value: None/number/string for a single scalar return, a + dict for a wave return (see _decode_wave), or a list of such values (in + declaration order) for a multi-return ("Function [a, b] Foo()") function. + + Raises IgorZmqUnreachable if no reply arrives within timeout_ms at all, or + IgorZmqError if Igor Pro replied but reported errorCode.value != 0 (the error + message includes any "history" the XOP reports alongside the error, which often + shows exactly where inside the called function things went wrong). + """ + request = { + "version": 1, + "messageID": uuid.uuid4().hex, + "CallFunction": {"name": name, "params": list(params) if params else []}, + } + payload = json.dumps(request) + + endpoint = _igor_zmq_endpoint() + sock = _get_zmq_context().socket(zmq.REQ) + sock.setsockopt(zmq.RCVTIMEO, timeout_ms) + sock.setsockopt(zmq.SNDTIMEO, _ZMQ_SEND_TIMEOUT_MS) + sock.setsockopt(zmq.LINGER, _ZMQ_LINGER_MS) + sock.connect(endpoint) + try: + sock.send_string(payload) + reply_raw = sock.recv_string() + except zmq.error.Again: + raise IgorZmqUnreachable( + f"No reply from Igor Pro within {timeout_ms}ms while calling {name!r}. " + f"Make sure Igor Pro is running, ZMQ_BridgeHelpers.ipf is #include-d and " + f"compiled in the current experiment, and its ZeroMQ server socket is " + f"bound to {endpoint!r} (see check_bridge_health)." + ) from None + finally: + sock.close() + + try: + reply = json.loads(reply_raw) + except json.JSONDecodeError as e: + raise IgorZmqError( + f"Malformed reply from Igor Pro for {name!r}: {reply_raw!r}" + ) from e + + error_code = reply.get("errorCode") or {} + if error_code.get("value", 0) != 0: + detail = ( + f"Igor Pro reported an error calling {name!r} (code " + f"{error_code.get('value')}): {error_code.get('msg', '(no message)')}" + ) + history = reply.get("history") + if history: + detail += f"\nIgor history during this call: {history!r}" + raise IgorZmqError(detail) + + result = reply.get("result") + if isinstance(result, list): + return [_decode_typed(item) for item in result] + return _decode_typed(result) + + +def _reachable(timeout_ms=1000): + """True if ZBR#ZBR_Ping answers within timeout_ms, False otherwise. Used for + "is anything listening right now" checks (already_running / poll loops) where the + caller doesn't need the actual reply.""" + try: + call_function("ZBR#ZBR_Ping", timeout_ms=timeout_ms) + return True + except (IgorZmqError, IgorZmqUnreachable): + return False + + +# --- Command execution (submit/poll) --------------------------------------------------- + +_SUBMIT_POLL_INTERVAL_SECONDS = 0.1 +_SUBMIT_POLL_TIMEOUT_SECONDS = 30.0 + + +def _submit_and_poll(submit_function: str, command: str, timeout_seconds: float) -> str: + """Submit `command` via the given ZBR submit function (ZBR_SubmitCommand or + ZBR_SubmitCommandUnattended) and poll ZBR_PollCommand until it reports done, + returning the captured text. See the module docstring for why this submit/poll + dance is needed at all (Execute cannot run unqueued inside a Function).""" + token = call_function(f"ZBR#{submit_function}", [command]) + + deadline = time.monotonic() + timeout_seconds + while True: + is_done, text = call_function("ZBR#ZBR_PollCommand", [token]) + if is_done: + return text + if time.monotonic() >= deadline: + raise RuntimeError( + f"Timed out after {timeout_seconds:.0f}s waiting for a submitted " + f"command to finish (token {token!r}). Command was: {command}" + ) + time.sleep(_SUBMIT_POLL_INTERVAL_SECONDS) + + +@mcp.tool() +def execute_igor_command( + command: str, timeout_seconds: float = _SUBMIT_POLL_TIMEOUT_SECONDS +) -> dict: + """Execute a single Igor Pro command string in the running Igor instance. + + **If `command`'s runtime is unknown or could be long (more than roughly a + minute), use submit_igor_command/poll_igor_command instead.** This tool blocks + the whole MCP call for up to `timeout_seconds` and will itself get killed by + Claude Desktop's own MCP request timeout well before that if `timeout_seconds` + is set too high -- it is only suitable for commands expected to finish quickly. + + **To get data back, include a `print` call in `command` -- NOT `fprintf 0, ...`.** + Confirmed live: this transport's capture mechanism (`CaptureHistory`) picks up + `print` output but does not capture `fprintf`-to-history output at all (refnum 0, + -1, or -2 all silently produce nothing) -- see the module docstring's "Behavior + changes" section for why (the old COM-based Execute2 had its own separate + mechanism specifically for fprintf(0,...), which no longer applies here). + Whatever `print`s (and the echoed command itself, unless prefixed with + `Silent 1;`) is captured and returned in both "results" and "history" (see the + module docstring for why this transport can no longer separate the two the way + Execute2 did). + + Example: execute_igor_command('WaveStats/Q jack; print V_avg') + + Implementation note: this queues `command` (ZBR_SubmitCommand) and polls for + completion (ZBR_PollCommand) rather than running it in one synchronous round trip + -- Igor's Execute operation cannot run unqueued from inside a Function at all, so + there is no direct equivalent of COM's Execute2 here. `timeout_seconds` bounds how + long this will poll before giving up (raises TimeoutError-style RuntimeError). + + **Caution:** if `command` calls user-defined procedure code and Igor Pro's + Debugger is currently enabled, a breakpoint/runtime error/abort/stale-reference + pause in that code will hang the underlying command indefinitely (this poll loop + will keep timing out and retrying, never actually seeing it finish) -- there is no + scriptable way to resume or dismiss the Debugger window (see set_debugger_enabled's + docstring). Use execute_igor_command_unattended instead whenever nobody is + watching who could close that popup manually. + + **If `command` itself fails to parse or hits a genuine Igor-level runtime error + (Debugger not involved), this now still returns normally instead of hanging until + `timeout_seconds` expires** -- confirmed live for both cases. There is currently no + way to tell that apart from "ran fine and printed nothing", though: the returned + text will simply be shorter/emptier than expected, with no error indication. If + verifying success matters, have `command` `print` an explicit sentinel/result + value itself. + """ + text = _submit_and_poll("ZBR_SubmitCommand", command, timeout_seconds) + return {"results": text, "history": text} + + +@mcp.tool() +def execute_igor_command_unattended( + command: str, timeout_seconds: float = _SUBMIT_POLL_TIMEOUT_SECONDS +) -> dict: + """Run `command` exactly like execute_igor_command, but automatically disable + Igor's Debugger before running it and restore it again afterward -- even if + `command` raises an Igor-level error. + + **This is the tool to reach for whenever `command` might call user-defined + procedure code and nothing is watching that could close a Debugger popup by + hand.** See set_debugger_enabled's docstring for why a Debugger pause has no + scriptable resume. + + Only reach for plain execute_igor_command when you deliberately want the Debugger + available (e.g. interactively testing a breakpoint). + """ + text = _submit_and_poll("ZBR_SubmitCommandUnattended", command, timeout_seconds) + return {"results": text, "history": text} + + +_UNKNOWN_TOKEN_PREFIX = "ERROR: unknown token " + + +@mcp.tool() +def submit_igor_command(command: str) -> dict: + """Queue `command` for deferred execution and return a token immediately, WITHOUT + waiting for it to finish. + + **Use this instead of execute_igor_command whenever a command's runtime is + unknown or could be long -- minutes, hours, even weeks.** execute_igor_command + blocks the entire MCP tool call until the command finishes or timeout_seconds + elapses, which cannot work for a genuinely long-running calculation: it will hit + Claude Desktop's own MCP request timeout (observed in practice to trigger in well + under a minute) long before a multi-hour command finishes, even though the + command itself keeps running in Igor Pro regardless of what the MCP call does. + + Call poll_igor_command(token) afterward -- as many times as needed, spaced + however far apart in time you like -- to check whether it's done yet and + retrieve its output once it is. + + **Why this is reliable even across very long waits**: the token is just a row + index into plain data waves in root:Packages:ZBR (done/resultText), maintained + entirely by Igor Pro itself. Nothing about polling it later depends on this + bridge's own Python process, on any particular MCP/Claude Desktop session + staying open, or on how much time passes between calls -- this bridge process + restarting, or Claude Desktop being closed and reopened, does not lose or + invalidate the token. The one thing that DOES end the underlying job is Igor + Pro itself quitting, crashing, or restarting -- in that case the computation + itself is gone, not just the token, so there is nothing to recover regardless of + transport. + + **Caution:** if `command` calls user-defined procedure code and Igor Pro's + Debugger is enabled, a breakpoint/runtime error/abort/stale-reference pause + leaves poll_igor_command reporting "not done" forever -- indistinguishable from + a command that's still genuinely, legitimately running, since there is no + scriptable way to detect or resume a Debugger pause (see + set_debugger_enabled's docstring). **Use submit_igor_command_unattended instead + for anything long-running -- this matters far more here than for + execute_igor_command, since nobody is likely to be watching a job that might run + for weeks.** + + **Separately (Debugger not involved): `command` failing to parse, or hitting a + genuine Igor-level runtime error partway through, will NOT leave the token stuck + forever** -- confirmed live. The finish-callback that flips poll_igor_command's + "done" flag is queued as its own independent step, so it always runs regardless + of what happens to `command`. There is, however, no reliable generic way to tell + "command ran and legitimately printed nothing" apart from "command errored out + with no output" -- both come back from poll_igor_command as `"done": True` with + an empty/short result and no error indication. If verifying success matters, have + `command` `print` an explicit sentinel/result value itself. + """ + token = call_function("ZBR#ZBR_SubmitCommand", [command]) + return {"token": token} + + +@mcp.tool() +def submit_igor_command_unattended(command: str) -> dict: + """Same as submit_igor_command, but disables Igor's Debugger for the duration of + `command` and restores it afterward -- see execute_igor_command_unattended's + docstring for the general reasoning. + + **This is the recommended tool for anything long-running submitted via + submit_igor_command/poll_igor_command.** Without it, a Debugger pause partway + through a multi-hour or multi-week calculation would silently hang forever with + no way to distinguish it from the command still legitimately running -- there is + no periodic "is this actually still making progress" signal beyond + poll_igor_command's own done/not-done state. + """ + token = call_function("ZBR#ZBR_SubmitCommandUnattended", [command]) + return {"token": token} + + +@mcp.tool() +def poll_igor_command(token: str) -> dict: + """Check whether a command submitted via submit_igor_command/ + submit_igor_command_unattended has finished yet, and retrieve its captured + output if so. + + Returns `{"done": False}` while still pending -- call again later, there is no + limit on how long you can wait or how many times you poll (see + submit_igor_command's docstring for why this stays reliable no matter how much + time passes or how many times this bridge process itself restarts in the + meantime). Returns `{"done": True, "results": , "history": }` once + finished -- both keys hold the same captured text, matching + execute_igor_command's own return shape (see that tool's docstring for why + "results"/"history" can no longer be kept separate over this transport, and why + `print`, not `fprintf`, is what actually gets captured). + + For a job expected to run over a very long horizon (hours to weeks), consider + setting up a scheduled task that calls this periodically and reports back once + `"done"` flips to true, rather than relying on this conversation staying open. + + Raises if `token` is not recognized -- e.g. a typo, or a token from a + since-quit/since-restarted Igor Pro instance (tokens do not survive Igor Pro + itself restarting, only this bridge process or Claude Desktop restarting). + + Does NOT raise just because the submitted command itself failed to parse or hit + a runtime error -- that case still reports `"done": True`, just with an + empty/shorter-than-expected result and no explicit error indication (see + submit_igor_command's docstring for why: there is currently no generic way to + detect this here). + """ + is_done, text = call_function("ZBR#ZBR_PollCommand", [token]) + if not is_done: + return {"done": False} + if isinstance(text, str) and text.startswith(_UNKNOWN_TOKEN_PREFIX): + raise RuntimeError(text) + return {"done": True, "results": text, "history": text} + + +@mcp.tool() +def read_session_history(stop: bool = False) -> dict: + """Read back everything sent to Igor's history area (print output, command + echoing, error messages, etc.) since this bridge (specifically, ZMQ_BridgeHelpers. + ipf's own capture) started tracking it -- the reliable way to verify a PAST + execute_igor_command/execute_igor_command_unattended call's output actually + happened, without asking a human to look at Igor's screen. + + A capture is started automatically, Igor-side, the first time it's needed (see + ZBR_EnsureCaptureStarted in ZMQ_BridgeHelpers.ipf) so this always has something to + report. Each call returns the FULL accumulated text since that start point, not + just what's new since the last read -- calling this repeatedly with stop=False + (the default) is always safe. + + stop=True stops the capture (no further text will be recorded for it) and returns + whatever was captured up to that point; the next call (to this tool, or the next + command run through this bridge) transparently starts a brand-new capture. + """ + text = call_function("ZBR#ZBR_ReadSessionHistory", [1 if stop else 0]) + return {"history_text": text, "capture_stopped": stop} + + +@mcp.tool() +def get_wave(wave_path: str) -> dict: + """Return an existing Igor wave's data and metadata. + + wave_path should be an absolute Igor path, e.g. "root:testWave" or + "root:myFolder:testWave". + + Unlike the old COM-based version of this tool (limited to 1D, real-valued waves, + read one point at a time via GetNumericWavePointValue), this transport's native + wave serialization supports any dimensionality (up to 4D), real and complex + numeric waves, text waves, and wave-reference waves (waves of waves) -- the entire + wave comes back from ONE CallFunction round trip. See the module docstring's wave + serialization notes for the JSON format this is decoded from. + + Returns a dict with "wave_path", "type" (e.g. "NT_FP64", "TEXT_WAVE_TYPE", + "WAVE_TYPE"), "dim_size" (1 to 4 numbers), "data" (nested Python lists matching + the wave's own dimensionality -- data[row][col]... for multi-dimensional waves, + or a single flat list for 1D), "unit", "note", and "dimension" (delta/offset/ + label/unit per dimension, if set). + + Raises if wave_path does not refer to an existing wave. + """ + wave = call_function("ZBR#ZBR_GetWaveGeneric", [wave_path]) + if wave is None: + raise RuntimeError(f"Wave not found: {wave_path}") + return {"wave_path": wave_path, **wave} + + +# --- Compilation state ----------------------------------------------------------------- + + +@mcp.tool() +def check_compilation_state() -> dict: + """Check whether Igor Pro's procedure code is currently compiled or uncompiled. + + Functions from procedure code can only be called while compiled. Igor Pro enters + the uncompiled state when procedure code is edited inside Igor (only possible + while nothing is running), or when nothing is running and an included procedure + file changed on disk. Use reload_and_compile_procedures to get back to compiled + after editing a .ipf file on disk. + + Note: since ZBR (the independent module this bridge's Igor-side code lives in) + compiles separately from ProcGlobal/regular MIES code, a "true" result here + specifically means ProcGlobal's compile state -- confirmed reachable via ZBR at + all already implies ZBR itself is compiled (otherwise this call would have failed + with IgorZmqUnreachable/IgorZmqError instead of returning a result). + """ + compiled = call_function("ZBR#ZBR_IsCompiled") + return {"compiled": bool(compiled)} + + +_COMPILE_POLL_INTERVAL_SECONDS = 0.5 +_COMPILE_POLL_TIMEOUT_SECONDS = 15.0 + + +def _read_compile_counter(): + """ZBR_ReadCompileCounter(), or None if the call itself fails (e.g. Igor is + mid-recompile and briefly unreachable) -- treated as "unknown", never fatal, same + as the counter's own -1-means-unavailable convention Igor-side.""" + try: + value = call_function("ZBR#ZBR_ReadCompileCounter") + except (IgorZmqError, IgorZmqUnreachable): + return None + return None if value is None or value < 0 else value + + +@mcp.tool() +def reload_and_compile_procedures() -> dict: + """Force Igor Pro to reload procedure code from the .ipf files on disk and attempt + a fresh compilation, then report whether it ended up compiled. + + Use this after editing a .ipf file directly on disk. Only call this while Igor Pro + is not currently running other procedure code. + + **Caution, carried over from the COM-based version**: Igor Pro has been observed + becoming unreachable shortly after a reload/compile attempt on more than one + occasion during this bridge's development (crashed or was closed). As of v2.3.0, + crash-dump analysis traced this to a genuine EXCEPTION_ACCESS_VIOLATION deep inside + Igor64.exe itself (not this bridge's own code), and a likely mechanism was + identified: this bridge's ZeroMQ handler runs as a background thread that keeps + dispatching incoming CallFunction requests regardless of what Igor's main thread is + doing, so a request arriving while COMPILEPROCEDURES is mid-rebuild of Igor's own + internal function/symbol tables is a plausible cross-thread race. v2.3.0 mitigates + this by stopping the ZeroMQ handler before RELOAD CHANGED PROCS/COMPILEPROCEDURES + run and restarting it only after compilation finishes. This is a well-reasoned + mitigation, not a proven fix -- Igor64.exe ships no public symbols, so the exact + fault can't be confirmed from here, and the crash was already rare/nondeterministic. + If a tool call after this one starts failing anyway, check_bridge_health() and be + prepared for Igor Pro to need relaunching. + + **v2.3.1 fix -- handler could stay stopped forever on a failed compile**: v2.3.0's + restart path was AfterCompiledHook alone, which Igor only calls after a + *successful* compile. If the edited .ipf had a syntax error, COMPILEPROCEDURES + failed, AfterCompiledHook never fired, and the ZeroMQ handler -- already stopped by + ZBR_StopHandlerBeforeRecompile -- stayed stopped permanently, killing the bridge + with no recovery path short of restarting Igor. Fixed by ZBR_ArmRecompileWatchdog/ + ZBR_RecompileWatchdogTick in ZMQ_BridgeHelpers.ipf: a named background task, armed + right before the handler is stopped, that unconditionally rebinds/restarts the + handler regardless of whether the compile succeeds or fails. It is registered with + `start=60` (an explicit ~1-second floor before its first possible tick, independent + of its `period=30` interval) so it cannot fire before RELOAD CHANGED + PROCS/COMPILEPROCEDURES have finished draining Igor's operation queue -- confirmed + necessary by live timing instrumentation (stopmstimer(-2)) showing the background + task could otherwise tick within ~62ms of being armed, well before a real compile + (~414ms observed) finishes. AfterCompiledHook still restarts the handler + immediately on the success path; the watchdog is what covers the failure path, and + self-disarms (CtrlNamedBackground .. stop) the first time either one runs. + + **v2.3.1 fix -- ZBR_IsCompiled() checked the wrong module**: its FunctionInfo() call + was unqualified, so it resolved against ZBR's own (independent-module) namespace + -- which compiles separately from ProcGlobal -- instead of ProcGlobal's. This meant + it could report "compiled" even while ProcGlobal itself had a compile error. Fixed + by qualifying the lookup as `FunctionInfo("ProcGlobal#...")`. + + **v2.3.1 fix -- "Function Execution Module is still active" dialog**: an unrelated + pre-existing MIES background thread (not task) left running during + COMPILEPROCEDURES could raise this modal dialog and freeze the entire operation + queue. Mitigated by a new BeforeUncompiledHook in ZMQ_BridgeHelpers.ipf that calls + ThreadGroupRelease(-2) to release any running thread groups before Igor uncompiles. + + Mechanism: calls ZBR_SubmitReloadAndCompile(), which queues (as three independent + Execute/P entries) a call to stop the ZeroMQ handler and arm the watchdog, then + `Execute/P "RELOAD CHANGED PROCS "`, then `Execute/P "COMPILEPROCEDURES "` + Igor-side (see that function's docstring in ZMQ_BridgeHelpers.ipf), then polls for + completion using two independent signals, same as the COM-based version did: + + 1. root:gClaudeHelperCompileCounter (ZBR_ReadCompileCounter), bumped by + AfterCompiledHook every time Igor confirms a successful compile -- race-free: + any increase over the baseline read before submitting is trusted immediately. + 2. ZBR_IsCompiled() (the FunctionInfo-based check), as a fallback. + + Poll errors (IgorZmqError/IgorZmqUnreachable) during either check are treated as + "not ready yet" rather than fatal, since Igor Pro can be briefly unreachable while + genuinely mid-recompile. + + If this returns "compiled": False, check Igor's history/procedure window directly + -- if Igor Pro was launched with /UNATTENDED, a genuine compile error shows up as + a plain "::: error: " line there (readable via + read_session_history), rather than a modal dialog. If NOT launched /UNATTENDED, a + stuck "Function Compilation Error" dialog is also possible -- see + dismiss_compile_error_dialog. As of v2.3.1, the bridge itself should still be + reachable in this case (see the watchdog fix above) -- fix the .ipf and call this + tool again rather than needing to relaunch Igor Pro. + """ + baseline_counter = _read_compile_counter() + + call_function("ZBR#ZBR_SubmitReloadAndCompile") + + deadline = time.monotonic() + _COMPILE_POLL_TIMEOUT_SECONDS + attempts = 0 + while True: + attempts += 1 + + counter = _read_compile_counter() + if ( + baseline_counter is not None + and counter is not None + and counter > baseline_counter + ): + return { + "compiled": True, + "poll_attempts": attempts, + "confirmed_via": "AfterCompiledHook counter (ZBR_ReadCompileCounter)", + } + + try: + if call_function("ZBR#ZBR_IsCompiled"): + return { + "compiled": True, + "poll_attempts": attempts, + "confirmed_via": "ZBR_IsCompiled", + } + except (IgorZmqError, IgorZmqUnreachable): + pass # treat as "not ready yet", same as a False result + + if time.monotonic() >= deadline: + break + time.sleep(_COMPILE_POLL_INTERVAL_SECONDS) + + dismiss_result = _attempt_dismiss_compile_error_dialog() + return { + "compiled": False, + "poll_attempts": attempts, + "auto_dismiss_attempted": dismiss_result, + "note": ( + f"Still not compiled after polling for {_COMPILE_POLL_TIMEOUT_SECONDS:.0f}s. " + "This is more likely a genuine compile error than a timing artifact -- " + "check Igor's history/procedure window directly (or call " + "read_session_history() if launched with /UNATTENDED, see the " + "'::: error: ...' line reported there), or check for a " + "stuck compile-error dialog (see 'auto_dismiss_attempted' above)." + ), + } + + +# --- Debugger control -------------------------------------------------------------- +# +# Unchanged in spirit from the COM-based version -- see that version's extensive +# comment block (still true) for why the Debugger MUST be disabled for any +# unattended/automated session: there is no scriptable way to resume, step, or +# dismiss the Debugger window once something pauses it, and a paused call hangs +# forever. DebuggerOptions is NOT subject to the Execute-only restriction (confirmed +# live), so ZBR_GetDebuggerState/ZBR_SetDebuggerEnabled/ZBR_RestoreDebuggerSettings +# are all single, synchronous CallFunction calls -- no submit/poll needed. + +_saved_debugger_settings = None + + +def _decode_debugger_state(multi) -> dict: + enable, debug_on_error, debug_on_abort, nvar_checking = multi + return { + "enable": bool(enable), + "debug_on_error": bool(debug_on_error), + "debug_on_abort": bool(debug_on_abort), + "nvar_svar_wave_checking": bool(nvar_checking), + } + + +@mcp.tool() +def get_debugger_state() -> dict: + """Read Igor Pro's current Debugger settings (enable, debugOnError, debugOnAbort, + NVAR_SVAR_WAVE_Checking) without changing them, and save a snapshot inside this + bridge process for restore_debugger_settings to restore later. + + **Call this before starting any unattended/automated session**, immediately + before calling set_debugger_enabled(False). + """ + global _saved_debugger_settings + state = _decode_debugger_state(call_function("ZBR#ZBR_GetDebuggerState")) + _saved_debugger_settings = dict(state) + return state + + +@mcp.tool() +def set_debugger_enabled( + enabled: bool, + debug_on_error: bool = None, + debug_on_abort: bool = None, + nvar_svar_wave_checking: bool = None, +) -> dict: + """Turn Igor Pro's Debugger on or off (and optionally its debugOnError/ + debugOnAbort/NVAR_SVAR_WAVE_Checking sub-settings). + + **For any unattended/automated session, the debugger MUST be disabled: call + set_debugger_enabled(False) before starting.** See get_debugger_state's docstring + and the module-level Debugger-control comment above for why. + + enabled=False clears all four settings regardless of the other arguments -- this + is Igor's own documented DebuggerOptions behavior, not a limitation of this + function -- so the sub-flags are only applied when enabled=True. Any sub-flag left + as None (the default) falls back to Igor's CURRENT setting for that flag rather + than being forced off. + + Recommended pattern around an unattended session: + get_debugger_state() # read + save the current settings + set_debugger_enabled(False) # disable for the unattended run + ... run the unattended session ... + restore_debugger_settings() # put the saved settings back + """ + current = _decode_debugger_state(call_function("ZBR#ZBR_GetDebuggerState")) + if not enabled: + call_function("ZBR#ZBR_SetDebuggerEnabled", [0]) + else: + call_function( + "ZBR#ZBR_RestoreDebuggerSettings", + [ + 1, + int( + current["debug_on_error"] + if debug_on_error is None + else debug_on_error + ), + int( + current["debug_on_abort"] + if debug_on_abort is None + else debug_on_abort + ), + int( + current["nvar_svar_wave_checking"] + if nvar_svar_wave_checking is None + else nvar_svar_wave_checking + ), + ], + ) + return _decode_debugger_state(call_function("ZBR#ZBR_GetDebuggerState")) + + +@mcp.tool() +def restore_debugger_settings() -> dict: + """Restore Igor Pro's Debugger settings to whatever get_debugger_state last + captured. + + **Call this when an unattended/automated session ends.** + + Raises if get_debugger_state was never called in this bridge process. + """ + if _saved_debugger_settings is None: + raise RuntimeError( + "No saved Debugger settings to restore -- call get_debugger_state() " + "before starting the unattended session so there is something to " + "restore afterward." + ) + s = _saved_debugger_settings + call_function( + "ZBR#ZBR_RestoreDebuggerSettings", + [ + int(s["enable"]), + int(s["debug_on_error"]), + int(s["debug_on_abort"]), + int(s["nvar_svar_wave_checking"]), + ], + ) + return _decode_debugger_state(call_function("ZBR#ZBR_GetDebuggerState")) + + +# --- Environment summary ----------------------------------------------------------- +# +# Composed client-side from the small, generic ZBR_IgorInfo/ZBR_WinList/ +# ZBR_ProcedureText/ZBR_DataFolderDir wrappers in ZMQ_BridgeHelpers.ipf -- exactly +# mirroring how the COM-based version worked (it also just ran fprintf-wrapped +# built-in calls and parsed/structured the raw string results in Python). See that +# file's own comments for the confirmed IgorInfo() index meanings and the +# ProcedureText("", 0, "Procedure") argument-order gotcha. + + +def _categorize_procedure_file(name: str) -> str: + """Bucket an included procedure file name into a coarse category, purely to make a + ~250-entry file list skimmable in a summary. Buckets reflect this specific repo's + naming conventions (MIES_*, UTF_* unit tests, igortest-* test framework, IPNWB_*), + not a general Igor Pro convention.""" + if name.startswith("igortest"): + return "igortest_framework" + if name.startswith("UTF_"): + return "unit_tests" + if name.startswith("IPNWB"): + return "ipnwb" + if name.startswith("MIES_"): + return "mies_production" + return "other" + + +@mcp.tool() +def get_environment_summary() -> dict: + """Summarize the current Igor Pro instance's live environment: Igor version, the + loaded experiment, loaded external operations (XOPs), which procedure files are + actually included right now, the contents of the always-present "Procedure" + window, and the top-level global data folder layout. + + Returns a dict with: + - igor_version_info / os_info: raw IgorInfo(0) / IgorInfo(3) strings + - experiment_file_name / experiment_file_kind: e.g. "Basic.pxp" / "Packed" + - loaded_xops: list of loaded external operations + - procedure_window_text: raw contents of the special "Procedure" window -- + inspect this for experiment-specific #include/#define directives + - included_procedure_file_count / included_procedure_files_by_category / + included_procedure_files: currently included .ipf files + - data_folders / top_level_waves: top-level layout under root: + - debugger_settings: current Debugger state (see set_debugger_enabled's + docstring for why this matters before any unattended session) + """ + igor_version_info = call_function("ZBR#ZBR_IgorInfo", [0]) + os_info = call_function("ZBR#ZBR_IgorInfo", [3]) + loaded_xops_raw = call_function("ZBR#ZBR_IgorInfo", [10]) + experiment_file_kind = call_function("ZBR#ZBR_IgorInfo", [11]) + experiment_file_name = call_function("ZBR#ZBR_IgorInfo", [12]) + included_raw = call_function("ZBR#ZBR_WinList", ["*", "WIN:128"]) + data_folders_raw = call_function("ZBR#ZBR_DataFolderDir", [3]) + procedure_window_text = call_function("ZBR#ZBR_ProcedureText", ["", 0, "Procedure"]) + debugger_settings = _decode_debugger_state( + call_function("ZBR#ZBR_GetDebuggerState") + ) + + included_procedure_files = [ + name for name in included_raw.split(";") if name and name != "Procedure" + ] + loaded_xops = [x for x in loaded_xops_raw.split(";") if x] + + folders_part, waves_part = "", "" + for part in data_folders_raw.split("\r"): + part = part.strip() + if part.startswith("FOLDERS:"): + folders_part = part[len("FOLDERS:") :].rstrip(";") + elif part.startswith("WAVES:"): + waves_part = part[len("WAVES:") :].rstrip(";") + data_folders = [f for f in folders_part.split(",") if f] + top_level_waves = [w for w in waves_part.split(",") if w] + + category_counts: dict = {} + for name in included_procedure_files: + category = _categorize_procedure_file(name) + category_counts[category] = category_counts.get(category, 0) + 1 + + return { + "igor_version_info": igor_version_info, + "os_info": os_info, + "experiment_file_name": experiment_file_name, + "experiment_file_kind": experiment_file_kind, + "loaded_xops": loaded_xops, + "procedure_window_text": procedure_window_text, + "included_procedure_file_count": len(included_procedure_files), + "included_procedure_files_by_category": category_counts, + "included_procedure_files": included_procedure_files, + "data_folders": data_folders, + "top_level_waves": top_level_waves, + "debugger_settings": debugger_settings, + } + + +# --- Reading .ihf help files --------------------------------------------------------- +# +# The actual CloseHelp/OpenNotebook/SaveNotebook/KillWindow/OpenHelp sequence now runs +# synchronously, Igor-side, in ZBR_ReadHelpFile (ZMQ_BridgeHelpers.ipf) -- none of +# those operations are subject to the Execute-only restriction, so this needs no +# submit/poll. The exported HTML is written to a temp file (built here, same as the +# COM-based version did) and read directly off disk afterward rather than serialized +# back through the CallFunction reply -- both processes run on the same machine, so +# this sidesteps any question about reply-size limits for a potentially large export. + + +class _NotebookHTMLParser(html.parser.HTMLParser): + """Extracts one {"style": ..., "text": ...} record per

paragraph from a + notebook's HTML export (SaveNotebook/S=5).""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.paragraphs = [] + self._in_paragraph = False + self._style = "" + self._text_parts = [] + + def handle_starttag(self, tag, attrs): + if tag.lower() == "p": + self._in_paragraph = True + self._style = "" + self._text_parts = [] + for key, value in attrs: + if key.lower() == "class" and value: + self._style = value + + def handle_endtag(self, tag): + if tag.lower() == "p" and self._in_paragraph: + text = "".join(self._text_parts).strip() + self.paragraphs.append({"style": self._style, "text": text}) + self._in_paragraph = False + + def handle_data(self, data): + if self._in_paragraph: + self._text_parts.append(data) + + +@mcp.tool() +def read_help_file(file_path: str, timeout_ms: int = 30000) -> dict: + """Read an Igor Pro help file (.ihf) as structured, formatted text -- e.g. to + look up an operation's exact flags/behavior straight from Igor's own docs -- + without leaving any lasting change to Igor's help-window state. + + file_path must be a full path to an existing .ihf file (e.g. one found via + get_environment_summary's "loaded_xops" field plus the global/user Help Files + folders under Igor's own installation for XOP-supplied help files). + + timeout_ms defaults to 30 seconds rather than this bridge's usual 5-second + default -- confirmed live that exporting a genuinely large help file (e.g. the + entire "Igor Reference.ihf" manual) as HTML can take longer than 5 seconds, which + would otherwise time out this call even though Igor-side the export eventually + succeeds anyway (harmlessly logging a "Host unreachable" ZeroMQ-XOP error to + history when it tries to reply to a client that already gave up -- see + check_bridge_health if you see that in read_session_history's output after a + timeout here). Pass a larger value still for very large help files if 30s isn't + enough. + + Returns a dict with: + - "paragraphs": [{"style": "Topic", "text": "Debugging"}, ...] -- "style" is + WaveMetrics' own paragraph-class convention (e.g. "Topic" for a heading, + "Code1" for example code, "Steps" for a bullet item), "" if unset. + - "restore_failures": bare file names that could not be resolved back to a full + path and so were NOT reopened as help windows (e.g. a help file supplied from + somewhere other than the two standard Help Files folders). + + Raises if file_path does not exist, or if the underlying OpenNotebook/SaveNotebook + sequence fails Igor-side (e.g. file_path is not actually a notebook-compatible + file) -- help-window restoration is still attempted even then. + """ + normalized = os.path.abspath(file_path) + if not os.path.isfile(normalized): + raise RuntimeError(f"'{normalized}' does not exist or is not a file.") + + tmp_fd, tmp_html_path = tempfile.mkstemp(suffix=".html", prefix="igor_help_") + os.close(tmp_fd) + os.remove( + tmp_html_path + ) # SaveNotebook must create it fresh; only the name is reused + + try: + status = call_function( + "ZBR#ZBR_ReadHelpFile", [normalized, tmp_html_path], timeout_ms=timeout_ms + ) + parts = status.split("|") + outcome = parts[0] if parts else "" + restore_failures = [f for f in (parts[-1] if parts else "").split(";") if f] + + if outcome != "OK": + message = parts[1] if len(parts) > 1 else "(no message)" + raise RuntimeError( + f"Could not read help file {normalized!r}: {message} " + f"(restore_failures={restore_failures!r})" + ) + + if not os.path.isfile(tmp_html_path): + raise RuntimeError( + f"ZBR_ReadHelpFile reported success but {tmp_html_path!r} was not " + "created." + ) + + with open(tmp_html_path, "r", encoding="utf-8") as f: + html_text = f.read() + parser = _NotebookHTMLParser() + parser.feed(html_text) + finally: + try: + if os.path.isfile(tmp_html_path): + os.remove(tmp_html_path) + except Exception: + pass + + return {"paragraphs": parser.paragraphs, "restore_failures": restore_failures} + + +# --- Bridge identity / health -------------------------------------------------------- + +# Kept as a hardcoded constant (not read from manifest.json at runtime) for the same +# reason as before: the on-disk layout after Claude Desktop installs a .mcpb isn't +# guaranteed to keep server.py and manifest.json at a fixed relative path, and this +# needs to be confirmable from inside a conversation independent of that. +_BRIDGE_VERSION = "2.3.2" + + +def _installed_package_version(distribution_name: str) -> str | None: + try: + return importlib.metadata.version(distribution_name) + except importlib.metadata.PackageNotFoundError: + return None + + +@mcp.tool() +def get_bridge_version() -> dict: + """Return the version of this Igor Pro Bridge build that is actually running right + now, plus which Python interpreter and package versions it's running with. + + Call this whenever it matters to confirm which build is active -- e.g. before + relying on a specific fix, or when reporting results from a test that depends on + a particular fix being in effect. Installing a newer .mcpb requires restarting + Claude Desktop; this is the only way to confirm afterward which version actually + loaded. + """ + return { + "version": _BRIDGE_VERSION, + "python_executable": sys.executable, + "python_version": sys.version.split()[0], + "mcp_package_version": _installed_package_version("mcp"), + "pyzmq_version": _installed_package_version("pyzmq"), + } + + +@mcp.tool() +def check_bridge_health() -> dict: + """Check whether this bridge can actually reach Igor Pro's ZeroMQ server right + now, and report what's known if not. + + Call this first whenever a command fails or behaves unexpectedly. + + Unlike the old COM-based version, this can no longer cleanly distinguish "Igor + Pro isn't running" from "Igor Pro is running but ZMQ_BridgeHelpers.ipf isn't + included/compiled/bound" from "wrong port/firewall" -- a ZeroMQ REQ socket that + gets no reply at all looks the same in all three cases (see the module docstring's + "Behavior changes" section). If this reports FAIL, check by hand: is Igor64.exe + actually running (Task Manager)? Is Packages/MIES/ZMQ_BridgeHelpers.ipf + #include-d and does check_compilation_state-equivalent info suggest it's + compiled? Try `netstat -a -b` (needs admin) to see whether anything is actually + listening on this bridge's currently configured port (see _igor_zmq_endpoint; + reported in the "problem" message below on FAIL) -- if a custom port was set via + configure_igor_launch(port=...), make sure the target Igor Pro instance was + actually launched with that same port in effect. + + Returns a dict with a "status" key ("OK" or "FAIL") and, on FAIL, a "problem" key. + """ + try: + info = call_function("ZBR#ZBR_Ping") + except IgorZmqUnreachable as e: + return { + "status": "FAIL", + "problem": ( + f"No reply from Igor Pro's ZeroMQ server ({e}). This can mean Igor " + "Pro isn't running, ZMQ_BridgeHelpers.ipf isn't #include-d/compiled " + "in the current experiment (see that file's own header comment for " + "the one-time setup step), or its ZeroMQ socket isn't bound to " + f"{_igor_zmq_endpoint()} for some other reason." + ), + } + except IgorZmqError as e: + return { + "status": "FAIL", + "problem": f"Igor Pro's ZeroMQ server replied but reported an error: {e}", + } + + return {"status": "OK", "igor_info": info} + + +# --- Compile-error dialog dismissal (posted Escape key message) --------------------- +# +# Unchanged from the COM-based version: this is pure OS-level window handling +# (win32gui/win32api), entirely independent of which transport talks to Igor Pro's +# procedure code. See the original version's extensive comment history (still +# accurate) for how the dialog's title/class were identified live, why title-only +# matching is used (a Copilot PR review flagged blanket "#32770" matching as unsafe), +# and why PostMessage (not a real hardware key event) is used. + +_IGOR_PROCESS_NAME_PREFIX = "igor" +_KNOWN_STUCK_DIALOG_TITLES = ("Function Compilation Error",) +_POSTED_KEY_GAP_SECONDS = 0.05 +_POSTED_KEY_SETTLE_SECONDS = 0.2 + + +def _is_stuck_dialog_window(class_name: str, title: str) -> bool: + return any(known.lower() in title.lower() for known in _KNOWN_STUCK_DIALOG_TITLES) + + +def _get_process_exe_name(pid: int): + ACCESS = win32con.PROCESS_QUERY_INFORMATION | win32con.PROCESS_VM_READ + hProcess = None + try: + hProcess = win32api.OpenProcess(ACCESS, False, pid) + path = win32process.GetModuleFileNameEx(hProcess, 0) + return os.path.basename(path) + except Exception: + return None + finally: + if hProcess is not None: + win32api.CloseHandle(hProcess) + + +def _list_pids_for_exe_basename(basename_lower: str) -> list: + """Return the PIDs of every currently running process whose own module file name + matches basename_lower exactly (case-insensitively), e.g. "igor64.exe". + + Used by load_experiment to wait for an Igor Pro process to fully exit -- **not** + the same thing as it going quiet over ZeroMQ. Confirmed live (user report, this + session): ZeroMQ stops answering well before the underlying Igor64.exe process + actually terminates, and launching a replacement `Igor64.exe /UNATTENDED ` + command line while the old process is still alive -- even if it's already mid-quit + -- does NOT spawn a new process at all. Windows/Igor's single-instance-per-user + behavior instead either (a) redirects the launch request into the still-live old + instance, which can pop an unhandled "save changes?" dialog if it has unsaved + edits, or (b) if the old instance is already mid-shutdown, silently drops the + request altogether -- which looks exactly like the relaunch had no effect at all, + with no error reported anywhere. Waiting for the actual OS process list to be + clear of the target exe name, rather than trusting ZeroMQ silence, avoids both. + """ + matches = [] + for pid in win32process.EnumProcesses(): + if pid == 0: + continue + exe_name = _get_process_exe_name(pid) + if exe_name and exe_name.lower() == basename_lower: + matches.append(pid) + return matches + + +def _find_igor_dialog_window(): + matches = [] + + def _callback(hwnd, _extra): + if win32gui.IsWindowVisible(hwnd): + title = win32gui.GetWindowText(hwnd) + class_name = win32gui.GetClassName(hwnd) + if _is_stuck_dialog_window(class_name, title): + _, pid = win32process.GetWindowThreadProcessId(hwnd) + exe_name = _get_process_exe_name(pid) + if exe_name and exe_name.lower().startswith(_IGOR_PROCESS_NAME_PREFIX): + matches.append((hwnd, title, exe_name)) + return True + + win32gui.EnumWindows(_callback, None) + return matches[0] if matches else None + + +def _list_igor_top_level_windows() -> list: + windows = [] + + def _callback(hwnd, _extra): + if win32gui.IsWindowVisible(hwnd): + _, pid = win32process.GetWindowThreadProcessId(hwnd) + exe_name = _get_process_exe_name(pid) + if exe_name and exe_name.lower().startswith(_IGOR_PROCESS_NAME_PREFIX): + windows.append( + { + "title": win32gui.GetWindowText(hwnd), + "class_name": win32gui.GetClassName(hwnd), + "process": exe_name, + } + ) + return True + + win32gui.EnumWindows(_callback, None) + return windows + + +def _attempt_dismiss_compile_error_dialog() -> dict: + found = _find_igor_dialog_window() + if found is None: + return { + "attempted": False, + "reason": ( + "No visible window with a title containing one of " + f"{_KNOWN_STUCK_DIALOG_TITLES}, owned by an Igor Pro process, was " + "found. Either there is no stuck dialog right now, or it's a kind " + "not seen before -- see 'igor_windows_seen' below." + ), + "igor_windows_seen": _list_igor_top_level_windows(), + } + + hwnd, window_title, exe_name = found + + try: + win32api.PostMessage(hwnd, win32con.WM_KEYDOWN, win32con.VK_ESCAPE, 0) + time.sleep(_POSTED_KEY_GAP_SECONDS) + win32api.PostMessage(hwnd, win32con.WM_KEYUP, win32con.VK_ESCAPE, 0) + time.sleep(_POSTED_KEY_SETTLE_SECONDS) + except Exception as e: + return { + "attempted": False, + "reason": f"Posting the simulated Escape key press failed: {e}", + "dialog_window_title": window_title, + "dialog_window_process": exe_name, + } + + return { + "attempted": True, + "dialog_window_title": window_title, + "dialog_window_process": exe_name, + "note": ( + "Posted a simulated Escape key press directly to this dialog window " + "(no OS foreground/focus change was made or needed). This does NOT " + "recover the actual compile-error message -- it only closes whatever " + "modal dialog was showing. Follow up with check_compilation_state() or " + "reload_and_compile_procedures() to see whether this actually un-stuck " + "anything." + ), + } + + +@mcp.tool() +def dismiss_compile_error_dialog() -> dict: + """Attempt to close a stuck Igor Pro modal compile-error dialog by posting a + simulated Escape key press directly to it, WITHOUT recovering the actual error + message and WITHOUT requiring or changing OS focus/foreground state. + + Use this manually when you suspect Igor Pro has a compile-error dialog open (e.g. + reload_and_compile_procedures kept reporting "not compiled" even after fixing a + known syntax error). Note: launching Igor Pro with /UNATTENDED (see + launch_igor_pro_unattended) suppresses this dialog entirely in favor of a plain + history line, so this should rarely be needed for an instance launched that way. + + Mechanism: enumerates top-level windows for a visible one, owned by a process + whose exe name starts with "igor", whose title matches a known stuck-dialog title + ("Function Compilation Error", confirmed live on both Igor Pro 10.03 and 9.06, + both a Qt window rather than a native "#32770" dialog). Posts WM_KEYDOWN/WM_KEYUP + for VK_ESCAPE via PostMessage, without requiring focus or foreground. + + If no matching window is found, reports "attempted": False along with + "igor_windows_seen": every visible top-level window currently owned by an Igor + Pro process, so a new stuck dialog's real title/class can be identified. + + **Trade-off: this does not tell you what the error was.** It only clears whatever + dialog is blocking Igor's operation queue so work can continue. + """ + return _attempt_dismiss_compile_error_dialog() + + +# --- Launching / relaunching Igor Pro ------------------------------------------------- +# +# Still pure Python/OS-level (starting a whole new Igor Pro *process* has to be done +# from outside any already-running instance -- no transport can do this from the +# inside). **No more elevation branching at all** -- see the module docstring: ZeroMQ +# has no privilege-matching requirement, so Igor Pro is always launched as a plain +# child process of this bridge process, at whatever privilege level that already is. + +# Matches ZBR_ZEROMQ_ENV_PORT in Packages/MIES/ZMQ_BridgeHelpers.ipf: when set, a +# launched Igor Pro instance's ZBR_EnsureZeroMQBound binds its ZeroMQ socket to this +# port instead of its own default (5680). Preparation for talking to more than one +# Igor Pro instance -- see configure_igor_launch. _configured_igor_port itself lives +# up in the "ZeroMQ transport" section since _igor_zmq_endpoint() also reads it. +_IGOR_PRO_BRIDGE_PORT_ENV_VAR = "IGOR_PRO_BRIDGE_PORT" + +_configured_igor_exe_path = None + + +def _build_igor_launch_env(): + """Return an environment dict for subprocess.Popen when launching Igor Pro, + patching in COMSPEC if this process's own environment is missing it. + + Confirmed necessary during this bridge's development: MIES's own startup hook + (IgorStartOrNewHook -> ... -> ExecuteGitForMIESVersion, MIES_GlobalStringAndVariable + Access.ipf) shells out to git via ExecuteScriptText using GetCmdPath()/COMSPEC to + find cmd.exe. A child process launched via subprocess.Popen with no explicit env + inherits this process's own environment -- which may not have COMSPEC set (a + normal interactive login session always does; this bridge process's own + environment, inherited from whatever launched Claude Desktop, might not). Without + it, MIES's git-shell-out becomes malformed and + `ASSERT(!V_flag, "We have git installed but could not regenerate version.txt")` + trips on every launch via this path. + + Also carries through _IGOR_PRO_BRIDGE_PORT_ENV_VAR if configure_igor_launch set + (or cleared) it on this process's own os.environ -- no extra handling needed here + since this starts from a plain copy of that. + """ + env = os.environ.copy() + if not env.get("COMSPEC"): + system_root = env.get("SystemRoot", r"C:\Windows") + env["COMSPEC"] = os.path.join(system_root, "System32", "cmd.exe") + return env + + +@mcp.tool() +def configure_igor_launch(exe_path: str, port: Optional[int] = None) -> dict: + """Record the full path to the Igor Pro executable (e.g. "...\\IgorBinaries_x64\\ + Igor64.exe") to use for launch_igor_pro_unattended/load_experiment, for the rest + of this bridge process's session. + + **Whatever agent is calling this tool should ask the user for this path once, at + the start of a session that might need to launch Igor Pro** -- do not guess or + default to a typical installation path; this repo alone has been tested against + Igor Pro installed in more than one differently-named folder. This setting is + session-scoped: it resets if this bridge process itself restarts. + + port, if given, is a custom ZeroMQ port for the NEXT launched instance to bind + to, instead of its own default (5680) -- preparation for eventually talking to + more than one Igor Pro instance at once. Setting this writes + IGOR_PRO_BRIDGE_PORT=str(port) into this bridge process's own environment; + launch_igor_pro_unattended/load_experiment inherit that when they start Igor Pro + (see _build_igor_launch_env), and the launched instance's own + ZBR_EnsureZeroMQBound (ZMQ_BridgeHelpers.ipf) reads it back to decide which port + to bind. **Omitting port (or passing None) clears any previously-configured + custom port** -- it removes IGOR_PRO_BRIDGE_PORT from this process's environment + entirely, so the next launch uses Igor's own default port again. This is not + "leave unchanged": repeat the same port value on every call while a custom port + should stay in effect. + + Also updates which port THIS bridge itself connects to for every subsequent tool + call (see _igor_zmq_endpoint) -- every ZMQ-talking function goes through that one + helper, so setting/clearing port here immediately retargets all of them, not just + the next launch. This bridge still only tracks ONE currently-configured + endpoint at a time, though -- talking to two Igor Pro instances simultaneously + (rather than switching which single one this points at) isn't supported yet. + + Raises if exe_path does not point to an existing file, or if port is given but + is not a valid TCP port number (1-65535). Does not otherwise validate that + exe_path is actually Igor Pro (beyond a soft filename check). + """ + global _configured_igor_exe_path, _configured_igor_port + + normalized = os.path.abspath(exe_path) + if not os.path.isfile(normalized): + raise RuntimeError( + f"'{normalized}' does not exist or is not a file. Ask the user for the " + "exact full path to the Igor Pro executable (typically something like " + r'"C:\Program Files\WaveMetrics\Igor Pro 9 Folder\IgorBinaries_x64\Igor64.exe"' + " -- the exact folder name varies by Igor Pro version) and try again." + ) + + if port is not None and ( + not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535) + ): + raise RuntimeError( + f"'{port}' is not a valid TCP port -- expected an integer between 1 and " + "65535, or omit/None to clear a previously-configured custom port." + ) + + note = None + if "igor" not in os.path.basename(normalized).lower(): + note = ( + "This file name does not look like a typical Igor Pro executable " + "(expected something like 'Igor64.exe'). Proceeding anyway in case the " + "user has a renamed executable." + ) + + if port is not None: + os.environ[_IGOR_PRO_BRIDGE_PORT_ENV_VAR] = str(port) + else: + os.environ.pop(_IGOR_PRO_BRIDGE_PORT_ENV_VAR, None) + + _configured_igor_exe_path = normalized + _configured_igor_port = port + return {"configured_exe_path": normalized, "configured_port": port, "note": note} + + +_POST_LAUNCH_POLL_INTERVAL_SECONDS = 1.0 + + +@mcp.tool() +def launch_igor_pro_unattended(wait_for_ready_seconds: float = 30.0) -> dict: + """Launch the configured Igor Pro executable with the /UNATTENDED command-line + flag. + + Per Igor Pro Folder/Igor Help Files/Advanced Topics.ihf ("Calling Igor from + Scripts"), /UNATTENDED "suppresses certain interactions that are inconvenient for + unattended operations" -- documented examples are the About Autosave dialog and + (Igor Pro 10+) the license activation dialog. Also confirmed empirically (not + documented anywhere in Igor's help files): /UNATTENDED also suppresses the modal + "Function Compilation Error" dialog on a bad procedure compile, reporting the + error as a plain history line instead (format "::: error: + ", readable via read_session_history). + + **Requires configure_igor_launch(exe_path) to have been called first.** + + Refuses to launch (returns "launched": False) if something already answers + ZBR#ZBR_Ping right now -- launching the executable again with only /UNATTENDED + starts a genuinely new instance rather than reusing an existing one (Advanced + Topics.ihf), which would leave two Igor64.exe processes running. + + **Always launches as a plain child process (subprocess.Popen), at whatever + privilege level this bridge process itself is running at -- no elevation request, + no UAC prompt, ever.** This is a deliberate change from the COM-based version: + ZeroMQ has no privilege-matching requirement at all (unlike COM, which needed this + process and Igor Pro to run at the SAME privilege level), so there is nothing to + branch on anymore. + + Patches COMSPEC into the child's environment if missing (see + _build_igor_launch_env) -- needed for MIES's own git-based startup hook. + + After launching, polls for ZBR#ZBR_Ping to start answering (every ~1s) up to + wait_for_ready_seconds. Returns whether it became ready and how many polling + attempts that took. + """ + if not _configured_igor_exe_path: + raise RuntimeError( + "No Igor Pro executable path configured yet. Ask the user for the full " + "path to their Igor Pro executable (e.g. " + r'"C:\Program Files\WaveMetrics\Igor Pro 9 Folder\IgorBinaries_x64\Igor64.exe")' + ", then call configure_igor_launch(exe_path) with it before calling " + "this tool." + ) + + if _reachable(timeout_ms=1000): + return { + "launched": False, + "reason": ( + "Something already answered ZBR#ZBR_Ping over ZeroMQ. Refusing to " + "launch a second Igor Pro instance -- close the existing one first " + "if a genuinely fresh one is actually wanted." + ), + } + + try: + subprocess.Popen( + [_configured_igor_exe_path, "/UNATTENDED"], + env=_build_igor_launch_env(), + ) + except Exception as e: + return {"launched": False, "reason": f"Failed to start the process ({e})."} + + deadline = time.monotonic() + wait_for_ready_seconds + attempts = 0 + while time.monotonic() < deadline: + attempts += 1 + if _reachable(timeout_ms=1000): + return {"launched": True, "zmq_ready": True, "poll_attempts": attempts} + time.sleep(_POST_LAUNCH_POLL_INTERVAL_SECONDS) + + return { + "launched": True, + "zmq_ready": False, + "poll_attempts": attempts, + "note": ( + f"The process was started, but nothing answered ZBR#ZBR_Ping within " + f"{wait_for_ready_seconds:.0f}s. Igor Pro may still be initializing " + "(slower on first launch or a cold machine), or ZMQ_BridgeHelpers.ipf may " + "not be #include-d/compiled in whatever experiment this instance opened " + "by default -- try check_bridge_health() again after waiting longer." + ), + } + + +@mcp.tool() +def load_experiment( + file_path: str, + wait_for_ready_seconds: float = 30.0, + process_exit_timeout_seconds: float = 30.0, +) -> dict: + """Load an Igor Pro experiment file (.pxp), replacing whatever is currently open. + + **Behavior change from the COM-based version, read carefully**: COM's + IApplication.LoadExperiment hot-swapped the experiment inside the SAME running + Igor Pro process. That method is COM-only (confirmed: neither "LoadExperiment" + nor "OpenFile" appear anywhere in Igor Reference.ihf, only in Automation + Server.ihf) -- there is no procedure-language equivalent, so this transport + cannot replicate it. Instead, this tool: + + 1. Asks the currently-running instance to quit (`Quit/N`, submitted via + ZBR_SubmitCommand -- deferred, so this step itself still gets a normal reply + before Igor actually exits). + 2. Waits for the underlying Igor64.exe **OS process** to fully disappear from the + process list -- see below for why this can't just check ZeroMQ reachability. + 3. Relaunches the configured Igor Pro executable (see configure_igor_launch) with + `/UNATTENDED` plus the target file path as a launch argument -- passing a file + path on launch is documented (Advanced Topics.ihf, "Calling Igor from + Scripts") to open that specific file. + 4. Polls for the new instance to start answering ZBR#ZBR_Ping, same as + launch_igor_pro_unattended. + + **Step 2 is not optional, and checking ZeroMQ reachability alone is not enough -- + confirmed live (user report) after an early version of this tool relied on exactly + that and silently failed.** ZeroMQ goes quiet well before the Igor64.exe process + actually terminates (Igor can take several seconds to fully exit after `Quit/N` + runs). If the replacement `Igor64.exe /UNATTENDED ` command line is launched + while the old process is still alive -- even mid-shutdown -- Windows/Igor's + single-instance-per-user behavior does not spawn a new process at all: it either + (a) redirects the launch into the still-live old instance, which pops an unhandled + "save changes?" dialog if it happens to have unsaved edits, or (b) if the old + instance is already mid-quit, silently drops the request altogether. Case (b) is + especially deceptive: nothing errors, no new process appears, and the net effect + looks exactly like the relaunch had no effect whatsoever. This tool instead polls + the actual OS process list (matching the configured executable's own file name, + e.g. "Igor64.exe") until no such process remains, up to + process_exit_timeout_seconds, before ever invoking the relaunch command line. If + that timeout elapses with the process still present, this raises rather than + proceeding -- proceeding anyway would just reproduce the same silent-failure risk + this check exists to prevent. A stuck "save changes?" dialog on the OLD instance + (e.g. if something modified the experiment after your last save) is the most + likely cause; check for one by hand if this happens. + + This is a genuine process restart, not an in-place swap. **Any unsaved changes in + the currently-open experiment are lost** -- call + execute_igor_command('SaveExperiment') first if that matters (matching the old + version's own behavior: LoadExperiment never auto-saved either, but there was at + least still a "hot" instance to save from afterward if you forgot; now there + isn't). + + Requires configure_igor_launch(exe_path) to have been called first. + + Raises if file_path does not point to an existing file, or if the old Igor Pro + process does not fully exit within process_exit_timeout_seconds. + """ + if not _configured_igor_exe_path: + raise RuntimeError( + "No Igor Pro executable path configured yet -- call " + "configure_igor_launch(exe_path) first." + ) + + normalized = os.path.abspath(file_path) + if not os.path.isfile(normalized): + raise RuntimeError(f"'{normalized}' does not exist or is not a file.") + + igor_basename_lower = os.path.basename(_configured_igor_exe_path).lower() + + try: + call_function("ZBR#ZBR_SubmitCommand", ["Quit/N"]) + except (IgorZmqError, IgorZmqUnreachable): + pass # nothing was running/reachable to begin with -- nothing to quit + + quit_deadline = time.monotonic() + process_exit_timeout_seconds + remaining_pids = _list_pids_for_exe_basename(igor_basename_lower) + while remaining_pids and time.monotonic() < quit_deadline: + time.sleep(0.5) + remaining_pids = _list_pids_for_exe_basename(igor_basename_lower) + + if remaining_pids: + raise RuntimeError( + f"'{igor_basename_lower}' did not fully exit within " + f"{process_exit_timeout_seconds:.0f}s of sending Quit/N (PIDs still " + f"running: {remaining_pids}). Relaunching now would risk silently doing " + "nothing (see this tool's own docstring) -- check whether the existing " + "Igor Pro instance is stuck on an unhandled dialog (e.g. 'save changes?' " + "if something modified the experiment since your last save) and resolve " + "it by hand, or retry with a longer process_exit_timeout_seconds." + ) + + try: + subprocess.Popen( + [_configured_igor_exe_path, "/UNATTENDED", normalized], + env=_build_igor_launch_env(), + ) + except Exception as e: + raise RuntimeError( + f"Failed to relaunch Igor Pro with {normalized!r}: {e}" + ) from e + + deadline = time.monotonic() + wait_for_ready_seconds + attempts = 0 + while time.monotonic() < deadline: + attempts += 1 + if _reachable(timeout_ms=1000): + return { + "loaded_file": normalized, + "zmq_ready": True, + "poll_attempts": attempts, + } + time.sleep(_POST_LAUNCH_POLL_INTERVAL_SECONDS) + + return { + "loaded_file": normalized, + "zmq_ready": False, + "poll_attempts": attempts, + "note": ( + f"Process relaunched with {normalized!r}, but nothing answered " + f"ZBR#ZBR_Ping within {wait_for_ready_seconds:.0f}s. Try " + "check_bridge_health() again after waiting longer." + ), + } + + +if __name__ == "__main__": + mcp.run()