Two issues caught when Karstein noted suppression wasn't actually working:
1. Wrong API. The script was setting:
script_engine.system.script_prompt_handling = ScriptPromptHandling.NoFlag
but ScriptPromptHandling has no 'NoFlag' member (it's 'SuppressPrompts'),
AND script_prompt_handling is a read-only property -- the settable one
is the obsolete-but-still-functional 'prompt_handling' (with the
obsolete PromptHandling enum, where PromptHandling.NONE = 0 is
documented as equivalent to ScriptPromptHandling.SuppressPrompts).
The bare AttributeError was silently swallowed by try/except so prompts
stayed forwarded to UI -> intermittent code-1 crashes when the storage
format dialog popped.
Fix: set system.prompt_handling = PromptHandling.NONE, with int-literal
fallback if the enum import doesn't resolve. Log when neither works.
2. After-swap stale state. Even after a successful update() that preserves
the device subtree, CODESYS keeps the OLD device's library version
pins in its in-memory project model. compile_project then errors with
'Could not open library IoStandard, 3.1.3.1 (System)'
'Device description for PLCWinNT is missing'
Fix: project.close() at the end of create_project (drops in-memory
state) + delete the precompilecache file. The next MCP tool call's
ensure_project_open reopens fresh against the swapped XML and compile
is clean.
Live verified on SP22 P1:
- create_project WinV3Test deviceName='CODESYS Control Win V3 x64'
-> succeeds, no UI dialog, project saved + closed.
- compile_project (first call after creation) -> 0 errors, 0 warnings.
- create_pou Application/FB_Counter + set_pou_code with real ST.
- PLC_PRG references FB_Counter.
- compile_project -> 0 errors, 0 warnings.
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Karstein's review of PR #10: the swap path was destructive -- it nuked
the device subtree (Application/PLC_PRG/MAIN task) and rebuilt empty.
Even at template-creation time the template's PLC_PRG was lost; only
the libraries and an empty Application object survived.
Root cause of the original update() crash: it was popping a 'storage
format conversion' modal that the script can't see. With the
ScriptPromptHandling.NoFlag forced at script top (already in tree),
update() now succeeds non-destructively. Verified live on SP22 P1:
1. create_project WinV3Test deviceName='CODESYS Control Win V3 x64'
-> succeeds, PLC_PRG preserved (was empty before, still empty
after).
2. delete <project>_project.precompilecache
-> needed because the cache holds the stale PLCWinNT IoStandard
3.1.3.1 ref. Compile shows 1 error before this step, 0 after.
3. create_pou Application/FB_Counter + set_pou_code with real ST
-> succeeds; PLC_PRG references FB_Counter.
4. compile_project -> 0 errors, 1 license warning.
The destructive remove() + project.add() path is kept as a fallback
for SPs where update() genuinely can't bridge the device kinds, but
update() is now the default (and works on SP22 P1).
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Live-tested on SP22 P1 against MCPTest's PLCWinNT-templated standard
project (PLCWinNT not installed on this host -> 17 errors before the
swap, 0 errors after). Three things had to change vs the original PR #8
implementation:
1. device_repository access path. The agent's research said the global
was injected as a builtin (like library_manager). The actual injection
is via the scriptengine module (Stubs/scriptengine/__init__.py line 25).
Try script_engine.device_repository first; fall back to the builtin
in case some IDE versions also inject it that way.
2. Swap strategy. existing_device.update(new_dev_id) crashes CODESYS
with exit code 1 (it's intended only for same-family version bumps).
existing_device.unplug() raises 'The argument guidSlot is not a slot
device' -- unplug is for slot-children, not top-level devices.
The right call for top-level devices is ScriptObject.remove() (the
generic delete that delete_object.py already uses), then
project.add(name, new_dev_id) for the replacement.
3. Prompt suppression. ScriptDeviceObject.remove() pops a 'storage
format conversion' confirmation dialog under SP22 P1 even though
ScriptPromptHandling.NoFlag is supposed to be the default silent
handling. Some plugin context resets it. Force-set it explicitly
to NoFlag at the top of the script so the dialog is auto-suppressed
instead of hanging the watcher waiting for a click.
After the swap, a stale precompilecache from the original PLCWinNT-targeted
build can still cause 'Device description for PLCWinNT is missing' on the
first compile -- delete the .precompilecache file (or just compile twice)
and the second build is clean.
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Second bug found live on MCPTest after the get_only_configured_signatures
fix landed. When _resolve_access falls back to int (because
'from scriptengine import SymbolAccess' returned a hollow class on this
SP), the C# setter for ScriptSymbolConfigVariable.configured_access
rejects every non-zero int with:
TypeError: Cannot convert numeric value 1 to SymbolAccess.
The value must be zero.
(only 0=None passes the implicit conversion). Recover by taking the enum
class from v.maximal_access -- always populated as a genuine SymbolAccess
value -- and re-parsing the int through it: enum_cls(int_value).
Verified live on MCPTest under SP22 P1: PLC_PRG.fb -> ReadOnly succeeds
where it previously errored. Same coercion applied to set_signature_access_bulk
(lazily on the first variable in the loop, since requested_access is shared
across all variables in a bulk run).
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
When _resolve_access falls back to a plain int (because the
'from scriptengine import SymbolAccess' import returned a hollow
object on this SP), the C# setter rejects every non-zero int:
Cannot convert numeric value N to SymbolAccess.
The value must be zero.
Only 0 (=None) survives the implicit conversion. Recover by parsing
the int through the enum class type we just got from
var.maximal_access (which is always a genuine SymbolAccess value
because it came back through the same scriptengine that's about to
accept it). DEBUG print for both the success and failure paths.
When the host machine doesn't have the template's default device installed
(e.g. the standard project's PLCWinNT target is missing on a fresh SP22 P1
install with only Win V3 x64 available), every project the user creates
ships with 17 compile errors before any code lands. This adds an optional
deviceName argument to create_project that swaps the device on the freshly
opened project.
How:
- device_repository.get_all_devices(name, None) -> tuple of devices whose
display name contains the substring (ScriptDeviceRepository.pyi line 377).
Highest-version match wins.
- ScriptDeviceObject.update(new_device.device_id) replaces the device
kind in-place, preserving the Application/POU/library subtree
(ScriptDeviceObject.pyi line 145).
- The existing PLC device is located by walking project.get_children(False)
for the first child whose ScriptDeviceObjectMarker.is_device == True
(ScriptDeviceObject.pyi line 104).
If deviceName is omitted/empty, behaviour is unchanged (template default).
If deviceName doesn't resolve in the local device repository, the script
fails with an actionable error pointing at Tools > Device Repository so
the caller can confirm the exact display name.
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
set_symbol_access and set_signature_access_bulk both looked up the target
signature via sc_obj.get_only_configured_signatures() first and only fell
back to get_all_signatures() if not found. The objects returned by
get_only_configured_signatures() are a read-only view; assigning to
.configured_access on them raises:
The access of the variable can only be changed in the list of all
signatures/data types.
This was hidden during initial testing because the bulk variant was
exercised before any variables were configured (so the configured view
was empty and the all-signatures fallback fired). Once the configured
set was populated, the single-var set_symbol_access path always picked
up the read-only object and broke.
Fix: always look up the mutation target via get_all_signatures(); use
get_only_configured_signatures() only as a tracking-only flag for
'was this signature already exported'. Verified live on MCPTest.project
under SP22 P1: bulk-set PLC_PRG to ReadWrite (4 vars), then per-var
set_symbol_access PLC_PRG.s1 = None succeeds and list_configured_symbols
reflects effective_access=None on s1.
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Adds remove_library as the symmetric counterpart to add_library.
Script (src/scripts/remove_library.py):
- IronPython 2.7 / ASCII-only; no f-strings, no KeyboardInterrupt in
except-Exception clauses.
- Placeholder substitution: LIBRARY_NAME (bare) and LIBRARY_FQN_OR_NAME
(bare or "Name, Version (Company)").
- Locates the project Library Manager via the same three-step discovery
used in add_library.py: has_library_manager/get_library_manager on the
project, first-level child walk, then find("Library Manager") fallback.
- Pre-check: walks lm.references using _ref_name_matches logic (handles
placeholder "#Name" and managed "Name, Version (Company)" forms).
If the library is not referenced the script exits SCRIPT_SUCCESS with
the "Library Not Present:" marker -- idempotent, same convention as
add_library's dedup no-op branch.
- If found: calls lm.remove_library(existing_name) per the SP22 stub
(ScriptLibManObject.pyi: remove_library(name: str)), confirms removal
from lm.references, then project.save().
- Emits SCRIPT_SUCCESS or SCRIPT_ERROR with traceback on exception.
Server (src/server.ts):
- Tool registered immediately after add_library (~line 2067).
- Reads "Library Not Present:" marker to pick idempotent vs removed
wording -- same marker-driven pattern as add_library's dedup wording.
- Thin surface: projectFilePath + libraryName (required) +
libraryFqnOrName (optional, for multi-version disambiguation).
Tests (tests/integration/e2e.test.ts):
- Template-prep test asserts: no leftover {PLACEHOLDER}s, substituted
values present, remove_library call present, references walk present,
"Library Not Present" marker present, SCRIPT_SUCCESS/SCRIPT_ERROR
markers present.
- All 22 tests pass.
References:
- helpme-codesys.com scripting engine > ScriptLibManObject
- SP22 stub: Stubs/scriptengine/ScriptLibManObject.pyi (remove_library
at line 455, references property at line 464)
Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Bumps codesys-mcp-sp21-plus from 0.7.1 to 0.8.0 and phobiCS-tui's
--version output from v0.2.0 to v0.3.0.
Headline changes since v0.7.1:
TUI:
- Viewer overlays each declared variable's live runtime value
inline next to its declaration when the runtime is online.
- Viewer joins multi-line (* ... *) comments across line
boundaries (lines fully inside a block no longer get
false-highlighted keywords).
MCP server:
- new --live-values CLI flag starts a 500 ms pump that reads
runtime values for the variables of the user's current POU
selection and writes them to tui-live-values.json next to the
existing tui-state.json.
- --approve-edits now also gates 4 runtime tools:
write_variable, download_to_device, start_stop_application,
restart_runtime_ssh (in addition to the 9 modifying project
tools landed in v0.7.0).
No tag created here; npm publish has to be run from your terminal
because the npm 2FA passkey can't be driven through the bash tool.
Task 7 of v0.3 plan.
ServerConfig.liveValues?: boolean.
bin.ts: --live-values commander option, plumbs into config, banner
'Live values: ENABLED (poll 500ms; writes tui-live-values.json)'.
server.ts: when config.liveValues is on, instantiate LiveValuesPump
with deps:
- readSelection from state-read
- readPouFile = fs.promises.readFile
- readVariable = run the existing read_variable script via the
executor; parse 'Value: <v>' from stdout
- writeLiveValues from live-values-write
Lifetime: start() right after server.connect(); stop() in the
shutdown handler before launcher.shutdown.
defaultStateDir() helper extracted; defaultStateFilePath() and the
new defaultLiveValuesFilePath() share it.
When the flag is off (default), the pump is never instantiated and
the server runs unchanged.
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.
Task 4 of v0.3 plan.
useLiveValues(filePath, pouName, intervalMs=500) sets up a recursive
setTimeout poll. Returns the values map iff readLiveValues returns
ok AND the snapshot's pou_name matches the requested pouName;
returns null otherwise (cursor not on a POU, file missing/stale,
wrong POU snapshot).
Cancelled flag + setTimeout (not setInterval) so the cleanup is
race-free: if the file read takes longer than intervalMs, the next
tick won't pile up.
Browser computes the live-values file path once via
liveValuesFilePath() (catches Windows LOCALAPPDATA-missing -> null
so we don't crash; pump just won't be able to write either) and
passes the resulting map down to <Viewer liveValues={...}/>.
Task 3 of v0.3 plan.
<Viewer liveValues={...}/> walks each visible line for an identifier
that's a key in the map. First match wins; appends ' ◀ live: <val>'
in green at end of line. No prop -> no overlay (default).
Matches against the original line text, not against highlighter
tokens: the highlighter splits mixed-case names like bRunning into
'b' + 'R' + 'unning' (uppercase-only ident regex) which would never
match a 'bRunning' key. Operating on the line preserves identifiers
intact.
Task 2 of v0.3 plan.
Mirrors src/state-read.ts (the server-side selection reader): same
discriminated union (ok | missing | stale | invalid), but lives in
the ESM TUI subpackage (TUI is the consumer, server is the
producer).
Tighter freshness window than the selection file (5s vs 60s). Stale
overlay would be misleading -- if the pump dies or the runtime goes
offline we want no overlay rather than frozen values.
Task 1 of the v0.3 inline-live-values plan.
LiveValuesPayload is the on-disk shape the server pump writes and
the TUI reads. Same versioning + envelope approach as the existing
selection state file: version=1, ISO updated_at, project_dir +
device + pou_name match the user's current selection so the TUI can
reject stale snapshots.
liveValuesFilePath() lives next to stateFilePath() and resolves to
%LOCALAPPDATA%/codesys-mcp/tui-live-values.json on Windows or
$XDG_STATE_HOME/codesys-mcp/tui-live-values.json (default
~/.local/state) on POSIX. Path joining stays platform-flavoured
even when the unit test forces a different process.platform, so the
suite is host-independent.
With --approve-edits on, the following live-PLC tools now prompt
before acting:
- write_variable del+add: variable + value + project
- download_to_device all-green: project + WARNING (full push)
- start_stop_application all-green: action + project
- restart_runtime_ssh all-green: host + service + WARNING
These tools have larger blast radius than the project-tree tools
gated in the previous followup -- they touch a running PLC. Gating
them is the whole point of --approve-edits for online sessions.
When the flag is off (default), all 4 run unchanged.
Adds tokenizeWithState(line, openComment) -> {tokens, commentLeftOpen}
and tokenizeLines(allLines) which threads the open-comment flag across
lines.
Viewer now tokenizes from line 0 (not just the visible slice) so the
state going into the visible window is correct, then renders the
slice. A line wholly inside a (* block is emitted as a single
'comment' token; PROGRAM and other keywords on those lines no longer
get falsely highlighted.
tokenize(line) kept as a thin wrapper for the single-line callers
(tests, future use).
The wrapper at server.ts:1896 always rendered "Library 'X' added" even
when the script's dedup pre-check no-op'd because the same library was
already referenced. The script itself emits distinct markers
("Library Already Present:" vs "Library Added:"); the wrapper now
inspects result.output to pick wording instead of hardcoding "added".
Bumps codesys-mcp-sp21-plus to 0.7.0 (main was at 0.6.4 from
intermediate releases) and phobiCS-tui's --version output to v0.2.0.
Headline changes since v0.6.4:
TUI:
- browser keybinds: / (filter), o (open in editor), d (cross-
device diff), r (rescan), ? (help overlay)
- approve mode: v toggles unified <-> side-by-side diff
- viewer: ST syntax highlighting (keywords/types/comments/strings)
- statusbar: stale-mirror indicator + small-terminal resize warn
MCP server:
- --approve-edits now gates ALL 9 modifying tools, not just
set_pou_code: create_pou, create_property, create_method,
create_dut, create_gvl, create_folder, delete_object,
rename_object, add_library
No tag created here; npm publish has to be run from your terminal
because the npm 2FA passkey can't be driven through the bash tool.
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).
tokenize(line) splits a single line into typed tokens:
keyword (cyan) PROGRAM, FUNCTION_BLOCK, IF/THEN/ELSE/END_IF,
FOR/TO/DO, VAR/END_VAR, CASE/OF, AND/OR/XOR/NOT,
TRUE/FALSE, EXTENDS, IMPLEMENTS, etc.
type (magenta) BOOL, INT, DINT, REAL, LREAL, TIME, STRING,
ANY_*, ARRAY, POINTER, REFERENCE, etc.
comment (gray) (* ... *) inline and // line-to-end
string (yellow) 'single' and "double" quoted
text (none) everything else
Identifier matching is case-sensitive uppercase only — matches
typical IEC 61131-3 convention and avoids false positives on lower-
case identifiers like if_x. Multi-line (* ... *) comments are not
joined across lines (out-of-scope for v0.2).
When the cursor is on a POU, d collects all OTHER devices that have
a POU with the same name.
- 0 peers: no-op (nothing to compare)
- exactly 1 peer: open the diff overlay immediately
- 2+ peers: open <CrossPicker> first; user picks with j/k + Enter
The diff overlay uses computeHunks against the full file contents
(not the IMPL_SENTINEL split, since we want to see decl differences
across devices too). Esc/q closes it.
Side-by-side renders old | new in two 50% columns separated by '│'.
Consecutive del/add hunks are paired row-by-row; a longer side gets
blank rows on the shorter side. Context lines mirror on both sides.
Footer now lists 'v toggle side-by-side'.
/ enters filter mode. Typed chars accumulate (case-insensitive
substring match against POU.name). Backspace removes a char. Enter
commits and stays out of input mode (filter persists). Esc clears
the filter and exits.
While the filter is active, devices auto-expand and only POUs whose
name contains the filter text are listed; devices with zero matching
POUs are hidden entirely.
Filter status line ('Filter: <text>') shows above the tree, cyan
while in input mode, dim once committed.
o on a POU row spawns $EDITOR (defaulting to 'code') with the
absolute path. detached + stdio:'ignore' + unref so the editor's
lifetime is independent of the TUI; shell:true so PATH lookup works
on Windows where 'code' is a .cmd shim.
Ignored on device rows (no abs_path).
? toggles a bordered help panel listing all keybindings. While the
panel is open, all other keys are ignored except Esc (which closes
it). The footer text gets a '? help' hint.
formatStaleness(mtimeMs) -> null | 'Xs' | 'Xm Ys' | 'Xh Ym'.
<ResizeWarning columns rows/> warns when terminal is below 80x20.
Browser header now shows 'mirror Xm Ys old' between the project
name and the closing rule when the mirror dir mtime is older than
STALE_THRESHOLD_MS (10s). The resize warning sits between the
header and the split view so it's the first thing the user sees
when they shrink the window.
The 0.6.3 fix replaced 'first-non-empty-pattern-wins' with category
iteration but used a HARDCODED list of 'well-known V3.5 GUIDs' that was
wrong. None of those GUIDs matched the actual Build category, so
compile errors stayed invisible (compile_project still reported '0
errors' even when the IDE-side download path saw them).
Diagnosed via a one-shot probe injected into compile_project on
2026-04-29: dumped attrs of script_engine.system, then called
script_engine.system.get_message_categories() (the METHOD) directly.
That returned the actual 7 category GUIDs in this CODESYS V3.5 SP22
Patch 1 install:
05581bd1-66d3-4251-aff2-047cc8e9adf7 Offline Help
936e1a33-3af8-47fa-b40b-903f0ae0b6cc Application Composer
a9b26e07-6ae1-4c06-9cd1-9ddddd397a2d SVN
0a6fcb64-7f24-43c6-a6d3-f70cb5d31114 (no parameterless ctor; Git)
194b48a9-ab51-43ae-b9a9-51d3edaaddf3 Script Messages
97f48d64-a2a3-4856-b640-75c046e37ea9 Build <-- the one we needed
220493a1-f49b-4416-9a3f-a545db707cbe Additional code checks
Real fix: replace the hardcoded list in _enumerate_categories() in both
compile_project.py and get_compile_messages.py with a call to
system.get_message_categories(); label each one via
get_message_category_description(guid). Iterate per category as before.
GUIDs are now discovered at runtime so the same code works on any SP
and any locale.
Verification: injected `THIS_IS_NOT_VALID_IEC_KEYWORD;` into
PLC_PRG.implementation, ran compile_project; output now reads
"1 error(s), 2 warning(s). ERROR: Identifier
'THIS_IS_NOT_VALID_IEC_KEYWORD' not defined". Restored PLC_PRG;
0 error(s) again. End-to-end fix confirmed.
Bumped 0.6.3 -> 0.6.4.
Wraps ScriptSymbolConfigObject (ScriptLib/Stubs/scriptengine/ScriptSymbolConfigObject.pyi
and helpme-codesys.com/en/ScriptingEngine/ScriptSymbolConfigObject.html).
Discovery (read-only):
find_symbol_config locate the SymbolConfiguration object(s) in a project
list_all_signatures every POU/FB/Method (compile=true to force build)
list_all_datatypes every DUT/struct/enum/alias
list_configured_symbols only the variables actually configured for export,
with configured/maximal/effective access per variable
get_symbol_config_settings every knob: feature flags, attr filter, comment
filter, direct I/O access (+ obstacles), layout calc
Setup:
create_symbol_config application.create_symbol_config(...) -- IDEMPOTENT,
no-ops with success if a symbol config already exists
anywhere in the project tree
Mutate:
set_symbol_config_settings partial-update of any subset of the 6 knobs;
refuses to enable direct I/O if obstacles exist
set_symbol_access per-variable configured_access setter
set_signature_access_bulk expose every variable in one signature at once
Output:
export_symbol_xsd write the get_symbol_configuration_xsd() bytes
All 10 tools share a SYMCONF_HELPERS list (ensure_project_open +
find_symbol_config_object). Enum mapping for SymbolAccess /
SymbolConfigContentFeatureFlags / SymbolAttributeFilterTypes /
SymbolCommentFilterType lives partly in TS (string -> int), partly in
Python (probe enum class at runtime, fall back to int literal).
Tool count: 31 -> 41.
Plan: docs/superpowers/plans/2026-04-28-symbol-config-tools.md.
Live verification + README/function-test doc updates land in a follow-up.
Two real bugs surfaced today:
1. compile_project / get_compile_messages reported 0 errors even when
the IDE's download path saw real compile errors. Root cause: both
scripts queried get_message_objects() with no category arg, which
returns only the IDE's last-active message tab (typically "Other"
for the WATCHER startup messages). Build/Code-Generation errors
live in a different category and were never queried.
Fix: enumerate script_engine.system.message_categories AND probe a
set of well-known V3.5 category GUIDs (Compile, Build, Online,
LibMan); query target_app.get_message_objects(cat) and
system.get_message_objects(cat) per category; aggregate dedup'd
entries (severity, text, object, line). Each entry now carries its
originating category label.
2. add_library's post-add _is_resolved() check trusted is_placeholder
== False as proof of resolution. CODESYS lets you call
add_placeholder(name_str) for a name that is NOT in the installed
Library Repository -- the resulting reference reports
is_placeholder=False yet has empty effective_version and the IDE
shows it with a yellow-warning triangle in Library Manager (no
Effective Version column populated). Karstein hit this with
"OPC UA PubSub SL": list_project_libraries reported it as
[managed] but compile failed because the IDE couldn't resolve it.
Fix: for the non-placeholder branch, probe effective_version /
resolved_version / version / resolved_library / managed_library /
library. ALL must be empty/None for the ref to be considered
hollow. Logs a DEBUG with the ref's attribute list so the next
such bug is diagnosable without source spelunking.
Bumped 0.6.2 -> 0.6.3.
Yesterday's 0.6.1 fix refused on every miss from _resolve_in_repo,
including the case where the IDE-level library_manager global is not
injected into the script context (which is the actual situation when
running through the MCP's script execution channel rather than the
interactive script REPL). Result: false-negative refuses for libraries
that ARE installed, just not visible from this script context.
This commit factors out _resolve_in_repo_accessible() and uses it to
distinguish three outcomes:
(a) Found in repo -> proceed; managed reference
(b) Repo accessible, name NOT hit -> HARD REFUSE (the bricking case)
Opt-in via ALLOW_UNRESOLVED=1.
(c) Repo NOT accessible at all -> proceed; rely on the post-add
_is_resolved() guard at line ~309
to catch hollow placeholders.
The (c) path is the safe relaxation: we cannot prove the library is
missing, so we defer to the post-add check rather than refuse blindly.
Adds for installed libraries succeed normally, broken adds still get
caught and removed before save.
Verified live: add_library 'OPC UA PubSub SL' against MCPTest2.project
(library was installed via Tools > Library Repository) now succeeds
cleanly with reference 'OPC UA PubSub SL [managed] ns=OPC_UA_PubSub_SL'.
The pre-resolve via library_manager.find_library() at line 218 already
detected when the requested name was not present in the installed
library repository -- it logged "Pre-resolve... returned no hit" -- but
the script then proceeded to call add_placeholder(LIBRARY_NAME) without
a managed-lib argument anyway. CODESYS happily creates such a reference
with is_placeholder=False, so the post-add _is_resolved() guard returns
True and the project gets saved with a hollow reference. The next time
the project is opened, the IDE pops:
Library Manager: Error: Could not open library 'X'.
(Reason: The placeholder library 'X' could not be resolved.)
...and compile fails until the user manually deletes the bad reference
from the Library Manager.
Karstein hit this on 2026-04-28 trying to add "OPC UA Pub Sub" (the
real library is "OPC UA PubSub SL", an add-on SL package not present
in the stock V3.5 SP22 install). The script returned SCRIPT_SUCCESS,
list_project_libraries showed it as `[managed]`, and only on the next
set_pou_code call did the broken-placeholder error surface.
Fix: when _resolve_in_repo returns None, hard-refuse upfront with a
clear error pointing at the Library Repository / CODESYS Installer.
Opt-in via ALLOW_UNRESOLVED=1 (mapped to the new MCP arg
`allowUnresolved: true`) for the rare case where a placeholder for a
not-yet-installed library is genuinely wanted.
Tool description and arg docs updated to mark allowUnresolved as
DANGEROUS so future agent calls don't reach for it casually.
Bumped 0.6.0 -> 0.6.1.
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 the user-facing --approve-edits surface area:
- ServerConfig.approveEdits?: boolean
- bin.ts: --approve-edits commander option, plumbs into config,
emits 'Approve edits: ENABLED' banner when on
- server.ts: logs 'Approve edits: ON|off' at startup
v0.1 stops here. The actual gating wiring (set_pou_code -> phobiCS-tui
approve, only proceed on exit 0) lands in the next commit. Landing
the surface area now keeps the user-visible CLI stable while the
internals catch up.
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.
Bare 'phobiCS-tui' (or 'phobiCS-tui <path>') now enters browser mode:
walks up from cwd looking for mcp-mirror/, scans the project, and
renders the Browser composer with:
- readPou backed by fs.readFile(absPath)
- writeSelection backed by the atomic state-file writer (path
resolved via stateFilePath() = LOCALAPPDATA / XDG_STATE_HOME)
- onQuit unmount + exit 0
If no mcp-mirror/ is found anywhere upward, exits 1 with a
'Run mirror_export in CODESYS first' hint.
<Browser project readPou writeSelection onQuit/> wires Tree (40%) and
Viewer (60%) into a stateful split view.
Keys: j/k nav, l/Enter/right expand a device, h/left collapse,
q quit.
Two effects fire whenever the cursor sits on a POU:
- debounced 200 ms writeSelection() so rapid j/k presses do not
storm the state file
- lazy readPou() with a cancelled-flag guard so the previous POU's
text never lands after the user has already moved on
<Viewer pou text scrollTop visibleRows/> renders a POU's source as
line-numbered text (4-col padding) with a bold header
'<name>.st (<kind>, <loc> L)'. Falls back to a dim '(no POU
selected)' when pou or text is null.
No syntax highlighting in v0.1 — that's intentional and tracked as
deferred. Smoke build only; render behavior is exercised through the
browser-mode integration tests in subsequent tasks.
<Tree project cursorPath expanded/> renders the project as a
two-level collapsible list: device headers with POU count, and (when
expanded) POU rows with name + kind + LOC count. Cursor row marked
with a leading triangle.
devicePath()/pouPath() are exported keypath helpers used by the tree
state machine to address rows uniformly.
phobiCS-tui dispatcher:
- --version / -v: print version, exit 0
- approve <existing> <proposed>: read both files, render <Approve>,
exit 0 on accept / 1 on reject / 2 on bad args or read error
- no args: print 'browser mode coming' placeholder, exit 0
- SIGTERM/SIGINT during approve: unmount + exit 1
Integration tests cover the no-TTY paths (--version, missing file,
missing args). Accept/reject keybinds are covered by the
ink-testing-library tests in Approve.test.tsx; piping stdin into a
no-TTY ink process is flaky on Windows so we don't try to
integration-test that path.
The shebang line is intentionally absent from the source; the build
script prepends one to dist/tui/index.js.
<Approve fileName oldText newText onDecision/> renders a unified diff
(+/-/space sigils, 4-col line numbers, add/del totals header) and
binds y -> accept, n/q/ESC -> reject via ink useInput.
Tests use ink-testing-library; each keystroke case awaits a microtask
flush before stdin.write so that ink's useEffect-installed 'readable'
listener has actually been attached at the moment the byte arrives.
findProjectRoot(startDir) walks up the filesystem looking for an
mcp-mirror/ subdirectory and returns the dir that contains it. Returns
null if no mirror is found anywhere upward to the FS root.
computeHunks() returns Hunk[] (add | del | ctx) with line numbers on
the new side for add/ctx and the old side for del.
Both inputs are normalized to end with a newline before calling
jsdiff's diffLines, otherwise a missing EOF newline is treated as a
token boundary and the last line shows up as a spurious del+add
pair.
writeSelection() emits the v1 state envelope (version, updated_at,
project_dir, device, selection{kind,name,path,abs_path}, viewer_line)
to <file>.<pid>.tmp then atomic-renames into place. mkdir -p the
parent, no .tmp left behind on success.
Resolves the location of the cross-process TUI state file:
- Windows: %LOCALAPPDATA%/codesys-mcp/tui-state.json
- POSIX: $XDG_STATE_HOME/codesys-mcp/tui-state.json, falling back to
$HOME/.local/state/codesys-mcp/tui-state.json
Uses path.win32 / path.posix explicitly so the POSIX branches still
produce POSIX-flavored paths when this runs on a Windows host (test
machine), keeping the unit tests host-independent.