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.
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).
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.
- 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.
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.
CODESYS opens/saves projects from UNC paths (\server\share\...) only
unreliably -- it tends to fail late and opaquely. Add src/path-guard.ts
(isUncPath + uncPathError) and gate open_project, create_project,
save_project, and launch_codesys_with_project on it: each now returns
isError early telling the user to map a drive (net use Z: \server\share)
or copy the project to a local drive. Mapped drive letters and local
drives (incl. \?\C:\) are not treated as UNC, so existing workflows are
unaffected.
Also bumps version to 0.10.1 (first published release of the 0.10.x line;
includes the prior unreleased phobiCS-tui removal and add_device tool).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Knock-on cleanup from the phobiCS-tui nuke in 4b82b7c. With the TUI gone
the gate had no UI to render -- it just printed a stderr warning and
auto-approved on every call. Ripping it out kills ~470 lines of dead code
and a redundant CLI flag.
Removed:
- src/approve-gate.ts (whole file; runApproveGate + gateOpForTool + helpers)
- tests/unit/approve-gate.test.ts
- bin.ts: --approve-edits CLI option, approveEdits config wiring, startup log
- types.ts: ServerConfig.approveEdits field + its doc comment
- server.ts: import of runApproveGate/gateOpForTool, the `Approve edits:` log,
16 `gateOpForTool({...}); if (blocked) return blocked;` blocks across
every modifying MCP tool handler, and the lone `runApproveGate({...})`
block in set_pou_code.
Folded in (Karstein's prior WIP, gate-free now per his explicit choice
"Single rip commit, I edit your WIP too"):
- New `add_device` MCP tool in server.ts that wraps ScriptDeviceObject.add
for attaching child devices (Modbus TCP Server under Ethernet, Ethernet
under PLC, etc.). The supporting `src/scripts/add_device.py` remains
UNTRACKED in Karstein's working tree -- still his to commit separately.
Without that script the tool will fail at runtime; with it, fine.
Verify:
- `npx tsc --noEmit` clean
- `npm run build` clean
- `npx vitest --run tests/unit/` -> 14 files, 119 tests, all pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once launch() refused due to a foreign CODESYS, the error string was
cached in lastError and getStatus() returned that frozen snapshot
forever. Closing the foreign CODESYS did not update the status -- only
an MCP restart cleared it. From the user's POV: "every time CODESYS
HAS been open, you get a problem".
Fix: getStatus() now calls revalidateLaunchRefusal() first. If the
launcher is parked in 'error' state with a "Refusing to launch:"
prefix and findConflictingInstances() now returns empty, we transition
back to 'stopped' and clear lastError so the next status call / launch
attempt sees a fresh state. Other 'error' states (process died,
watcher timeout, exe not found) are not auto-cleared -- only the
launch-refusal cache, since that's the one that goes stale on its own
when the user closes the foreign window.
Bump 0.9.13 -> 0.9.14.
Tests: 2 new launcher unit tests pin the auto-clear behaviour and
verify unrelated 'error' states are NOT auto-cleared. 22/22 launcher
tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the pump beyond top-level vars: when a var's declared type
resolves to another mirror .st file, we descend one level and read
each of that type's vars as <var>.<member>.
Parser: parseVarDecls(text) now returns name + declared type per
decl. ARRAY/POINTER/REFERENCE wrappers stripped to the inner type.
parseVarNames is kept as a thin wrapper for the existing callers.
Pump: new resolveTypeMirror(typeName, deviceRoot) dep. Pump itself
doesn't know how to find a type's source -- the caller plugs in a
strategy. server.ts wires a recursive walk under the device root
looking for '<typeName>.st'; the mirror layout guarantees stable
filenames for POU/FB/DUT (every code-bearing object).
Pump's deviceRootFor(absPath) parses the abs path back to the
device root by locating the '/mcp-mirror/<device>/' segment. If
that fails we fall back to the file's parent dir (still works for
same-folder type lookups, just won't find types in sibling folders).
Constructor now accepts partial deps and fills in safe defaults
(no-op resolveTypeMirror -> never descend, matching v0.3
top-level-only behaviour). Existing tests don't need to change.
407/407 tests pass; new coverage:
- parseVarDecls: name+type extraction, ARRAY/POINTER/REFERENCE
stripping, AT %loc prefix, missing-type pathological case
- LiveValuesPump.tick: descends when resolver returns content;
doesn't descend when resolver returns null (primitives)
* feat(live-values): --live-values-interval <ms> CLI flag
Replaces the hardcoded 500ms poll with a configurable interval.
Clamped to [100, 60000] -- below 100 the read_variable IPC round-
trip-per-var dominates and the pump can't keep up; above 60000 a
session shorter than the interval would never see any update.
ServerConfig.liveValuesIntervalMs?: number plumbs through bin.ts
(parseInt + clamp) into the LiveValuesPump constructor and the
'Live-values pump started (<ms>ms)' log line.
Default unchanged at 500ms, so existing --live-values invocations
behave identically.
* test(e2e): regression coverage for the four 2026-04-29 fixes
Five new template-prep assertions:
1. set_symbol_access mutation lookup goes to get_all_signatures FIRST
(PR #7). The configured-view probe is tracking-only and must come
AFTER both compile=False/True all-signatures lookups -- if the order
ever flips, the mutation hits the read-only view and CODESYS rejects
the assignment with 'can only be changed in the list of all
signatures/data types'.
2. set_symbol_access int->SymbolAccess coerce (PR #9). The fallback
block must use type(maximal_access) and call enum_cls(int_val)
BEFORE 'var.configured_access = requested_access'. Without it,
non-zero ints are rejected with 'must be zero'.
3. set_signature_access_bulk same coerce, lazily on the first variable
in the for-loop (PR #9). Verifies loop -> coerce -> assign order.
4. create_project without deviceName preserves the no-swap path
(backwards compat for PR #8). Empty DEVICE_NAME substitutes; the
if-DEVICE_NAME branch is in the template but the runtime gates it.
5. create_project deviceName swap (PRs #8/#10/#11/#12 combined).
Verifies:
- PromptHandling.NONE setter is rendered (script_prompt_handling
is read-only -- earlier code silently failed).
- device_repository accessed via script_engine, not as a builtin.
- update() is the first-attempt mutation, remove+add only fires
'if not update_ok'.
- project.close() + precompilecache delete render after swap.
Vitest: 27/27 in tests/integration/e2e.test.ts (was 22).
---------
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Task 6 of v0.3 plan (parser + pump class; full --live-values CLI
wiring is Task 7).
parseVarNames(text) extracts variable names from any VAR /
VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL / etc. block. Handles:
- one name per declaration line
- 'AT %QX0.1' location prefix
- := initializer
- inline (* ... *) and // line comments
- multi-line (* ... *) blocks (state threaded across lines)
Doesn't handle the multi-name shorthand 'a, b : INT;' -- vanishingly
rare in practice, cost of getting it wrong is just a missing overlay.
LiveValuesPump owns a setInterval. Each tick:
- reads tui-state.json via the injected readSelection
- reads the POU's .st file from the mirror
- parseVarNames -> per-var read_variable round-trip (errors per-var
are silent, others may succeed)
- writeLiveValues snapshot
Reentrancy guard: if a tick is still in flight when the next
interval fires, skip the new tick rather than queueing. Errors at any
layer are swallowed -- pump must never crash the server.
start()/stop() lifecycle so the server can hand it to the existing
shutdown path.
Task 5 of v0.3 plan.
writeLiveValues(filePath, projectDir, payload) wraps the caller's
{device, pou_name, values} in the v1 envelope (version, updated_at,
project_dir) and writes atomically via <file>.<pid>.tmp + rename.
Creates parent dirs as needed.
Mirrors src/tui/shared/state-write.ts. Kept separate because the
TUI subpackage is ESM and the server is CJS; the duplication is
~15 lines of code that almost never changes.
Generalizes runApproveGate beyond set_pou_code by adding:
- runApproveGateOp({slug, oldText, newText}) — writes synthetic
before/after files into a tmpdir, spawns 'phobiCS-tui approve' on
them, cleans up the tmpdir on return. Used for ops that don't have
a clean existing-file -> proposed-file mapping (create/delete/rename
/add).
- gateOpForTool({enabled, slug, oldText, newText}) — MCP-tool-shaped
wrapper. Returns null when the op should proceed (gate disabled,
accepted, or no-existing); returns a {content, isError} block-
response otherwise. Lets each tool gate with one if-statement.
Wired into the 9 modifying tools (with --approve-edits on, each one
prompts via the TUI before applying):
- create_pou all-green diff: name + type + language + parent
- create_property all-green diff: name + type + parent FB
- create_method all-green diff: name + return type + parent FB
- create_dut all-green diff: name + DUT type + parent
- create_gvl all-green diff: name + parent + (optional decl)
- create_folder all-green diff: name + parent
- delete_object all-red diff: object + project
- rename_object del+add: old name -> new name
- add_library all-green diff: library name + project
set_pou_code keeps using the existing runApproveGate (which composes
real merged file content via the IMPL_SENTINEL split, giving the
nicest possible diff against the real mirror file).
When --approve-edits is on, set_pou_code:
1. finds the existing mirror .st for the POU (best-effort glob; no
match -> skip the gate, proceed)
2. composes the proposed merged content (decl/impl swapped at the
IMPL_SENTINEL line, keeping the unchanged half)
3. writes the proposal to <existing>.staged
4. spawns 'phobiCS-tui approve <existing> <staged>' with stdio
inherited so the user sees and answers it
5. cleans up .staged regardless of outcome
Exit 0 -> accepted -> apply the change. Exit 1 -> user-facing
'rejected' response (isError=false; the user actively said no).
Exit 2 -> error response (isError=true).
When the flag is off (default), set_pou_code runs unchanged -- this
keeps existing scripted flows from regressing.
Adds an MCP tool the LLM can call to ground modifying calls in what
the user is currently looking at in the phobiCS-tui browser.
Backed by readSelection() from src/state-read.ts.
defaultStateFilePath() is intentionally duplicated from
src/tui/shared/state-paths.ts: the TUI subpackage is ESM and the MCP
server is CJS, so a cross-import would force one or the other to
change module systems for ~15 lines of code that almost never
changes.
readSelection(path) -> discriminated union of:
- ok (the parsed v1 payload)
- missing (file does not exist)
- stale (updated_at older than FRESHNESS_MS = 60s)
- invalid (bad JSON or unknown version)
Lives outside src/tui/ because the MCP server (CJS) consumes it; the
TUI itself only writes the file.
Before delegating to CODESYS for the actual open, inspect the
.project's projectinspectiondata.auxiliary (via src/inspect.ts -- pure
offline, ZIP+XML, no CODESYS) and compare its saved SP+patch against
the server's configured --codesys-profile.
Three outcomes:
- exact match -> proceed silently
- same SP, different patch -> proceed with a warning prefix in the
response (CODESYS will pop its patch-difference dialog)
- SP mismatch -> refuse without opening; suggest the user either pick
a different MCP server entry or run --print-config --for-project to
generate one for the project's required SP
If inspection itself fails (file missing, malformed .project, profile
unparseable), pre-flight falls through silently -- the existing CODESYS
open path then produces its original error.
Run --print-config with --for-project pointing at a .project file
and the snippet narrows to just the install(s) that can open it
(exact SP+patch match, or fallback to same-SP-different-patch with
a warning about the conversion dialog). No more eyeballing -- the
project's projectinspectiondata.auxiliary tells us which CODESYS
to route to, and --for-project just looks it up.
Mutually exclusive with --sp. Errors are explicit (no install
matches at all, or both flags supplied).
The default mirror root was hard-coded to <projectDir>/mcp-mirror/,
which collides when two .project files live in the same folder
(e.g. \files\...\Multi plc test\ProjectA.project +
ProjectB.project both default to the same mirror -- each
mirror_export call clobbers the other's output).
New resolution rule (preserves backward-compat -- existing setups
unaffected):
- If <projectDir>/mcp-mirror/ already exists, use it
- Else if exactly one .project sibling, use <projectDir>/mcp-mirror/
- Else (multiple .project) use <projectDir>/<basename>_mcp_mirror/
Implemented as src/mirror-paths.ts (TS helper, used by server.ts
maybeOpenMirrorInVscode and unit-testable) plus the same logic
inlined in the Python scripts that need it (mirror_export.py et al.,
no shared-import infra exists CODESYS-side).
New MCP tool + --ssh-version CLI flag. Bypasses the CODESYS IDE
entirely: SSH to a CODESYS Control Linux PLC, sudo strings the boot
application binary, extract the X.Y.Z.W literal of
_MCP_PROJECT_VERSION.sVersion. Filters out 3.5.x.y CODESYS runtime
versions automatically.
Solves the case where the .project file is locked by another CODESYS
instance, or no CODESYS install is reachable, but the PLC is. Read-
only on the PLC (just strings the boot binary).
Requires SSH key auth + passwordless sudo for /usr/bin/strings on
the PLC. Both error paths surface exact-instructions error messages
(PowerShell key install command, sudoers line) instead of opaque
failures.
Smoke-tested against codesys-pi (RPi running CODESYS Control 3.5.22)
with MCPTest2 v1.5.0.0 downloaded -- correctly extracts 1.5.0.0 and
filters out the 3.5.22.0 runtime version literal.
Previous guard refused any CODESYS.exe in tasklist regardless of which
exe path the launcher was configured for. This broke the multi-install
setup the README documents (codesys-21 + codesys-22 entries are
supposed to coexist), and refused to launch any time the user had a
manual CODESYS window open from a different install.
Different CODESYS installs (e.g. SP21 + SP22) are designed to run in
parallel -- they're separate processes, separate IPC, separate file
locks. The only genuine conflict is two instances of the SAME exe
trying to attach to the SAME .project file (CODESYS pops 'project is
currently in use'). The same-exe case can't share IPC with us anyway
since we didn't spawn it.
Implementation:
- New findRunningCodesys() returns [{pid, exePath}] via PowerShell
Get-Process (tasklist doesn't expose ExecutablePath; WMIC is
deprecated on modern Windows).
- pathsEqual() exported helper: case-insensitive, slash-normalised,
trims trailing separators.
- Spawn-guard now filters by pathsEqual(p.exePath, config.codesysPath).
Refusal message names the conflicting exe and PIDs explicitly.
- shutdown_codesys orphan-killer also filters by exe path so we never
kill a CODESYS instance the user owns or that belongs to a different
MCP entry.
Tests:
- 6 new pathsEqual cases (identical / case-insensitive / slash-mix /
trailing-sep / different installs / different drives).
- detect test for the new --print-config caveat copy (no longer
warns 'only one at a time'; warns about same-.project conflict).
- 58/58 pass.
Also updates --print-config CAVEAT in src/detect.ts to reflect that
multiple entries can be active simultaneously, with the only hard rule
being don't open the same .project from two CODESYS instances.
New flags:
- --print-config: scan installs and emit a JSON block per install with
derived server names (codesys-sp21-patch5, codesys-sp22-patch1, etc.)
- --sp <n>: filter to one SP family; collapses entry name to 'codesys'
when exactly one install matches
- --name <name>: override the entry name (only valid with --sp narrowing
to one)
Side effect: --detect now reuses the same detector and additionally
prints the derived profile name + suggested server entry name per
install, so even users sticking to manual config get the values
without guessing.
Refactored install discovery into src/detect.ts so both --detect and
--print-config share one implementation. New unit test fixture covers
version parsing, missing-exe, dedup, sort order, --sp filter behaviour,
--name override constraints, and verifies the emitted JSON parses back
once // comments are stripped.
The output also surfaces the multi-install caveat from launcher.ts:
the double-spawn guard refuses to start a second CODESYS.exe even on
a different exe path, so only one configured entry can be active at
a time.
create_folder v2 (positional foldername) returned None silently against
the SP22 Application object -- no exception raised, no folder created.
Investigation showed:
- ScriptObject.create_folder(foldername) is documented to "create a
folder in the structured view of the parent node", but on Application
specifically it's a silent no-op (the structured view isn't pinned to
POU view there).
- ScriptProject.create_folder(foldername, structured_view=None) on the
project itself with explicit SV_POU GUID
({21AF5390-2942-461a-BF89-951AAF6999F1}) is the documented and
reliable pathway -- the resulting folder appears under Application
in the IDE tree because that's where SV_POU lives.
v3 fix: try strategies in order until one returns non-None:
(1) primary_project.create_folder(name, SV_POU_GUID) -- new, primary
(2) parent.create_folder(name) positional -- pre-SP21 path
(3) parent.create_folder(foldername=name) -- alt keyword
(4) primary_project.create_folder(name) -- default view
(5) parent.create_object(typeUuid='85d1215e-...') -- alt factory
(6) parent.add(script_engine.types.IecFolder, name=name) -- legacy
Each strategy guards on hasattr + return-value-not-None, so a silent
no-op falls through instead of being mistaken for success.
ScriptManager: dropped the in-memory template cache. Each loadTemplate
call now reads the .py from disk fresh. Cost: ~1ms per call vs ~1.5s
of CODESYS execution time -- invisible. Win: edits to dist/scripts/
take effect without an MCP restart, which makes iterating on script-
side fixes (like this very create_folder loop) much faster. Existing
"cache hit" unit test rewritten as "two loads return equal content".
tests/test-fixes.mjs: standalone harness that drives a single persistent
CODESYS through HeadlessExecutor + CodesysLauncher to verify the four
broken-tool fixes end-to-end. Useful for regression-testing without
needing a vsc reboot loop. Currently only smoke-tests
create_folder + compile + cross-project; expand as more fixes need
verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.3.0 returning-watcher architecture — background thread polls for commands
and marshals execution onto the CODESYS UI thread, keeping the IDE fully
responsive between operations. File-based IPC with atomic writes, async mutex
command serialization, headless fallback, and 35 passing tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>