agentic-numerical-control 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edward Coventry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,44 @@
1
- # Temporary Holding Version
1
+ # Agentic Numerical Control
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ See and run CNC jobs from Claude: a 3D toolpath viewer inside the chat, live machine status and job control, for CNCjs/GRBL machines. Claude on the web, desktop and phone.
4
+
5
+ The product name, npm package name and cloud address live in [product.json](product.json); renaming is a change there.
6
+
7
+ ## How it fits together
8
+
9
+ ```
10
+ Claude ──MCP+OAuth──▶ cloud/ (Cloudflare Worker) ◀──outbound WebSocket── PC app (npm package) ──▶ CNCjs ──USB──▶ GRBL
11
+ ```
12
+
13
+ - **cloud/**: the address Claude connects to. Sign-in through Neon Auth, OAuth for Claude, PC pairing by code, and a per-user relay (Durable Object). Also serves the product page and the one-line installers. Deployed by hand: `cd cloud && npm run deploy`.
14
+ - **PC app** (repo root, published to npm): runs beside the machine, bundles CNCjs, serves the MCP tools and the viewer, and keeps an outbound connection to the cloud. Nothing listens on the network.
15
+ - **sim/**: grblHAL simulator exposed to CNCjs as a GRBL-SIM serial port, for testing without a machine (development only, not shipped).
16
+
17
+ ## For users
18
+
19
+ ```powershell
20
+ irm https://agenticnumericalcontrol.netlify.app/install.ps1 | iex # Windows
21
+ ```
22
+ ```bash
23
+ curl -fsSL https://agenticnumericalcontrol.netlify.app/install.sh | sh # macOS / Linux
24
+ ```
25
+
26
+ The installer puts a private Node.js and the app in the user's app-data folder (no admin), links the PC to the account with a code, and starts it at every login. G-code goes in `Documents/Agentic Numerical Control`. The app updates itself from npm when it starts.
27
+
28
+ CLI: `anc setup | start | status | unlink | stdio | serve`.
29
+
30
+ ## Development
31
+
32
+ ```powershell
33
+ npm install
34
+ npm run build # viewer + compiled app in dist/
35
+ npm run dev # build and run the app against the cloud in product.json
36
+ npm run dev:serve # local MCP endpoint only, http://127.0.0.1:3311/mcp/<token>
37
+ npm run sim:build # build the simulator (WSL on Windows)
38
+ npm run sim:e2e # connect GRBL-SIM, load the demo job, start, pause, stop
39
+ npm pack # what would be published (dist, product.json, demo job, sim hook)
40
+ ```
41
+
42
+ State (local token, bundled CNCjs config, device link, log) is in `%LOCALAPPDATA%\AgenticNumericalControl`, `~/Library/Application Support/AgenticNumericalControl` or `~/.local/share/agentic-numerical-control`.
43
+
44
+ Design notes and decisions: [docs/design-notes.md](docs/design-notes.md).
@@ -0,0 +1,85 @@
1
+ // Start the app when the user logs in, without admin rights: the per-user Run
2
+ // key on Windows, a launchd agent on macOS, a systemd user service on Linux.
3
+ // Each runs `<cli> start --log`.
4
+ import { execFileSync, spawn } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { STATE_DIR } from "./paths.js";
9
+ import { PRODUCT } from "./product.js";
10
+ const LABEL = `com.${PRODUCT.name}.agent`;
11
+ const RUN_KEY = String.raw `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`;
12
+ const LAUNCHER = path.join(STATE_DIR, "start.cmd");
13
+ function quiet(file, args) {
14
+ try {
15
+ execFileSync(file, args, { stdio: "ignore" });
16
+ }
17
+ catch {
18
+ // nothing to remove / not loaded
19
+ }
20
+ }
21
+ function command(cliPath) {
22
+ return [process.execPath, cliPath, "start", "--log"];
23
+ }
24
+ export function enableAutostart(cliPath) {
25
+ const cmd = command(cliPath);
26
+ if (process.platform === "win32") {
27
+ // A short launcher keeps the Run entry well under its length limit;
28
+ // conhost --headless runs it without a visible window. Logon-triggered
29
+ // scheduled tasks would need admin rights, the Run key does not.
30
+ fs.mkdirSync(STATE_DIR, { recursive: true });
31
+ fs.writeFileSync(LAUNCHER, `@${cmd.map((c) => `"${c}"`).join(" ")}\r\n`);
32
+ execFileSync("reg", ["add", RUN_KEY, "/v", PRODUCT.displayName, "/t", "REG_SZ", "/d", `conhost.exe --headless "${LAUNCHER}"`, "/f"], {
33
+ stdio: "ignore",
34
+ });
35
+ spawn("conhost.exe", ["--headless", LAUNCHER], { detached: true, stdio: "ignore", windowsHide: true }).unref();
36
+ return "Windows sign-in startup entry (no admin needed)";
37
+ }
38
+ if (process.platform === "darwin") {
39
+ const plist = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
40
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
41
+ fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
42
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
43
+ <plist version="1.0"><dict>
44
+ <key>Label</key><string>${LABEL}</string>
45
+ <key>ProgramArguments</key><array>${cmd.map((c) => `<string>${c}</string>`).join("")}</array>
46
+ <key>RunAtLoad</key><true/>
47
+ <key>KeepAlive</key><true/>
48
+ </dict></plist>
49
+ `);
50
+ quiet("launchctl", ["unload", plist]);
51
+ execFileSync("launchctl", ["load", "-w", plist], { stdio: "ignore" });
52
+ return `launchd agent ${plist}`;
53
+ }
54
+ const unit = path.join(os.homedir(), ".config", "systemd", "user", `${PRODUCT.name}.service`);
55
+ fs.mkdirSync(path.dirname(unit), { recursive: true });
56
+ fs.writeFileSync(unit, `[Unit]
57
+ Description=${PRODUCT.displayName} agent
58
+
59
+ [Service]
60
+ ExecStart=${cmd.map((c) => `"${c}"`).join(" ")}
61
+ Restart=always
62
+ RestartSec=5
63
+
64
+ [Install]
65
+ WantedBy=default.target
66
+ `);
67
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
68
+ execFileSync("systemctl", ["--user", "enable", "--now", `${PRODUCT.name}.service`], { stdio: "ignore" });
69
+ return `systemd user service ${PRODUCT.name}.service`;
70
+ }
71
+ export function disableAutostart() {
72
+ if (process.platform === "win32") {
73
+ quiet("reg", ["delete", RUN_KEY, "/v", PRODUCT.displayName, "/f"]);
74
+ fs.rmSync(LAUNCHER, { force: true });
75
+ }
76
+ else if (process.platform === "darwin") {
77
+ const plist = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
78
+ quiet("launchctl", ["unload", plist]);
79
+ fs.rmSync(plist, { force: true });
80
+ }
81
+ else {
82
+ quiet("systemctl", ["--user", "disable", "--now", `${PRODUCT.name}.service`]);
83
+ fs.rmSync(path.join(os.homedir(), ".config", "systemd", "user", `${PRODUCT.name}.service`), { force: true });
84
+ }
85
+ }
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ // Command-line entry point.
3
+ //
4
+ // <name> setup link this PC to your account, start now and at every login
5
+ // <name> start run in the foreground (what the login task runs)
6
+ // <name> status show the link, jobs folder and version
7
+ // <name> unlink forget this PC's link and stop starting at login
8
+ // <name> stdio local MCP over stdio (Claude Desktop / Claude Code config)
9
+ // <name> serve local MCP over HTTP at /mcp/<token> (development)
10
+ import { createMcpExpressApp } from "@modelcontextprotocol/express";
11
+ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
13
+ import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { disableAutostart, enableAutostart } from "./autostart.js";
18
+ import { cncjs } from "./cncjs.js";
19
+ import { STATE_DIR } from "./paths.js";
20
+ import { JOBS_DIR, PRODUCT, VERSION } from "./product.js";
21
+ import { DEVICE_FILE, pair, readLink, runRelay } from "./relay.js";
22
+ import { createServer } from "./server.js";
23
+ import { updateIfAvailable } from "./update.js";
24
+ const TOKEN_FILE = path.join(STATE_DIR, "token");
25
+ const LOG_FILE = path.join(STATE_DIR, "agent.log");
26
+ const PID_FILE = path.join(STATE_DIR, "agent.pid");
27
+ /** Stops a background copy started at login or by an earlier setup, if running. */
28
+ function stopRunning() {
29
+ try {
30
+ const pid = Number(fs.readFileSync(PID_FILE, "utf-8"));
31
+ if (pid && pid !== process.pid)
32
+ process.kill(pid);
33
+ }
34
+ catch {
35
+ // not running
36
+ }
37
+ fs.rmSync(PID_FILE, { force: true });
38
+ }
39
+ const CLI_PATH = fileURLToPath(import.meta.url);
40
+ /**
41
+ * The local HTTP endpoint is served only at /mcp/<token>, so other programs
42
+ * on this computer cannot drive the machine through it. The relay knows the
43
+ * token; nothing outside this computer ever sees the endpoint.
44
+ */
45
+ function accessToken() {
46
+ if (process.env.ANC_TOKEN)
47
+ return process.env.ANC_TOKEN;
48
+ if (fs.existsSync(TOKEN_FILE))
49
+ return fs.readFileSync(TOKEN_FILE, "utf-8").trim();
50
+ const token = randomBytes(24).toString("base64url");
51
+ fs.mkdirSync(STATE_DIR, { recursive: true });
52
+ fs.writeFileSync(TOKEN_FILE, token + "\n", { mode: 0o600 });
53
+ return token;
54
+ }
55
+ function tokenMatches(candidate, token) {
56
+ const a = Buffer.from(candidate);
57
+ const b = Buffer.from(token);
58
+ return a.length === b.length && timingSafeEqual(a, b);
59
+ }
60
+ /** Starts the loopback MCP endpoint and resolves with its URL. */
61
+ function startHttp() {
62
+ const port = parseInt(process.env.PORT ?? "3311", 10);
63
+ const token = accessToken();
64
+ const app = createMcpExpressApp({ host: "127.0.0.1" });
65
+ app.all("/mcp/:token", async (req, res) => {
66
+ if (!tokenMatches(String(req.params.token), token)) {
67
+ res.status(404).end();
68
+ return;
69
+ }
70
+ const server = createServer();
71
+ const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
72
+ res.on("close", () => {
73
+ transport.close().catch(() => { });
74
+ server.close().catch(() => { });
75
+ });
76
+ try {
77
+ await server.connect(transport);
78
+ await transport.handleRequest(req, res, req.body);
79
+ }
80
+ catch (error) {
81
+ console.error("MCP error:", error);
82
+ if (!res.headersSent) {
83
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
84
+ }
85
+ }
86
+ });
87
+ return new Promise((resolve) => {
88
+ app.listen(port, "127.0.0.1", (err) => {
89
+ if (err) {
90
+ const inUse = err.code === "EADDRINUSE";
91
+ console.error(inUse
92
+ ? `${PRODUCT.displayName} is already running on this computer (port ${port} is taken).`
93
+ : `${PRODUCT.displayName} could not listen on port ${port}: ${err.message}`);
94
+ process.exit(1);
95
+ }
96
+ resolve(`http://127.0.0.1:${port}/mcp/${token}`);
97
+ });
98
+ });
99
+ }
100
+ function logToFile() {
101
+ fs.mkdirSync(STATE_DIR, { recursive: true });
102
+ const stream = fs.createWriteStream(LOG_FILE, { flags: "a" });
103
+ const write = (chunk) => stream.write(`${new Date().toISOString()} ${String(chunk)}`);
104
+ process.stdout.write = ((chunk) => write(chunk));
105
+ process.stderr.write = ((chunk) => write(chunk));
106
+ }
107
+ function linkCncjs() {
108
+ cncjs.ensure().catch((e) => console.error(`[cncjs] ${e.message}`));
109
+ }
110
+ const [command = "start", ...rest] = process.argv.slice(2);
111
+ switch (command) {
112
+ case "setup": {
113
+ console.log(`\n ${PRODUCT.displayName} ${VERSION}`);
114
+ const link = readLink();
115
+ if (link?.cloudUrl === PRODUCT.cloudUrl)
116
+ console.log(" This PC is already linked.");
117
+ else
118
+ await pair(PRODUCT.cloudUrl);
119
+ stopRunning();
120
+ const how = enableAutostart(CLI_PATH);
121
+ console.log(` Running now, and at every login (${how}).`);
122
+ console.log(` Put G-code files in: ${JOBS_DIR}`);
123
+ console.log(` Next: add the connector in Claude: ${PRODUCT.cloudUrl}\n`);
124
+ break;
125
+ }
126
+ case "start": {
127
+ if (rest.includes("--log"))
128
+ logToFile();
129
+ fs.mkdirSync(STATE_DIR, { recursive: true });
130
+ fs.writeFileSync(PID_FILE, String(process.pid));
131
+ if (await updateIfAvailable())
132
+ process.exit(0);
133
+ linkCncjs();
134
+ const cloudUrl = rest.find((a) => a.startsWith("http")) ?? PRODUCT.cloudUrl;
135
+ await runRelay(cloudUrl, await startHttp());
136
+ break;
137
+ }
138
+ case "status": {
139
+ const link = readLink();
140
+ console.log(`${PRODUCT.displayName} ${VERSION}`);
141
+ console.log(` Linked: ${link ? `yes, as "${link.name}" to ${link.cloudUrl}` : "no (run setup)"}`);
142
+ console.log(` Jobs folder: ${JOBS_DIR}`);
143
+ console.log(` Data: ${STATE_DIR}`);
144
+ console.log(` Log: ${LOG_FILE}`);
145
+ break;
146
+ }
147
+ case "unlink": {
148
+ disableAutostart();
149
+ stopRunning();
150
+ fs.rmSync(DEVICE_FILE, { force: true });
151
+ console.log(`Unlinked this PC and stopped ${PRODUCT.displayName} starting at login.`);
152
+ break;
153
+ }
154
+ case "stdio": {
155
+ linkCncjs();
156
+ await createServer().connect(new StdioServerTransport());
157
+ break;
158
+ }
159
+ case "serve": {
160
+ linkCncjs();
161
+ const url = await startHttp();
162
+ console.log(`MCP endpoint: ${url.replace(/\/mcp\/.+$/, "/mcp/<token>")} (token in ${TOKEN_FILE})`);
163
+ break;
164
+ }
165
+ case "--version":
166
+ case "version":
167
+ console.log(VERSION);
168
+ break;
169
+ default:
170
+ console.log(`Usage: ${PRODUCT.name} [setup | start | status | unlink | stdio | serve | version]`);
171
+ process.exit(command === "help" || command === "--help" ? 0 : 1);
172
+ }
@@ -0,0 +1,294 @@
1
+ // Link to CNCjs over its socket.io API, with a cached view of controller
2
+ // state. Agentic Numerical Control uses an existing CNCjs at CNCJS_URL when one answers there;
3
+ // otherwise it starts the CNCjs bundled in node_modules on that address,
4
+ // with its own config file in .anc/.
5
+ import { spawn } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { createRequire } from "node:module";
10
+ import jwt from "jsonwebtoken";
11
+ import io from "socket.io-client";
12
+ const require = createRequire(import.meta.url);
13
+ export const CNCJS_URL = process.env.CNCJS_URL ?? "http://127.0.0.1:8000";
14
+ import { STATE_DIR } from "./paths.js";
15
+ import { ROOT } from "./product.js";
16
+ const MANAGED_CONFIG = path.join(STATE_DIR, "cncrc");
17
+ const USER_CONFIG = process.env.CNCJS_CONFIG ?? path.join(os.homedir(), ".cncrc");
18
+ /** Telemetry older than this is shown as stale rather than live. */
19
+ const STALE_MS = 3000;
20
+ /** Wait this long after a failed connect before trying again. */
21
+ const RETRY_MS = 15000;
22
+ const MOVING_STATES = new Set(["Run", "Jog", "Home"]);
23
+ class CNCjsLink {
24
+ socket = null;
25
+ child = null;
26
+ managed = false;
27
+ connected = false;
28
+ error = null;
29
+ starting = null;
30
+ lastFailure = 0;
31
+ port = null;
32
+ controller = null;
33
+ status = null;
34
+ parserState = null;
35
+ workflow = "idle";
36
+ sender = null;
37
+ alarm = null;
38
+ gcodeName = null;
39
+ telemetryAt = 0;
40
+ ports = [];
41
+ onLoadListeners = [];
42
+ onGcodeLoad(fn) {
43
+ this.onLoadListeners.push(fn);
44
+ }
45
+ /** Connects (starting a bundled CNCjs if needed). Safe to call repeatedly. */
46
+ ensure() {
47
+ if (this.connected)
48
+ return Promise.resolve();
49
+ if (!this.starting && Date.now() - this.lastFailure < RETRY_MS) {
50
+ return Promise.reject(new Error(this.error ?? "CNCjs not connected"));
51
+ }
52
+ this.starting ??= this.start().finally(() => {
53
+ this.starting = null;
54
+ });
55
+ return this.starting;
56
+ }
57
+ async start() {
58
+ try {
59
+ // An already-running CNCjs is either the user's (~/.cncrc) or one Agentic Numerical Control
60
+ // started earlier and outlived (.anc/cncrc).
61
+ let configFile = this.managed || (!fs.existsSync(USER_CONFIG) && fs.existsSync(MANAGED_CONFIG))
62
+ ? MANAGED_CONFIG
63
+ : USER_CONFIG;
64
+ if (!(await reachable(CNCJS_URL))) {
65
+ await this.launchBundled();
66
+ configFile = MANAGED_CONFIG;
67
+ }
68
+ await this.connect(accessToken(configFile));
69
+ this.error = null;
70
+ }
71
+ catch (e) {
72
+ this.error = e.message;
73
+ this.lastFailure = Date.now();
74
+ throw e;
75
+ }
76
+ }
77
+ async launchBundled() {
78
+ const url = new URL(CNCJS_URL);
79
+ if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
80
+ throw new Error(`No CNCjs answered at ${CNCJS_URL}, and Agentic Numerical Control only starts its own CNCjs on this computer.`);
81
+ }
82
+ fs.mkdirSync(STATE_DIR, { recursive: true });
83
+ const bin = require.resolve("cncjs/bin/cncjs");
84
+ const port = url.port || "8000";
85
+ console.error(`[anc] Starting bundled CNCjs on 127.0.0.1:${port} (config ${MANAGED_CONFIG})`);
86
+ // The preload adds the GRBL-SIM port (sim/) alongside real serial ports.
87
+ const simHook = path.join(ROOT, "sim", "virtual-port.cjs");
88
+ this.child = spawn(process.execPath, ["--require", simHook, bin, "-H", "127.0.0.1", "-p", port, "-c", MANAGED_CONFIG], {
89
+ stdio: ["ignore", "ignore", "pipe"],
90
+ windowsHide: true,
91
+ });
92
+ this.child.stderr?.on("data", (d) => process.stderr.write(`[cncjs] ${d}`));
93
+ this.child.on("exit", (code) => {
94
+ console.error(`[anc] Bundled CNCjs exited (${code})`);
95
+ this.child = null;
96
+ });
97
+ this.managed = true;
98
+ process.once("exit", () => this.child?.kill());
99
+ for (const sig of ["SIGINT", "SIGTERM"]) {
100
+ process.once(sig, () => {
101
+ this.child?.kill();
102
+ process.exit(0);
103
+ });
104
+ }
105
+ for (let i = 0; i < 60; i++) {
106
+ if (await reachable(CNCJS_URL))
107
+ return;
108
+ await sleep(500);
109
+ }
110
+ throw new Error("The bundled CNCjs did not start within 30 seconds.");
111
+ }
112
+ connect(token) {
113
+ return new Promise((resolve, reject) => {
114
+ const socket = io(CNCJS_URL, { query: `token=${token}`, reconnection: true });
115
+ const timer = setTimeout(() => {
116
+ socket.close();
117
+ reject(new Error(`Timed out connecting to CNCjs at ${CNCJS_URL}`));
118
+ }, 10000);
119
+ socket.on("connect", () => {
120
+ clearTimeout(timer);
121
+ this.connected = true;
122
+ this.socket = socket;
123
+ resolve();
124
+ });
125
+ socket.on("error", (err) => {
126
+ clearTimeout(timer);
127
+ this.error = `CNCjs refused the connection: ${typeof err === "string" ? err : JSON.stringify(err)}`;
128
+ reject(new Error(this.error));
129
+ });
130
+ socket.on("disconnect", () => {
131
+ this.connected = false;
132
+ });
133
+ this.listen(socket);
134
+ });
135
+ }
136
+ listen(socket) {
137
+ socket.on("serialport:list", (ports) => {
138
+ this.ports = ports ?? [];
139
+ });
140
+ socket.on("serialport:open", (o) => {
141
+ this.port = o?.port ?? null;
142
+ this.controller = o?.controllerType ?? null;
143
+ });
144
+ socket.on("serialport:close", () => {
145
+ this.port = null;
146
+ this.controller = null;
147
+ this.status = null;
148
+ this.sender = null;
149
+ this.workflow = "idle";
150
+ });
151
+ socket.on("controller:state", (type, state) => {
152
+ this.controller = type;
153
+ this.status = state?.status ?? this.status;
154
+ this.parserState = state?.parserstate ?? this.parserState;
155
+ // CNCjs also emits its default (empty) state on open; only a parsed
156
+ // status report from the controller counts as telemetry.
157
+ if (state?.status?.activeState)
158
+ this.telemetryAt = Date.now();
159
+ if (this.status?.activeState !== "Alarm")
160
+ this.alarm = null;
161
+ });
162
+ socket.on("serialport:read", (data) => {
163
+ const m = /ALARM:(\d+)(.*)/.exec(data ?? "");
164
+ if (m)
165
+ this.alarm = `ALARM:${m[1]}${m[2]}`.trim();
166
+ });
167
+ socket.on("workflow:state", (s) => {
168
+ this.workflow = s;
169
+ });
170
+ socket.on("sender:status", (s) => {
171
+ this.sender = s;
172
+ });
173
+ socket.on("gcode:load", (name, gcode) => {
174
+ this.gcodeName = name;
175
+ // CNCjs appends its own "%wait" dwell line; drop it from the preview.
176
+ const program = gcode.replace(/\n%wait ; Wait for the planner to empty\s*$/, "");
177
+ for (const fn of this.onLoadListeners)
178
+ fn(name, program);
179
+ });
180
+ socket.on("gcode:unload", () => {
181
+ this.gcodeName = null;
182
+ this.sender = null;
183
+ });
184
+ }
185
+ snapshot() {
186
+ const age = Date.now() - this.telemetryAt;
187
+ // CNCjs emits controller:state only when the state changes, so a quiet
188
+ // stationary machine sends nothing. Silence means stale only while the
189
+ // machine is reported as moving; a lost serial port arrives as a close.
190
+ const moving = MOVING_STATES.has(this.status?.activeState);
191
+ const live = this.connected && this.port !== null && this.telemetryAt > 0 && (!moving || age < STALE_MS);
192
+ const link = !this.connected || !this.port ? "none" : live ? "live" : "stale";
193
+ const state = !this.connected
194
+ ? this.error ?? "CNCjs not connected"
195
+ : !this.port
196
+ ? "CNCjs connected, no machine port open"
197
+ : link === "stale"
198
+ ? this.telemetryAt
199
+ ? `No status for ${Math.round(age / 1000)} s`
200
+ : "Waiting for first status report"
201
+ : this.status?.activeState ?? "Unknown";
202
+ return {
203
+ link,
204
+ state,
205
+ cncjs: { url: CNCJS_URL, managed: this.managed, connected: this.connected, error: this.error },
206
+ port: this.port,
207
+ controller: this.controller,
208
+ activeState: this.status?.activeState ?? null,
209
+ workflow: this.workflow,
210
+ mpos: toVec(this.status?.mpos),
211
+ wpos: toVec(this.status?.wpos),
212
+ wcs: this.parserState?.modal?.wcs ?? null,
213
+ alarm: this.alarm,
214
+ sender: this.sender,
215
+ loadedFile: this.gcodeName,
216
+ telemetryAt: this.telemetryAt ? new Date(this.telemetryAt).toISOString() : null,
217
+ };
218
+ }
219
+ async listPorts() {
220
+ await this.ensure();
221
+ this.socket.emit("list");
222
+ await sleep(800);
223
+ return this.ports;
224
+ }
225
+ async openPort(port, baudrate, controllerType) {
226
+ await this.ensure();
227
+ await new Promise((resolve, reject) => {
228
+ this.socket.emit("open", port, { controllerType, baudrate }, (err) => {
229
+ if (err)
230
+ reject(new Error(`CNCjs could not open ${port}: ${JSON.stringify(err)}`));
231
+ else
232
+ resolve();
233
+ });
234
+ });
235
+ }
236
+ async closePort() {
237
+ await this.ensure();
238
+ const port = this.requirePort();
239
+ this.socket.emit("close", port);
240
+ }
241
+ /** Loads a program into CNCjs's sender. Does not start it. */
242
+ async load(name, gcode) {
243
+ await this.ensure();
244
+ const port = this.requirePort();
245
+ await new Promise((resolve, reject) => {
246
+ this.socket.emit("command", port, "gcode:load", name, gcode, {}, (err) => {
247
+ if (err)
248
+ reject(new Error(`CNCjs rejected the program: ${err.message ?? JSON.stringify(err)}`));
249
+ else
250
+ resolve();
251
+ });
252
+ });
253
+ }
254
+ async command(cmd, ...args) {
255
+ await this.ensure();
256
+ const port = this.requirePort();
257
+ this.socket.emit("command", port, cmd, ...args);
258
+ }
259
+ requirePort() {
260
+ if (!this.port)
261
+ throw new Error("No machine port is open in CNCjs.");
262
+ return this.port;
263
+ }
264
+ }
265
+ function accessToken(configFile) {
266
+ if (process.env.CNCJS_TOKEN)
267
+ return process.env.CNCJS_TOKEN;
268
+ let secret;
269
+ try {
270
+ secret = JSON.parse(fs.readFileSync(configFile, "utf-8")).secret;
271
+ }
272
+ catch {
273
+ throw new Error(`Could not read the CNCjs secret from ${configFile}. Set CNCJS_CONFIG or CNCJS_TOKEN.`);
274
+ }
275
+ if (!secret)
276
+ throw new Error(`${configFile} has no "secret".`);
277
+ return jwt.sign({ id: "", name: "anc" }, secret, { expiresIn: "30d" });
278
+ }
279
+ async function reachable(url) {
280
+ try {
281
+ const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
282
+ return res.status < 500;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ function toVec(v) {
289
+ if (!v)
290
+ return null;
291
+ return { x: Number(v.x), y: Number(v.y), z: Number(v.z) };
292
+ }
293
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
294
+ export const cncjs = new CNCjsLink();