mindvest-atlas 0.58.0 → 0.63.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.58.0",
3
+ "version": "0.63.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": {
@@ -1,11 +1,25 @@
1
1
  // `atlas alerts` — subscribe to the live alert-fire SSE feed, print each fire,
2
2
  // and optionally decode the attached chart PNG to a file.
3
+ //
4
+ // ONE STREAM PER COMPUTER, and it outlives the terminal. Both are in
5
+ // `background`: a second stream would hand a reader every event twice, and a
6
+ // stream that dies with the window is the wrong lifetime for something whose
7
+ // job is to be there when an alert fires. `--detach` puts it in its own
8
+ // session, `--stop` ends it, and a plain `atlas alerts` is refused while one is
9
+ // already up.
3
10
  import fs from 'node:fs';
4
11
  import path from 'node:path';
5
12
  import { API } from '../config.js';
6
13
  import { runStream } from '../util/stream-loop.js';
7
14
  import { formatAlert } from '../util/format.js';
8
15
  import { color, println, eprintln } from '../util/ui.js';
16
+ import * as background from '../util/background.js';
17
+
18
+ // Names the slot, the claim file and the log, and it is the command's own name
19
+ // so the advice this prints ("atlas alerts --stop") is always the truth.
20
+ const SLOT = 'alerts';
21
+
22
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
9
23
 
10
24
  function sanitizeFilename(name) {
11
25
  return String(name || '').replace(/[^A-Za-z0-9._-]/g, '_') || 'alert.png';
@@ -13,6 +27,21 @@ function sanitizeFilename(name) {
13
27
 
14
28
  export async function alertsCommand(args) {
15
29
  const flags = args.flags;
30
+ if (flags.stop) return background.stop(SLOT);
31
+ if (flags.detach) return background.detach(SLOT);
32
+ const holder = background.holdUntilStopped(SLOT);
33
+ if (holder) {
34
+ background.alreadyRunning(SLOT, holder);
35
+ return 1;
36
+ }
37
+ try {
38
+ return await stream(flags);
39
+ } finally {
40
+ background.release(SLOT);
41
+ }
42
+ }
43
+
44
+ async function stream(flags) {
16
45
  const qs = new URLSearchParams();
17
46
  if (flags.symbol) qs.set('symbol', String(flags.symbol).toUpperCase());
18
47
  const name = flags.name || flags.type || flags['alert-type'];
@@ -20,8 +49,18 @@ export async function alertsCommand(args) {
20
49
  // --no-chart (flags.chart === false) suppresses chart payloads server-side.
21
50
  if (flags.chart === false || flags['no-chart']) qs.set('chart', '0');
22
51
 
23
- const saveDir = (typeof flags['save-charts'] === 'string' && flags['save-charts'])
52
+ let saveDir = (typeof flags['save-charts'] === 'string' && flags['save-charts'])
24
53
  || (typeof flags.out === 'string' && flags.out);
54
+ // DETACHED, THE CHARTS NEED SOMEWHERE TO GO. With no terminal there is nobody
55
+ // to have passed --save-charts, and the alternative is that every chart lives
56
+ // only as base64 inside a log that the size cap then rolls away. So a
57
+ // background stream saves them beside its log unless it was told not to fetch
58
+ // charts at all, or told where to put them.
59
+ let ownDir = false;
60
+ if (!saveDir && qs.get('chart') !== '0' && background.isBackground(SLOT)) {
61
+ saveDir = background.chartsDir(SLOT);
62
+ ownDir = true;
63
+ }
25
64
  if (saveDir) fs.mkdirSync(saveDir, { recursive: true });
26
65
 
27
66
  const json = !!flags.json;
@@ -31,23 +70,40 @@ export async function alertsCommand(args) {
31
70
  eprintln(color.dim('Listening for alert fires' + (flags.symbol ? ` on ${String(flags.symbol).toUpperCase()}` : '') + (name ? ` (${name})` : '') + '…'));
32
71
  }
33
72
 
34
- return runStream(pathname, {
73
+ const onStream = {
35
74
  label: 'alerts',
36
75
  // `--no-reconnect` parses to flags.reconnect === false (the --no- convention).
37
76
  reconnect: flags.reconnect !== false,
38
77
  onEvent: ({ event, payload }) => {
39
78
  // Decode + save the chart if present and requested.
79
+ let saved = null;
40
80
  if (saveDir && payload.chart && payload.chart.image_base64) {
41
- const file = path.join(saveDir, sanitizeFilename(payload.chart.filename));
81
+ // The server names every chart after its alert ("alert_SPY.png"), so in
82
+ // a stream that runs for months each fire would overwrite the one before
83
+ // it and only the latest per symbol would survive. A stamp in front
84
+ // keeps them all. Only in our OWN directory: somebody who passed
85
+ // --save-charts is relying on the name they already get.
86
+ let base = sanitizeFilename(payload.chart.filename);
87
+ if (ownDir) base = `${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}_${base}`;
88
+ const file = path.join(saveDir, base);
42
89
  try {
43
90
  fs.writeFileSync(file, Buffer.from(payload.chart.image_base64, 'base64'));
91
+ saved = file;
44
92
  if (!json) eprintln(color.green(' ↳ chart saved: ') + file);
45
93
  } catch (err) {
46
94
  eprintln(color.red(' ↳ could not save chart: ') + err.message);
47
95
  }
96
+ if (ownDir) background.pruneDir(background.chartsDir(SLOT));
48
97
  }
49
98
 
50
99
  if (json) {
100
+ // The picture is already on disk, so the line points AT it instead of
101
+ // carrying a second copy as base64. That is what keeps a JSON log small
102
+ // enough to be worth keeping.
103
+ if (saved) {
104
+ const { image_base64: _drop, ...rest } = payload.chart;
105
+ payload = { ...payload, chart: { ...rest, file: saved } };
106
+ }
51
107
  println(JSON.stringify(payload));
52
108
  return;
53
109
  }
@@ -56,5 +112,20 @@ export async function alertsCommand(args) {
56
112
  for (const b of payload.bullets) println(' ' + color.dim(b));
57
113
  }
58
114
  },
59
- });
115
+ };
116
+
117
+ if (flags.reconnect === false) return runStream(pathname, onStream);
118
+
119
+ // RUNS UNTIL SOMEBODY STOPS IT. runStream RETURNS only when it has given up,
120
+ // and it gives up on things that pass: an expired sign-in whose refresh
121
+ // happened to fail, a refusal from the server. A stream that is detached,
122
+ // with nobody watching the terminal, must not end for good on one of those -
123
+ // it would sit there dead and nothing would say so. A clean stop (Ctrl-C, or
124
+ // `--stop`) is the only thing that ends this loop.
125
+ for (;;) {
126
+ const code = await runStream(pathname, onStream);
127
+ if (code === 0) return 0;
128
+ eprintln(color.yellow('● the feed stopped. Trying again in a minute.'));
129
+ await sleep(60_000);
130
+ }
60
131
  }
@@ -195,7 +195,9 @@ async function handleAsk(ask, root) {
195
195
  else if (ask.op === 'publish') {
196
196
  out = await sharing.publish(ask.job || '', root, {
197
197
  name: ask.name, description: ask.description || '', notes: ask.notes || '',
198
- asNew: Boolean(ask.as_new), force: Boolean(ask.force),
198
+ asNew: (ask.as_new === undefined || ask.as_new === null)
199
+ ? null : Boolean(ask.as_new),
200
+ force: Boolean(ask.force),
199
201
  exclude: ask.exclude,
200
202
  });
201
203
  } else if (ask.op === 'unshare') out = await sharing.unpublish(ask.job || '', root);
@@ -49,7 +49,8 @@ const SUB_HELP = `atlas share <state|publish|off|peek|file|install|updates|fetch
49
49
 
50
50
  state --job <name> is this project shared, and its link
51
51
  publish --job <name> [--notes …] put the current files out as a new version
52
- [--as-new] [--force] [--name …] [--description …]
52
+ [--as-new | --same] [--force] [--name …] [--description …]
53
+ --as-new: a different project. --same: the one you already share.
53
54
  [--hold-back runs,notes.md] files to leave out (kept in atlas.json)
54
55
  off --job <name> stop sharing; the link stops working
55
56
  peek --link <id:token> download and describe, without installing
@@ -78,7 +79,8 @@ export async function shareCommand(args) {
78
79
  name: flag(args, 'name'),
79
80
  description: String(flag(args, 'description') || ''),
80
81
  notes: String(flag(args, 'notes') || ''),
81
- asNew: Boolean(flag(args, 'as-new', 'as_new', 'new')),
82
+ asNew: flag(args, 'as-new', 'as_new', 'new') ? true
83
+ : (flag(args, 'same', 'existing') ? false : null),
82
84
  force: Boolean(flag(args, 'force')),
83
85
  exclude: holdBack(args),
84
86
  });
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.58.0';
5
+ export const VERSION = '0.63.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';
@@ -167,10 +167,27 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
167
167
  const deadline = timeoutMs == null ? null : Date.now() + Number(timeoutMs);
168
168
  let attempt = 0;
169
169
  while (deadline == null || Date.now() < deadline) {
170
+ let quiet = null;
171
+ let untilDeadline = null;
170
172
  try {
173
+ // TWO DIFFERENT CLOCKS, and only one of them was here before. The
174
+ // deadline is how long the CALLER still wants to wait. The watchdog is
175
+ // how long SILENCE is allowed on a feed that is meant to be up: without
176
+ // it a half-open socket (a laptop that slept, a NAT that dropped the
177
+ // mapping) never errors and never delivers, so the read below waits for
178
+ // ever on a connection that is already dead, the job looks alive, and no
179
+ // alert arrives again. A keepalive lands every 20s, so a minute of
180
+ // nothing means gone and the reconnect underneath gets its turn.
181
+ const ac = new AbortController();
182
+ const heard = () => {
183
+ if (quiet) clearTimeout(quiet);
184
+ quiet = setTimeout(() => ac.abort(), 60000);
185
+ };
186
+ if (deadline != null) untilDeadline = setTimeout(() => ac.abort(), Math.max(1, deadline - Date.now()));
187
+ heard();
171
188
  const res = await fetch(`${base}/api/v1/alerts?chart=0`, {
172
189
  headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
173
- signal: deadline == null ? undefined : AbortSignal.timeout(Math.max(1, deadline - Date.now())),
190
+ signal: ac.signal,
174
191
  });
175
192
  if (!res.ok || !res.body) throw new Error(`alerts feed ${res.status}`);
176
193
  attempt = 0;
@@ -180,6 +197,7 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
180
197
  for (;;) {
181
198
  const { done, value } = await reader.read();
182
199
  if (done) break;
200
+ heard(); // it spoke, so the silence starts over
183
201
  buffer += decoder.decode(value, { stream: true });
184
202
  let cut;
185
203
  // A BLANK LINE ENDS A FRAME. Both endings, because a feed read on
@@ -210,6 +228,9 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
210
228
  if (deadline != null) nap = Math.min(nap, Math.max(0, deadline - Date.now()));
211
229
  if (nap <= 0) return;
212
230
  await new Promise((r) => setTimeout(r, nap));
231
+ } finally {
232
+ if (quiet) clearTimeout(quiet);
233
+ if (untilDeadline) clearTimeout(untilDeadline);
213
234
  }
214
235
  }
215
236
  }
@@ -226,8 +226,19 @@ def alerts(match=None, timeout=None, reconnect=True):
226
226
  base + "/api/v1/alerts?chart=0",
227
227
  headers={"Authorization": "Bearer " + token, "Accept": "text/event-stream"},
228
228
  )
229
+ # TWO DIFFERENT CLOCKS, and only one of them was here before.
230
+ # `left` is how long the CALLER still wants to wait. The read
231
+ # timeout is how long SILENCE is allowed on a feed that is supposed
232
+ # to be up, and without it a half-open socket - a laptop that
233
+ # slept, a NAT that dropped the mapping, a load balancer that
234
+ # reaped an idle connection - never raises and never delivers: the
235
+ # loop below blocks for ever on a connection that is already dead,
236
+ # the job looks alive, and no alert ever arrives again. The feed
237
+ # sends a keepalive every 20s, so a minute of nothing means gone,
238
+ # and the reconnect underneath finally gets its turn.
229
239
  left = None if deadline is None else max(1, deadline - _time.time())
230
- with urllib.request.urlopen(req, timeout=left) as r:
240
+ quiet = 60 if left is None else min(left, 60)
241
+ with urllib.request.urlopen(req, timeout=quiet) as r:
231
242
  attempt = 0
232
243
  kind, data = "", []
233
244
  for raw in r:
@@ -2421,7 +2432,7 @@ def _sleep_or_stop(seconds, slice_s=1.0):
2421
2432
  return _still_mine()
2422
2433
 
2423
2434
 
2424
- def watch(instruction, every=5, most=200, hours=8, look=True):
2435
+ def watch(instruction, every=5, most=200, hours=8, look=True, model=""):
2425
2436
  """Watch the screen and act when it is time. Runs until it is done.
2426
2437
 
2427
2438
  THE SHAPE OF A JOB THAT IS MOSTLY WAITING (owner, 2026-09-06: "watch this
@@ -2454,6 +2465,24 @@ def watch(instruction, every=5, most=200, hours=8, look=True):
2454
2465
  choosing, and why a model that says "nothing is happening, look again in an
2455
2466
  hour" is doing its job rather than being lazy.
2456
2467
 
2468
+ A SMALL MODEL CAN DO THE WATCHING (owner, 2026-09-10: "a smaller model to
2469
+ watch continuously, then the router is able to route to larger to call tools
2470
+ and do other stuff"). `model=` names who is asked each round, and the round
2471
+ is the cheap half of this: nothing has changed, look again later. It is the
2472
+ ACTING that wants a big model, and the watcher hands that over the same way
2473
+ anything else does, by calling ask() with one:
2474
+
2475
+ watch("Watch the SPY 5-minute chart. Say QUIET and nothing else while "
2476
+ "it is quiet. When the opening range high breaks on rising "
2477
+ "volume, call: "
2478
+ " ask('Break just happened, here is the screen: <path>. Decide "
2479
+ " the trade and place it.', model='claude-opus-5')",
2480
+ every=2, model="claude-haiku-4-5")
2481
+
2482
+ Whoever is watching still has this whole computer, so a watcher that can
2483
+ decide for itself should just act. Route when the decision is worth the
2484
+ bigger model, not on principle. models() lists what this machine can reach.
2485
+
2457
2486
  Ends when the model writes DONE on a line by itself, when `most` rounds have
2458
2487
  passed, or when `hours` have. Pressing Stop ends it like anything else.
2459
2488
  """
@@ -2523,7 +2552,7 @@ def watch(instruction, every=5, most=200, hours=8, look=True):
2523
2552
  else "The screen could not be photographed this round.")
2524
2553
  )
2525
2554
  try:
2526
- answer = ask(asked)
2555
+ answer = ask(asked, model=model)
2527
2556
  except Exception as err:
2528
2557
  answer = ""
2529
2558
  seen.append({"round": rounds, "error": str(err)})
package/src/sharing.js CHANGED
@@ -287,6 +287,35 @@ export function describeProject(dir) {
287
287
  return '';
288
288
  }
289
289
 
290
+ /**
291
+ * Settings in `atlas.json` that describe the AUTHOR's publishing rather than
292
+ * the project's behaviour. They are theirs, they mean nothing in a copy, and
293
+ * they are the shape of remnant nobody would think to look for: an importer
294
+ * opening the share dialog on their own copy found the author's ticks already
295
+ * in it, holding back files they had never chosen to hold back.
296
+ *
297
+ * The schedule, the run line and `on_alert` are NOT here: those are what the
298
+ * project DOES, and a copy that did not do it would not be a copy.
299
+ */
300
+ const AUTHOR_ONLY_SETTINGS = [EXCLUDE_KEY];
301
+
302
+ /**
303
+ * One file's bytes as they should arrive in somebody else's project.
304
+ *
305
+ * Only `atlas.json` is touched, and only to drop the author's own publishing
306
+ * settings. Anything unreadable passes through untouched: a file we cannot
307
+ * parse is a file we have no business editing.
308
+ */
309
+ function forTheCopy(rel, data) {
310
+ if (rel !== 'atlas.json') return data;
311
+ let raw;
312
+ try { raw = JSON.parse(data.toString('utf8')); } catch { return data; }
313
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return data;
314
+ if (!AUTHOR_ONLY_SETTINGS.some((k) => k in raw)) return data;
315
+ for (const key of AUTHOR_ONLY_SETTINGS) delete raw[key];
316
+ return Buffer.from(JSON.stringify(raw, null, 2));
317
+ }
318
+
290
319
  export function packProject(job, root, { name, description = '', exclude } = {}) {
291
320
  const dir = projectDir(job, root);
292
321
  if (!fs.existsSync(dir)) return { ok: false, error: `There is no project called ${job} here.` };
@@ -303,6 +332,10 @@ export function packProject(job, root, { name, description = '', exclude } = {})
303
332
  for (const rel of files) {
304
333
  let data;
305
334
  try { data = fs.readFileSync(path.join(dir, rel)); } catch { continue; }
335
+ // Hashed AFTER, so the manifest describes what actually arrives — an update
336
+ // comparing against a hash of something else would report every copy as
337
+ // locally modified.
338
+ data = forTheCopy(rel, data);
306
339
  hashes[rel] = sha(data);
307
340
  entries.push({ name: rel, data });
308
341
  }
@@ -321,7 +354,7 @@ export function packProject(job, root, { name, description = '', exclude } = {})
321
354
  // ── publishing ─────────────────────────────────────────────────────────────
322
355
 
323
356
  export async function publish(job, root, {
324
- name, description = '', notes = '', asNew = false, force = false, exclude,
357
+ name, description = '', notes = '', asNew = null, force = false, exclude,
325
358
  } = {}) {
326
359
  const dir = projectDir(job, root);
327
360
  // The choice is REMEMBERED before it is used, so the next publish holds the
@@ -332,6 +365,27 @@ export async function publish(job, root, {
332
365
  if (!packed.ok) return packed;
333
366
 
334
367
  const pin = readPin(dir) || {};
368
+ // WHOSE PACKAGE THIS PUBLISH IS A VERSION OF.
369
+ //
370
+ // `.atlas-source.json` answers two questions with one field: on the author's
371
+ // own folder `package_id` is "the package I publish", on an IMPORTED copy it
372
+ // is "the package I came from", which belongs to somebody else. Sending it
373
+ // either way asked the server to publish a new version of a stranger's
374
+ // package and came back "That package is not yours, or no longer exists" —
375
+ // a refusal about ownership, worded as if the project had vanished (owner
376
+ // report 2026-09-09; "this should create its own link").
377
+ //
378
+ // A copy you have changed is your own work built on theirs: it publishes as
379
+ // YOUR package, under your own link, with where it came from recorded rather
380
+ // than pretended away.
381
+ const mine = Boolean(pin.author) || !pin.package_id;
382
+ const upstream = mine ? null : {
383
+ package_id: pin.package_id,
384
+ version: pin.version,
385
+ share: pin.share,
386
+ name: pin.name,
387
+ author_name: pin.author_name,
388
+ };
335
389
  const fields = {
336
390
  slug: path.basename(dir),
337
391
  name: name || pin.name || path.basename(dir),
@@ -344,9 +398,14 @@ export async function publish(job, root, {
344
398
  machine_id: machineId(),
345
399
  machine_name: computerName(),
346
400
  };
347
- if (pin.package_id) fields.package_id = String(pin.package_id);
348
- if (pin.version) fields.base_version = String(pin.version);
349
- if (asNew) fields.as_new = 'true';
401
+ // Only an author's own pin names the package being versioned. A copy has no
402
+ // package of its own yet, so it asks for one.
403
+ if (mine && pin.package_id) fields.package_id = String(pin.package_id);
404
+ if (mine && pin.version) fields.base_version = String(pin.version);
405
+ // Three-valued: null = nobody has been asked yet (the server may come back
406
+ // with the slug conflict), true = "a new project", false = "yes, that one —
407
+ // publish its next version". Sent only when there IS an answer.
408
+ if (asNew !== null && asNew !== undefined) fields.as_new = asNew ? 'true' : 'false';
350
409
  if (force) fields.force = 'true';
351
410
 
352
411
  let status; let data;
@@ -376,13 +435,24 @@ export async function publish(job, root, {
376
435
  // button has a LINK to offer, not a line of hex.
377
436
  share_url: data.share_url,
378
437
  name: fields.name,
438
+ // WHAT THE PROJECT IS, KEPT (owner, 2026-09-09: "when publishing changes,
439
+ // it always ask what the project is for — if the user has added this just
440
+ // recall it"). It was sent to the server and forgotten locally, so the
441
+ // publish dialog opened with an empty box on every single release and the
442
+ // author retyped the same sentence or, more often, shipped without one.
443
+ description: fields.description,
379
444
  author: true,
445
+ // Where it started, kept for as long as the copy exists. Provenance, not a
446
+ // subscription: the update check follows `package_id`, which is now this
447
+ // person's own package.
448
+ ...(upstream ? { forked_from: upstream } : {}),
380
449
  files: packed.manifest.files,
381
450
  at: Math.floor(Date.now() / 1000),
382
451
  });
383
452
  return {
384
453
  ok: true, share: data.share, share_url: data.share_url, package_id: data.package_id,
385
454
  version: data.version, requires: packed.manifest.requires,
455
+ ...(upstream ? { forked_from: upstream } : {}),
386
456
  };
387
457
  }
388
458
 
@@ -454,9 +524,16 @@ export function shareState(job, root) {
454
524
  share: pin.share,
455
525
  package_id: pin.package_id,
456
526
  version: pin.version,
457
- // An imported copy is somebody else's project: updatable, never publishable.
527
+ // Whether this folder is the one YOU publish. An imported copy starts
528
+ // False; publishing it makes a package of your own and flips it. Not a lock
529
+ // on the button — a copy you have changed is your own work, and the link it
530
+ // gets is your own.
458
531
  mine: Boolean(pin.author),
459
532
  author_name: pin.author_name,
533
+ forked_from: pin.forked_from,
534
+ // What this project IS, as last published. The publish dialog seeds its
535
+ // box from here, so a re-release starts with the sentence already in it.
536
+ description: pin.description,
460
537
  };
461
538
  }
462
539
 
@@ -595,6 +672,46 @@ function extract(zipPath, target, priorHashes) {
595
672
  return { written, kept, backed };
596
673
  }
597
674
 
675
+ /**
676
+ * Empty a folder into `.atlas-backup/<ts>/`, and say what moved.
677
+ *
678
+ * A COPY ARRIVES UNSEEDED (owner rule 2026-09-09: "when someone sends a copy,
679
+ * or when they fork out, let everything be unseeded — there is an issue where
680
+ * the seeding is even affecting the keys").
681
+ *
682
+ * An install used to write the package's files ON TOP of whatever was already
683
+ * in the folder and remove nothing, so a project taking the name of one that
684
+ * was there before inherited it: its leftover scripts, its `runs/`, its
685
+ * `trades.json`, its `atlas.json` schedule — and its `.env`, which is how a
686
+ * brand-new copy came up already holding somebody else's keys. Files the
687
+ * package happened to also contain were replaced; everything else stayed,
688
+ * mixed in and indistinguishable from the copy itself.
689
+ *
690
+ * So a NEW project starts on an empty folder. Nothing is deleted — the old
691
+ * contents move, whole, into `.atlas-backup/<ts>/`, which is where this module
692
+ * already puts anything it would otherwise overwrite — and the backup folder
693
+ * itself stays put, so a second import never buries the first.
694
+ *
695
+ * An UPDATE of the same package does NOT come through here: keeping your keys
696
+ * and your edits across an update is the entire point of the pin.
697
+ */
698
+ function sweepAside(target) {
699
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
700
+ const keep = path.join(target, BACKUP_DIR, stamp);
701
+ const moved = [];
702
+ let entries = [];
703
+ try { entries = fs.readdirSync(target).sort(); } catch { return moved; }
704
+ for (const name of entries) {
705
+ if (name === BACKUP_DIR) continue; // where things go; never a thing that goes
706
+ try {
707
+ fs.mkdirSync(keep, { recursive: true });
708
+ fs.renameSync(path.join(target, name), path.join(keep, name));
709
+ moved.push(name);
710
+ } catch { /* unmovable (locked, in use) — one leftover, not a failed install */ }
711
+ }
712
+ return moved;
713
+ }
714
+
598
715
  export function install(packageId, root, { job } = {}) {
599
716
  const stage = stagingDir(root, packageId);
600
717
  const zipPath = path.join(stage, 'package.zip');
@@ -609,10 +726,18 @@ export function install(packageId, root, { job } = {}) {
609
726
 
610
727
  const slug = safeSlug(job || manifest.slug || 'project');
611
728
  const target = projectDir(slug, root);
612
- const prior = (readPin(target) || {}).files || {};
729
+ const pinBefore = readPin(target) || {};
730
+ let prior = pinBefore.files || {};
731
+ // AN UPDATE, or A NEW PROJECT LANDING HERE? The pin tells them apart: same
732
+ // package = the author published again and this copy moves forward (keys,
733
+ // edits and all). Anything else — no pin, or a pin for a different package —
734
+ // is a new project taking this folder, and it starts clean. See sweepAside.
735
+ const isUpdate = String(pinBefore.package_id || '') === String(packageId);
613
736
  try { fs.mkdirSync(target, { recursive: true }); } catch (err) {
614
737
  return { ok: false, error: `Could not make the folder: ${err.message}` };
615
738
  }
739
+ const swept = isUpdate ? [] : sweepAside(target);
740
+ if (swept.length) prior = {}; // nothing here came from this package
616
741
 
617
742
  let result;
618
743
  try { result = extract(zipPath, target, prior); } catch (err) {
@@ -637,6 +762,10 @@ export function install(packageId, root, { job } = {}) {
637
762
  written: result.written,
638
763
  kept: result.kept,
639
764
  backed_up: result.backed,
765
+ // What was in the folder before and is now in `.atlas-backup/`. Named so a
766
+ // screen can say it: somebody who had something here deserves to be told
767
+ // where it went, and somebody who did not sees an empty list.
768
+ moved_aside: swept,
640
769
  requires,
641
770
  missing_keys: missingKeys(target, requires, root),
642
771
  };
@@ -0,0 +1,385 @@
1
+ // One named background process per computer: claim it, detach it, stop it.
2
+ //
3
+ // `atlas alerts` holds a live connection to the alert feed. Two things follow
4
+ // from that, and this is where both are answered:
5
+ //
6
+ // ONE AT A TIME. The stream is a thing you can act on, so a second one on the
7
+ // same computer is a second copy of every event: doubled lines in the log a
8
+ // reader is tailing, and doubled work in whatever reads it. The slot is
9
+ // CHECKED, never assumed - a claim whose process is gone, or that has not said
10
+ // it is here inside STALE_MS, is holding nothing, so a crash costs the next
11
+ // start no wait.
12
+ //
13
+ // IT SHOULD OUTLIVE THE TERMINAL. A stream that dies when you close the
14
+ // window, or when you run another atlas command, is the wrong lifetime for
15
+ // something whose whole job is to be there when an alert fires. `--detach`
16
+ // gives it its own session; `--stop` ends it.
17
+ //
18
+ // State lives beside the CLI's own config, not in a workspace, because a stream
19
+ // has nothing to do with any project folder.
20
+ //
21
+ // Mirror of python/atlas_cli/background.py.
22
+ import { spawn, spawnSync } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { configDir } from '../config.js';
26
+ import { color, println, eprintln } from './ui.js';
27
+
28
+ /**
29
+ * This process, named. Regenerated every start, so a claim written by an
30
+ * EARLIER one is recognisable as belonging to a process that is gone.
31
+ */
32
+ export const PROCESS_ID = `${Date.now()}-${process.pid}`;
33
+
34
+ /** How often a background process says it is still here, and how long that is
35
+ * worth believing. Three missed beats means it is not coming back. */
36
+ export const BEAT_MS = 20_000;
37
+ export const STALE_MS = 90_000;
38
+
39
+ /** How large the log may get, and how often that is checked. A stream meant to
40
+ * run for months writes for months, and an uncapped log is a disk that fills
41
+ * up silently at 3am. */
42
+ export const LOG_MAX_BYTES = Number(process.env.ATLAS_LOG_MAX_BYTES) || 16 * 1024 * 1024;
43
+ export const LOG_CHECK_MS = 60_000;
44
+
45
+ /** The same, for saved charts. One PNG per fire across every alert adds up. */
46
+ export const CHARTS_MAX_BYTES = Number(process.env.ATLAS_CHARTS_MAX_BYTES) || 256 * 1024 * 1024;
47
+
48
+ /** Set on the child `detach` starts, so it can tell it has no terminal: that is
49
+ * what makes it keep its own log in check and save charts somewhere findable
50
+ * rather than assuming a person is watching. */
51
+ export const BACKGROUND_ENV = 'ATLAS_BACKGROUND';
52
+
53
+ /** True inside the process `detach` started. */
54
+ export function isBackground(name) {
55
+ const got = (process.env[BACKGROUND_ENV] || '').trim();
56
+ return !!got && (name === undefined || got === name);
57
+ }
58
+
59
+ export const chartsDir = (name) => path.join(configDir(), `${name}-charts`);
60
+
61
+ /** How many full logs are kept behind the live one. Total on disk is therefore
62
+ * bounded by LOG_MAX_BYTES * (LOG_KEEP + 1). */
63
+ export const LOG_KEEP = 5;
64
+
65
+ export const oldLog = (name, i) => path.join(configDir(), `${name}.${i}.log`);
66
+
67
+ /**
68
+ * Full? Start a NEW file, and shift the old ones along.
69
+ *
70
+ * `alerts.log` is always the live one, `alerts.1.log` the last full one, and so
71
+ * on to LOG_KEEP, past which the oldest is dropped. So the name a reader tails
72
+ * never changes, and history stays in whole files instead of a window that
73
+ * keeps eating its own beginning.
74
+ *
75
+ * THE WRITER IS THIS PROCESS. Renaming alone would not be enough: our stdout is
76
+ * an append-mode fd on the file we just moved, so we would go on writing into
77
+ * `alerts.1.log` and the new `alerts.log` would stay empty for ever. Node has
78
+ * no dup2, but close(1) then open() hands back the lowest free descriptor,
79
+ * which IS 1 - that is what moves the writing.
80
+ */
81
+ export function rollLog(name, cap = LOG_MAX_BYTES) {
82
+ const log = logFile(name);
83
+ try {
84
+ if (fs.statSync(log).size < cap) return false;
85
+ } catch {
86
+ return false;
87
+ }
88
+ try {
89
+ const oldest = oldLog(name, LOG_KEEP);
90
+ if (fs.existsSync(oldest)) fs.unlinkSync(oldest);
91
+ for (let i = LOG_KEEP - 1; i >= 1; i -= 1) {
92
+ const src = oldLog(name, i);
93
+ if (fs.existsSync(src)) fs.renameSync(src, oldLog(name, i + 1));
94
+ }
95
+ fs.renameSync(log, oldLog(name, 1));
96
+ } catch {
97
+ // Windows will not rename a file this process holds open. Falling back
98
+ // keeps the log bounded there too, just as a window rather than files.
99
+ return trimLog(log, cap);
100
+ }
101
+ // ONLY THE WRITER MOVES ITS OWN OUTPUT. These are fd 1 and 2 of THIS process,
102
+ // so anything else calling rollLog (a test, a tidy-up) would otherwise find
103
+ // its own stdout pointing into the log it just rolled.
104
+ if (isBackground(name)) {
105
+ for (const want of [1, 2]) {
106
+ try {
107
+ fs.closeSync(want);
108
+ const got = fs.openSync(log, 'a');
109
+ if (got !== want) { // somebody took it first: do not write blind
110
+ fs.closeSync(got);
111
+ return true;
112
+ }
113
+ } catch { /* nothing more we can do about this descriptor */ }
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * Keep the END of the log and drop the beginning, IN PLACE.
121
+ *
122
+ * The fallback for when rolling is not possible (Windows holds the open file
123
+ * against a rename). The last line before the cut is dropped rather than left
124
+ * half-written, so a reader parsing one JSON object per line never meets a
125
+ * broken one.
126
+ */
127
+ export function trimLog(file, cap = LOG_MAX_BYTES) {
128
+ let size;
129
+ try {
130
+ size = fs.statSync(file).size;
131
+ } catch {
132
+ return false;
133
+ }
134
+ if (size <= cap) return false;
135
+ const keep = Math.floor(cap / 2);
136
+ try {
137
+ const fd = fs.openSync(file, 'r');
138
+ const buf = Buffer.alloc(keep);
139
+ fs.readSync(fd, buf, 0, keep, size - keep);
140
+ fs.closeSync(fd);
141
+ const cut = buf.indexOf(0x0a);
142
+ const tail = cut === -1 ? buf : buf.subarray(cut + 1);
143
+ fs.writeFileSync(file, Buffer.concat([
144
+ Buffer.from('... earlier lines dropped: the log passed its size cap ...\n'), tail,
145
+ ]));
146
+ } catch {
147
+ return false;
148
+ }
149
+ return true;
150
+ }
151
+
152
+ /** Keep a directory under a size cap, oldest file first. */
153
+ export function pruneDir(dir, cap = CHARTS_MAX_BYTES) {
154
+ let files;
155
+ try {
156
+ files = fs.readdirSync(dir).map((n) => {
157
+ const f = path.join(dir, n);
158
+ const st = fs.statSync(f);
159
+ return st.isFile() ? { f, mtime: st.mtimeMs, size: st.size } : null;
160
+ }).filter(Boolean);
161
+ } catch {
162
+ return 0;
163
+ }
164
+ let total = files.reduce((a, x) => a + x.size, 0);
165
+ if (total <= cap) return 0;
166
+ let dropped = 0;
167
+ for (const x of files.sort((a, b) => a.mtime - b.mtime)) {
168
+ if (total <= cap) break;
169
+ try {
170
+ fs.unlinkSync(x.f);
171
+ total -= x.size;
172
+ dropped += 1;
173
+ } catch { /* someone else got there first */ }
174
+ }
175
+ return dropped;
176
+ }
177
+
178
+ /** Keep this background process's own log under the cap, for as long as it runs. */
179
+ export function startLogCap(name) {
180
+ const charts = chartsDir(name);
181
+ const t = setInterval(() => {
182
+ try {
183
+ rollLog(name);
184
+ if (fs.existsSync(charts)) pruneDir(charts);
185
+ } catch { /* tidying is never worth dying over */ }
186
+ }, LOG_CHECK_MS);
187
+ t.unref?.();
188
+ }
189
+
190
+ export const claimFile = (name) => path.join(configDir(), `${name}.json`);
191
+ export const logFile = (name) => path.join(configDir(), `${name}.log`);
192
+
193
+ /** {id, pid, beat} of whoever holds this slot, or null. */
194
+ export function readClaim(name) {
195
+ let raw;
196
+ try {
197
+ raw = JSON.parse(fs.readFileSync(claimFile(name), 'utf8'));
198
+ } catch {
199
+ return null;
200
+ }
201
+ if (!raw || typeof raw !== 'object' || !raw.id) return null;
202
+ return { id: String(raw.id), pid: Number(raw.pid) || 0, beat: Number(raw.beat) || 0 };
203
+ }
204
+
205
+ function pidAlive(pid) {
206
+ if (!pid || pid <= 0) return false;
207
+ try {
208
+ process.kill(pid, 0);
209
+ return true;
210
+ } catch (err) {
211
+ return err.code === 'EPERM'; // someone else's, but it exists
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The OTHER process holding this slot right now, or null.
217
+ *
218
+ * HELD IS A CHECKED FACT. A file on disk only says somebody once started; it
219
+ * takes a live pid and a recent beat to say somebody is still going.
220
+ */
221
+ export function heldByOther(name, nowMs = Date.now()) {
222
+ const info = readClaim(name);
223
+ if (!info || info.id === PROCESS_ID) return null;
224
+ if (nowMs - info.beat * 1000 > STALE_MS) return null;
225
+ if (info.pid && !pidAlive(info.pid)) return null;
226
+ return info;
227
+ }
228
+
229
+ /** Say this process is still here. False once the slot is somebody else's. */
230
+ export function beat(name) {
231
+ if (heldByOther(name)) return false;
232
+ try {
233
+ const f = claimFile(name);
234
+ fs.mkdirSync(path.dirname(f), { recursive: true });
235
+ fs.writeFileSync(f, JSON.stringify({ id: PROCESS_ID, pid: process.pid, beat: Date.now() / 1000 }));
236
+ } catch { /* a beat that cannot be written is not fatal */ }
237
+ return true;
238
+ }
239
+
240
+ /** Take the slot, or return the holder that already has it. */
241
+ export function claim(name, nowMs = Date.now()) {
242
+ const holder = heldByOther(name, nowMs);
243
+ if (holder) return holder;
244
+ beat(name);
245
+ // Two starts in the same instant can both find it free. The file ends up with
246
+ // one name in it, so read it back: whoever is not in it stands down.
247
+ const again = readClaim(name);
248
+ if (again && again.id !== PROCESS_ID) return again;
249
+ return null;
250
+ }
251
+
252
+ /** Give the slot up on the way out. Only ever ours to drop. */
253
+ export function release(name) {
254
+ try {
255
+ const info = readClaim(name);
256
+ if (info && info.id !== PROCESS_ID) return;
257
+ fs.unlinkSync(claimFile(name));
258
+ } catch { /* nothing there to give up */ }
259
+ }
260
+
261
+ /**
262
+ * Claim the slot for this process and arrange to give it back.
263
+ *
264
+ * Returns the holder when the slot is taken (the caller should stop), or null
265
+ * once it is ours.
266
+ */
267
+ export function holdUntilStopped(name) {
268
+ const holder = claim(name);
269
+ if (holder) return holder;
270
+ const go = () => { release(name); process.exit(0); };
271
+ process.once('SIGTERM', go); // `--stop` asks with this
272
+ process.once('exit', () => release(name));
273
+ const t = setInterval(() => { try { beat(name); } catch { /* not fatal */ } }, BEAT_MS);
274
+ t.unref?.(); // a heartbeat must not hold the process up
275
+ if (isBackground(name)) startLogCap(name);
276
+ return null;
277
+ }
278
+
279
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
280
+
281
+ /**
282
+ * Run this same command again, off the terminal, and hand the prompt back.
283
+ *
284
+ * Same command, same flags, its own session, so nothing about how it behaves
285
+ * depends on having been detached.
286
+ *
287
+ * A pid is not a running stream. This waits for the child to CLAIM the slot
288
+ * before it says anything started, because "we spawned something" and "it is
289
+ * up" are different facts and only the second is worth printing.
290
+ */
291
+ export async function detach(name, flag = '--detach') {
292
+ const holder = heldByOther(name);
293
+ if (holder) {
294
+ alreadyRunning(name, holder);
295
+ return 1;
296
+ }
297
+
298
+ const log = logFile(name);
299
+ let child;
300
+ let fd;
301
+ try {
302
+ fs.mkdirSync(path.dirname(log), { recursive: true });
303
+ fd = fs.openSync(log, 'a');
304
+ const argv = process.argv.slice(2).filter((a) => a !== flag && a !== `${flag}=true`);
305
+ child = spawn(process.execPath, [process.argv[1], ...argv], {
306
+ detached: true, // its own session: closing the window
307
+ stdio: ['ignore', fd, fd], // cannot hang it up
308
+ windowsHide: true,
309
+ env: { ...process.env, [BACKGROUND_ENV]: name },
310
+ });
311
+ child.unref();
312
+ } catch (err) {
313
+ eprintln(color.red(`Could not start it in the background: ${err.message}`));
314
+ return 1;
315
+ } finally {
316
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* the child holds it now */ } }
317
+ }
318
+
319
+ let exited = false;
320
+ child.once('exit', () => { exited = true; });
321
+ const end = Date.now() + 15_000;
322
+ while (Date.now() < end) {
323
+ const info = readClaim(name);
324
+ if (info && info.pid === child.pid) {
325
+ println(color.green('Streaming in the background.') + color.dim(` pid ${child.pid}`));
326
+ println(color.dim(` Log: ${log}`));
327
+ println(color.dim(` Read: tail -f ${log}`));
328
+ println(color.dim(` Stop: atlas ${name} --stop`));
329
+ return 0;
330
+ }
331
+ if (exited) break; // it stopped instead of taking the slot
332
+ await sleep(200);
333
+ }
334
+ eprintln(color.red('It started but never took the slot.'));
335
+ eprintln(color.dim(` What it wrote: ${log}`));
336
+ return 1;
337
+ }
338
+
339
+ /**
340
+ * Stop the background process holding this slot, detached or not.
341
+ *
342
+ * STOPPED IS A FACT WE CHECK, not one we assume: asking is not the same as it
343
+ * having happened. This waits for the slot to come free and says which of the
344
+ * two actually did.
345
+ */
346
+ export async function stop(name) {
347
+ const holder = heldByOther(name);
348
+ if (!holder) {
349
+ println(color.dim('Nothing is streaming here.'));
350
+ return 0;
351
+ }
352
+ if (!holder.pid) {
353
+ eprintln(color.red('Something holds the slot but wrote no pid, so there is nothing '
354
+ + `to stop. It frees itself within ${STALE_MS / 1000}s.`));
355
+ return 1;
356
+ }
357
+ try {
358
+ if (process.platform === 'win32') {
359
+ spawnSync('taskkill', ['/PID', String(holder.pid), '/T', '/F'], { stdio: 'ignore' });
360
+ } else {
361
+ process.kill(holder.pid, 'SIGTERM');
362
+ }
363
+ } catch (err) {
364
+ eprintln(color.red(`Could not stop pid ${holder.pid}: ${err.message}`));
365
+ return 1;
366
+ }
367
+ const end = Date.now() + 15_000;
368
+ while (Date.now() < end) {
369
+ if (heldByOther(name) === null) {
370
+ println(color.green('Stopped.') + color.dim(` (pid ${holder.pid})`));
371
+ return 0;
372
+ }
373
+ await sleep(200);
374
+ }
375
+ eprintln(color.yellow(`Asked pid ${holder.pid} to stop, and it is still going after 15s.`));
376
+ return 1;
377
+ }
378
+
379
+ /** Say who has the slot, for a caller that has just been refused. */
380
+ export function alreadyRunning(name, holder) {
381
+ eprintln(color.red('Already streaming on this computer.') + color.dim(` pid ${holder.pid}`));
382
+ eprintln(color.dim(' One stream at a time, so a reader never sees an event twice.'));
383
+ eprintln(color.dim(` Stop it with: atlas ${name} --stop`));
384
+ eprintln(color.dim(` Or watch what it writes: tail -f ${logFile(name)}`));
385
+ }
package/src/util/help.js CHANGED
@@ -30,6 +30,7 @@ export function printRootHelp() {
30
30
  println(c.bold('STREAMS'));
31
31
  println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
32
32
  println(' [--save-charts <dir>] [--no-chart] [--json]');
33
+ println(' [--detach] [--stop] Stream in the background, or stop it');
33
34
  println(' flow stream --symbol NVDA [--side call] Live options-flow feed (SSE)');
34
35
  println(' remote-control [--dir DIR] Let Atlas run this computer\'s jobs');
35
36
  println(' update-cli Install the newest Atlas CLI');