mindvest-atlas 0.68.0 → 0.68.2

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.68.0",
3
+ "version": "0.68.2",
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": {
@@ -48,6 +48,14 @@ async function listAlerts(flags) {
48
48
  return 0;
49
49
  }
50
50
 
51
+ /** The shape of every event on the feed, as JSON Schema, so a script or a
52
+ * model can read it once and rely on it. Alerts and trades share it. */
53
+ async function schema() {
54
+ const data = await requestJson(`${API.alerts}/schema`);
55
+ println(prettyJson(data));
56
+ return 0;
57
+ }
58
+
51
59
  async function deleteAlerts(args) {
52
60
  const flags = args.flags;
53
61
  // --id may repeat; extra positionals after the verb are ids too, so
@@ -86,6 +94,7 @@ export async function alertsCommand(args) {
86
94
  const verb = String((args._ && args._[0]) || '').toLowerCase();
87
95
  if (verb === 'list' || verb === 'ls') return await listAlerts(flags);
88
96
  if (verb === 'delete' || verb === 'rm' || verb === 'off') return await deleteAlerts(args);
97
+ if (verb === 'schema') return await schema();
89
98
  if (flags.stop) return background.stop(SLOT);
90
99
  if (flags.detach) return background.detach(SLOT);
91
100
  const holder = background.holdUntilStopped(SLOT);
@@ -67,9 +67,13 @@ export async function askCommand(args) {
67
67
  }
68
68
  }
69
69
  try {
70
+ // THE JOB IT IS RUNNING IN, unless told otherwise: a model asking from its
71
+ // own shell used to land in a folder called "job", in a session unrelated
72
+ // to the project it was working on.
70
73
  const said = await askModel(prompt, {
71
- job: String(flags.job || ''),
74
+ job: String(flags.job || process.env.ATLAS_JOB || ''),
72
75
  model,
76
+ peer: String(flags.peer || '').trim(),
73
77
  timeoutMs: Number(flags.timeout || 900) * 1000,
74
78
  });
75
79
  println(typeof said === 'string' ? said : JSON.stringify(said, null, 2));
@@ -22,6 +22,7 @@ ${color.bold('USAGE')}
22
22
  ${color.bold('COMMANDS')}
23
23
  groups The groups you can post to, and the ones you follow
24
24
  plays [--board community|private] The plays themselves [--days N] [--limit N]
25
+ post --symbol SPY ... Post a play, on its own or as a LINKED pair
25
26
  show --id <play> One play, as a card
26
27
  take --id <play> [--contracts N] Place a play in YOUR account [--account <id>]
27
28
  performance --group <id> [--mine] Closed trades and the win rate for a group
@@ -31,11 +32,27 @@ ${color.bold('COMMANDS')}
31
32
  follow --group <id> [--off] Follow a public group, or stop
32
33
  schema What a play is made of — read before posting
33
34
 
35
+ ${color.bold('POSTING')}
36
+ --symbol SPY --call|--put --strike 765 --expiry 2026-09-14
37
+ --entry 765 [--entry-unit price|contract] [--target N ...] [--stop N]
38
+ [--group <id>] a group of yours; left out, it goes to the community board
39
+ [--reason "..."] [--score 8] [--contracts 2] [--take-it-myself]
40
+
41
+ A LINKED PAIR — the second trade rides the same flags prefixed with-, and
42
+ anything you leave out is taken from the first leg:
43
+ --link oco whichever fills first cancels the other
44
+ --link oeo the second starts only once the first fills
45
+ --with-put --with-strike 760 --with-entry 759 --with-target 758 --with-stop 761
46
+
34
47
  ${color.bold('EXAMPLES')}
35
48
  atlas signals groups
36
49
  atlas signals plays --board community --days 1
37
50
  atlas signals performance --group <id>
38
51
  atlas signals delete --group <id> --stop-subscribers
52
+ atlas signals post --symbol SPY --call --strike 765 --expiry 2026-09-14 \\
53
+ --entry 765 --target 765.5 --target 766.1 --stop 764 --group <id>
54
+ atlas signals post --symbol SPY --call --strike 765 --expiry 2026-09-14 --entry 765 \\
55
+ --link oco --with-put --with-strike 760 --with-entry 759
39
56
  `;
40
57
 
41
58
  const callTool = (name, body) => requestJson(API.tool(name), { method: 'POST', body: body || {} });
@@ -60,6 +77,61 @@ function rows(list) {
60
77
  return Array.isArray(list) ? list : (list && Array.isArray(list.signals) ? list.signals : []);
61
78
  }
62
79
 
80
+ // One play's fields, from flags. `pre` is '' for the first leg and 'with-' for
81
+ // the linked one, so the two are described the same way and the second only has
82
+ // to say what DIFFERS — everything else falls back to the first leg.
83
+ export function legFrom(flags, pre, base) {
84
+ const f = (name) => flags[`${pre}${name}`];
85
+ const pick = (name, fallback) => {
86
+ const v = f(name);
87
+ return v === undefined || v === true ? fallback : v;
88
+ };
89
+ const call = f('call') === true || String(pick('option', '')).toLowerCase() === 'call';
90
+ const put = f('put') === true || String(pick('option', '')).toLowerCase() === 'put';
91
+ const targets = []
92
+ .concat(f('target') === undefined ? [] : f('target'))
93
+ .concat(f('targets') === undefined ? [] : f('targets'))
94
+ .filter((t) => t !== true)
95
+ .map((t) => num(t))
96
+ .filter((t) => t !== null);
97
+ const leg = {
98
+ symbol: String(pick('symbol', base?.symbol || '')).toUpperCase(),
99
+ side: String(pick('side', base?.side || 'buy')).toLowerCase(),
100
+ };
101
+ const assetFlag = String(pick('asset-class', '')).toLowerCase();
102
+ const isOption = call || put || assetFlag === 'option'
103
+ || (!assetFlag && base ? base.asset_class === 'option' : false)
104
+ || (!assetFlag && !base && (f('strike') !== undefined || f('expiry') !== undefined));
105
+ leg.asset_class = isOption ? 'option' : (assetFlag || 'stock');
106
+ if (isOption) {
107
+ leg.option_type = call ? 'call' : put ? 'put' : (base?.option_type || 'call');
108
+ const strike = num(pick('strike', null));
109
+ if (strike !== null) leg.strike = strike;
110
+ const exp = pick('expiry', pick('expiration', base?.expiration_date));
111
+ if (exp) leg.expiration_date = String(exp);
112
+ }
113
+ const entry = num(pick('entry', null));
114
+ if (entry !== null) leg.entry = entry;
115
+ const eu = pick('entry-unit', base?.entry_unit);
116
+ if (eu) leg.entry_unit = String(eu);
117
+ const eop = pick('entry-operator', null);
118
+ if (eop) leg.entry_operator = String(eop);
119
+ if (targets.length) leg.targets = targets;
120
+ const tu = pick('tp-unit', base?.tp_unit);
121
+ if (tu) leg.tp_unit = String(tu);
122
+ const stop = num(pick('stop', null));
123
+ if (stop !== null) leg.stop = stop;
124
+ const su = pick('stop-unit', base?.stop_unit);
125
+ if (su) leg.stop_unit = String(su);
126
+ const contracts = num(pick('contracts', null));
127
+ if (contracts !== null) leg.contracts = contracts;
128
+ const reason = pick('reason', null);
129
+ if (reason) leg.reason = String(reason);
130
+ const score = num(pick('score', null));
131
+ if (score !== null) leg.score = score;
132
+ return leg;
133
+ }
134
+
63
135
  export async function signalsCommand(args) {
64
136
  const flags = args.flags || {};
65
137
  const sub = String(args._[0] || 'groups').toLowerCase();
@@ -102,6 +174,35 @@ export async function signalsCommand(args) {
102
174
  }
103
175
  return 0;
104
176
  }
177
+ case 'post': {
178
+ const leg = legFrom(flags, '', null);
179
+ if (!leg.symbol) { eprintln(color.red('Which stock? --symbol SPY')); return 1; }
180
+ const body = { ...leg };
181
+ if (flags.group) body.group_id = String(flags.group);
182
+ if (flags['take-it-myself'] || flags['trade-for-me']) body.trade_for_me = true;
183
+ if (flags.account) body.snaptrade_account_id = String(flags.account);
184
+ // A LINKED PAIR, from the CLI (owner, 2026-09-12). The tool has taken a
185
+ // second leg since linked plays shipped; what did not exist was a way to
186
+ // say it in flags, so anyone at a terminal had to hand-write JSON — and
187
+ // an agent reading the CLI concluded a linked pair could not be posted
188
+ // directly at all.
189
+ const link = String(flags.link || flags['link-mode'] || '').toLowerCase();
190
+ if (link) {
191
+ if (link !== 'oco' && link !== 'oeo') {
192
+ eprintln(color.red('--link is oco (whichever fills first cancels the other) or oeo (the second starts once the first fills).'));
193
+ return 1;
194
+ }
195
+ const second = legFrom(flags, 'with-', leg);
196
+ if (!second.symbol) { eprintln(color.red('The linked trade needs a stock: --with-symbol, or it takes the first leg\'s.')); return 1; }
197
+ body.second_leg = second;
198
+ body.link_mode = link;
199
+ }
200
+ const data = await callTool('Signal-Post', body);
201
+ if (failed(data)) return printFailure(data);
202
+ if (json) { println(prettyJson(data)); return 0; }
203
+ println(data.message || (link ? 'Posted both, linked.' : 'Posted.'));
204
+ return 0;
205
+ }
105
206
  case 'show': {
106
207
  const id = flags.id || flags.play || flags.signal;
107
208
  if (!id) { eprintln(color.red('Which play? --id <play-id>')); return 1; }
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.68.0';
5
+ export const VERSION = '0.68.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';
@@ -83,8 +83,27 @@ export async function atlas(tool, params = {}) {
83
83
  * words each one takes. A name it does not have is refused BY NAME rather than
84
84
  * quietly answered by the default, because a script that asked for a specific
85
85
  * model and silently got another one is worse than one that stops.
86
+ *
87
+ * PEER TO PEER. Every model you name keeps ITS OWN conversation in this job,
88
+ * with its own tools, its own shell and its own view of the project, and it can
89
+ * call ask() itself (or `atlas ask --model ...` from its shell) to talk to
90
+ * another one. So two models can work a problem between them, each doing its
91
+ * own things, and you carry the words across:
92
+ *
93
+ * const plan = await ask("Plan the research; list the questions.", { model: "claude-opus-4-8" });
94
+ * const found = await ask(`Answer these with real data:\n${plan}`, { model: "gpt-5.4" });
95
+ * const call = await ask(`GPT found:\n${found}\nRevise the plan or decide.`, { model: "claude-opus-4-8" });
96
+ *
97
+ * Each call continues the conversation that model already had in this job, so
98
+ * the last one remembers the plan it wrote. fresh:true starts a model over.
99
+ *
100
+ * peer:"planner" names a conversation of its own on top of the model, so two
101
+ * ROLES on one model ("planner" and "critic", both gpt-5.4) keep separate
102
+ * memories. A model cannot ask itself: the bridge refuses the conversation you
103
+ * are already in. fanOut([...]) asks several at once (see below). A warm
104
+ * conversation nobody speaks to for six hours is closed and starts afresh.
86
105
  */
87
- export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000, model = "", commands = true } = {}) {
106
+ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000, model = "", commands = true, peer = "" } = {}) {
88
107
  const url = process.env.ATLAS_ASK_URL;
89
108
  const token = process.env.ATLAS_ASK_TOKEN;
90
109
  if (!url || !token) {
@@ -93,7 +112,10 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
93
112
  "Connect a model in Atlas Studio, or do this step in code."
94
113
  );
95
114
  }
96
- const body = { job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh, commands: commands !== false };
115
+ const body = { job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh, commands: commands !== false,
116
+ // Who is asking, so the bridge can refuse a model asking itself.
117
+ from: process.env.ATLAS_ASK_PEER || "" };
118
+ if (String(peer || "").trim()) body.peer = String(peer).trim();
97
119
  // Sent only when named, so a script that does not care keeps whatever is
98
120
  // connected rather than pinning itself to today's default.
99
121
  if (String(model || "").trim()) body.model = String(model).trim();
@@ -120,6 +142,31 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
120
142
  * const cheap = roster.find((m) => m.ready && m.id.includes("haiku"));
121
143
  * await ask("Sector or company?", { model: cheap?.id });
122
144
  */
145
+ /**
146
+ * Ask several models (or peers) AT ONCE and get every answer back, in order.
147
+ *
148
+ * const answers = await fanOut([
149
+ * { prompt: "Bull case for NVDA, 5 lines.", model: "gpt-5.4", peer: "bull" },
150
+ * { prompt: "Bear case for NVDA, 5 lines.", model: "gpt-5.4", peer: "bear" },
151
+ * { prompt: "Read the tape and give a number.", model: "claude-opus-4-8" },
152
+ * ]);
153
+ * for (const a of answers) console.log(a.peer || a.model, "->", a.ok ? a.answer : "failed: " + a.error);
154
+ *
155
+ * Each entry takes the same keys as ask(). Different peers run in parallel
156
+ * (each is its own session); asks of one peer queue behind each other. One
157
+ * failing does not stop the others: every entry comes back as
158
+ * { ok, answer | error, model, peer }.
159
+ */
160
+ export async function fanOut(asks) {
161
+ const list = Array.isArray(asks) ? asks : [];
162
+ return Promise.all(list.map(async (spec) => {
163
+ const { prompt, ...rest } = spec || {};
164
+ const out = { ok: true, model: rest.model || "", peer: rest.peer || "" };
165
+ try { out.answer = await ask(prompt, rest); } catch (err) { out.ok = false; out.error = String(err && err.message || err); }
166
+ return out;
167
+ }));
168
+ }
169
+
123
170
  export async function models() {
124
171
  const url = process.env.ATLAS_ASK_URL;
125
172
  const token = process.env.ATLAS_ASK_TOKEN;
@@ -145,8 +192,18 @@ function alertMatches(event, match) {
145
192
  const want = (Array.isArray(match) ? match : [match])
146
193
  .map((w) => String(w ?? "").trim().toLowerCase()).filter(Boolean);
147
194
  if (!want.length) return true;
195
+ const ev = event || {};
196
+ // A TRADE (a fill, a stop, a take-profit rung, a close, a draft signal)
197
+ // answers only to "trade", "trade:SPY" or its trigger id: "*" and a bare
198
+ // symbol keep meaning "my alerts", as they always have.
199
+ if (String(ev.name ?? "").trim().toLowerCase() === "trade") {
200
+ const sym = String(ev.symbol ?? "").trim().toLowerCase();
201
+ const tid = String(ev.id ?? "").trim().toLowerCase();
202
+ const wanted = new Set(["trade", sym ? `trade:${sym}` : "", tid].filter(Boolean));
203
+ return want.some((w) => wanted.has(w));
204
+ }
148
205
  const facets = ["label", "id", "symbol", "name", "alert_type"]
149
- .map((k) => String((event || {})[k] ?? "").trim().toLowerCase()).filter(Boolean);
206
+ .map((k) => String(ev[k] ?? "").trim().toLowerCase()).filter(Boolean);
150
207
  return want.some((w) => w === "*" || facets.includes(w));
151
208
  }
152
209
 
@@ -414,6 +471,11 @@ export const computer = {
414
471
  ready: () => useComputer("computer_ready"),
415
472
  screen: () => useComputer("screen"),
416
473
  region: (x, y, w, h) => useComputer("region", [x, y, w, h]),
474
+ /** A close-up around one spot, x3, with a ruler every 20 desktop pixels and a
475
+ * crosshair on the spot: read the click off THIS before a small target.
476
+ * const look = await computer.lookAt(850, 450); // -> { path, grid: 20, ... }
477
+ * await computer.click(858, 446); */
478
+ lookAt: (x, y, opts = {}) => useComputer("look_at", [x, y], opts),
417
479
  screens: () => useComputer("screens"),
418
480
  useScreen: (n) => useComputer("use_screen", [n]),
419
481
  /**
@@ -54,7 +54,7 @@ def atlas(tool, **params):
54
54
  raise RuntimeError("Atlas %s failed (%d): %s" % (tool, e.code, body))
55
55
 
56
56
 
57
- def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
57
+ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True, peer=""):
58
58
  """Ask YOUR OWN model, the way you would in the chat, and get its answer back.
59
59
 
60
60
  IT IS A CHAT SESSION, NOT A TEXT BOX (owner, 2026-09-11). The model runs on
@@ -99,6 +99,31 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
99
99
  does not have is refused by name rather than quietly answered by the
100
100
  default, because a script that asked for a specific model and silently got
101
101
  another one is worse than one that stops.
102
+
103
+ PEER TO PEER. Every model you name keeps ITS OWN conversation in this job,
104
+ with its own tools, its own shell and its own view of the project, and it
105
+ can call ask() itself (or `atlas ask --model ...` from its shell) to talk
106
+ to another one. So two models can work a problem between them, each doing
107
+ its own things, and you carry the words across:
108
+
109
+ plan = ask("Plan the research; list the questions.", model="claude-opus-4-8")
110
+ found = ask("Answer these with real data:\n" + plan, model="gpt-5.4")
111
+ call = ask("GPT found:\n" + found + "\nRevise the plan or decide.", model="claude-opus-4-8")
112
+
113
+ Each of those three continues the conversation that model already had in
114
+ this job, so the last call remembers the plan it wrote. fresh=True starts a
115
+ model over. A model that should answer only from what you gave it takes
116
+ commands=False and does no work of its own.
117
+
118
+ peer="planner" names a conversation of its own on top of the model, so two
119
+ ROLES on one model ("planner" and "critic", both gpt-5.4) keep separate
120
+ memories. A model cannot ask itself: the bridge refuses the conversation
121
+ you are already in and says to name another model or peer.
122
+
123
+ FAN OUT with fan_out([...]) to ask several at once: each peer is its own
124
+ session, so they run in parallel; two asks of the SAME peer queue, one
125
+ after the other, and never mix their answers. A warm conversation that
126
+ nobody speaks to for six hours is closed; the next ask starts it afresh.
102
127
  """
103
128
  import json as _json
104
129
  import urllib.error
@@ -112,7 +137,11 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
112
137
  "Connect a model in Atlas Studio, or do this step in code."
113
138
  )
114
139
  body = {"job": os.environ.get("ATLAS_JOB", ""), "prompt": str(prompt),
115
- "tools": bool(tools), "fresh": bool(fresh), "commands": bool(commands)}
140
+ "tools": bool(tools), "fresh": bool(fresh), "commands": bool(commands),
141
+ # Who is asking, so the bridge can refuse a model asking itself.
142
+ "from": os.environ.get("ATLAS_ASK_PEER", "")}
143
+ if str(peer or "").strip():
144
+ body["peer"] = str(peer).strip()
116
145
  # Sent only when named, so a script that does not care keeps whatever is
117
146
  # connected rather than pinning itself to today's default.
118
147
  if str(model or "").strip():
@@ -137,6 +166,42 @@ def ask(prompt, tools=True, fresh=False, timeout=600, model="", commands=True):
137
166
  return out.get("answer") or ""
138
167
 
139
168
 
169
+ def fan_out(asks, workers=4):
170
+ """Ask several models (or peers) AT ONCE and get every answer back, in order.
171
+
172
+ answers = fan_out([
173
+ {"prompt": "Bull case for NVDA, 5 lines.", "model": "gpt-5.4", "peer": "bull"},
174
+ {"prompt": "Bear case for NVDA, 5 lines.", "model": "gpt-5.4", "peer": "bear"},
175
+ {"prompt": "Read the tape and give a number.", "model": "claude-opus-4-8"},
176
+ ])
177
+ for a in answers:
178
+ print(a["peer"] or a["model"], "->", a["answer"] if a["ok"] else "failed: " + a["error"])
179
+
180
+ Each entry takes the same keys as ask(). Different peers run in parallel
181
+ (each is its own session); asks of one peer queue behind each other. One
182
+ failing does not stop the others: every entry comes back as
183
+ {"ok", "answer" or "error", "model", "peer"} so you can decide what to do
184
+ with a missing voice. `workers` caps how many run at once.
185
+ """
186
+ from concurrent.futures import ThreadPoolExecutor
187
+ asks = list(asks or [])
188
+
189
+ def one(spec):
190
+ spec = dict(spec or {})
191
+ prompt = spec.pop("prompt", "")
192
+ out = {"ok": True, "model": spec.get("model", ""), "peer": spec.get("peer", "")}
193
+ try:
194
+ out["answer"] = ask(prompt, **spec)
195
+ except Exception as err:
196
+ out.update(ok=False, error=str(err))
197
+ return out
198
+
199
+ if not asks:
200
+ return []
201
+ with ThreadPoolExecutor(max_workers=max(1, min(int(workers), len(asks)))) as pool:
202
+ return list(pool.map(one, asks))
203
+
204
+
140
205
  def models():
141
206
  """Every model this computer can reach, newest answer from the app itself.
142
207
 
@@ -178,7 +243,16 @@ def _alert_matches(event, match):
178
243
  want = [str(w or "").strip().lower() for w in want if str(w or "").strip()]
179
244
  if not want:
180
245
  return True
181
- facets = [str((event or {}).get(k) or "").strip().lower()
246
+ ev = event or {}
247
+ # A TRADE (a fill, a stop, a take-profit rung, a close, a draft signal)
248
+ # answers only to "trade", "trade:SPY" or its trigger id: "*" and a bare
249
+ # symbol keep meaning "my alerts", as they always have.
250
+ if str(ev.get("name") or "").strip().lower() == "trade":
251
+ sym = str(ev.get("symbol") or "").strip().lower()
252
+ tid = str(ev.get("id") or "").strip().lower()
253
+ wanted = {"trade", "trade:%s" % sym if sym else "", tid} - {""}
254
+ return any(w in wanted for w in want)
255
+ facets = [str(ev.get(k) or "").strip().lower()
182
256
  for k in ("label", "id", "symbol", "name", "alert_type")]
183
257
  facets = [f for f in facets if f]
184
258
  return any(w == "*" or w in facets for w in want)
@@ -1226,9 +1300,12 @@ def _grab(m):
1226
1300
  except ImportError:
1227
1301
  pass
1228
1302
  # WITHOUT mss, STILL THE RIGHT SCREEN. pyautogui grabs a region of the
1229
- # virtual desktop, which is all this ever needed; mss is only faster.
1303
+ # virtual desktop, which is all this ever needed; mss is only faster. It
1304
+ # is asked in POINTER steps (a Retina Mac's real pixels would put the
1305
+ # region twice as far out and twice as large).
1306
+ pb = _pointer_box(m)
1230
1307
  try:
1231
- img = _gui().screenshot(region=(box["left"], box["top"], box["width"], box["height"]))
1308
+ img = _gui().screenshot(region=(int(pb["x"]), int(pb["y"]), int(pb["width"]), int(pb["height"])))
1232
1309
  except Exception as err:
1233
1310
  # Some platforms cannot reach a monitor left of the origin, where x is
1234
1311
  # negative. Say so rather than handing back the primary and letting
@@ -1249,7 +1326,7 @@ def _grid_step(w, h):
1249
1326
  return 100 if big <= 1400 else (200 if big <= 3000 else 400)
1250
1327
 
1251
1328
 
1252
- def _draw_grid(img, ox, oy, unit=1.0):
1329
+ def _draw_grid(img, ox, oy, unit=1.0, step=None):
1253
1330
  """A ruler, in the numbers you would actually click.
1254
1331
 
1255
1332
  Reading a position off a picture by eye is the one thing a model is poor at,
@@ -1273,7 +1350,7 @@ def _draw_grid(img, ox, oy, unit=1.0):
1273
1350
  # numbers and then converted, never the other way round: the label has to be
1274
1351
  # a number that can be clicked.
1275
1352
  unit = float(unit) or 1.0
1276
- step = _grid_step(int(w / unit), int(h / unit))
1353
+ step = int(step) if step else _grid_step(int(w / unit), int(h / unit))
1277
1354
  try:
1278
1355
  d = ImageDraw.Draw(img)
1279
1356
  except Exception:
@@ -1679,11 +1756,15 @@ def serve(idle_s=SERVE_IDLE_S):
1679
1756
  pass
1680
1757
 
1681
1758
  last = [_time.time()]
1759
+ busy = [False]
1682
1760
 
1683
1761
  def reap():
1684
1762
  while True:
1685
1763
  _time.sleep(5)
1686
- if _time.time() - last[0] > idle_s:
1764
+ # Idle means NOTHING IN FLIGHT and nothing for a while: a wait()
1765
+ # or a slow page past the quarter hour used to take the process
1766
+ # down mid-command, with the caller left holding a dead socket.
1767
+ if not busy[0] and _time.time() - last[0] > idle_s:
1687
1768
  try:
1688
1769
  os.remove(_serve_note())
1689
1770
  except OSError:
@@ -1722,6 +1803,7 @@ def serve(idle_s=SERVE_IDLE_S):
1722
1803
  if not callable(fn):
1723
1804
  reply = {"ok": False, "error": 'no "%s" here' % req.get("op")}
1724
1805
  else:
1806
+ busy[0] = True
1725
1807
  try:
1726
1808
  out = fn(*(req.get("args") or []), **(req.get("kwargs") or {}))
1727
1809
  reply = {"ok": True, "out": out}
@@ -1730,6 +1812,9 @@ def serve(idle_s=SERVE_IDLE_S):
1730
1812
  # somebody, and every refusal here is written to say
1731
1813
  # what to do next.
1732
1814
  reply = {"ok": False, "error": str(err)}
1815
+ finally:
1816
+ busy[0] = False
1817
+ last[0] = _time.time()
1733
1818
  conn.sendall((_json.dumps(reply, default=str) + "\n").encode("utf-8"))
1734
1819
  except Exception:
1735
1820
  # One bad connection is not a reason to stop serving the rest.
@@ -1964,6 +2049,29 @@ def do(*steps, **kw):
1964
2049
  return out
1965
2050
 
1966
2051
 
2052
+ def _arrive(g, x, y):
2053
+ """Put the pointer on the spot and let the app see it there before a press.
2054
+
2055
+ A pointer that appears on a button and presses in the same instant is a
2056
+ press some apps never see: Chromium and Electron surfaces want the hover
2057
+ before the button, a menu item highlights on arrival, and a drag that
2058
+ starts before the source is hovered picks up nothing (owner, 2026-09-14:
2059
+ "the clicking is sloppy"). pyautogui already waits its own 20ms after the
2060
+ move; ATLAS_COMPUTER_HOVER_MS (default 60, 0 for none, at most 500) is the
2061
+ only thing added on top, and it is the same for every kind of press.
2062
+ """
2063
+ mv = getattr(g, "moveTo", None)
2064
+ if not callable(mv):
2065
+ return
2066
+ mv(x, y)
2067
+ try:
2068
+ pause = float(os.environ.get("ATLAS_COMPUTER_HOVER_MS", "60")) / 1000.0
2069
+ except ValueError:
2070
+ pause = 0.06
2071
+ if pause > 0:
2072
+ __import__("time").sleep(min(pause, 0.5))
2073
+
2074
+
1967
2075
  def click(x, y, button="left", clicks=1, what=""):
1968
2076
  """Press something. SAY WHAT IT IS if you know.
1969
2077
 
@@ -1976,21 +2084,35 @@ def click(x, y, button="left", clicks=1, what=""):
1976
2084
  screen or out of find(); the log is the only place that knowledge can
1977
2085
  survive the click.
1978
2086
  """
1979
- return _do("click", lambda: _gui().click(x=x, y=y, button=button, clicks=clicks),
2087
+ def go():
2088
+ g = _gui()
2089
+ # ARRIVE, THEN PRESS (see _arrive). The press itself names no point:
2090
+ # the pointer is already there, and naming it again is a second move.
2091
+ _arrive(g, x, y)
2092
+ g.click(button=button, clicks=clicks)
2093
+ return _do("click", go,
1980
2094
  {"x": x, "y": y, "button": button, "clicks": clicks,
1981
2095
  "what": str(what)[:80], "screen": _SESSION_SCREEN["index"]},
1982
2096
  points=[(x, y)])
1983
2097
 
1984
2098
 
1985
2099
  def double_click(x, y, what=""):
1986
- return _do("double_click", lambda: _gui().doubleClick(x=x, y=y),
2100
+ def go():
2101
+ g = _gui()
2102
+ _arrive(g, x, y)
2103
+ g.doubleClick()
2104
+ return _do("double_click", go,
1987
2105
  {"x": x, "y": y, "what": str(what)[:80],
1988
2106
  "screen": _SESSION_SCREEN["index"]},
1989
2107
  points=[(x, y)])
1990
2108
 
1991
2109
 
1992
2110
  def right_click(x, y, what=""):
1993
- return _do("right_click", lambda: _gui().rightClick(x=x, y=y),
2111
+ def go():
2112
+ g = _gui()
2113
+ _arrive(g, x, y)
2114
+ g.rightClick()
2115
+ return _do("right_click", go,
1994
2116
  {"x": x, "y": y, "what": str(what)[:80],
1995
2117
  "screen": _SESSION_SCREEN["index"]},
1996
2118
  points=[(x, y)])
@@ -2003,7 +2125,8 @@ def move(x, y):
2003
2125
  def drag(from_x, from_y, to_x, to_y, duration=0.3):
2004
2126
  def go():
2005
2127
  g = _gui()
2006
- g.moveTo(from_x, from_y)
2128
+ # Hovered before the button goes down, or the drag picks up nothing.
2129
+ _arrive(g, from_x, from_y)
2007
2130
  g.dragTo(to_x, to_y, duration=duration, button="left")
2008
2131
  # BOTH ends: a drag that starts here and finishes on the other monitor is
2009
2132
  # the same mistake, made slowly.
@@ -3364,12 +3487,109 @@ def region(x, y, width, height, path=None):
3364
3487
  """
3365
3488
  def take():
3366
3489
  shot = _gui().screenshot(region=(int(x), int(y), int(width), int(height)))
3490
+ # POINTER SPACE, like screen() and look_at(). A Retina Mac hands back
3491
+ # twice the pixels asked for; left at that size, a position read off
3492
+ # the patch was half of where it should be.
3493
+ sc = _capture_scale()
3494
+ if sc > 1.01:
3495
+ try:
3496
+ shot = shot.resize((max(1, int(round(shot.width / sc))), max(1, int(round(shot.height / sc)))))
3497
+ except Exception:
3498
+ pass
3367
3499
  out = path or os.path.join(_session_dir(), "region-%d.png" % int(__import__("time").time() * 1000))
3368
3500
  shot.save(out)
3369
3501
  return out
3370
3502
  return _do("region", take, {"x": x, "y": y, "width": width, "height": height})
3371
3503
 
3372
3504
 
3505
+ def look_at(x, y, radius=100, zoom=3, path=None):
3506
+ """A CLOSE LOOK at one spot before pressing it, with the same ruler.
3507
+
3508
+ The ruler on a whole-screen picture is 100 or 200 desktop pixels apart, and
3509
+ a position judged by eye between two labels is off by ten or twenty: enough
3510
+ to miss a menu item, a checkbox or a small icon (owner, 2026-09-14: "the
3511
+ clicking is sloppy"). This photographs the patch around your guess at
3512
+ `zoom` times its size, with a ruler every 20 desktop pixels and a gold
3513
+ crosshair on the guess itself, so the second reading is a number you count
3514
+ to rather than one you estimate.
3515
+
3516
+ look_at(850, 450) # 200x200 around the guess, x3
3517
+ click(858, 446, what="Sign in") # what the close-up says
3518
+
3519
+ Same numbers in, same numbers on the ruler, as screen() and click(): desktop
3520
+ coordinates, nothing to add. The crosshair is where you SAID. The pointer is
3521
+ drawn too (a cyan ring) when it is inside the patch, so move(x, y) then
3522
+ look_at(x, y) shows exactly where a click would land before it does.
3523
+
3524
+ Returns {"path", "x", "y", "left", "top", "width", "height", "zoom",
3525
+ "grid": 20, "note"}. `radius` is in desktop pixels; the patch is clipped to
3526
+ the screen, so a corner gives a smaller picture rather than a refusal.
3527
+ """
3528
+ x, y = int(x), int(y)
3529
+ # A patch no bigger than a screen's quarter at no more than 5x: the ruler
3530
+ # needs exact picture pixels per desktop step, so the size cap that keeps
3531
+ # a whole-screen picture small cannot apply here, and this is the limit
3532
+ # instead.
3533
+ radius = max(20, min(int(radius), 400))
3534
+ zoom = max(1, min(int(zoom), 5))
3535
+ geo = {}
3536
+
3537
+ def take():
3538
+ from PIL import Image as _Im, ImageDraw as _Dr
3539
+ # AFTER the policy checks in _do (the switch, the claim, the strip, the
3540
+ # screen), so a refusal is the right refusal and is logged like any
3541
+ # other action.
3542
+ m = _current_screen()
3543
+ box = _pointer_box(m)
3544
+ left = max(int(box["x"]), x - radius)
3545
+ top = max(int(box["y"]), y - radius)
3546
+ right = min(int(box["x"]) + int(box["width"]), x + radius)
3547
+ bottom = min(int(box["y"]) + int(box["height"]), y + radius)
3548
+ w, h = right - left, bottom - top
3549
+ if w < 1 or h < 1:
3550
+ raise RuntimeError("this computer did not report its screens, so there is nothing to look at")
3551
+ geo.update({"left": left, "top": top, "width": w, "height": h})
3552
+ hide_consoles()
3553
+ img, _raw = _grab(m)
3554
+ sc = _capture_scale()
3555
+ # The grab is in the display's real pixels; the patch was measured in
3556
+ # pointer steps, and so is `box` (a Retina second monitor starts at
3557
+ # 1440 in pointer steps and 2880 in real pixels). Crop in real pixels
3558
+ # from the POINTER origin, then bring it to `zoom` picture pixels per
3559
+ # pointer step, so the ruler's unit is exact.
3560
+ crop = img.crop((int(round((left - box["x"]) * sc)), int(round((top - box["y"]) * sc)),
3561
+ int(round((right - box["x"]) * sc)), int(round((bottom - box["y"]) * sc))))
3562
+ img = crop.resize((w * zoom, h * zoom), _Im.LANCZOS)
3563
+ img = _draw_grid(img, left, top, unit=float(zoom), step=20)
3564
+ d = _Dr.Draw(img)
3565
+ cx, cy = (x - left) * zoom, (y - top) * zoom
3566
+ gold = (255, 215, 0)
3567
+ gap = 4 * zoom
3568
+ d.line([(cx, 0), (cx, cy - gap)], fill=gold, width=1)
3569
+ d.line([(cx, cy + gap), (cx, img.height)], fill=gold, width=1)
3570
+ d.line([(0, cy), (cx - gap, cy)], fill=gold, width=1)
3571
+ d.line([(cx + gap, cy), (img.width, cy)], fill=gold, width=1)
3572
+ try:
3573
+ px, py = get_cursor()
3574
+ if left <= int(px) < right and top <= int(py) < bottom:
3575
+ qx, qy = (int(px) - left) * zoom, (int(py) - top) * zoom
3576
+ r = 3 * zoom
3577
+ d.ellipse([qx - r, qy - r, qx + r, qy + r], outline=(0, 230, 255), width=2)
3578
+ except Exception:
3579
+ pass
3580
+ out = path or os.path.join(_session_dir(), "look-%d.png" % int(__import__("time").time() * 1000))
3581
+ _save_shot(img, out)
3582
+ return out
3583
+
3584
+ out = _do("look_at", take, {"x": x, "y": y, "radius": radius, "zoom": zoom}, points=[(x, y)])
3585
+ return {"path": out, "x": x, "y": y, "left": geo.get("left"), "top": geo.get("top"),
3586
+ "width": geo.get("width"), "height": geo.get("height"),
3587
+ "zoom": zoom, "grid": 20,
3588
+ "note": ("a close-up around %d, %d at %dx: the ruler is labelled in DESKTOP "
3589
+ "coordinates every 20 pixels - click what it says; the gold crosshair "
3590
+ "is the point you asked about") % (x, y, zoom)}
3591
+
3592
+
3373
3593
  def find(image, confidence=0.9):
3374
3594
  """Where something LOOKS like `image` on screen, as (x, y) of its middle.
3375
3595
 
@@ -525,6 +525,16 @@ export function readAlertTriggers(dir) {
525
525
  /** Does a fired alert (the /api/v1/alerts event) match a project's on_alert list? */
526
526
  export function alertMatches(triggers, event) {
527
527
  if (!triggers || !triggers.length || !event || typeof event !== 'object') return false;
528
+ // A TRADE on the feed (a fill, a stop, a take-profit rung, a close, a draft
529
+ // signal) is reached only by asking for it: "trade", "trade:SPY", or the
530
+ // trigger's id. Never by "*" or a bare symbol, which every project listening
531
+ // before trades joined the feed already uses to mean "my alerts" (2026-09-14).
532
+ if (String(event.name || '').trim().toLowerCase() === 'trade') {
533
+ const sym = String(event.symbol || '').trim().toLowerCase();
534
+ const tid = String(event.id || '').trim().toLowerCase();
535
+ const wanted = new Set(['trade', sym ? `trade:${sym}` : '', tid].filter(Boolean));
536
+ return triggers.some((t) => wanted.has(t));
537
+ }
528
538
  const facets = [event.label, event.id, event.symbol, event.name, event.alert_type]
529
539
  .map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
530
540
  return triggers.some((t) => t === '*' || facets.includes(t));
@@ -1395,7 +1405,7 @@ export async function askModel(
1395
1405
  // named model in one runtime and quietly used the default in the other. That
1396
1406
  // is the shape of bug where an orchestrator believes it asked three models and
1397
1407
  // asked one three times.
1398
- { job = '', tools = true, fresh = false, timeoutMs = 900000, model = '' } = {},
1408
+ { job = '', tools = true, fresh = false, timeoutMs = 900000, model = '', peer = '' } = {},
1399
1409
  ) {
1400
1410
  const url = process.env.ATLAS_ASK_URL;
1401
1411
  const token = process.env.ATLAS_ASK_TOKEN;
@@ -1408,7 +1418,12 @@ export async function askModel(
1408
1418
  const res = await fetch(url, {
1409
1419
  method: 'POST',
1410
1420
  headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
1411
- body: JSON.stringify({ job, prompt: String(prompt), tools, fresh, ...(model ? { model } : {}) }),
1421
+ body: JSON.stringify({
1422
+ job: job || process.env.ATLAS_JOB || '', prompt: String(prompt), tools, fresh,
1423
+ ...(model ? { model } : {}), ...(peer ? { peer } : {}),
1424
+ // Who is asking, so the bridge can refuse a model asking itself.
1425
+ from: process.env.ATLAS_ASK_PEER || '',
1426
+ }),
1412
1427
  signal: AbortSignal.timeout(timeoutMs),
1413
1428
  });
1414
1429
  const out = await res.json().catch(() => ({}));
package/src/util/help.js CHANGED
@@ -37,6 +37,7 @@ export function printRootHelp() {
37
37
  println();
38
38
  println(c.bold('STREAMS'));
39
39
  println(' alerts list [--all] [--symbol SPY] Your alerts, with their ids');
40
+ println(' alerts schema The shape of every feed event (JSON Schema)');
40
41
  println(' alerts delete --id <id> [--id <id2>] [--purge] Switch alerts off, or delete for good');
41
42
  println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
42
43
  println(' [--save-charts <dir>] [--no-chart] [--json]');