mindvest-atlas 0.43.1 → 0.44.0
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/package.json +1 -1
- package/src/commands/computer-use.js +62 -0
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.py +130 -0
package/package.json
CHANGED
|
@@ -105,6 +105,55 @@ export function shellSession(root) {
|
|
|
105
105
|
return made;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Ask the session's live process to do it. null when there is none.
|
|
110
|
+
*
|
|
111
|
+
* THE FAST PATH, and never the only one. Spawning python per action pays for a
|
|
112
|
+
* start and pyautogui's imports every time, which on Windows is most of a
|
|
113
|
+
* second for work that takes twenty milliseconds. One process stays up for the
|
|
114
|
+
* session and this talks to it.
|
|
115
|
+
*
|
|
116
|
+
* Nothing here throws: an unreachable server is an absence, not an error, and
|
|
117
|
+
* every caller falls back to spawning. A machine where this cannot run is
|
|
118
|
+
* slower and never broken.
|
|
119
|
+
*/
|
|
120
|
+
export function askServer(job, op, args, root) {
|
|
121
|
+
const { dir } = workspace(job || '', root);
|
|
122
|
+
const session = process.env.ATLAS_COMPUTER_SESSION || shellSession(root);
|
|
123
|
+
const note = path.join(dir, '.computer-use', session, 'serve.json');
|
|
124
|
+
let port;
|
|
125
|
+
let token;
|
|
126
|
+
try {
|
|
127
|
+
const n = JSON.parse(fs.readFileSync(note, 'utf8'));
|
|
128
|
+
port = Number(n.port);
|
|
129
|
+
token = String(n.token);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
// A synchronous ask, because this command has nothing else to do until the
|
|
134
|
+
// answer comes and the caller is a plain exit code.
|
|
135
|
+
const body = JSON.stringify({ token, op, args: args || [], kwargs: {} });
|
|
136
|
+
const out = spawnSync(process.execPath, ['-e', `
|
|
137
|
+
const net = require('node:net');
|
|
138
|
+
const s = net.connect(${port}, '127.0.0.1');
|
|
139
|
+
let got = '';
|
|
140
|
+
s.setTimeout(120000, () => { s.destroy(); process.exit(3); });
|
|
141
|
+
s.on('connect', () => s.write(process.argv[1] + '\n'));
|
|
142
|
+
s.on('data', (d) => { got += d; if (got.includes('\n')) { process.stdout.write(got); s.end(); } });
|
|
143
|
+
s.on('error', () => process.exit(3));
|
|
144
|
+
`, body], { encoding: 'utf8' });
|
|
145
|
+
if (out.status !== 0 || !out.stdout) {
|
|
146
|
+
// A note pointing at a port nobody answers is what a crash leaves behind.
|
|
147
|
+
try { fs.rmSync(note, { force: true }); } catch { /* it may be gone */ }
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(out.stdout.trim().split('\n').pop());
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
108
157
|
/** Run one atlas_env call inside the project, and hand back what it said. */
|
|
109
158
|
export function runInProject(job, code, root) {
|
|
110
159
|
const { dir } = workspace(job, root);
|
|
@@ -161,6 +210,9 @@ export function computerUseCommand(args) {
|
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
if (sub === 'stop') {
|
|
213
|
+
// The live process goes with the computer: leaving it up would hold
|
|
214
|
+
// pyautogui and a port for a session that is over.
|
|
215
|
+
askServer(job, '_bye');
|
|
164
216
|
// Given back by NAME, so one shell cannot release another's hold.
|
|
165
217
|
const released = releaseComputerClaim(shellSession());
|
|
166
218
|
println(color.dim(released ? 'Gave the computer back.' : 'It was not yours to give back.'));
|
|
@@ -213,6 +265,16 @@ export function computerUseCommand(args) {
|
|
|
213
265
|
return 1;
|
|
214
266
|
}
|
|
215
267
|
const values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
|
|
268
|
+
// THE LIVE PROCESS FIRST: same function, same rules, without a python start.
|
|
269
|
+
const answer = askServer(job, name, values);
|
|
270
|
+
if (answer) {
|
|
271
|
+
if (answer.ok) {
|
|
272
|
+
println(JSON.stringify(answer.out, null, 2));
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
eprintln(color.red(String(answer.error || 'That did not work.')));
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
216
278
|
// JSON rather than pasting: the text may hold quotes, and a snippet built by
|
|
217
279
|
// pasting is a snippet that breaks on somebody's password.
|
|
218
280
|
const code = 'import atlas_env as e, json\n'
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.44.0';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
7
|
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
8
|
export const CLIENT_NAME = 'Atlas CLI';
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -1210,6 +1210,136 @@ def screen(path=None):
|
|
|
1210
1210
|
% (m["x"], m["y"])}
|
|
1211
1211
|
|
|
1212
1212
|
|
|
1213
|
+
# ── STAYING ALIVE BETWEEN COMMANDS ──────────────────────────────────────────
|
|
1214
|
+
#
|
|
1215
|
+
# WHERE THE TIME ACTUALLY WENT. Working the computer from a shell is one process
|
|
1216
|
+
# per action, and the process is the expensive part: python starts, imports
|
|
1217
|
+
# pyautogui (which pulls in Pillow, pyscreeze and mouseinfo), does something
|
|
1218
|
+
# that takes twenty milliseconds, and exits. On Windows that is most of a second
|
|
1219
|
+
# for every click (owner, 2026-09-06: the iteration cycle of doing a command,
|
|
1220
|
+
# checking, going back).
|
|
1221
|
+
#
|
|
1222
|
+
# So one process stays up for the session and everything else talks to it. The
|
|
1223
|
+
# imports are paid once; an action becomes a message.
|
|
1224
|
+
#
|
|
1225
|
+
# IT IS ALWAYS OPTIONAL. Every caller falls back to spawning a process the old
|
|
1226
|
+
# way when there is nothing listening, so a machine where this cannot run is
|
|
1227
|
+
# slower and never broken.
|
|
1228
|
+
#
|
|
1229
|
+
# A TOKEN, BECAUSE IT IS A PORT. Bound to 127.0.0.1 only, but any other program
|
|
1230
|
+
# on the machine can reach that, and what is on the other end moves the mouse
|
|
1231
|
+
# and types. The token is written beside the port in a file only this user can
|
|
1232
|
+
# read, and a request without it is refused.
|
|
1233
|
+
SERVE_IDLE_S = 900
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _serve_note():
|
|
1237
|
+
return os.path.join(_session_dir(), "serve.json")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def serve(idle_s=SERVE_IDLE_S):
|
|
1241
|
+
"""Stay up and do what is asked, so a run stops paying start-up per click.
|
|
1242
|
+
|
|
1243
|
+
Ends by itself after a quarter of an hour with nothing to do, so a forgotten
|
|
1244
|
+
one cannot sit holding the computer for ever.
|
|
1245
|
+
"""
|
|
1246
|
+
import json as _json
|
|
1247
|
+
import socket
|
|
1248
|
+
import threading
|
|
1249
|
+
import time as _time
|
|
1250
|
+
|
|
1251
|
+
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
1252
|
+
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1253
|
+
srv.bind(("127.0.0.1", 0))
|
|
1254
|
+
srv.listen(16)
|
|
1255
|
+
port = srv.getsockname()[1]
|
|
1256
|
+
token = base64_token()
|
|
1257
|
+
note = {"port": port, "token": token, "pid": os.getpid(), "at": _time.time()}
|
|
1258
|
+
try:
|
|
1259
|
+
with open(_serve_note(), "w", encoding="utf-8") as fh:
|
|
1260
|
+
_json.dump(note, fh)
|
|
1261
|
+
try:
|
|
1262
|
+
os.chmod(_serve_note(), 0o600)
|
|
1263
|
+
except OSError:
|
|
1264
|
+
pass
|
|
1265
|
+
except OSError:
|
|
1266
|
+
return
|
|
1267
|
+
|
|
1268
|
+
# WARM BEFORE THE FIRST REQUEST, which is the entire point: importing on the
|
|
1269
|
+
# first click would just move the wait rather than remove it.
|
|
1270
|
+
try:
|
|
1271
|
+
_gui()
|
|
1272
|
+
except Exception:
|
|
1273
|
+
pass
|
|
1274
|
+
|
|
1275
|
+
last = [_time.time()]
|
|
1276
|
+
|
|
1277
|
+
def reap():
|
|
1278
|
+
while True:
|
|
1279
|
+
_time.sleep(5)
|
|
1280
|
+
if _time.time() - last[0] > idle_s:
|
|
1281
|
+
try:
|
|
1282
|
+
os.remove(_serve_note())
|
|
1283
|
+
except OSError:
|
|
1284
|
+
pass
|
|
1285
|
+
os._exit(0)
|
|
1286
|
+
|
|
1287
|
+
threading.Thread(target=reap, daemon=True).start()
|
|
1288
|
+
|
|
1289
|
+
while True:
|
|
1290
|
+
try:
|
|
1291
|
+
conn, _ = srv.accept()
|
|
1292
|
+
except OSError:
|
|
1293
|
+
return
|
|
1294
|
+
try:
|
|
1295
|
+
conn.settimeout(300)
|
|
1296
|
+
raw = b""
|
|
1297
|
+
while not raw.endswith(b"\n"):
|
|
1298
|
+
chunk = conn.recv(65536)
|
|
1299
|
+
if not chunk:
|
|
1300
|
+
break
|
|
1301
|
+
raw += chunk
|
|
1302
|
+
req = _json.loads(raw.decode("utf-8") or "{}")
|
|
1303
|
+
if req.get("token") != token:
|
|
1304
|
+
reply = {"ok": False, "error": "not for you"}
|
|
1305
|
+
elif req.get("op") == "_bye":
|
|
1306
|
+
conn.sendall((_json.dumps({"ok": True}) + "\n").encode("utf-8"))
|
|
1307
|
+
conn.close()
|
|
1308
|
+
try:
|
|
1309
|
+
os.remove(_serve_note())
|
|
1310
|
+
except OSError:
|
|
1311
|
+
pass
|
|
1312
|
+
os._exit(0)
|
|
1313
|
+
else:
|
|
1314
|
+
last[0] = _time.time()
|
|
1315
|
+
fn = globals().get(str(req.get("op")))
|
|
1316
|
+
if not callable(fn):
|
|
1317
|
+
reply = {"ok": False, "error": 'no "%s" here' % req.get("op")}
|
|
1318
|
+
else:
|
|
1319
|
+
try:
|
|
1320
|
+
out = fn(*(req.get("args") or []), **(req.get("kwargs") or {}))
|
|
1321
|
+
reply = {"ok": True, "out": out}
|
|
1322
|
+
except Exception as err:
|
|
1323
|
+
# The MESSAGE, not a traceback: the caller prints this to
|
|
1324
|
+
# somebody, and every refusal here is written to say
|
|
1325
|
+
# what to do next.
|
|
1326
|
+
reply = {"ok": False, "error": str(err)}
|
|
1327
|
+
conn.sendall((_json.dumps(reply, default=str) + "\n").encode("utf-8"))
|
|
1328
|
+
except Exception:
|
|
1329
|
+
# One bad connection is not a reason to stop serving the rest.
|
|
1330
|
+
pass
|
|
1331
|
+
finally:
|
|
1332
|
+
try:
|
|
1333
|
+
conn.close()
|
|
1334
|
+
except Exception:
|
|
1335
|
+
pass
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def base64_token():
|
|
1339
|
+
import base64
|
|
1340
|
+
return base64.urlsafe_b64encode(os.urandom(18)).decode("ascii").rstrip("=")
|
|
1341
|
+
|
|
1342
|
+
|
|
1213
1343
|
def _screen_changed(path):
|
|
1214
1344
|
"""True when this picture differs from the one before it.
|
|
1215
1345
|
|