0
0
Fork 0
Commit graph

237 commits

Author SHA1 Message Date
Karstein Phobic Nyvold Kvistad
a990cf23ee fix: stop interpolate() mangling $-sequences in param values
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.
2026-06-12 12:52:17 +02:00
Karstein Phobic Nyvold Kvistad
aeb1b67ee9 docs: master plan for SP21 ScriptEngine full functional coverage (phases 1-5) 2026-06-12 12:41:20 +02:00
Karstein Phobic Nyvold Kvistad
549e40ffab debug: log sibling names when find_object_by_path misses a direct child 2026-06-12 12:41:20 +02:00
Karstein Phobic Nyvold Kvistad
dead49ada0 fix: never fall back to headless --noUI in persistent mode (v0.10.3)
Persistent mode now uses a LazyPersistentExecutor everywhere the launcher
is not running: --no-auto-launch before first use, after shutdown_codesys,
and after a launch conflict. The first tool call launches the VISIBLE IDE
and delegates; nothing silently spawns --noUI processes anymore.

Why: headless spawns pop modal dialogs nobody can see (tool calls just
abort), hold .project locks (stale .~u files), and leave orphaned
CODESYS.exe processes that then block the next launch. Diagnosed live on
2026-06-11 while deploying TestN2k_v2_Fable: open_project/download ran
headless via the --no-auto-launch fallback and burned ~30 min on invisible
dialogs, zombie PIDs and lock-file cleanup.

Headless execution now requires explicit opt-in: --mode headless or
--fallback-headless.

Also lands the task-configuration tools from the 2026-06-09 session that
were complete but uncommitted: list_tasks, add_pou_to_task,
remove_pou_from_task (+ their IronPython scripts).
2026-06-11 14:15:50 +02:00
Karstein Phobic Nyvold Kvistad
a037025303 docs: add OpenAI Codex CLI (config.toml) setup instructions
The server is a standard stdio MCP server and works with any MCP client, not
just Claude Code. Added a "Use with OpenAI Codex CLI" section covering the
~/.codex/config.toml location, the [mcp_servers.<name>] TOML table (the
equivalent of the --print-config JSON block), env-based credential injection,
tool_timeout_sec for slow CODESYS launches, and the codex mcp add shortcut.
Linked it from Quick Start step 3.
2026-06-10 09:59:20 +02:00
Karstein Phobic Nyvold Kvistad
fb8b5b0c67 fix(ide-bridge): reap CodesysMCPBridge.exe so it never orphans
registerIdeBridgeTools() spawned CodesysMCPBridge.exe but the SIGINT/SIGTERM shutdown handler never closed the client, so every orchestrator exit left the shim running. They piled up across sessions (7 observed live with no IDE open).

- ide-bridge: add findOrphanedBridgePids()/killOrphanedBridges() using dead-parent detection (only reaps shims whose parent process is gone, so live sessions are untouched); harden close() with a taskkill /F fallback; expose .pid getter.
- server: sweep orphaned shims at startup, track the bridge client and close() it on shutdown, and add a process.on('exit') taskkill safety net for non-signal exits (stdin EOF / fatal).

Verified end-to-end against compiled dist: real orphan detected+reaped, live-parent shim left alone. Build clean, 156 tests pass. Bump 0.10.1 -> 0.10.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:22:11 +02:00
Karstein Phobic Nyvold Kvistad
d801038dda feat(project-tools): reject UNC paths with a clear, actionable error
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>
2026-06-04 11:02:48 +02:00
Karstein Phobic Nyvold Kvistad
8d22242507 feat(add_device): script backing the new add_device MCP tool
Pairs with the add_device MCP tool that landed in e1f47a1 (folded in
during the approve-gate rip). Wraps ScriptDeviceObject.add(name, device_id)
to programmatically attach a child device under a parent device:
'Modbus TCP Server' under an Ethernet adapter, 'Ethernet' under the top-
level PLC, etc.

Behaviour:
- Required inputs: PROJECT_FILE_PATH, PARENT_PATH ('/'-separated, e.g.
  'MainPLC' or 'MainPLC/Ethernet1'), DEVICE_NAME, TARGET_NAME (substring
  of device repository display name).
- Optional: TARGET_VERSION (exact, e.g. '4.5.0.0'). Omit -> highest-version
  match wins, mirroring update_device_type's resolver.
- Suppresses CODESYS prompt dialogs via PromptHandling.NONE (with int=0
  fallback for SP21).
- Idempotent: if a child with DEVICE_NAME already exists under the parent,
  no-ops with SCRIPT_SUCCESS rather than creating a duplicate or erroring.
- Refuses when:
  - parentPath isn't found in the project,
  - the object at parentPath isn't a device (only attaches under device-
    typed ScriptObjects),
  - TARGET_NAME (+ optional version) has no match in the device repo --
    error suggests inspecting Tools > Device Repository.
- On ScriptDeviceObject.add() failure prints the full traceback plus the
  two common causes (parent doesn't allow this child type; device
  descriptor needs an uninstalled library).

After add, saves the project. Save failure is a WARN, not a hard error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:57:20 +02:00
Karstein Phobic Nyvold Kvistad
e1f47a1c1c chore: rip approve-gate (TUI follow-up); fold add_device tool in gate-free
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>
2026-05-18 17:55:55 +02:00
Karstein Phobic Nyvold Kvistad
4b82b7c569 chore: nuke phobiCS-tui front-end (v0.10.0)
The phobiCS-tui CLI/UI is retired in favour of driving the codesys-mcp-sp21-plus
MCP tools directly from Claude Code (see https://docs.anthropic.com/claude-code).
The MCP server itself is unchanged; only the TUI front-end goes.

Removed:
- src/tui/ (browser + approve + shared + entry; 14 files)
- dist/tui/ (compiled output)
- tests/tui/ (14 .test.* files + the mini-mirror fixture tree)
- tsconfig.tui.json
- 3 superpowers plans/specs docs (2026-04-28 phobics-tui v0.1-v0.2, 2026-04-29 v0.3-live-values, 2026-04-28 tui-design)
- package.json: phobiCS-tui bin entry, build:tui script, TUI compile step in build, TUI typecheck step
- package.json: dependencies ink + react + diff (TUI-only); devDependencies @types/diff + @types/react + ink-testing-library
- README.md: ## phobiCS-tui section + ### Inline live values subsection

Git history side:
- Worktree .worktrees/phobics-tui removed (was on feature/phobics-tui-followup @ 7e427e9)
- Local + origin branches deleted:
  - feature/phobics-tui              (was 08ee361, 0 unmerged vs origin/main)
  - feature/phobics-tui-followup     (was 7e427e9, 0 unmerged vs origin/main)
  - feature/phobics-tui-v0.3-live-values (was 9f4dc48, 0 unmerged vs origin/main)
- All three branches were merged into main, so deleting refs loses no history --
  the commits remain reachable through main.

Knock-on (deliberately deferred):
- src/approve-gate.ts and the --approve-edits flag in src/bin.ts / src/server.ts
  still exist. With the TUI gone, the gate auto-approves at every prompt and
  prints a `[approve-gate] No TTY available -- phobiCS-tui cannot render` warning
  to stderr. The infrastructure also still has callers in the uncommitted
  add_device work, so a clean rip-out is left for a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:48:28 +02:00
Karstein Phobic Nyvold Kvistad
465718e93b fix(launcher): revalidate stale "Refusing to launch" cache on get_codesys_status
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>
2026-05-15 14:37:02 +02:00
Karstein Phobic Nyvold Kvistad
74a1d894a4 feat(device): update_device_type tool (in-place device-type swap)
Wraps ScriptObject.update(device_id) per the CODESYS Forge snippet so
a project can be retargeted between device families without destroying
the Application/POU/library subtree. Used for the WAGO PFC200 ->
CODESYS Control for Raspberry Pi MC SL workflow documented in the new
deploying-plc-project-to-rpi skill in mr-engineering-skill.

Intentionally NOT a remove+add fallback: if update() raises (cross-
family swap the IDE can't do in-place), surface the error rather than
silently destroying the subtree the user wants to keep.

Inputs:
  projectFilePath     -- target .project
  targetDeviceName    -- substring of repo display name
  devicePath          -- optional: device under project root; auto-picks
                         the first routed device, then any top-level
                         device, when omitted
  targetVersion       -- optional: exact version (else latest)
2026-05-12 19:21:42 +02:00
Karstein Phobic Nyvold Kvistad
7b0c5db125 feat(launch): launch_codesys_with_project tool
Detached spawn of an arbitrary CODESYS.exe with a .project as CLI arg
and optional --Profile= override. Useful when you want an SP22-saved
project opened in an SP21 IDE for SIM/inspection work, or when this
MCP is bound to install A but you want install B to handle the open
without registering a second server.

The launched IDE is not managed by this MCP: no IPC, no watcher, no
shutdown_codesys. Validates the exe + project paths up front and
returns the new PID.
2026-05-12 18:57:07 +02:00
Karstein Phobic Nyvold Kvistad
885ef84215 chore: bump version to 0.9.11 (ide-bridge passthrough) 2026-05-12 18:45:24 +02:00
Karstein Phobic Nyvold Kvistad
02bc1d001a feat(ide-bridge): passthrough for CODESYS-shipped MCP bridge (SP22.10+)
Adds an opt-in --ide-bridge auto|on|off flag. When the CODESYS install
ships the bridge shim (CodesysMCPBridge.exe alongside CODESYS.exe in
SP22 Patch 1 onward) and the in-IDE plugin is loaded, the sp21-plus
server spawns the shim as a child process, fetches its tools/list, and
re-registers each bridge tool under an 'ide_' prefix that forwards
JSON-RPC verbatim. Gives us the bridge's authoring tools (which mutate
the live project graph and pop the affected POU into the editor view
immediately) while keeping our 48 watcher tools as the home of online,
runtime, SSH, symbol-config, and release-pipeline work.

Backward-compatible by design: SP19/SP21 installs don't ship the
bridge, so defaultExePath() returns null and registration silently
skips under mode=auto. Under mode=on the server fails loudly.

The bridge's stdout speaks newline-delimited MCP JSON-RPC, exactly
like our own stdio transport, so the client is a small subprocess
wrapper plus a minimal JSON-Schema -> Zod-shape converter (handles
string/number/boolean/array/object/enum + optionality, falls back to
z.unknown() for anything else). Verified end-to-end against an open
MCPTest2 project on SP22.10:

  Bridge initialize OK (protocolVersion=2024-11-05)
  IDE bridge attached. Registering 19 passthrough tool(s) with 'ide_' prefix.
  ide_get_active_app_as_path -> 'CodesysRpi.Plc Logic.Application'

Tools exposed (prefixed): browse_project_tree, check_for_errors,
create_or_replace_structured_text_object, replace_text_in_structured_text,
get_structured_text_content, get_active_app_as_path,
get_active_path_and_selection, create_folder, remove_object,
add_library, add_program_call_to_task, get_available_libraries_list,
get_libraries_referenced_in_application, get_library_documentation,
get_detailed_library_documentation, get_device_and_io_configuration,
search_in_files_by_regex, search_libraries_for_type, search_path_by_glob.
2026-05-12 18:39:21 +02:00
Karstein Phobic Nyvold Kvistad
4236ab07ad docs(readme): document 7 missing tools (network scan/rebind, device user mgmt, remove_library, read_running_version_ssh); bump tool count 41 -> 48 2026-05-12 16:42:15 +02:00
Karstein Phobic Nyvold Kvistad
19fbaef9f4 feat(device-mgmt): add_device_user + grant_object_access tools
Two new MCP tools to drive previously IDE-only workflows:

- add_device_user: add (or update password of) a user in the PLC
  runtime's live User Management via
  ScriptOnlineDevice.create_live_user_management() + add_user(). Required
  for OPC UA authentication on CODESYS Control SP16+, where the OPC UA
  server reads its UserIdentityToken policies from the runtime UserMgr.
  Handles add-vs-update by calling live_um.upload() to sync local cache,
  and falls back to set_user_password if add_user reports "already
  existing" (the upload() race).

- grant_object_access: set Access Control permissions on a project
  object for a user group. Maps to the IDE's Properties -> Access
  Control matrix. Wraps ScriptUserManagement.get_object_permission() +
  ScriptObjectPermission.set_permission_state(). Common use case: grant
  Everyone View+Modify on the Symbol Configuration so the downloaded
  OPC UA server can advertise UserIdentityTokens for clients.

Both tools follow the existing pattern: JSON markers in the script,
extractMarkerJson() to surface the output, ensure_project_open helper.

Also bumps grant_object_access timeout to 120s after observing that
first-touch project ACL operations can be slow.
2026-05-11 20:34:21 +02:00
Karstein Phobic Nyvold Kvistad
7bd4155c8a fix(ensure_project_open): drop SilentMode so project is visible in IDE
ensure_project_open opened projects with VersionUpdateFlags.NoUpdates |
SilentMode. SilentMode loads the project into the scriptengine context
only -- the IDE's project explorer stays empty, so the user can't see
which project the MCP is operating on (or whether it actually loaded).
download_to_device, set_pou_code, etc. all still worked, but the IDE
window looked blank, which was confusing.

Drop SilentMode. NoUpdates stays (don't auto-update libraries on open;
that would silently change project content). With this change, opening
a project via the MCP behaves like a normal File -> Open: the tree
populates, the user can navigate it, and we can still drive
modifications from script.
2026-05-11 19:07:38 +02:00
Karstein Phobic Nyvold Kvistad
46b9c2967c fix(approve-gate): auto-approve when no TTY (stdio transport)
When the MCP server runs under Claude Code (stdio JSON-RPC), there is
no TTY attached -- the parent process owns stdin/stdout for the protocol
channel. phobiCS-tui spawned with stdio=inherit then has nothing to
render to and exits immediately, which gateOpForTool was interpreting
as "user rejected the change" -- silently blocking every modifying
operation (download_to_device, set_pou_code, create_*, etc.) with no
visible cause.

Detect non-TTY at gate entry. When phobiCS-tui isn't viable, log a
clear warning to stderr (visible in MCP server logs) and let the
operation proceed instead of falsely rejecting it. Applies to both
the generic gateOpForTool path and the set_pou_code runApproveGate
path.

User can pass --no-approve-edits to silence the warning when they
explicitly don't want the gate. The right long-term fix is to route
approval through the MCP elicitation protocol so the user gets the
prompt in their chat UI; that's a separate change.
2026-05-11 18:41:57 +02:00
Karstein Phobic Nyvold Kvistad
41db7d86e9 fix(device): non-blocking pre-flight, always-force rebind, ensure connect()
End-to-end download via MCP was failing for three reasons; all three
fixed here so the agent can drive the whole flow without manual IDE
clicks.

1. verify_device_reachable did a live gateway.perform_network_scan()
   on every call. That freezes the CODESYS UI thread for the full
   scan duration, so the user couldn't interact with the IDE during
   pre-flight (and a 60s tool timeout was easy to hit). Now reads
   gateway.get_cached_network_scan_result() first (instant, no UI
   block), only falls back to a live scan when no cache exists.
   Adds scan_source ("cache" | "live") to the JSON so callers know
   which path was taken.

2. rebind_device_to_scan short-circuited with "already-bound" when
   the resolved address matched the cached one. But the IDE's
   Select-Device + OK flow re-applies set_gateway_and_address even
   when the address didn't change -- that's what refreshes the
   device's scanned_* properties and re-establishes a session.
   Skipping it left the binding stale even after a successful
   rebind call. Always apply now.

3. ensure_online_connection created an online_application but never
   opened a device session. login() then waited on (or popped a
   dialog for) a session nobody had opened -- which is what the
   user was doing manually as "right-click device -> Connect".
   Adds _ensure_device_connected() helper that locates the project's
   PLC device, creates a ScriptOnlineDevice, and calls connect() if
   not already connected. Idempotent; best-effort (logs and falls
   through on failure so login() can still try its own path).
2026-05-11 18:09:34 +02:00
Karstein Phobic Nyvold Kvistad
4200b15b40 fix(device-tools): surface JSON output in tool responses
scan_network_devices, verify_device_reachable, and
rebind_device_to_scan_result all emit JSON between marker pairs in their
script stdout. The tool handlers wrapped that output with
formatToolResponse which returns only the success line on success --
hiding the actual data so the agent had nothing to act on.

Adds extractMarkerJson() helper that pulls the block between the start
and end markers and pretty-prints it, then returns the JSON in the tool
response. Now the agent sees the full scan results / reachability
candidates / rebind outcome.
2026-05-11 17:45:30 +02:00
Karstein Phobic Nyvold Kvistad
1feb76d009 fix(scripts): raw-string PROJECT_FILE_PATH in new device tools
The scan_network_devices, verify_device_reachable, and
rebind_device_to_scan templates used plain "..." strings for
PROJECT_FILE_PATH. With a UNC path like \\files\... the rendered Python
source had "\\files\..." which Python's source parser collapses to
"\files\..." (one backslash). ensure_project_open then saw the mangled
path as different from the (correctly-opened) primary, CLOSED the
primary project, and failed to reopen the corrupted path.

Other scripts use r"..." to bypass source-level escaping. Applying the
same to the three new ones.

Also bumps to 0.9.5.
2026-05-11 17:27:15 +02:00
Karstein Phobic Nyvold Kvistad
50b8b5763b chore: bump version to 0.9.4 2026-05-11 17:18:39 +02:00
Karstein Phobic Nyvold Kvistad
4f87e6ba83 feat(device): network scan + verify + rebind, with download pre-flight
Stale device addresses (cached in the project's Communication Settings)
are the #1 cause of download_to_device hanging silently: the gateway is
up, but the PLC has moved to a new router address after a reboot/DHCP
event, so login() waits forever on a UI dialog the agent can't see.

Adds three new tools:

- scan_network_devices: drives gateway.perform_network_scan() and emits
  the list of discovered targets (device_name, type_name, vendor_name,
  address, device_id). Supports useCache=true to read the gateway's
  last cached scan result instead.

- verify_device_reachable: scans the gateway and reports whether the
  project's cached device address matches a live target. Returns
  reachable=true/false plus the candidate list.

- rebind_device_to_scan_result: matches a scan result (by name, by
  device_id, by forced address, or single-candidate) and calls
  device.set_gateway_and_address() + saves the project so the new
  binding persists across CODESYS restarts.

And wires download_to_device to run verify_device_reachable BEFORE
login(). If unreachable, the tool fails fast with a clear hint to call
rebind_device_to_scan_result, instead of dropping into the IDE dialog.
Pass skipReachabilityCheck=true to force-bypass if the gateway/cache
lookup itself is broken on a given SP.

Helper find_target_device.py is shared across the three new scripts.
2026-05-11 17:18:01 +02:00
Karstein Phobic Nyvold Kvistad
876cfc0d18 fix(launcher): wait for killed PIDs to evict before re-scan
killExisting=true taskkilled the conflicting CODESYS.exe, but the
immediately-following findConflictingInstances() still saw the PID and
the launcher threw -- even though the process was on its way out.

Windows is async about evicting a PID from the process table after
taskkill returns. Poll for up to 2s, dropping out as soon as none of
the PIDs we just killed are still listed. Only PIDs the user did NOT
ask us to kill end up in the post-kill conflicting list.
2026-05-11 17:07:01 +02:00
Karstein Phobic Nyvold Kvistad
da49dc16fe chore: bump version to 0.9.3 2026-05-11 16:52:00 +02:00
Karstein Phobic Nyvold Kvistad
3547096f89 feat(launcher): soft-fail on same-install conflict + killExisting opt
Before: if a stray CODESYS.exe of the same install was running when the
MCP server started (typical: orphan from a prior MCP session whose
parent died), launcher.launch() threw and the whole MCP server died on
startup, leaving the user with Failed-to-connect in the IDE and no way
to resolve it without manually killing the orphan first.

Now:
- launcher.launch() tags the conflict error with code
  CODESYS_LAUNCH_CONFLICT and a list of conflictingPids.
- server.ts treats that code specially during auto-launch: stay
  connected, fall back to HeadlessExecutor, log a warning. The model
  (and the user) can now call launch_codesys from chat to resolve.
- launch_codesys gains a killExisting boolean. When true, the launcher
  taskkills the same-install PIDs (per the existing kill semantics --
  other CODESYS installs are unaffected) and retries the spawn in one
  shot.
- Refactors the kill loop into launcher.killConflictingInstances() and
  the scan into launcher.findConflictingInstances() so both the
  pre-launch guard and the kill path share one source of truth.
2026-05-11 16:51:12 +02:00
Karstein Phobic Nyvold Kvistad
4cabd0b69f feat(symbol-config): auto-compile after symbol-modifying tools
Symbol Configuration artifacts (XSD/XML, .app/.crc symbol table) only land
on disk as a side effect of code generation -- not when the config is
edited. So a sequence like create_symbol_config -> set_symbol_access ->
download_to_device would ship the OLD symbol table because no build ran
in between.

After this change, the four symbol-config-modifying tools automatically
run compile_project on success and append the build outcome to the
response: create_symbol_config, set_symbol_config_settings,
set_symbol_access, set_signature_access_bulk.

Refactors the existing compile body out of the compile_project tool into
a runCompile() closure so both tools share the same parse + format
logic, and adds a withAutoCompile() wrapper for the symbol tools.
2026-05-11 16:37:17 +02:00
Karstein Phobic Nyvold Kvistad
98c9c5f66e fix(connect+download): default loginWaitSeconds 60→10 + force agent to announce
Two real frictions this session:

1. loginWaitSeconds defaulted to 60. When the user has to fill the
   Device User Login dialog, that's done in seconds — but the tool
   call would block for the full 60s after they clicked OK, before
   reporting back. Worse: download_to_device's tool-side timeout is
   (waitSec + 120)s, so 180s if anything else went sideways. New
   default: 10s, which still gives the user time to click the dialog
   but doesn't punish a fast click.

2. Karstein noted that download was being called silently. Both tool
   descriptions now carry an explicit AGENT BEHAVIOUR REQUIRED
   directive: announce before calling, warn that the dialog may pop,
   user must be ready to click. The directive lives in the tool
   description so future agents see it on every call-time schema lookup.

Bumped 0.7.0 → 0.7.1.
2026-05-11 16:22:37 +02:00
phobicdotno
403a4336ae
fix: no headless fallback by default + clear stale lastError (v0.9.2) (#19)
* fix: disable headless fallback by default

Persistent mode silently fell back to running CODESYS with --noUI
whenever the launch refused (e.g. another GUI instance was already
running). That left the user staring at no window while the MCP
spawned hidden processes per command. Flip the default of
--fallback-headless to false so the MCP errors out loudly instead;
opt in explicitly if silent --noUI is actually wanted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clear stale lastError on launch retry and successful ready

get_codesys_status kept reporting historical "Last Error: Refusing
to launch..." messages forever because lastError was set on failure
but never reset. Reset to null at the top of launch() so a retry
starts clean, and again when the watcher signals ready so a
successful re-launch wipes prior errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release: v0.9.2 -- no headless fallback by default + clear stale lastError

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Karstein Phobic Nyvold Kvistad <karstein.kvistad@maritimerobotics.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:16:01 +02:00
phobicdotno
bc815a2dac
Merge pull request #18 from phobicdotno/docs/readme-fork-line
docs: drop redundant 'This fork:' self-link
2026-04-29 18:13:33 +02:00
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