mindvest-atlas 0.91.0 → 0.93.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/README.md CHANGED
@@ -30,6 +30,7 @@ atlas options-chain --symbol SPY --expiration 2026-06-20
30
30
  atlas historical-contract --symbol NVDA --expiry 2026-07-13 --strike 205 --side call --from 2026-07-06 --to 2026-07-10
31
31
  atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts
32
32
  atlas flow stream --symbol NVDA
33
+ atlas phone-notify --title "Backtest done" --body "SPY: 61% win" --key backtest # your own phone
33
34
  ```
34
35
 
35
36
  ## Layout
@@ -55,3 +56,28 @@ npm test
55
56
  Runs PKCE/arg/SSE/storage unit tests plus integration tests that drive the real
56
57
  OAuth client and loopback server against in-process mock servers — no network
57
58
  required.
59
+
60
+ ## Trade settings, paused workflows, sharing, the marketplace
61
+
62
+ Everything below was only on the website before 0.93.1.
63
+
64
+ ```bash
65
+ atlas signal-trade-settings # your defaults, accounts, and every group's settings
66
+ atlas signal-trade-settings --max-per-trade 230 # your budget for one trade
67
+ atlas signal-trade-settings --group-id <id> --accept-mode auto --max-per-trade 150
68
+ atlas signal-take --signal-id <id> --late true --max-per-trade 230
69
+ atlas signal-my-order --signal-id <id> --contracts 2 # or --cancel true
70
+ atlas signal-pending # plays waiting for your yes or no
71
+ atlas signal-answer --signal-id <id> --accept true
72
+
73
+ atlas workflow-restore --workflow-id <id> # turn a paused workflow back on
74
+ atlas workflow-review --workflow-id <id> # the run held for review; then --action place|discard --review-id <id>
75
+ atlas workflow-abort --workflow-id <id> # stop a run in progress
76
+ atlas workflow-follow --workflow-id <id> --follow false # make an imported copy your own
77
+ atlas workflow-history --workflow-id <id> # earlier versions; --version-id <id> --restore true goes back
78
+
79
+ atlas workflow-collab-find --query ada
80
+ atlas workflow-collab-set --workflow-id <id> --user-id <id> --roles '["writer","operator"]'
81
+ atlas marketplace-mine # what you sell and what you pay for
82
+ atlas marketplace-listing-update --kind signal_group --ref-id <id> --price 20 --status live
83
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.91.0",
3
+ "version": "0.93.1",
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": {
@@ -22,6 +22,7 @@
22
22
  // a project's script would call, inside the project, so the switch, the claim,
23
23
  // the action log and standing back for a person are identically the same rules.
24
24
  import { spawn, spawnSync } from 'node:child_process';
25
+ import net from 'node:net';
25
26
  import crypto from 'node:crypto';
26
27
  import fs from 'node:fs';
27
28
  import path from 'node:path';
@@ -59,14 +60,14 @@ const HELP = `atlas computer-use <command>
59
60
  press "<name>" press one of them by name or id (e7), found live [--right] [--double] [--wait <s>]
60
61
  type-into "<name>" "<text>" [--enter] fill a field by its name and read it back
61
62
  wait-for "<name>" [--gone] [--timeout <s>] until a control or text appears (or goes)
62
- web-setup fetch Playwright + Chromium once, for the web verbs
63
- web-open <url> a page in Atlas's browser; your mouse stays yours
64
- web-click "<what>" press a button or link by what it says (or a selector)
65
- web-type "<what>" "<text>" [--enter] fill a field by its label
66
- web-read ["<what>"] the page (or one part) as text
67
- web-find "<what>" where something is on the page
68
- web-shot a sharp picture of the page itself
69
- web-close close Atlas's browser (logins are kept)
63
+ shortcut <name> [n] a move by its name, the right keys for this computer: zoom_out, back,
64
+ next_tab, next_field, open_list, top, bottom, copy ... (shortcuts lists them)
65
+ shortcuts every named move and its keys here
66
+ fill-form <v> <v>... a whole form from the keyboard, Tab between: text, "pick:Canada" for a
67
+ drop-down, "key:space" for a tick box, "-" to leave a field [--submit] [--keep]
68
+ pick "<option>" choose in the drop-down that has the focus, by name [--open]
69
+ read-text the words in what is in front, as text (select all, copy)
70
+ open-link "<words>" follow a link or press a button on a page by its words, no aiming
70
71
  take-over carry on after a person used it by hand
71
72
  stop give the computer back
72
73
  sessions this project's recordings, with their ids
@@ -100,14 +101,16 @@ export const OPS = {
100
101
  cursor: ['get_cursor', 0],
101
102
  changed: ['changed', 0],
102
103
  code: ['run_code', 1],
103
- 'web-setup': ['web_setup', 0],
104
- 'web-open': ['web_open', 1],
105
- 'web-click': ['web_click', 1],
106
- 'web-type': ['web_type', -1],
107
- 'web-read': ['web_read', -1],
108
- 'web-find': ['web_find', 1],
109
- 'web-shot': ['web_shot', 0],
110
- 'web-close': ['web_close', 0],
104
+ // ── THE KEYS EVERY APP ALREADY ANSWERS TO (2026-10-04) ────────────────
105
+ // No separate browser verbs any more: one way of using a computer. A named
106
+ // move needs no aiming and knows this computer's keys (a Mac's are not
107
+ // Windows's), and a whole form is one call.
108
+ shortcut: ['shortcut', -1],
109
+ shortcuts: ['shortcuts', 0],
110
+ 'fill-form': ['fill_form', -1],
111
+ pick: ['pick', -1],
112
+ 'read-text': ['read_text', 0],
113
+ 'open-link': ['open_link', -1],
111
114
  'take-over': ['take_over', 0],
112
115
  ready: ['computer_ready', 0],
113
116
  // WAIT ESPECIALLY. A sequence without it cannot pause for a page, which is
@@ -160,6 +163,21 @@ export const OPS = {
160
163
  'type-into': ['type_into', -1],
161
164
  'wait-for': ['wait_for', -1],
162
165
  };
166
+ // Verbs whose words are ONE value inside a do "..." step.
167
+ const PHRASES = ['type', 'note', 'find', 'code', 'controls', 'press', 'wait-for', 'pick', 'open-link'];
168
+
169
+ /** A form's values as fill_form takes them: "-" leaves a field alone; the
170
+ * rest are the words as given ("pick:Canada" and "key:space" are read by the
171
+ * helper itself). */
172
+ export function formValues(list) {
173
+ return (list || []).map((v) => String(v).trim()).map((v) => (v === '-' ? null : v));
174
+ }
175
+
176
+ /** read-text for a person or a model: the words, then how many there were. */
177
+ function readTextLines(out) {
178
+ return `${out.text}\n(${out.chars} characters${out.cut ? ', cut short' : ''})`;
179
+ }
180
+
163
181
  // Which values are numbers. Passing "840" as a string to click() would move the
164
182
  // pointer nowhere and report success. THE SAME SET THE PYTHON CLI USES: a verb
165
183
  // that takes numbers in one runtime and strings in the other is a verb nobody
@@ -221,14 +239,22 @@ export function askServer(job, op, args, root) {
221
239
  }
222
240
  // A synchronous ask, because this command has nothing else to do until the
223
241
  // answer comes and the caller is a plain exit code.
242
+ //
243
+ // THE NEWLINES BELOW ARE WRITTEN \\n ON PURPOSE. This is a script inside a
244
+ // template string: a plain \n there becomes a real line break in the middle
245
+ // of a quoted string, the child fails to parse, and this returned null for
246
+ // every command, every time, from 2026-09-06 to 2026-10-04. So this runtime
247
+ // (the one Atlas Studio bundles) never once used the live process, paid for
248
+ // a fresh Python per command, and deleted the note on the way out. A test
249
+ // now talks to a real listener through this function.
224
250
  const body = JSON.stringify({ token, op, args: args || [], kwargs: {} });
225
251
  const out = spawnSync(process.execPath, ['-e', `
226
252
  const net = require('node:net');
227
253
  const s = net.connect(${port}, '127.0.0.1');
228
254
  let got = '';
229
255
  s.setTimeout(120000, () => { s.destroy(); process.exit(3); });
230
- s.on('connect', () => s.write(process.argv[1] + '\n'));
231
- s.on('data', (d) => { got += d; if (got.includes('\n')) { process.stdout.write(got); s.end(); } });
256
+ s.on('connect', () => s.write(process.argv[1] + '\\n'));
257
+ s.on('data', (d) => { got += d; if (got.includes('\\n')) { process.stdout.write(got); s.end(); } });
232
258
  s.on('error', () => process.exit(3));
233
259
  `, body], { encoding: 'utf8' });
234
260
  if (out.status !== 0 || !out.stdout) {
@@ -243,6 +269,97 @@ export function askServer(job, op, args, root) {
243
269
  }
244
270
  }
245
271
 
272
+ /**
273
+ * The same ask WITHOUT a second process, for a command that can wait for its
274
+ * answer: this process talks to the live one itself. askServer() starts a node
275
+ * child for every ask, because it must answer synchronously; that child is a
276
+ * tenth of a second on a fast machine and more on a slow one, per command.
277
+ * Resolves to the answer, or null when nobody is listening (and then the note
278
+ * that pointed nowhere is removed, as askServer does).
279
+ */
280
+ export function askLive(job, op, args, root) {
281
+ return new Promise((resolve) => {
282
+ let note;
283
+ let port;
284
+ let token;
285
+ try {
286
+ const { dir } = workspace(job || '', root);
287
+ const session = process.env.ATLAS_COMPUTER_SESSION || shellSession(root);
288
+ note = path.join(dir, '.computer-use', session, 'serve.json');
289
+ const n = JSON.parse(fs.readFileSync(note, 'utf8'));
290
+ port = Number(n.port);
291
+ token = String(n.token);
292
+ } catch {
293
+ resolve(null);
294
+ return;
295
+ }
296
+ let got = '';
297
+ let done = false;
298
+ const finish = (answer) => {
299
+ if (done) return;
300
+ done = true;
301
+ if (answer === null) { try { fs.rmSync(note, { force: true }); } catch { /* it may be gone */ } }
302
+ resolve(answer);
303
+ };
304
+ const s = net.connect(port, '127.0.0.1');
305
+ s.setTimeout(120000, () => { s.destroy(); finish(null); });
306
+ s.on('connect', () => s.write(JSON.stringify({ token, op, args: args || [], kwargs: {} }) + '\n'));
307
+ s.on('data', (d) => {
308
+ got += d;
309
+ if (!got.includes('\n')) return;
310
+ s.end();
311
+ try { finish(JSON.parse(got.trim().split('\n').pop())); } catch { finish(null); }
312
+ });
313
+ s.on('error', () => finish(null));
314
+ s.on('close', () => finish(null));
315
+ });
316
+ }
317
+
318
+ /** The interpreter in its no-console form, where one exists (Windows:
319
+ * pythonw.exe). Only for a process whose output nobody reads. */
320
+ export function windowless(python, platform = process.platform) {
321
+ if (platform !== 'win32' || !python.length) return python;
322
+ const exe = python[0];
323
+ for (const [stem, quiet] of [['python.exe', 'pythonw.exe'], ['py.exe', 'pyw.exe']]) {
324
+ if (exe.toLowerCase().endsWith(stem)) {
325
+ const other = exe.slice(0, -stem.length) + quiet;
326
+ if (fs.existsSync(other)) return [other, ...python.slice(1)];
327
+ }
328
+ }
329
+ return python;
330
+ }
331
+
332
+ /**
333
+ * Leave one process up for this session. Best effort, always: a machine where
334
+ * this cannot run spawns per action as before. It ends itself after a quarter
335
+ * of an hour idle, and `stop` ends it at once.
336
+ */
337
+ export function startServer(job, root) {
338
+ try {
339
+ if (askServer(job, 'computer_ready', [], root)) return true; // already up
340
+ const { dir } = workspace(job || '', root);
341
+ const venv = venvPython(dir);
342
+ const python = windowless(venv ? [String(venv)] : [...ambientPython()]);
343
+ const env = { ...process.env, ...jobEnv(dir), ATLAS_JOB: path.basename(dir) };
344
+ if (!env.ATLAS_ASK_URL || !env.ATLAS_ASK_TOKEN) {
345
+ const at = askAddress(root);
346
+ if (at) { env.ATLAS_ASK_URL = at.url; env.ATLAS_ASK_TOKEN = at.token; }
347
+ }
348
+ if (!env.ATLAS_COMPUTER_SESSION) env.ATLAS_COMPUTER_SESSION = shellSession(root);
349
+ if (!env.ATLAS_COMPUTER_PID) env.ATLAS_COMPUTER_PID = '0';
350
+ env.PYTHONPATH = [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
351
+ // Before it comes up: it imports pyautogui at the start on purpose.
352
+ ensureDeps(python, dir, env);
353
+ const child = spawn(python[0], [...python.slice(1), '-c', 'import atlas_env; atlas_env.serve()'],
354
+ { cwd: dir, env, stdio: 'ignore', detached: true, windowsHide: true });
355
+ child.on('error', () => { /* slower, not broken */ });
356
+ child.unref();
357
+ return true;
358
+ } catch {
359
+ return false; // slower, not broken
360
+ }
361
+ }
362
+
246
363
  /** Run one atlas_env call inside the project, and hand back what it said. */
247
364
  /** What using the computer needs, and what it is called when you import it. */
248
365
  const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
@@ -478,6 +595,13 @@ export function computerUseCommand(args) {
478
595
  + "e.note('took the computer')\n"
479
596
  + 'print(json.dumps(e.computer_ready(), default=str))\n';
480
597
  const out = runInProject(job, code);
598
+ // AND LEAVE ONE PROCESS UP for the rest of the session. Everything after
599
+ // this is a message to it rather than a Python start and pyautogui's
600
+ // imports, which on Windows is most of a second for an action that takes
601
+ // twenty milliseconds. The Python CLI has done this since September; this
602
+ // one, the one Atlas Studio bundles, never did, so every command in a chat
603
+ // paid for a fresh start while the guide said it would not (2026-10-04).
604
+ if (out === 0) startServer(job);
481
605
  if (out === 0) {
482
606
  println('');
483
607
  println(color.dim('Watching opened in Atlas Studio. Now use it:'));
@@ -572,18 +696,31 @@ export function computerUseCommand(args) {
572
696
  const [name] = OPS[verb];
573
697
  // A quoted phrase is one value: `type "hello there"` means two words
574
698
  // typed, not a word typed twice.
575
- let vals = (['type', 'note', 'find', 'code', 'web-open', 'web-click', 'web-read', 'web-find',
576
- 'controls', 'press', 'wait-for'].includes(verb))
699
+ let vals = (PHRASES.includes(verb))
577
700
  ? (bits.length > 1 ? [bits.slice(1).join(' ')] : [])
578
701
  : bits.slice(1).map((v) => (NUMERIC.has(verb) ? Number(v) : v));
579
702
  // A name can be several words, so the text comes after a "|":
580
703
  // "type-into Last name | Smith". Without one, the first word is the name.
581
704
  if (verb === 'type-into') vals = splitTarget(bits.slice(1).join(' '));
705
+ // "shortcut zoom_out 2": the name, then how many times.
706
+ if (verb === 'shortcut') vals = bits.length > 2 ? [bits[1], Number(bits[2])] : bits.slice(1);
707
+ // "fill-form Ada | Lovelace | pick:Canada": one value per field.
708
+ if (verb === 'fill-form') vals = [formValues(bits.slice(1).join(' ').split('|'))];
582
709
  steps.push([name, ...vals]);
583
710
  }
584
- const code = 'import atlas_env as e, json\n'
585
- + `print(json.dumps(e.do(*[tuple(s) for s in json.loads(${JSON.stringify(JSON.stringify(steps))})]), default=str, indent=2))\n`;
586
- return runInProject(job, code);
711
+ // THE LIVE PROCESS FIRST, like every other command: a run of steps was the
712
+ // one thing that always paid for a fresh Python, which is most of what a
713
+ // short run costs.
714
+ return askLive(job, 'do', steps).then((live) => {
715
+ if (live) {
716
+ if (live.ok) { println(JSON.stringify(live.out, null, 2)); return 0; }
717
+ eprintln(color.red(String(live.error || 'That did not work.')));
718
+ return 1;
719
+ }
720
+ const code = 'import atlas_env as e, json\n'
721
+ + `print(json.dumps(e.do(*[tuple(s) for s in json.loads(${JSON.stringify(JSON.stringify(steps))})]), default=str, indent=2))\n`;
722
+ return runInProject(job, code);
723
+ });
587
724
  }
588
725
 
589
726
  if (!OPS[sub]) {
@@ -604,22 +741,34 @@ export function computerUseCommand(args) {
604
741
  eprintln(color.red(`${sub} takes ${count} value${count === 1 ? '' : 's'}.`));
605
742
  return 1;
606
743
  }
607
- if (count === -1 && !rest.length && sub !== 'web-read' && sub !== 'controls') {
744
+ if (count === -1 && !rest.length && sub !== 'controls') {
608
745
  eprintln(color.red(`${sub} needs at least one.`));
609
746
  return 1;
610
747
  }
611
748
  let values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
612
749
  // A block of code, a sentence to find, an address: the words are one value.
613
- if (['code', 'note', 'find', 'type', 'web-open', 'web-click', 'web-read', 'web-find'].includes(sub) && rest.length > 1) {
750
+ if (['code', 'note', 'find', 'type', 'open-link'].includes(sub) && rest.length > 1) {
614
751
  values = [rest.join(' ')];
615
752
  }
753
+ if (sub === 'shortcut') {
754
+ // The name, then how many times: `shortcut zoom_out 2`.
755
+ values = rest.length > 1 ? [rest[0], Number(rest[1])] : [rest[0]];
756
+ if (rest.length > 2 || (rest.length > 1 && !Number.isFinite(values[1]))) {
757
+ eprintln(color.red('shortcut takes a name and, if you like, how many times: shortcut zoom_out 2'));
758
+ return 1;
759
+ }
760
+ }
761
+ // One value per field, as given; then whether to press Enter, and whether to
762
+ // replace what each field held.
763
+ if (sub === 'fill-form') values = [formValues(rest), Boolean(flags.submit), !flags.keep];
764
+ if (sub === 'pick') values = [rest.join(' '), Boolean(flags.open)];
616
765
  if (sub === 'press' || sub === 'wait-for' || sub === 'controls') {
617
766
  values = rest.length ? [rest.join(' ')] : [];
618
767
  if (sub === 'press') {
619
768
  values.push(right ? 'right' : 'left', twice ? 2 : 1);
620
769
  // How long it may wait for the control to appear, stop being grey, or
621
770
  // come out from under something (default 5s).
622
- if (flags.wait !== undefined) values.push(null, 1.0, Number(flags.wait));
771
+ if (flags.wait !== undefined) values.push(null, null, Number(flags.wait));
623
772
  }
624
773
  if (sub === 'wait-for') {
625
774
  values.push(gone, flags.timeout !== undefined ? Number(flags.timeout) : 10);
@@ -633,32 +782,33 @@ export function computerUseCommand(args) {
633
782
  values = [rest[0], rest.slice(1).join(' '), enter];
634
783
  if (flags.wait !== undefined) values.push(true, null, Number(flags.wait));
635
784
  }
636
- if (sub === 'web-type') {
637
- if (rest.length < 2) {
638
- eprintln(color.red('web-type takes the field and the text: web-type "Email" "me@x.com" [--enter]'));
785
+ // THE LIVE PROCESS FIRST: same function, same rules, without a python start
786
+ // (and without a second node either: this command can wait for its answer).
787
+ return askLive(job, name, values).then((answer) => {
788
+ if (answer) {
789
+ if (answer.ok) {
790
+ // THE LIST AS LINES: one control per line is what gets read, and the
791
+ // same list as JSON is five times the words to read it in.
792
+ if (sub === 'controls' && answer.out && answer.out.summary) println(answer.out.summary);
793
+ else if (sub === 'read-text' && answer.out && typeof answer.out.text === 'string') println(readTextLines(answer.out));
794
+ else if (sub === 'shortcuts' && answer.out) println(Object.entries(answer.out).map(([k, v]) => `${k.padEnd(18)} ${v}`).join('\n'));
795
+ else println(JSON.stringify(answer.out, null, 2));
796
+ return 0;
797
+ }
798
+ eprintln(color.red(String(answer.error || 'That did not work.')));
639
799
  return 1;
640
800
  }
641
- values = [rest[0], rest.slice(1).join(' '), Boolean(flags.enter)];
642
- }
643
- // THE LIVE PROCESS FIRST: same function, same rules, without a python start.
644
- const answer = askServer(job, name, values);
645
- if (answer) {
646
- if (answer.ok) {
647
- // THE LIST AS LINES: one control per line is what gets read, and the
648
- // same list as JSON is five times the words to read it in.
649
- if (sub === 'controls' && answer.out && answer.out.summary) println(answer.out.summary);
650
- else println(JSON.stringify(answer.out, null, 2));
651
- return 0;
652
- }
653
- eprintln(color.red(String(answer.error || 'That did not work.')));
654
- return 1;
655
- }
656
- // JSON rather than pasting: the text may hold quotes, and a snippet built by
657
- // pasting is a snippet that breaks on somebody's password.
658
- const code = 'import atlas_env as e, json\n'
659
- + `out = e.${name}(*json.loads(${JSON.stringify(JSON.stringify(values))}))\n`
660
- + (sub === 'controls'
661
- ? 'print(out["summary"] if isinstance(out, dict) and out.get("summary") else json.dumps(out, default=str))\n'
662
- : 'print(json.dumps(out, default=str))\n');
663
- return runInProject(job, code);
801
+ // JSON rather than pasting: the text may hold quotes, and a snippet built by
802
+ // pasting is a snippet that breaks on somebody's password.
803
+ const code = 'import atlas_env as e, json\n'
804
+ + `out = e.${name}(*json.loads(${JSON.stringify(JSON.stringify(values))}))\n`
805
+ + (sub === 'controls'
806
+ ? 'print(out["summary"] if isinstance(out, dict) and out.get("summary") else json.dumps(out, default=str))\n'
807
+ : sub === 'read-text'
808
+ ? 'print(out["text"]); print("(%d characters%s)" % (out["chars"], ", cut short" if out.get("cut") else ""))\n'
809
+ : sub === 'shortcuts'
810
+ ? 'print("\\n".join("%-18s %s" % kv for kv in out.items()))\n'
811
+ : 'print(json.dumps(out, default=str))\n');
812
+ return runInProject(job, code);
813
+ });
664
814
  }
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.91.0';
5
+ export const VERSION = '0.93.1';
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';
@@ -329,6 +329,62 @@ export async function mcpTools({ server = "", search = "" } = {}) {
329
329
  return atlas("mcp-tools", { server, search });
330
330
  }
331
331
 
332
+ /**
333
+ * Tell the owner's PHONE (the MindVest Atlas app). A fixed layout you fill in.
334
+ *
335
+ * A title (cut at 40 characters) and up to two short lines; plain words. The
336
+ * same `key` again REPLACES what is on the phone instead of adding to it.
337
+ * `progress` (0 to 1) draws a bar. `button` puts ONE button on it (16
338
+ * characters): pressing it runs this project again, by hand, with the press in
339
+ * the payload (see phonePress()). `done: true` finishes a card that is up. At
340
+ * most 30 new ones an hour, so say what matters, not every step.
341
+ *
342
+ * await notify("Backtest done", { body: "SPY: 61% win\n214 trades", key: "backtest" });
343
+ * await notify("Backtest running", { key: "backtest", progress: 0.4 });
344
+ * await notify("Backtest done", { key: "backtest", button: "Run on QQQ" });
345
+ *
346
+ * Returns the tool's answer. `sent` is 0 when the owner switched these off
347
+ * (Profile, Manage notifications, Studio); that is their choice, not an error.
348
+ */
349
+ export async function notify(title, { body = "", key = "", progress = null, button = "", done = false } = {}) {
350
+ const params = { title, body: body || "" };
351
+ if (key) params.key = key;
352
+ if (progress !== null && progress !== undefined) params.progress = progress;
353
+ if (button) {
354
+ params.button = button;
355
+ // The project the button starts: this one.
356
+ params.project = process.env.ATLAS_JOB || "";
357
+ }
358
+ if (done) params.done = true;
359
+ return atlas("phone-notify", params);
360
+ }
361
+
362
+ /**
363
+ * The button press this is about, or null.
364
+ *
365
+ * When a press STARTED this run it is in the payload: {key, button, title,
366
+ * pressed_at}. A script that was already running when the button was pressed
367
+ * (a listener, a long job) is not started twice; it passes the `key` it sent
368
+ * the button with and is told whether it has been pressed.
369
+ *
370
+ * const press = await phonePress(); // did a button start this run?
371
+ * if (await phonePress("backtest")) stop(); // still running: pressed yet?
372
+ */
373
+ export async function phonePress(key = "") {
374
+ let started = null;
375
+ try {
376
+ started = JSON.parse(process.env.ATLAS_JOB_PAYLOAD || "{}").phone;
377
+ } catch {
378
+ started = null;
379
+ }
380
+ if (started && typeof started === "object" && (!key || started.key === key)) return started;
381
+ if (!key) return null;
382
+ const answer = await atlas("phone-button", { key });
383
+ return answer && answer.pressed
384
+ ? { key, button: answer.button || "", pressed_at: answer.pressed_at }
385
+ : null;
386
+ }
387
+
332
388
  /**
333
389
  * Use this project's dashboard the way a person would.
334
390
  *
@@ -508,6 +564,22 @@ export const computer = {
508
564
  press: (target, opts = {}) => useComputer("press", [String(target)], opts),
509
565
  typeInto: (target, text, opts = {}) => useComputer("type_into", [String(target), String(text)], opts),
510
566
  waitFor: (target, opts = {}) => useComputer("wait_for", [String(target)], opts),
567
+ /**
568
+ * THE KEYS EVERY APP ALREADY ANSWERS TO. A keystroke needs no aiming, and
569
+ * these know this computer's keys (a Mac's are not Windows's), in any browser.
570
+ * await computer.shortcut("zoom_out", 2); // also back, next_tab, top, bottom, open_list...
571
+ * await computer.fillForm(["Ada", "Lovelace", { pick: "Canada" }, null, { key: "space" }], { submit: true });
572
+ * const page = await computer.readText(); // { text, chars, cut }
573
+ * await computer.openLink("Pricing"); // a link or a button, by its words
574
+ * fillForm starts where the focus is (press or click the first field), puts
575
+ * one value in each field with Tab between, and looks once at the end.
576
+ */
577
+ shortcut: (name, times = 1) => useComputer("shortcut", [String(name), times]),
578
+ shortcuts: () => useComputer("shortcuts"),
579
+ fillForm: (values, opts = {}) => useComputer("fill_form", [values || []], opts),
580
+ pick: (option, opts = {}) => useComputer("pick", [String(option)], opts),
581
+ readText: (opts = {}) => useComputer("read_text", [], opts),
582
+ openLink: (words, opts = {}) => useComputer("open_link", [String(words)], opts),
511
583
  click: (x, y) => useComputer("click", [x, y]),
512
584
  doubleClick: (x, y) => useComputer("double_click", [x, y]),
513
585
  rightClick: (x, y) => useComputer("right_click", [x, y]),