From 8aa2553996c895f658d8f01d5562647e39833f78 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Wed, 29 Apr 2026 09:58:04 +0200 Subject: [PATCH] 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: ' 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. --- src/bin.ts | 5 +++++ src/server.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++---- src/types.ts | 7 +++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 5381934..a71bdf1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -48,6 +48,7 @@ program .option('--keep-alive', 'Keep CODESYS running after server stops', false) .option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching /mcp-mirror/ sees changes live', false) .option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false) + .option('--live-values', 'Pump runtime values for the selected POU into tui-live-values.json so phobiCS-tui can overlay them inline (500ms poll). Requires the runtime to be online; failures are silent.', false) .option('--timeout ', 'Default command timeout in ms', '60000') .option('--verbose', 'Enable verbose logging') .option('--debug', 'Enable debug logging (more verbose)') @@ -199,6 +200,7 @@ if (opts.sshVersion) { mode: (opts.mode === 'headless' ? 'headless' : 'persistent') as ExecutionMode, autoMirror: opts.autoMirror || false, approveEdits: opts.approveEdits || false, + liveValues: opts.liveValues || false, }; process.stderr.write(`Starting CODESYS MCP Server v${version}\n`); @@ -212,6 +214,9 @@ if (opts.sshVersion) { if (config.approveEdits) { process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`); } + if (config.liveValues) { + process.stderr.write(` Live values: ENABLED (poll 500ms; writes tui-live-values.json)\n`); + } startMcpServer(config).catch((err) => { process.stderr.write(`FATAL: ${err.message}\n`); diff --git a/src/server.ts b/src/server.ts index 6f60249..79a37e2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,6 +26,8 @@ import { inspectProjectFile } from './inspect'; import { parseProfileName } from './detect'; import { decideOpenProjectPreflight } from './preflight'; import { readSelection } from './state-read'; +import { writeLiveValues } from './live-values-write'; +import { LiveValuesPump } from './live-values-pump'; import { runApproveGate, gateOpForTool } from './approve-gate'; /** @@ -707,17 +709,25 @@ export async function buildGetUserSelectionResponse(stateFilePath: string) { }; } -function defaultStateFilePath(): string { +function defaultStateDir(): string { if (process.platform === 'win32') { const localAppData = process.env.LOCALAPPDATA; if (!localAppData) { - return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json'); + return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp'); } - return path.join(localAppData, 'codesys-mcp', 'tui-state.json'); + return path.join(localAppData, 'codesys-mcp'); } const xdg = process.env.XDG_STATE_HOME; const base = xdg ?? path.join(os.homedir(), '.local', 'state'); - return path.join(base, 'codesys-mcp', 'tui-state.json'); + return path.join(base, 'codesys-mcp'); +} + +function defaultStateFilePath(): string { + return path.join(defaultStateDir(), 'tui-state.json'); +} + +function defaultLiveValuesFilePath(): string { + return path.join(defaultStateDir(), 'tui-live-values.json'); } export async function startMcpServer(config: ServerConfig): Promise { @@ -728,6 +738,7 @@ export async function startMcpServer(config: ServerConfig): Promise { serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`); serverLog.info(`Mode: ${config.mode}`); serverLog.info(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`); + serverLog.info(`Live values: ${config.liveValues ? 'ON' : 'off'}`); serverLog.info(`CODESYS Path: ${config.codesysPath}`); serverLog.info(`Profile: ${config.profileName}`); serverLog.info(`Workspace: ${config.workspaceDir}`); @@ -3140,10 +3151,49 @@ export async function startMcpServer(config: ServerConfig): Promise { server.connect(transport); serverLog.info('MCP Server connected and listening.'); + // ─── Live values pump (opt-in) ─────────────────────────────────────── + + let liveValuesPump: LiveValuesPump | null = null; + if (config.liveValues) { + liveValuesPump = new LiveValuesPump( + { + stateFilePath: defaultStateFilePath(), + liveValuesFilePath: defaultLiveValuesFilePath(), + intervalMs: 500, + }, + { + readSelection, + readPouFile: (absPath) => fs.promises.readFile(absPath, 'utf8'), + readVariable: async (projectFilePath, variablePath) => { + // Reuse the existing read_variable script. Returns the value as + // a string captured from script stdout. Errors throw. + const script = scriptManager.prepareScriptWithHelpers( + 'read_variable', + { PROJECT_FILE_PATH: projectFilePath, VARIABLE_PATH: variablePath }, + ['ensure_project_open', 'ensure_online_connection'] + ); + const result = await executor.executeScript(script); + if (!result.success || !result.output.includes('SCRIPT_SUCCESS')) { + throw new Error(result.error || 'read_variable failed'); + } + // The script prints `Value: ` on the success line. + const m = /Value:\s*(.*)$/m.exec(result.output); + return m ? m[1].trim() : ''; + }, + writeLiveValues, + } + ); + liveValuesPump.start(); + serverLog.info('Live-values pump started (500ms)'); + } + // ─── Graceful Shutdown ─────────────────────────────────────────────── const shutdown = async () => { serverLog.info('Shutdown signal received'); + if (liveValuesPump) { + liveValuesPump.stop(); + } if (launcher) { try { await launcher.shutdown(); diff --git a/src/types.ts b/src/types.ts index a7506b9..f7fec8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,13 @@ export interface ServerConfig extends LauncherConfig { * so existing scripted flows are not regressed. */ approveEdits?: boolean; + /** + * If true, run a background pump that reads runtime values for the + * variables of the user's currently-selected POU and writes them to + * tui-live-values.json so the TUI Viewer can overlay them inline. + * Off by default. 500 ms interval (fixed in v0.3). + */ + liveValues?: boolean; } /** Script template parameters */