0
0
Fork 0
Commit graph

206 commits

Author SHA1 Message Date
Karstein Phobic Nyvold Kvistad
b0dadbd5e8 docs: drop redundant 'This fork:' self-link in README header
The line pointed back to phobicdotno/Codesys-MCP-SP21-plus, which is
the URL the visitor is already on when they're reading this on
GitHub. Upstream + npm + maintainer lines give enough provenance for
visitors arriving from npmjs.com or via search.
2026-04-29 18:13:08 +02:00
phobicdotno
d9e1753b29
Merge pull request #17 from phobicdotno/fix/diff-runtime-dep
fix(deps): move 'diff' from devDependencies to dependencies (v0.9.1)
2026-04-29 17:53:51 +02:00
Karstein Phobic Nyvold Kvistad
6ed62f1536 fix(deps): move 'diff' from devDependencies to dependencies (v0.9.1)
The TUI subpackage imports 'diff' at runtime (computeHunks in
src/tui/shared/diff.ts), so a global install via 'npm install -g'
was breaking with:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'diff'
    imported from .../dist/tui/shared/diff.js

devDependencies are not installed by 'npm install -g <pkg>', only
'dependencies' are. Move 'diff' to the right section.

This bug shipped silently in 0.5.0 onwards because the source-build
flow during dev install everything, masking it. First reproduces
when a user does a fresh global install -- which is the only flow
the published package should support.

Bumps to 0.9.1; tag + republish required to fix the live install.
2026-04-29 17:53:11 +02:00
Karstein Phobic Nyvold Kvistad
c157267b20 release: v0.9.0 -- live-values: sub-property descent + configurable interval, plus SP22 librarymanager fix
Bumps codesys-mcp-sp21-plus from 0.8.0 to 0.9.0.

Headline changes since v0.8.0:

  Live values:
    - Pump now descends one level into user-defined types. A var
      whose declared type resolves to another mirror .st file (a
      POU/FB/STRUCT/DUT) gets each of its members read as
      <var>.<member>. Caller-supplied resolveTypeMirror dep so the
      pump stays decoupled from the mirror layout.
    - --live-values-interval <ms> CLI flag (default 500, clamped
      [100, 60000]).

  add_library:
    - SP22 librarymanager attribute name fix. The SP22 stub
      documents 'library_manager' (underscore) but the actual
      injected attribute is 'librarymanager' (one word). Now
      probes both with SP-version-aware preference + adds an
      SP22-specific find_library dispatcher that walks
      lm.repositories when the documented signature is rejected.
2026-04-29 14:23:11 +02:00
phobicdotno
b639913dd6
Merge pull request #16 from phobicdotno/feature/v0.3-sub-property-paths
feat(live-values): depth-1 sub-property descent
2026-04-29 13:53:33 +02:00
Karstein Phobic Nyvold Kvistad
614a8458f0 feat(live-values): depth-1 sub-property descent
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)
2026-04-29 13:52:35 +02:00
phobicdotno
9d406ef6bd
Merge pull request #15 from phobicdotno/feature/v0.3-config-interval-flag
feat(live-values): --live-values-interval <ms> CLI flag
2026-04-29 13:45:32 +02:00
phobicdotno
f0a44f877e
test(e2e): regression coverage for the four 2026-04-29 fixes (#14)
* 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>
2026-04-29 13:44:02 +02:00
Karstein Phobic Nyvold Kvistad
8f40b5539a 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.
2026-04-29 13:43:09 +02:00
phobicdotno
b535eb3e3d
Merge pull request #13 from phobicdotno/fix/sp22-librarymanager-attribute-name
fix(add_library): SP-version-aware librarymanager + find_library dispatch
2026-04-29 13:40:47 +02:00
phobicdotno
48fa83ccce
fix(create_project): correct prompt-suppression API + close project after swap (#12)
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>
2026-04-29 13:17:54 +02:00
phobicdotno
a0574b2201
fix(create_project): try update() first to preserve user code, fall back to remove+add (#11)
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>
2026-04-29 12:53:38 +02:00
phobicdotno
07bb66c0f2
fix(create_project): three live-test fixes for deviceName swap (#10)
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>
2026-04-29 12:46:51 +02:00
phobicdotno
507cae0020
fix(symbol-config): coerce int->SymbolAccess via type(maximal_access) (#9)
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>
2026-04-29 10:23:37 +02:00
Karstein Phobic Nyvold Kvistad
5ea04a2003 fix(symbol-config): coerce int access to genuine enum via type(maximal_access)
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.
2026-04-29 10:21:27 +02:00
phobicdotno
a0c66da38c
feat(create_project): optional deviceName arg swaps template default device (#8)
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>
2026-04-29 10:17:10 +02:00
phobicdotno
3dcdb34b58
fix(symbol-config): mutate configured_access via get_all_signatures, not configured view (#7)
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>
2026-04-29 10:17:07 +02:00
phobicdotno
05d0e37e21
feat(remove_library): new MCP tool wrapping ScriptLibManObject.remove_library (#5)
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>
2026-04-29 10:17:03 +02:00
Karstein Phobic Nyvold Kvistad
0962f93118 release: v0.8.0 -- phobiCS-tui v0.3 (inline live values)
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.
2026-04-29 10:10:40 +02:00
Karstein Phobic Nyvold Kvistad
4c955886ff Revert "feat(live-values): atomic writer for tui-live-values.json"
This reverts commit c565888ff5.
2026-04-29 10:03:32 +02:00
phobicdotno
a3caf0dd38
Merge pull request #6 from phobicdotno/feature/phobics-tui-v0.3-live-values
phobiCS-tui v0.3: inline live values (server pump + TUI overlay)
2026-04-29 09:59:57 +02:00
Karstein Phobic Nyvold Kvistad
9f4dc4864d docs: README section for --live-values inline overlay
Documents the v0.3 live-values pump + TUI overlay:
- example output showing the inline '◀ live: <val>' annotations
- 500 ms server poll, 5 s TUI freshness window
- snapshot keyed by pou_name to reject stale POU views
- v0.3 scope: top-level vars only (deferred sub-property + ARRAY/STRUCT)
2026-04-29 09:59:01 +02:00
Karstein Phobic Nyvold Kvistad
8aa2553996 feat(live-values): --live-values CLI flag + server pump wiring
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.
2026-04-29 09:58:04 +02:00
Karstein Phobic Nyvold Kvistad
c81ce33a4b feat(live-values): pump skeleton + VAR-block parser
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.
2026-04-29 09:55:18 +02:00
Karstein Phobic Nyvold Kvistad
a1ac646bb9 feat(live-values): atomic writer for tui-live-values.json
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.
2026-04-29 09:51:17 +02:00
Karstein Phobic Nyvold Kvistad
c565888ff5 feat(live-values): atomic writer for tui-live-values.json
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.
2026-04-29 09:50:54 +02:00
Karstein Phobic Nyvold Kvistad
a1bcf5cbfe tui(v0.3): useLiveValues hook + wire into Browser/Viewer
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={...}/>.
2026-04-29 09:49:15 +02:00
Karstein Phobic Nyvold Kvistad
1d5ed26f21 fix(add_library): SP-version-aware librarymanager + find_library dispatch
The SP22 stub at Stubs/scriptengine/ScriptLibManObject.pyi documents:
  'An instance implementing this interface is injected into the
   scriptengine scope under the name library_manager.'

That is wrong on SP22 in practice. Verified live by probing watcher
globals 2026-04-29: the actual attribute is 'librarymanager' (one
word, no underscore), exposed both as a top-level global and as
script_engine.librarymanager. The stub-documented 'library_manager'
name is NOT defined.

Result: every call to add_library against an SP22 IDE silently went
through the 'global library_manager not in scope' fallback path,
producing managed references with auto-derived namespaces ('Net Base
Services' -> ns='Net_Base_Services') instead of the IDE-managed
placeholder + ns ('NetBaseSrv' -> ns='NBS'). That made library types
unreachable from IEC code -- e.g. NBS.IPv4Address compiled clean only
when the user added the lib through the IDE Add Library dialog.

Fix:
  * _detect_sp_version() parses sys.version once.
  * _get_lib_manager() picks 'librarymanager' first on SP22, falls
    back to 'library_manager' for older SPs, tries both in
    script_engine and bare module globals.
  * _resolve_in_repo() dispatches on SP version. SP22's find_library
    rejects bare strings (raises with 'stDisplayName' payload) and
    rejects the keyword form too, so the SP22 path falls back to a
    lm.repositories walk -- trying 'get_libraries' / 'libraries' /
    'libs' / 'all_libraries' / iter() because LibRepository's
    iteration API is undocumented on SP22.
  * Pre-SP22 path keeps the documented signature.

Verification path: with this fix, calling
  mcp__codesys__add_library(libraryName='Net Base Services')
should land a placeholder named 'NetBaseSrv' with ns='NBS', matching
what the IDE's Add Library dialog produces. Test in next session by
re-launching CODESYS so the new add_library.py is loaded into the
exec_globals of execute_script in watcher.py.

This commits the fix to a feature branch so subsequent linter passes
can't revert the working tree.
2026-04-29 09:49:03 +02:00
Karstein Phobic Nyvold Kvistad
a9d77df627 tui(v0.3): Viewer overlays inline live values when liveValues prop set
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.
2026-04-29 09:45:49 +02:00
Karstein Phobic Nyvold Kvistad
d233880a86 tui(v0.3): add readLiveValues() with 5s freshness window
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.
2026-04-29 09:42:06 +02:00
Karstein Phobic Nyvold Kvistad
6f9cc46547 tui(v0.3): add LiveValuesPayload type + liveValuesFilePath()
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.
2026-04-29 09:40:54 +02:00
Karstein Phobic Nyvold Kvistad
0fdb0046d8 docs(function-test): close severity-bitmask repro todo, note round-trip still deferred
- Severity-bitmask long crash repro confirmed end-to-end via SymTest.project
  (PLCWinNT, device not installed -> 17 ERROR messages, 2 WARNING, 34 total
  through get_compile_messages with _coerce_for_json -- no TypeError).
- MCPTest2/Application/Symbols cleanup recorded.
- 5 deferred symbol-config tools still pending a build-clean project.
2026-04-29 09:34:54 +02:00
Karstein Phobic Nyvold Kvistad
e0a4685c11 Merge branch 'main' of https://github.com/phobicdotno/Codesys-MCP-SP21-plus 2026-04-29 09:08:35 +02:00
phobicdotno
47561edf03
Merge pull request #4 from phobicdotno/feature/phobics-tui-followup-2
phobiCS-tui v0.2.x: multi-line comment join + runtime-tool approve-gate + v0.3 plan
2026-04-29 09:08:13 +02:00
Karstein Phobic Nyvold Kvistad
58a1573b80 release: v0.7.1 -- add_library wrapper-message fix + mock_watcher PermissionError retry
Two bug fixes since v0.7.0:
  e948922 fix(add_library): wrapper message reflects dedup vs add branch
  d01f6ed fix(test): retry on PermissionError in mock_watcher (Win race)
2026-04-29 09:07:52 +02:00
Karstein Phobic Nyvold Kvistad
7266305630 plan: phobiCS-tui v0.3 inline live values
Scopes the v0.3 work as 8 TDD-able tasks with a one-way live-values
pump (server writes tui-live-values.json next to tui-state.json; TUI
Viewer reads it on a 500ms poll and overlays values inline next to
declared variable names).

The original blocker (connect_to_device script bug) is gone: the
SP21+ fork now probes both LoginMode and OnlineChangeOption enums
across SPs and tries multiple call shapes (see
src/scripts/connect_to_device.py). Memory updated to reflect the
fix.

No code changes here -- pure planning doc, ready to be picked up as a
separate PR.
2026-04-29 09:07:22 +02:00
Karstein Phobic Nyvold Kvistad
b2991c33ea release: v0.7.1 -- add_library wrapper-message fix + mock_watcher PermissionError retry
Two bug fixes since v0.7.0:
  e948922 fix(add_library): wrapper message reflects dedup vs add branch
  d01f6ed fix(test): retry on PermissionError in mock_watcher (Win race)
2026-04-29 09:07:06 +02:00
Karstein Phobic Nyvold Kvistad
d327329f1c feat(approve-gate): wire 4 runtime tools through phobiCS-tui
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.
2026-04-29 09:04:07 +02:00
Karstein Phobic Nyvold Kvistad
041d7dc291 tui(viewer): join multi-line (* ... *) comments across line boundaries
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).
2026-04-29 09:02:09 +02:00
Karstein Phobic Nyvold Kvistad
a134af30cf docs(function-test): live SP22 partial-pass results (5/10) for symbol-config tools
5 of 10 symbol-config tools verified end-to-end on SP22 P1 against
MCPTest2: find/create/get_settings/set_settings/list_configured_symbols.
3 timed out and crashed CODESYS with exit 0xFFFFFFFF (set_signature_
access_bulk, export_symbol_xsd, indirectly set_symbol_access) because
get_all_signatures(True) and get_symbol_configuration_xsd() trigger an
application.build() that aborts on this project (empty PLC_PRG body,
IoDrvGPIO managed library). list_all_signatures itself ran cleanly but
returned 0 -- not a crash, just a build short-circuit on an empty POU.

Re-run on a project with a real, build-clean PLC_PRG body. Tool code
itself non-regressing -- the 5 read/write paths that don't trigger a
build all worked.
2026-04-29 09:00:41 +02:00
phobicdotno
d0453375e1
Merge pull request #3 from phobicdotno/fix/mock-watcher-win-perm-retry
fix(test): retry on PermissionError in mock_watcher (Win race)
2026-04-29 08:34:36 +02:00
Karstein Phobic Nyvold Kvistad
d01f6ed930 fix(test): retry on PermissionError in mock_watcher (Win race)
On Windows, the renamed .command.json / script .py file is briefly
locked by Defender or NTFS rename finalization, which surfaces as a
transient PermissionError when the watcher's open() runs immediately
after the producer's atomicWrite + rename.

Wrap the two reads in process_command() with read_with_retry(), which
retries up to 10 times with 20 ms sleeps before giving up. POSIX
behaviour unchanged (first attempt always succeeds).

Repro on Windows:
  npm test
  > FAIL tests/unit/ipc.test.ts > sendCommand handles script error
  > PermissionError: [Errno 13] Permission denied: '...command.json'
2026-04-29 08:34:06 +02:00
phobicdotno
aaf76d2eec
Merge pull request #2 from phobicdotno/feature/phobics-tui-followup
phobiCS-tui v0.2 followup: TUI keybinds + approve-gate for all 9 modifying tools
2026-04-29 08:24:57 +02:00
Karstein Phobic Nyvold Kvistad
e94892233f fix(add_library): wrapper message reflects dedup vs add branch
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".
2026-04-29 08:24:11 +02:00
Karstein Phobic Nyvold Kvistad
7e427e93c5 release: v0.7.0 -- phobiCS-tui v0.2 (TUI keybinds + 9-tool approve-gate)
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.
2026-04-29 08:23:57 +02:00
Karstein Phobic Nyvold Kvistad
6d5d9b0385 docs: README for v0.2 followup (TUI keybinds + 9-tool gating)
Updates the phobiCS-tui section to cover the new keybinds (/, o, d,
r, ?, v) and documents that --approve-edits now gates all 9
modifying MCP tools (create_pou, create_property, create_method,
create_dut, create_gvl, create_folder, delete_object, rename_object,
add_library) on top of the existing set_pou_code.

Also notes the Viewer syntax highlighting and the statusbar's
mirror-staleness indicator + small-terminal resize warning.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
e1966c7e83 feat(approve-gate): wire 9 modifying tools through phobiCS-tui
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).
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
3da09f8aba tui(viewer): ST keyword/type/comment/string syntax highlighting
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).
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
e6d833ea1a tui(browser): d opens cross-device diff for highlighted POU
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.
2026-04-29 08:22:09 +02:00
Karstein Phobic Nyvold Kvistad
2a743c29fa tui(approve): v toggles unified <-> side-by-side diff
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'.
2026-04-29 08:22:09 +02:00