mindvest-atlas 0.43.1 → 0.45.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.43.1",
3
+ "version": "0.45.0",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -105,7 +105,87 @@ 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. */
158
+ /** What using the computer needs, and what it is called when you import it. */
159
+ const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
160
+
161
+ /**
162
+ * Put what is missing into the interpreter that is ABOUT TO BE USED.
163
+ *
164
+ * Atlas installs these when the switch goes on, but into the ambient python,
165
+ * and a project with a venv of its own runs on that instead: the switch said
166
+ * ready and the run then found nothing. The interpreter here is the one the
167
+ * work will happen in, which is the only one worth checking.
168
+ *
169
+ * Best effort and quiet: a machine with no network or no pip is one that runs
170
+ * without it, and every call site already says what it cannot do.
171
+ */
172
+ export function ensureDeps(python, cwd, env) {
173
+ const check = `import ${NEEDED.map(([, mod]) => mod).join(', ')}`;
174
+ const has = () => spawnSync(python[0], [...python.slice(1), '-c', check],
175
+ { cwd, env, stdio: 'ignore' }).status === 0;
176
+ if (has()) return true;
177
+ println(color.dim(' Getting this computer ready (one time)…'));
178
+ // --user is wrong inside a venv and right outside one, so ask the interpreter
179
+ // which it is in rather than guessing.
180
+ const inVenv = spawnSync(python[0],
181
+ [...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
182
+ { cwd, env, stdio: 'ignore' }).status === 0;
183
+ const args = ['-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user'])];
184
+ spawnSync(python[0], [...python.slice(1), ...args, ...NEEDED.map(([name]) => name)],
185
+ { cwd, env, stdio: 'ignore', timeout: 300000 });
186
+ return has();
187
+ }
188
+
109
189
  export function runInProject(job, code, root) {
110
190
  const { dir } = workspace(job, root);
111
191
  if (!fs.existsSync(dir)) {
@@ -116,6 +196,10 @@ export function runInProject(job, code, root) {
116
196
  const venv = venvPython(dir);
117
197
  const python = venv ? [String(venv)] : [...ambientPython()];
118
198
  const env = { ...process.env, ...jobEnv(dir), ATLAS_JOB: path.basename(dir) };
199
+ // BEFORE ANYTHING TRIES TO IMPORT THEM. Checked every time and installed only
200
+ // when something is missing, so the cost is one python start on a machine
201
+ // that is already set up.
202
+ ensureDeps(python, dir, env);
119
203
  // THE SAME HOLDER FOR EVERY COMMAND in this shell, and no process behind the
120
204
  // claim: each of these exits, and a claim needing a live process would be
121
205
  // gone before the next one ran.
@@ -161,6 +245,9 @@ export function computerUseCommand(args) {
161
245
  }
162
246
 
163
247
  if (sub === 'stop') {
248
+ // The live process goes with the computer: leaving it up would hold
249
+ // pyautogui and a port for a session that is over.
250
+ askServer(job, '_bye');
164
251
  // Given back by NAME, so one shell cannot release another's hold.
165
252
  const released = releaseComputerClaim(shellSession());
166
253
  println(color.dim(released ? 'Gave the computer back.' : 'It was not yours to give back.'));
@@ -213,6 +300,16 @@ export function computerUseCommand(args) {
213
300
  return 1;
214
301
  }
215
302
  const values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
303
+ // THE LIVE PROCESS FIRST: same function, same rules, without a python start.
304
+ const answer = askServer(job, name, values);
305
+ if (answer) {
306
+ if (answer.ok) {
307
+ println(JSON.stringify(answer.out, null, 2));
308
+ return 0;
309
+ }
310
+ eprintln(color.red(String(answer.error || 'That did not work.')));
311
+ return 1;
312
+ }
216
313
  // JSON rather than pasting: the text may hold quotes, and a snippet built by
217
314
  // pasting is a snippet that breaks on somebody's password.
218
315
  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.43.1';
5
+ export const VERSION = '0.45.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';
@@ -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