From a134af30cf1eacbd39cff1528bccba1975f4c76c Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:00:41 +0200 Subject: [PATCH 1/5] 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. --- docs/function-test-2026-04-28.md | 33 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/function-test-2026-04-28.md b/docs/function-test-2026-04-28.md index 295b91f..a486b5d 100644 --- a/docs/function-test-2026-04-28.md +++ b/docs/function-test-2026-04-28.md @@ -70,16 +70,16 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i | # | Tool | Vitest | Live SP22 | Notes | |---|-------------------------------|--------|-----------|-------| -| 1 | `find_symbol_config` | OK | pending | Template-prep test asserts marker-walk + path serialiser | -| 2 | `list_all_signatures` | OK | pending | `compile=true/false` both rendered | -| 3 | `list_all_datatypes` | OK | pending | Mirror of #2 over `get_all_datatypes()` | -| 4 | `list_configured_symbols` | OK | pending | Walks signatures + datatypes; serialises `configured_access` / `maximal_access` / `effective_access` per variable | -| 5 | `get_symbol_config_settings` | OK | pending | All 6 knobs + obstacle explanations + available layout calculators | -| 6 | `create_symbol_config` | OK | pending | Idempotent (refuse-with-success if already present); `application.create_symbol_config(exp, opc, guid)` | -| 7 | `set_symbol_config_settings` | OK | pending | Partial-update via per-field APPLY_* flags; refuses direct I/O if obstacles | -| 8 | `set_symbol_access` | OK | pending | Per-variable `configured_access` with enum probe + int-literal fallback | -| 9 | `set_signature_access_bulk` | OK | pending | Iterates `sig.variables`, reports `changed` + `skipped` | -|10 | `export_symbol_xsd` | OK | pending | `get_symbol_configuration_xsd()` bytes -> file (UTF-8) | +| 1 | `find_symbol_config` | OK | OK (2026-04-29) | Returned `count=0` on virgin MCPTest2, then `count=1` (`CodesysRpi/Plc Logic/Application/Symbols`) after create. | +| 2 | `list_all_signatures` | OK | partial | `compile=true` returned `count=0` on the empty-PLC_PRG MCPTest2 -- script ran cleanly, no crash, but the build short-circuited. Re-run on a project with real signatures (e.g. X33). | +| 3 | `list_all_datatypes` | OK | not run | Skipped after #8/#9/#10 destabilised the watcher. | +| 4 | `list_configured_symbols` | OK | OK (2026-04-29) | Returned `signature_count=0, datatype_count=0` on freshly-created Symbol Configuration -- expected. | +| 5 | `get_symbol_config_settings` | OK | OK (2026-04-29) | Pre-set: `SupportOPCUA, XmlIncludeComments` (= 0x20001 default for SP22). Comment filter default is `None`, **not** `Both` as the plan predicted -- plan was wrong. | +| 6 | `create_symbol_config` | OK | OK (2026-04-29) | Created `Application/Symbols`. (One CODESYS crash *immediately after* create on the first run; restart-and-retry succeeded -- see "Caveats" below.) | +| 7 | `set_symbol_config_settings` | OK | OK (2026-04-29) | Wrote `[SupportOPCUA, IncludeComments, IncludeExecutables]`; re-read showed `content_feature_flags_int=19` (configured) / `effective=3` (IncludeExecutables masked off in `effective` -- normal SP22 runtime clamp). | +| 8 | `set_symbol_access` | OK | not run | Skipped after #9 timed out the watcher. Calls `get_all_signatures(True)` on the not-yet-configured fallback path -- same instability as #9. | +| 9 | `set_signature_access_bulk` | OK | crash | `Application.PLC_PRG ReadWrite` -> 60s timeout -> CODESYS exited with 0xFFFFFFFF. Suspected: the `get_all_signatures(True)` second-fallback re-builds, and the build crashes on this project (likely related to the Pi-only IoDrvGPIO managed library). Needs a project with a real, build-clean PLC_PRG. | +|10 | `export_symbol_xsd` | OK | crash | Same failure mode as #9. `get_symbol_configuration_xsd()` may also trigger an internal build. | **Vitest column**: `tests/integration/e2e.test.ts` -- 10 new template-prep assertions added; full suite 107/107 passing (excluding the orphan `.worktrees/phobics-tui` suite). Each test renders the script with realistic placeholders and asserts: - no leftover `{PLACEHOLDER}` in the rendered output, @@ -87,7 +87,9 @@ Ten new tools wrap `ScriptSymbolConfigObject` (CODESYS 3.5.10.0+) per the plan i - the helper functions are pulled in (`find_symbol_config_object`, `ensure_symbol_config`, `symbol_config_path`). - additionally Python 3 `ast.parse` was run against every script to catch any IronPython 2.7 syntax that Py3 would also flag. -**Live SP22 column**: deferred -- the Claude Code session's MCP tool list was negotiated at session start and doesn't refresh when the MCP server registers new tools mid-session. To run the live cycle, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan: +**Live SP22 column** (updated 2026-04-29 morning): partial pass -- 5/10 verified end-to-end, 2/10 crashed, 3/10 skipped after the crashes. Symbol-Configuration *creation, settings persistence, and read-only inspection* all work cleanly on SP22 P1. The build-triggering tools (`set_signature_access_bulk`, `set_symbol_access` via fallback, `export_symbol_xsd`) need a project that builds cleanly headless -- MCPTest2's empty PLC_PRG body + IoDrvGPIO managed library cause `application.build()` (called by `get_all_signatures(True)` and likely `get_symbol_configuration_xsd()`) to abort the process with exit 0xFFFFFFFF. Plan to re-run on a project with a real, build-clean POU body (e.g. a fresh `Standard project` template under SP22 with PLC_PRG containing one assignment). + +To run the live cycle from scratch on a different project, start a new Claude Code session (the symlinked global npm package will pick up the new build automatically) and execute the round-trip from the plan: ``` 1. mcp__codesys__open_project MCPTest2.project @@ -110,3 +112,12 @@ The plan also calls out the `SymbolAccess` enum-value probe risk: the SP22 stub --- *Function test executed against this fork @ HEAD `f34d002` on 2026-04-28; symbol-config tools added in `db688c2` and verified via vitest the same evening.* + +--- + +## Symbol-config live re-run notes (2026-04-29 morning, HEAD `e948922`) + +- **Crash pattern.** Three of the build-triggering tools (`get_all_signatures(True)` on a not-yet-configured Symbol Configuration, `set_signature_access_bulk` via the same fallback, and `export_symbol_xsd`) caused CODESYS to exit with 0xFFFFFFFF (NTSTATUS `STATUS_INVALID_HANDLE` / generic crash) on MCPTest2. The MCP launcher correctly reported state=error; restart-and-retry was always sufficient to recover. The crashes were *project-specific* -- `set_symbol_config_settings` flag-write succeeded on the *second* attempt with no code change between the failed and successful runs, suggesting a transient build-state race rather than a tool defect. +- **Wrapper message fix landed in `e948922`.** `add_library` no longer renders "Library 'X' added" on the dedup no-op path -- it now reads the script's `Library Already Present:` marker and renders "already referenced". Cosmetic todo from this doc closed. +- **Session ledger** (per `feedback_session_ledger.md` user rule): every CODESYS launch / shutdown / kill during this run logged to `~/.claude/projects//state/session_ledger.jsonl` so a next session knows which CODESYS.exe PIDs are mine to reclaim. +- **Symbol-config side effect on MCPTest2**: a `Symbols` object was created under `CodesysRpi/Plc Logic/Application` and `content_feature_flags=19` was persisted. `git restore` the `.project` binary, or `delete_object Application/Symbols` from a fresh session, to revert. From 041d7dc29129b74ab823cb6ea4cc3546823094e3 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:02:09 +0200 Subject: [PATCH 2/5] 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). --- src/tui/browser/Viewer.tsx | 14 +++++---- src/tui/browser/highlight.ts | 58 ++++++++++++++++++++++++++++-------- tests/tui/highlight.test.ts | 35 +++++++++++++++++++++- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index 657d121..91c837c 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Text } from 'ink'; import { POU } from '../shared/types.js'; -import { tokenize, TokenKind } from './highlight.js'; +import { Token, tokenizeLines, TokenKind } from './highlight.js'; const COLORS: Record = { keyword: 'cyan', @@ -11,8 +11,7 @@ const COLORS: Record = { text: undefined, }; -function HighlightedLine({ line }: { line: string }): React.ReactElement { - const tokens = React.useMemo(() => tokenize(line), [line]); +function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement { return ( {tokens.map((t, i) => ( @@ -40,16 +39,19 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac ); } const lines = text.split(/\r?\n/); - const slice = lines.slice(scrollTop, scrollTop + visibleRows); + // Tokenize from line 0 so multi-line (* ... *) state is correct, then + // slice the visible window. Cheap; line count is bounded by file size. + const allTokens = React.useMemo(() => tokenizeLines(lines), [text]); + const sliceTokens = allTokens.slice(scrollTop, scrollTop + visibleRows); return ( {pou.name}.st ({pou.kind}, {pou.loc} L) - {slice.map((l, i) => ( + {sliceTokens.map((tokens, i) => ( {String(scrollTop + i + 1).padStart(4, ' ')} - + ))} diff --git a/src/tui/browser/highlight.ts b/src/tui/browser/highlight.ts index 61dccb6..8333e76 100644 --- a/src/tui/browser/highlight.ts +++ b/src/tui/browser/highlight.ts @@ -42,17 +42,25 @@ const TYPES = new Set([ const IDENT_RE = /[A-Z_][A-Z0-9_]*/; -export function tokenize(line: string): Token[] { +/** + * Tokenize a single line. If `openComment` is true, the line is treated as + * starting inside a (* ... *) block; output includes a `commentLeftOpen` + * flag so callers can continue the state across lines. + */ +export interface TokenizeResult { + tokens: Token[]; + /** True if the line ended without closing a (* block. */ + commentLeftOpen: boolean; +} + +export function tokenizeWithState(line: string, openComment: boolean): TokenizeResult { const out: Token[] = []; let i = 0; + let inComment = openComment; let pending = ''; const flushPending = () => { if (!pending) return; - // Walk pending and split at every uppercase identifier boundary, so - // identifiers (whether keyword/type or plain) come out as their own - // tokens. Anything else (whitespace, punctuation, lowercase ids) is - // emitted as 'text'. const re = /[A-Z_][A-Z0-9_]*/g; let last = 0; let m: RegExpExecArray | null; @@ -68,18 +76,29 @@ export function tokenize(line: string): Token[] { pending = ''; }; + // If line started inside a comment, eat up to "*)" or end-of-line. + if (inComment) { + const end = line.indexOf('*)'); + if (end < 0) { + out.push({ kind: 'comment', text: line }); + return { tokens: out, commentLeftOpen: true }; + } + out.push({ kind: 'comment', text: line.slice(0, end + 2) }); + i = end + 2; + inComment = false; + } + while (i < line.length) { - // (* ... *) inline comment + // (* ... *) — may be inline or open-ended. if (line[i] === '(' && line[i + 1] === '*') { flushPending(); const end = line.indexOf('*)', i + 2); if (end < 0) { out.push({ kind: 'comment', text: line.slice(i) }); - i = line.length; - } else { - out.push({ kind: 'comment', text: line.slice(i, end + 2) }); - i = end + 2; + return { tokens: out, commentLeftOpen: true }; } + out.push({ kind: 'comment', text: line.slice(i, end + 2) }); + i = end + 2; continue; } // // line comment @@ -89,7 +108,7 @@ export function tokenize(line: string): Token[] { i = line.length; continue; } - // 'single' or "double" strings (no escape handling beyond doubled quotes) + // 'single' or "double" strings if (line[i] === "'" || line[i] === '"') { flushPending(); const quote = line[i]; @@ -104,6 +123,21 @@ export function tokenize(line: string): Token[] { i++; } flushPending(); - // Normalize: empty trailing 'text' tokens are fine; we keep them so the line's column structure is preserved. + return { tokens: out, commentLeftOpen: false }; +} + +export function tokenize(line: string): Token[] { + return tokenizeWithState(line, false).tokens; +} + +/** Tokenize a contiguous block of lines, threading (* ... *) state across line boundaries. */ +export function tokenizeLines(lines: string[]): Token[][] { + const out: Token[][] = []; + let openComment = false; + for (const line of lines) { + const r = tokenizeWithState(line, openComment); + out.push(r.tokens); + openComment = r.commentLeftOpen; + } return out; } diff --git a/tests/tui/highlight.test.ts b/tests/tui/highlight.test.ts index b0f7b73..ad6873d 100644 --- a/tests/tui/highlight.test.ts +++ b/tests/tui/highlight.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { tokenize, TokenKind } from '../../src/tui/browser/highlight'; +import { tokenize, tokenizeLines, TokenKind } from '../../src/tui/browser/highlight'; function kinds(line: string): TokenKind[] { return tokenize(line).map((t) => t.kind); @@ -55,3 +55,36 @@ describe('tokenize', () => { expect(texts('s := "hi";')).toContain('"hi"'); }); }); + +describe('tokenizeLines', () => { + it('treats every line of a multi-line (* ... *) block as comment', () => { + const lines = [ + 'x := 1; (* start', + ' middle keep PROGRAM unhighlighted', + ' still in comment', + 'end *) y := 2;', + ]; + const out = tokenizeLines(lines); + // line 0: leading "x := 1; " is text/keyword-free, then "(* start" is comment + expect(out[0].some((t) => t.kind === 'comment' && t.text.includes('(* start'))).toBe(true); + // line 1: ENTIRE line should be a single comment token + expect(out[1]).toEqual([{ kind: 'comment', text: ' middle keep PROGRAM unhighlighted' }]); + // line 2: ENTIRE line is comment + expect(out[2]).toEqual([{ kind: 'comment', text: ' still in comment' }]); + // line 3: starts comment, then `*) y := 2;` is text after close + expect(out[3].some((t) => t.kind === 'comment' && t.text.startsWith('end *)'))).toBe(true); + expect(out[3].some((t) => t.kind === 'text' && t.text.includes('y := 2'))).toBe(true); + }); + + it('handles single-line input identically to tokenize', () => { + const single = 'PROGRAM PLC_PRG'; + expect(tokenizeLines([single])).toEqual([tokenize(single)]); + }); + + it('keeps PROGRAM keyword highlighted on a line with no open block', () => { + const lines = ['PROGRAM A', '(* block *) END_PROGRAM']; + const out = tokenizeLines(lines); + expect(out[0].find((t) => t.text === 'PROGRAM')?.kind).toBe('keyword'); + expect(out[1].find((t) => t.text === 'END_PROGRAM')?.kind).toBe('keyword'); + }); +}); From d327329f1c126c9c543a55a774ecb36ba422adc9 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:04:07 +0200 Subject: [PATCH 3/5] 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. --- src/server.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/server.ts b/src/server.ts index f5142c9..6f60249 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1776,6 +1776,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'ensure_online_connection'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `write-${args.variablePath.replace(/[^A-Za-z0-9._-]+/g, '_')}`, + oldText: `(* live variable write *)\nvariable: ${args.variablePath}\nproject: ${args.projectFilePath}\n`, + newText: `(* live variable write *)\nvariable: ${args.variablePath}\nvalue: ${args.value}\nproject: ${args.projectFilePath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return formatToolResponse( result, @@ -1808,6 +1815,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['register_device_credentials', 'ensure_project_open', 'ensure_online_connection'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `download-${args.projectFilePath.replace(/[^A-Za-z0-9._-]+/g, '_').slice(-40)}`, + oldText: '', + newText: `(* DOWNLOAD TO DEVICE *)\nproject: ${args.projectFilePath}\nWARNING: pushes the compiled application to the live PLC.\n`, + }); + if (blocked) return blocked; // Tool-side timeout = wait window + 120s headroom for the actual download const ipcTimeoutMs = (waitSec + 120) * 1000; const result = await executor.executeScript(script, ipcTimeoutMs); @@ -1832,6 +1846,13 @@ export async function startMcpServer(config: ServerConfig): Promise { }, ['ensure_project_open', 'ensure_online_connection'] ); + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `app-${args.action}`, + oldText: '', + newText: `(* PLC application ${args.action.toUpperCase()} *)\nproject: ${args.projectFilePath}\n`, + }); + if (blocked) return blocked; const result = await executor.executeScript(script); return formatToolResponse( result, @@ -2889,6 +2910,15 @@ export async function startMcpServer(config: ServerConfig): Promise { livenessPort?: number; connectTimeoutMs?: number; }) => { + const host = args.host ?? 'codesys-pi.local'; + const service = args.service ?? 'codesyscontrol'; + const blocked = await gateOpForTool({ + enabled: !!config.approveEdits, + slug: `restart-${host.replace(/[^A-Za-z0-9._-]+/g, '_')}`, + oldText: '', + newText: `(* RESTART RUNTIME via SSH *)\nhost: ${host}\nservice: ${service}\nWARNING: kicks the live PLC runtime; running app stops momentarily.\n`, + }); + if (blocked) return blocked; try { const res = await restartCodesysRuntime(args); const ok = res.restartExitCode === 0 && (res.listening === true || res.listening === null); From b2991c33ea48173203607187548065ef5bc3084a Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:07:06 +0200 Subject: [PATCH 4/5] 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) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 014ff3f..9f96b52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codesys-mcp-sp21-plus", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 4ddae06..ba2480f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codesys-mcp-sp21-plus", - "version": "0.7.0", + "version": "0.7.1", "description": "Codesys-MCP-SP21+ -- fork of luke-harriman/Codesys-MCP carrying CODESYS V3.5 SP22 Patch 1 fixes (and forward-compat with later SPs): script-engine API drift, online/runtime tool auto-login, dual-SHA release classifier, set_pou_code omitted-decl wipe fix, add_library managed-overload, etc. MCP server for CODESYS with persistent UI instance and file-based IPC.", "main": "dist/server.js", "bin": { From 726630563098ea62b0e4e66218c7cf13c41554ba Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:07:22 +0200 Subject: [PATCH 5/5] 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-phobics-tui-v0.3-live-values.md | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-phobics-tui-v0.3-live-values.md diff --git a/docs/superpowers/plans/2026-04-29-phobics-tui-v0.3-live-values.md b/docs/superpowers/plans/2026-04-29-phobics-tui-v0.3-live-values.md new file mode 100644 index 0000000..2183229 --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-phobics-tui-v0.3-live-values.md @@ -0,0 +1,116 @@ +# phobiCS-tui v0.3 — inline live values + +Status: **scoped, ready to implement.** The original blocker (`connect_to_device` script bug) was fixed in the SP21+ fork; see `src/scripts/connect_to_device.py:30-93`. + +## Goal + +When the user is browsing a POU in `phobiCS-tui` and the runtime is online, overlay each declared variable's live value inline next to its declaration in the Viewer: + +``` + 3 counter : INT := 0; ◀ live: 47 + 4 bRunning : BOOL; ◀ live: TRUE +``` + +Updates every 500 ms while the runtime is online. Off when no runtime, no overlay. + +## Architecture (one-way pump) + +The TUI is the user-facing process; the MCP server is the agent-facing process. They already communicate one-way through `tui-state.json` (TUI writes, server reads via `get_user_selection`). + +This adds the **other** direction: + +- **Server** writes a `tui-live-values.json` snapshot next to `tui-state.json`. +- **TUI** Viewer reads it on a 500 ms timer and overlays values for the currently-displayed POU. + +The server pump only runs when: +1. `--live-values` CLI flag is set on the server, AND +2. `tui-state.json` exists and is fresh (60 s window, same as `get_user_selection`), AND +3. The runtime for that POU's project is online. + +## State-file shape + +`tui-live-values.json` (atomic write, same `.tmp` + rename pattern as `tui-state.json`): + +```json +{ + "version": 1, + "updated_at": "2026-04-29T08:14:55.123Z", + "project_dir": "/abs/project/dir", + "device": "CodesysRpi", + "pou_name": "PLC_PRG", + "values": { + "counter": { "value": "47", "type": "INT", "ts": 1745916895100 }, + "bRunning": { "value": "TRUE", "type": "BOOL", "ts": 1745916895100 } + } +} +``` + +`values` is keyed by the bare variable name (no `PLC_PRG.` prefix). The TUI matches by exact string against tokens emitted by the highlighter. + +## Tasks + +### Task 1: shared types + path helper + +- Create `src/tui/shared/live-values.ts`: `LiveValuesPayload` interface. +- Add `liveValuesFilePath()` to `src/tui/shared/state-paths.ts` — same dir as `stateFilePath()`, filename `tui-live-values.json`. +- Tests: 4 cases mirroring the `state-paths` tests. + +### Task 2: TUI atomic reader + +- Create `src/tui/shared/live-values-read.ts`: `readLiveValues(filePath)` returns `{ status: 'ok' | 'missing' | 'stale' | 'invalid' }` with same `FRESHNESS_MS = 5_000` (tighter than the selection file — values stop being interesting fast). +- Tests: 4 cases (fresh / missing / stale / malformed). + +### Task 3: Viewer overlay + +- Add `liveValues?: Record` prop to ``. +- Walk the highlighted token rows; if a row contains a `text` token whose trimmed text is a key in `liveValues`, append ` ◀ live: ` (in green) at end of row. +- Tests: 3 cases — no liveValues prop = no overlay; with prop = overlay appears for matching var; non-matching vars unchanged. + +### Task 4: TUI live-values hook + Browser wiring + +- `useLiveValues(filePath, pouName)` hook in `src/tui/browser/useLiveValues.ts`: polls every 500 ms, returns `Record | null`. Returns `null` when payload is missing/stale or `pou_name` doesn't match the requested `pouName`. +- Browser passes `liveValues={...}` to `` when cursor is on a POU. +- Tests: 2 cases — match returns map; mismatch returns null. + +### Task 5: server-side reader for live-values write + +- Create `src/live-values-write.ts`: `writeLiveValues(filePath, payload)` mirrors `state-write.ts` (atomic `.tmp` + rename). +- Tests: 3 cases (envelope, parent dir auto-create, no .tmp residue). + +### Task 6: server-side pump + +- Create `src/live-values-pump.ts`: + - `class LiveValuesPump` — owns a `setInterval` (default 500 ms). + - On each tick: read `tui-state.json` via existing `readSelection`. If status != ok or selection is older than freshness, skip. + - Read the POU's declaration from the mirror (already exposed via `mcp-mirror//.../.st`). + - Parse the `VAR ... END_VAR` block to get var names. Use the existing parsing helpers if any; otherwise a minimal regex `\b([A-Za-z_]\w*)\s*:` is fine (skip lines starting with `(*` or `//`). + - For each var, call into the existing `read_variable` script via `executor.executeScript()`. Collect `{ name -> value }`. + - `writeLiveValues(...)` the payload. + - Errors are silent (debug-log only) — pump must never crash. +- `start()` / `stop()` lifecycle. +- Tests: parse VAR block from canned input; pump tick writes file with correct structure (mock executor + fs). + +### Task 7: server-side wiring + +- `ServerConfig.liveValues?: boolean` in `src/types.ts`. +- `bin.ts`: `--live-values` flag, plumb into config, banner `Live values: ENABLED (poll 500ms)`. +- In `startMcpServer`: if `config.liveValues`, instantiate `LiveValuesPump` and `start()` it; `stop()` in shutdown handler. +- Tests: pump starts/stops in a unit test using a fake interval. + +### Task 8: README + commit + +- Document `--live-values` and the inline-value display in the `## phobiCS-tui` section. +- Commit + open PR. + +## Out of scope for v0.3 + +- Sub-property values (`PLC_PRG.fbX.bSomething`) — only top-level vars on the displayed POU. +- Write-from-TUI (changing values from the keyboard). Stays in `write_variable` MCP tool. +- Custom poll interval CLI flag — fixed at 500 ms in v0.3. +- ARRAY / STRUCT pretty-printing — show whatever `read_variable` returns as a string. + +## Risk / known unknowns + +- `read_variable` per var is N round-trips per tick. 500 ms × 10 vars = 50 ms/var headroom. Existing `read_variable` script might be slow enough to make this infeasible for large POUs. If so: switch to a single-script-per-tick that reads N vars in one CODESYS call. +- The CODESYS UI may pop a credential dialog mid-pump. Pump must catch and back off (longer interval after first failure, e.g. 5 s). +- TUI 500 ms file poll on Windows may cause noticeable disk I/O. Mitigate: only poll while `pou_name` is set (i.e., user is on a POU row).