A CODESYS install launched with --additionalfolder that registers a large
add-on set boots far slower than a bare install. SP19 Patch 2 with the
161-plugin MarinerX07 folder (Script Engine included) took ~70s to signal
ready (measured 2026-07-27: spawn 07:14:53, ready.signal 07:16:03).
The old 60s READY_TIMEOUT_MS cut off ~10s before ready. Worse, the launcher
then dropped its tracked PID, so the automatic retry hit refuse-on-duplicate
against the very instance that had just finished coming up -- a deadlock that
needed a manual shutdown_codesys to clear. 150s covers the slow-plugin boot
with headroom.
get_all_pou_code died with UnicodeDecodeError on any project holding a
non-ASCII character in POU text -- a degree sign, a plus-minus, box-drawing in
a comment banner. IronPython 2.7's json encoder opens with
if isinstance(s, str) and HAS_UTF8.search(s) is not None:
s = s.decode('utf-8')
and in IronPython `isinstance(u'x', str)` is True, because str and unicode both
wrap System.String. So the branch is always taken for text with a high byte,
the lone byte is not valid UTF-8, and the whole dump dies. Coercing the value
to unicode first does not help -- the isinstance check passes either way.
The script now emits its own JSON, escaping every non-ASCII character to
\uXXXX before it can reach that code path. Output is byte-identical to what
CPython's json.dumps would produce, so the TypeScript side is unchanged.
mirror_export was never affected: it writes through codecs.open, not json.
Also close the hole this exposed in release_project_version. The pipeline
logged "pou-dump.md: skipped (markers not found in output)" and then went on to
commit, tag and push a release that was missing an artefact. Every later
release short-circuits on "no version change", so the gap could never close on
its own. The no-change path now regenerates any missing artefact at the current
version and commits it as a repair, without cutting a tag.
Artefact regeneration is extracted into regenerateArtifact() so a repaired file
is byte-identical to a freshly released one.
Verified against MRCodesysMarinerX07_000.project (150 objects, 384 KB of code),
which is the project that surfaced the bug. Unit suite not run: vitest hangs at
startup on this box right now, before loading any test file, including files
unrelated to this change.
The CODESYS Installer registers add-on packages (Script Engine included) into
<install>\CODESYS\AdditionalFolders\<InstallationName>\, each with its own
profile.xml reusing the SAME <ProfileName> as the bare base profile. Launching
with --profile alone resolves to the base profile, which on such a box can have
zero plugins registered.
Symptoms this fixes:
- "The command line option 'runscript' has been set. However, there is no
script engine implementation available" -- the watcher never runs, so every
tool fails with "Watcher did not signal ready within 60000ms".
- A load dialog that reads as a contradiction: "created with CODESYS V3.5 SP19
Patch 2 and contains data that cannot be loaded by CODESYS V3.5 SP19
Patch 2". Same profile name, different plugin set.
Observed on this box: three profiles all named "CODESYS V3.5 SP19 Patch 2",
with 0 / 118 / 161 plugins registered. Only the 161 one has the Script Engine.
The Start Menu shortcut the installer generates already passes
--additionalfolder=; the server now can too.
detect.ts ranks every AdditionalFolders\* by registered plugin count and emits
the fullest in --detect / --print-config. Installs without AdditionalFolders are
unaffected and get no flag.
The open_project pre-flight was supposed to prevent this, but it reads
the project's profile from projectinspectiondata.auxiliary inside the
.project ZIP -- and a plain .project is NOT a ZIP. Verified across
3.5.19.20 .. 3.5.22.10: magic 23 89 ED 33, unzip -l fails, no plaintext
profile string. So inspectProjectFile always throws, the pre-flight
catches and proceeds, and the guard is dead code on real projects.
It also only ever covered open_project -- not the release pipeline.
Consequence in the field: a server bound to SP21 Patch 5 opened and
re-saved a 3.5.19.20 vessel project during release_project_version.
4,639,776 -> 4,688,496 bytes, committed and tagged. The tagged binary
stopped being the software on the boat.
Since the version cannot be recovered from the file, pin it in the repo.
New src/version-pin.ts resolves, most specific first:
1. .codesys-version next to the .project (one line, "3.5.19.20" or
"CODESYS V3.5 SP19"; comments and blanks skipped) -- the only
option when seeding a project with no release history
2. library.md's "CODESYS Development System" row -- every project
gets a pin for free after its first release
Policy is asymmetric so it guards the dangerous path without breaking
existing repos: bump_project_version and release_project_version refuse
on mismatch AND on no-pin; get_project_info, mirror_export and
list_project_libraries only warn on mismatch and proceed when unpinned.
Both saving tools accept allowVersionUpgrade: true to override.
Parsing note: library.md's row carries no dotted profile version, so it
is matched on the SP/Patch label only -- a naive version regex would
otherwise bind to the ScriptEngine's 4.2.0.0. Covered by a regression
test.
25 new unit tests; suite 180/180.
Also fixes an unrelated pre-existing test failure: the ASCII-only script
template check used readdirSync without filtering, so a local untracked
src/scripts/__pycache__/ made it fail with EISDIR.
The pipeline wrote to 'Changelog.md' and git-added the same literal;
on Windows the write lands in a CHANGELOG.md (case-insensitive fs) but
git add under the wrong casing does not update the tracked index entry,
so the commit carried a STALE changelog and Lib001's pre-commit
version-match hook rejected the release (v0.22.0.0, 2026-07-24).
resolveChangelogName() picks the real filename for both the write and
the git add.
ScriptIecLanguageObjectContainer.create_pou raises 'out of the range of
valid values / Parameter name: return_type' when creating a Function
without one. Parse 'FUNCTION Name : TYPE' from the provided declaration
(fallback BOOL; the declaration replace overwrites the header anyway).
ScriptPouObjectCollection.remove(name) can return without effect on
SP21 (no exception, entry persists -- observed after a program rename
left a stale task call). Delete by index first and fail loud if the
name is still in the call list after save.
Project close is refused while logged in, which silently defeated the
editor-view flush for entire online sessions -- views accumulated until
the IDE died with 'running low on system resources' (Sea Leopard
2026-07-24, ~30 scripted edits + downloads in one online day). Attempt
online_application.logout() before close; the next online tool call
re-logs-in via ensure_online_connection with pre-registered credentials.
Auto-increment per bump burned through Drive sequence numbers for
builds that never left the machine (KK: 'stay on 006 until further
notice'). The number now advances only when a build is actually
uploaded -- set manually in the GVL at upload time. Seeding when
absent is unchanged.
New GVL variable carrying the Drive export name for the build
(KK convention: '<project stem>_NNN'). Incremented from the previous
GVL value on every bump (zero-padding preserved, manual seeding
respected); seeds '<stem>_001' when absent. Gaps in the sequence mean
a version was never uploaded. Lets the running PLC report exactly
which Drive file it came from.
list_device_parameters and set_device_parameter only iterated
device.device_parameters and connector.parameters. Host-side parameter
sets -- e.g. the WAGO 750-series 'K-BUS Parameters' grid (program start
interlock, k-bus cycle time, TCM control) -- are exposed exclusively via
ScriptConnector.host_parameters (SP21 stub ScriptDeviceParameters.pyi;
helpme-codesys ScriptEngine, ScriptConnector.host_parameters), so both
tools reported zero parameters on the Kbus master node.
Verified on the Sea Leopard PFC200 project: id 150 'program start
interlock' now lists and sets offline.
BUG 1: the README.md version replace was content.replace(/v\d+\.\d+\.\d+\.\d+/g,
newVersion) -- a blanket sweep that rewrote every version-looking string in
the file, corrupting historical fix landmarks, build-archive snapshots, and
even a different project's synced-library version (observed live on
SeaLeopard's README v1.3.0.0 -> v1.3.1.0). Replaced with updateReadmeVersion:
only touches the version token on the first "# " title heading, or a single
"**Version:**" line (Lib001 convention). If no anchor is found (or the
Version-line anchor is ambiguous), nothing is changed and the reason is
reported -- no global-replace fallback.
BUG 2: appendChangelogEntry's "ownership guard" checked for the literal text
'Auto-generated by `bump_project_version`', but the intro this same tool
seeds into new files says 'Auto-appended by `bump_project_version` on
release.' -- the strings never matched, so every one of this tool's own
previously-created Changelog.md files (SeaLeopard, since its v1.0.0.0 seed)
was treated as foreign and silently skipped. The skip only logged to
stderr while release_project_version's caller printed a fixed "Changelog.md:
appended vX" success line regardless. Separately, a genuinely hand-maintained
Keep-a-Changelog file (Lib001's CHANGELOG.md, "## [Unreleased]" / "## [x.y.z.w]
- date") was never understood at all. Confirmed via SeaLeopard commits
3c87d18 / c98c264, which hand-repaired both the missing changelog entries and
the corrupted README.
Replaced with buildChangelogUpdate (pure) + appendChangelogEntry (I/O
wrapper): detects the existing file's heading style from its own headings
(not intro wording) and emits a matching entry -- "ours" style
(## vX.Y.Z.W -- date) inserted before the first existing entry, or
Keep-a-Changelog style (## [X.Y.Z.W] - date) inserted after any
"## [Unreleased]" section (or before the first version heading if none).
An unrecognized format is left untouched with a clear skip reason. The write
is verified by re-reading the file before release_project_version reports
success; a write that didn't happen is now reported as NOT appended.
Rebuilt dist/ (tsc + scripts copy) since the MCP server runs from there.
Tests: 16 new (update-readme-version, build-changelog-update,
append-changelog-entry), full suite: npx vitest --run -- 229 passed, 1
pre-existing unrelated failure (script-manager.test.ts trips on a local
__pycache__ dir under src/scripts, present before this change too).
Per feedback: emit '<Namespace> : STRING := "<version>";' (self-describing,
name carries the library) instead of sLibNN with the name inside the value.
Namespace sanitized to a valid IEC identifier; deduped by name; sVersion /
uiLibraryCount reserved-name guarded.
Per feedback: emit one 'sLibNN : STRING := "Namespace Version"' per reference
(same style as sVersion, individually online-readable) instead of
asLibraries ARRAY[1..N] OF STRING(79). uiLibraryCount unchanged.
Enumerate all library-manager references (name + resolved version) and write
them into the runtime-readable GVL as uiLibraryCount + asLibraries ARRAY OF
STRING(79), alongside sVersion. Refreshed on every version bump so the running
PLC reports its full library manifest. Best-effort enumeration (soft-fails to
an empty manifest so a bump never fails); ARRAY omitted when empty. Enumeration
reuses the verified list_project_libraries API (has_library_manager -> lm.references).
Not yet active in a running MCP: needs a server restart (+ rebuild/republish if
scripts are bundled) and a test bump to verify manifest population and that
read_running_version_online still parses sVersion.
project.import_native lands at the PROJECT ROOT (POU pool, visible only in
the POUs view), and CODESYS refuses to move root-level objects into an
application afterwards ('Cannot move X from <root>') -- a wrong-level bulk
import is unrecoverable by script. ScriptObject.import_native (API 3.4.4.0)
imports under a node; expose it via parentObjectPath so library subtrees
can be imported straight into e.g. 'Application/MRLib'.
CODESYS persists the open-editor window layout in
<Project>-<user>-<machine>.opt and restores it on project open -- so a
plain close/reopen (or even a full IDE restart) brings every accumulated
editor view straight back, as seen live: a fresh IDE hit 'low on system
resources' immediately after reopening a project whose .opt held ~60
views (647 kB sidecar). Deleting the per-user .opt (pure UI state,
regenerates clean; AllUsers.opt kept) makes the flush actually stick.
Every scripted textual_declaration/textual_implementation write and object
creation opens an editor view in the visible IDE. The ScriptEngine has no
API to close views (ScriptCommands is lookup-only per ScriptSystem.pyi and
helpme-codesys ScriptingEngine docs; the WinForms menus are also invisible
to UI Automation). After ~40-60 scripted edits the IDE exhausts UI
resources ('Please close some views to free up resources') and every
subsequent script call times out at the IPC layer.
Mitigation: count edit-tool calls (set_pou_code, create_pou/method/
property/dut/gvl); before the Nth edit since the last flush, run
flush_editor_views.py which saves, closes and reopens the primary project
-- disposing all editor views in seconds. Threshold configurable via
CODESYS_EDITOR_FLUSH_THRESHOLD (default 20, 0 disables). The flush result
is surfaced as a NOTE/WARN line in the tool response.
Both tools silently DISCARDED declarationCode/implementationCode (zod strips
unknown keys): the object was created EMPTY, compiled clean, and did nothing.
Cost a full download/debug cycle on the fp-j1939 bench session 2026-07-17.
Now the params are real: applied after creation via ScriptTextualObject
textual_declaration/textual_implementation.replace() (same proven API as
set_pou_code), failing LOUDLY if provided code cannot be applied.
Also extend findReservedIecIdentifiers beyond time-suffix letters to the full
IEC 61131-3 ST reserved-keyword set, checked case-insensitively ('by'/'BY' are
both the FOR-loop step keyword -- 'by : BYTE;' failed on the bench 2026-07-16).
Sources: IEC 61131-3 (3rd ed.) keyword tables; CODESYS export-format keywords
(content.helpme-codesys.com/en/CODESYS%20Development%20System/_cds_keywords.html).
Standard-function names (MIN/MAX/ABS/...) deliberately excluded: not confirmed
compiler-rejected, and a false positive blocks legitimate code. Scanner now also
checks every name in comma-separated declaration lists and AT %address forms.
Guard wired into create_pou + create_method declarationCode, same as set_pou_code.
Tests: tests/unit/reserved-iec-identifiers.test.ts (8 cases); full suite 214 green.
set_gateway_and_address raises 'Invalid address format' for 'ip[:port]'
strings. Detect IP form and route it to ScriptDeviceObject
.set_gateway_and_ip_address (helpme-codesys ScriptDeviceObject, since
3.5.8.0), which binds the block driver directly by IP -- needed for
SSH-tunnelled PLCs (e.g. 127.0.0.1:11740 -> lab PLC via jump host).
Live-verified against WAGO PFC200 10.0.0.202 through codesys1-prox.
Two field-observed defects in bump_project_version (hit on nmea2000-mr-library):
1. appendChangelogEntry wrote into 'Changelog.md', which on a case-insensitive
Windows filesystem is the SAME file as a repo's hand-maintained
CHANGELOG.md. With Keep-a-Changelog '## [x.y.z.w]' headings (no '## v'),
the appender fell through to the append-at-end branch and dumped an auto
entry at the bottom of the curated file. Now: if the existing file was not
created by this tool (missing the auto-generated intro marker), skip the
append with a warning instead of writing.
2. First-run seed ignored existing releases: with Project Information.Version
unset but the repo tagged up to v0.8.0.0, a manual minor bump seeded
1.0.0.0 -- out of series. Now the TS side derives a seed from the latest
v* tag bumped at the requested level (new SEED_VERSION script param);
the Python side falls back to the classic 1.0.0.0 only when no usable
tag exists.
tsc clean, vitest 206/206.
Live verification against the PFC200: device-level download_source on
SP21 writes its temp file into the CODESYS install dir (Program Files)
and dies with access-denied; the failed attempt leaves a locked
Archive.prj in the project dir that breaks the app-level fallback with
'file is being used by another process'. Now: remove stale Archive.prj
up front, use app-level source_download unless compact was explicitly
requested.
28 of the 46 new tools verified against CODESYS V3.5 SP21 Patch 5; 7 SP21
stub-vs-runtime drift bugs found live and fixed. Remaining live checks:
import_text_list_file and the 12 online tools (need the WAGO PFC200).
SP21 removed IScriptUser.change_password ('no longer supported'). The
script now probes change_password/set_password/reset_password and, if
none works, saves the user WITH A WARNING instead of failing the whole
creation. Creation is idempotent so a half-failed prior run doesn't
block a retry on the duplicate name.
Same overload drift as export_xml: positional (path, None, folders) put
the path string in the reporter slot ('expected IImportReporter, got
str'). All args now passed by keyword with positional fallback.
Live verification on SP21: users.create raised "permission 'Modify' not
granted to user '(nobody)'". The scripts now log in as 'Owner' with empty
password (CODESYS default) when nobody is logged on; new optional
adminUser/adminPassword args override for protected projects.
Live verification: positional (objects, None, path, ...) bound 'objects'
into the reporter slot on SP21's overload, silently switching export_xml
into export-to-string mode -- tool reported success with no file written.
All args now passed by keyword; export_plcopen_xml and export_native
raise if the destination file does not exist after the call.
Live verification against CODESYS V3.5 SP21 Patch 5: calling it raised
'bool is not callable'. Handle both the property (SP21) and callable
(stub-documented) shapes.
1. find_object_by_path: final name verification used the original
full_path instead of the dot->slash normalized segments, so
dot-separated paths ('Application.MyPOU') traversed correctly but
failed the final check and returned None.
2. User-arbitrary values (plcPath/plcDirectory, passwords, comments,
project-info fields, device parameter name/value, task event,
device credentials) were interpolated into r"..."/r"""...""" Python
literals unescaped -- a quote or triple-quote in the value broke the
generated script (or injected code). Templates now take pre-escaped
literals via pyStringLiteral().
Reviewed-range: dead49a..e9aa714. Third reviewer finding (task.priority
must be int) was rejected: SP21 ScriptTaskConfigObject.pyi types the
priority/interval/interval_unit setters as str.
- add_library.py line 49 had UTF-8 box-drawing dashes in a comment with no
coding declaration -- latent IronPython 2.7 source-encoding risk.
- build now removes dist/scripts before copying, so deleted/renamed
templates (compile_project.py.bak, probe_app_error_state.py,
set_library_namespace.py) no longer ship in the npm tarball.
- script-manager test now asserts EVERY template is ASCII-only instead of
per-phase lists.
102 tools total. SP21 ScriptEngine functional coverage complete per plan
(SVN/Composer/Cloud deliberately out of scope; plug_module and trace
tools deferred).
String.replace with a string replacement interprets $$/$& as regex
replacement patterns, corrupting IEC string literals like '$R$N' passed
through tool params (set_pou_code code bodies, write_variable values).
Use a function replacement so values pass through verbatim.
Persistent mode now uses a LazyPersistentExecutor everywhere the launcher
is not running: --no-auto-launch before first use, after shutdown_codesys,
and after a launch conflict. The first tool call launches the VISIBLE IDE
and delegates; nothing silently spawns --noUI processes anymore.
Why: headless spawns pop modal dialogs nobody can see (tool calls just
abort), hold .project locks (stale .~u files), and leave orphaned
CODESYS.exe processes that then block the next launch. Diagnosed live on
2026-06-11 while deploying TestN2k_v2_Fable: open_project/download ran
headless via the --no-auto-launch fallback and burned ~30 min on invisible
dialogs, zombie PIDs and lock-file cleanup.
Headless execution now requires explicit opt-in: --mode headless or
--fallback-headless.
Also lands the task-configuration tools from the 2026-06-09 session that
were complete but uncommitted: list_tasks, add_pou_to_task,
remove_pou_from_task (+ their IronPython scripts).
The server is a standard stdio MCP server and works with any MCP client, not
just Claude Code. Added a "Use with OpenAI Codex CLI" section covering the
~/.codex/config.toml location, the [mcp_servers.<name>] TOML table (the
equivalent of the --print-config JSON block), env-based credential injection,
tool_timeout_sec for slow CODESYS launches, and the codex mcp add shortcut.
Linked it from Quick Start step 3.