0
0
Fork 0

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.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-29 09:58:04 +02:00
parent c81ce33a4b
commit 8aa2553996
3 changed files with 66 additions and 4 deletions

View file

@ -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 <projectDir>/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 <ms>', '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`);

View file

@ -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<void> {
@ -728,6 +738,7 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
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<void> {
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: <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();

View file

@ -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 */