mindvest-atlas 0.2.0 → 0.4.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,7 +1,7 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.2.0",
4
- "description": "Atlas CLI OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
3
+ "version": "0.4.0",
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": {
7
7
  "atlas": "bin/atlas.js"
@@ -1,6 +1,7 @@
1
1
  // Loopback redirect catcher for the OAuth flow (RFC 8252 §7.3). Binds an
2
2
  // ephemeral port on 127.0.0.1, advertises it as the redirect_uri, and resolves
3
3
  // once the browser is redirected to /callback?code=...
4
+ import { LOGO_DATA_URI } from '../brand.js';
4
5
  import http from 'node:http';
5
6
  import { REDIRECT_PATH } from '../config.js';
6
7
 
@@ -9,25 +10,22 @@ const PAGE = (title, heading, body, accent) => `<!doctype html>
9
10
  <meta name="viewport" content="width=device-width, initial-scale=1">
10
11
  <title>${title}</title>
11
12
  <style>
12
- :root { color-scheme: light dark; }
13
- body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
14
- font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
15
- background:#0b0d12; color:#e6e8ee; }
16
- .card { max-width:420px; padding:40px 36px; border-radius:16px; background:#151923;
17
- box-shadow:0 10px 40px rgba(0,0,0,.45); text-align:center; }
18
- .badge { width:56px; height:56px; border-radius:50%; margin:0 auto 20px; display:flex;
19
- align-items:center; justify-content:center; font-size:28px; background:${accent}22; color:${accent}; }
20
- h1 { font-size:20px; margin:0 0 8px; }
21
- p { font-size:14px; line-height:1.5; color:#9aa3b2; margin:0; }
22
- .brand { margin-top:24px; font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:#5b6472; }
23
- code { background:#0b0d12; padding:1px 6px; border-radius:6px; color:#cbd2e0; }
13
+ :root { color-scheme: dark; }
14
+ body { margin:0; min-height:100vh; display:flex; flex-direction:column;
15
+ align-items:center; justify-content:center; text-align:center; gap:22px;
16
+ font-family:'Roboto',-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
17
+ background:#0e151d; color:#ffffff; padding:24px; }
18
+ img { width:72px; height:auto; display:block; }
19
+ h1 { font-size:26px; font-weight:700; letter-spacing:-.3px; margin:0; color:#ffffff; }
20
+ p { font-size:16px; line-height:1.6; margin:0; max-width:44ch; color:#ffffff; }
21
+ .tick { font-size:22px; color:${accent}; }
22
+ code { background:rgba(255,255,255,.08); padding:2px 7px; border-radius:6px; color:#ffffff; }
24
23
  </style></head>
25
- <body><div class="card">
26
- <div class="badge">${heading}</div>
24
+ <body>
25
+ <img src="${LOGO_DATA_URI}" alt="Atlas">
26
+ <div class="tick">${heading}</div>
27
27
  ${body}
28
- <div class="brand" style="color:#5b6472">Atlas CLI</div>
29
- </div>
30
- <script>setTimeout(function(){ try { window.close(); } catch (e) {} }, 1200);</script>
28
+ <script>setTimeout(function(){ try { window.close(); } catch (e) {} }, 1600);</script>
31
29
  </body></html>`;
32
30
 
33
31
  const SUCCESS_HTML = PAGE(
package/src/brand.js ADDED
@@ -0,0 +1,7 @@
1
+ // The Atlas mark, inlined.
2
+ //
3
+ // The sign-in page is served from an ephemeral localhost port and is the only
4
+ // thing a person sees at the end of the login flow, so it has to stand on its
5
+ // own with no network: a data URI is the only way to put a logo on it. Shrunk
6
+ // to 128px (6 KB) because it is rendered once, at 72px, on one screen.
7
+ export const LOGO_DATA_URI = 'data:image/png;base64,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';
package/src/cli.js CHANGED
@@ -12,6 +12,8 @@ import { whoamiCommand } from './commands/whoami.js';
12
12
  import { toolsCommand } from './commands/tools.js';
13
13
  import { alertsCommand } from './commands/alerts.js';
14
14
  import { flowCommand } from './commands/flow.js';
15
+ import { remoteControlCommand } from './commands/remote-control.js';
16
+ import { updateCliCommand } from './commands/update-cli.js';
15
17
  import { historicalContractCommand } from './commands/historical.js';
16
18
  import { callCommand } from './commands/call.js';
17
19
 
@@ -47,6 +49,14 @@ export async function main(rawArgs) {
47
49
  case 'tools': return await toolsCommand(withoutCommand(args));
48
50
  case 'alerts': return await alertsCommand(withoutCommand(args));
49
51
  case 'flow': return await flowCommand(withoutCommand(args));
52
+ // Long-lived: hold the job stream and run this computer's jobs.
53
+ // Put the newest CLI on this machine.
54
+ case 'update-cli':
55
+ case 'self-update':
56
+ return await updateCliCommand(withoutCommand(args));
57
+ case 'remote-control':
58
+ case 'remote_control':
59
+ return await remoteControlCommand(withoutCommand(args));
50
60
  // Friendly front end over Historical-Contract-Greeks (short flags + a table).
51
61
  case 'historical-contract':
52
62
  case 'hist-contract':
@@ -0,0 +1,153 @@
1
+ // `atlas remote-control` — let Atlas run this computer's jobs.
2
+ //
3
+ // down jobs, over SSE. The server owns the schedule and the market
4
+ // calendar, and is the only thing that knows a run was MISSED.
5
+ // up the run record, over plain POST to /api/local/runs.
6
+ // never the web talking to this machine. This process dials out, so there
7
+ // is no inbound port, no NAT traversal and no SSH.
8
+ //
9
+ // A job that could NOT run is reported too: silence is indistinguishable from
10
+ // a schedule that quietly broke, which is the failure the record exists to make
11
+ // visible.
12
+ import { spawn } from 'node:child_process';
13
+ import { API } from '../config.js';
14
+ import { requestJson } from '../api/client.js';
15
+ import { runStream } from '../util/stream-loop.js';
16
+ import { color, eprintln } from '../util/ui.js';
17
+ import {
18
+ DEFAULT_TIMEOUT_MS, computerName, jobEnv, staleness, summarize, tail, workspace, workspaceRoot,
19
+ } from '../remote-control.js';
20
+ import fs from 'node:fs';
21
+
22
+ const iso = (ms) => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');
23
+
24
+ /** POST one run record. Best-effort: a failed report must not take down the
25
+ * runner, and the next job still deserves to run. */
26
+ async function report(job, status, started, finished, extra = {}) {
27
+ try {
28
+ await requestJson(API.localRuns, {
29
+ method: 'POST',
30
+ body: {
31
+ automation_id: job.automation_id || job.id,
32
+ name: job.name || '',
33
+ source: job.source || 'schedule',
34
+ status,
35
+ summary: extra.summary ?? null,
36
+ output: extra.output ?? null,
37
+ error: extra.error ?? null,
38
+ started_at: iso(started),
39
+ finished_at: iso(finished),
40
+ // One account can run jobs on several computers; the results view says
41
+ // which one produced each record.
42
+ machine: computerName(),
43
+ },
44
+ });
45
+ } catch (err) {
46
+ eprintln(color.dim(` ↳ could not report the run: ${err.message}`));
47
+ }
48
+ }
49
+
50
+ function runOnce(argv, dir, env, timeoutMs) {
51
+ return new Promise((resolve) => {
52
+ let out = '';
53
+ let done = false;
54
+ const child = spawn(argv[0], argv.slice(1), { cwd: dir, env });
55
+ const finish = (r) => { if (!done) { done = true; resolve(r); } };
56
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ }
57
+ finish({ timedOut: true, out }); }, timeoutMs);
58
+ const cap = (c) => { out += String(c); if (out.length > 200_000) out = out.slice(-200_000); };
59
+ child.stdout.on('data', cap);
60
+ child.stderr.on('data', cap);
61
+ child.on('error', (err) => { clearTimeout(timer); finish({ error: err.message, out }); });
62
+ child.on('close', (code) => { clearTimeout(timer); finish({ code, out }); });
63
+ });
64
+ }
65
+
66
+ async function runJob(job, root, timeoutMs) {
67
+ const name = job.name || job.automation_id || 'job';
68
+ const started = Date.now();
69
+
70
+ const late = staleness(job);
71
+ if (late) {
72
+ eprintln(color.yellow(`↷ ${name} — too late`));
73
+ return report(job, 'skipped_stale', started, Date.now(), { summary: late });
74
+ }
75
+
76
+ const { dir, entry, argv } = workspace(job.automation_id, root);
77
+ if (!fs.existsSync(entry)) {
78
+ // NOT a failure. The schedule fired and this computer took the job; there
79
+ // was simply nothing to carry out yet. Its owner never sees the code, so
80
+ // they cannot tell "broken" from "unfinished" unless the status does it.
81
+ eprintln(color.yellow(`· ${name} — nothing to run yet`));
82
+ return report(job, 'not_built', started, Date.now(), {
83
+ summary: 'Nothing is running yet. This setup has not been built, so there was'
84
+ + ' nothing for your computer to do.',
85
+ });
86
+ }
87
+
88
+ eprintln(color.dim(`▶ ${name}`));
89
+ const env = {
90
+ ...process.env,
91
+ // Secrets first (shared .env, then the job's own), so a job's own env can
92
+ // deliberately override a shared key.
93
+ ...jobEnv(dir, root),
94
+ ...(job.env && typeof job.env === 'object'
95
+ ? Object.fromEntries(Object.entries(job.env).map(([k, v]) => [String(k), String(v)])) : {}),
96
+ ATLAS_JOB_PAYLOAD: JSON.stringify(job.payload || {}),
97
+ };
98
+ const r = await runOnce(argv, dir, env, timeoutMs);
99
+ const finished = Date.now();
100
+ if (r.timedOut) {
101
+ eprintln(color.red(`✗ ${name} — timed out`));
102
+ return report(job, 'failed', started, finished, {
103
+ error: `Stopped after ${Math.round(timeoutMs / 1000)}s. The script did not finish.`,
104
+ output: tail(r.out),
105
+ });
106
+ }
107
+ if (r.error) {
108
+ eprintln(color.red(`✗ ${name} — ${r.error}`));
109
+ return report(job, 'failed', started, finished, { error: r.error, output: tail(r.out) });
110
+ }
111
+ const ok = r.code === 0;
112
+ eprintln((ok ? color.green('✓ ') : color.red('✗ ')) + name
113
+ + color.dim(` (${((finished - started) / 1000).toFixed(1)}s)`));
114
+ return report(job, ok ? 'completed' : 'failed', started, finished, {
115
+ summary: summarize(r.out, ok),
116
+ output: tail(r.out),
117
+ error: ok ? null : `Exited ${r.code}.`,
118
+ });
119
+ }
120
+
121
+ export async function remoteControlCommand(args) {
122
+ const flags = args.flags;
123
+ const root = typeof flags.dir === 'string' ? flags.dir : undefined;
124
+ const timeoutMs = flags.timeout ? Number(flags.timeout) * 1000 : DEFAULT_TIMEOUT_MS;
125
+
126
+ eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
127
+ eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
128
+
129
+ return runStream(API.remoteJobs, {
130
+ label: 'remote-control',
131
+ reconnect: flags.reconnect !== false,
132
+ // On (re)connect, take anything queued while this computer was away. The
133
+ // server never needs the machine up when a job comes due: it appends to a
134
+ // queue and this drains it.
135
+ onMeta: async () => {
136
+ let pending;
137
+ try {
138
+ pending = await requestJson(API.remotePending);
139
+ } catch (err) {
140
+ eprintln(color.dim(`● could not check for queued jobs: ${err.message}`));
141
+ return;
142
+ }
143
+ const jobs = Array.isArray(pending) ? pending : (pending?.jobs || []);
144
+ if (jobs.length) eprintln(color.dim(`● ${jobs.length} job(s) queued while away`));
145
+ for (const job of jobs) await runJob(job, root, timeoutMs);
146
+ },
147
+ onEvent: async ({ event, payload }) => {
148
+ if (event === 'job' || event === 'message' || event === 'run') {
149
+ await runJob(payload, root, timeoutMs);
150
+ }
151
+ },
152
+ });
153
+ }
@@ -0,0 +1,59 @@
1
+ // `atlas update-cli` — put the newest Atlas CLI on this machine.
2
+ //
3
+ // It exists because the failure it fixes is invisible. A CLI a few versions
4
+ // behind does not announce itself: commands added since simply are not
5
+ // recognised, and the shell reports them the way it reports a typo. Someone can
6
+ // sit on 0.1.1 for months, watch a documented command fail, and reasonably
7
+ // conclude the docs are wrong.
8
+ //
9
+ // So this reports the version BEFORE and AFTER and says plainly whether
10
+ // anything moved.
11
+ import { spawnSync } from 'node:child_process';
12
+ import { VERSION } from '../config.js';
13
+ import { color, println, eprintln } from '../util/ui.js';
14
+
15
+ const PKG = 'mindvest-atlas';
16
+
17
+ function run(cmd, args) {
18
+ const r = spawnSync(cmd, args, { encoding: 'utf8', shell: process.platform === 'win32' });
19
+ return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim(), code: r.status };
20
+ }
21
+
22
+ export async function updateCliCommand(args) {
23
+ const global = args.flags.global !== false; // --no-global installs locally
24
+ eprintln(color.dim(`Currently ${VERSION}. Fetching the newest ${PKG}…`));
25
+
26
+ const npmArgs = ['install', ...(global ? ['-g'] : []), `${PKG}@latest`,
27
+ '--no-audit', '--no-fund'];
28
+ let r = run('npm', npmArgs);
29
+
30
+ // The commonest failure by far is a global prefix the user cannot write to.
31
+ // Saying "permission denied" and stopping is useless; name the fix.
32
+ if (!r.ok && /EACCES|permission denied/i.test(r.out)) {
33
+ eprintln(color.red('npm could not write to the global folder.'));
34
+ eprintln('Try again with elevated permissions, or install just for you:');
35
+ eprintln(color.dim(` npm install -g ${PKG}@latest # as administrator / sudo`));
36
+ eprintln(color.dim(` atlas update-cli --no-global # into this folder instead`));
37
+ return 1;
38
+ }
39
+ if (!r.ok) {
40
+ eprintln(color.red('Could not update automatically.'));
41
+ if (r.out) eprintln(color.dim(r.out.split('\n').slice(-4).join('\n')));
42
+ eprintln(`Install it by hand: ${color.bold(`npm install -g ${PKG}@latest`)}`);
43
+ return 1;
44
+ }
45
+
46
+ // Ask the freshly-installed CLI what it is, rather than trusting npm's noise.
47
+ const after = run('npm', ['view', PKG, 'version']);
48
+ const now = after.ok ? after.out.trim() : '';
49
+ if (now && now === VERSION) {
50
+ println(color.green(`Already the newest (${VERSION}).`));
51
+ } else if (now) {
52
+ println(color.green(`Updated: ${VERSION} → ${now}.`));
53
+ println(color.dim('Open a new terminal so your shell picks it up.'));
54
+ } else {
55
+ println(color.green('Updated.'));
56
+ println(color.dim('Open a new terminal so your shell picks it up.'));
57
+ }
58
+ return 0;
59
+ }
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.2.0';
5
+ export const VERSION = '0.4.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';
@@ -35,6 +35,12 @@ export const API = {
35
35
  tool: (name) => `/api/v1/tools/${encodeURIComponent(name)}`,
36
36
  alerts: '/api/v1/alerts',
37
37
  flowStream: '/api/v1/options-flow/stream',
38
+ // Remote computer: jobs come DOWN remoteJobs, the queue that built up while
39
+ // the computer was away is drained from remotePending, and each run record
40
+ // goes back UP to localRuns. The web never calls this machine.
41
+ remoteJobs: '/api/v1/remote-computer/jobs',
42
+ remotePending: '/api/v1/remote-computer/pending',
43
+ localRuns: '/api/local/runs',
38
44
  };
39
45
 
40
46
  // Config directory: $ATLAS_CONFIG_DIR, else $XDG_CONFIG_HOME/atlas, else
@@ -0,0 +1,175 @@
1
+ // Pure logic for `atlas remote-control`: where a job's code lives, which
2
+ // runtime executes it, and whether it is still worth running.
3
+ //
4
+ // Free of network and child processes so every decision here is testable
5
+ // offline — the same split as api/catalog.js against the commands that use it.
6
+ // Mirrors python/atlas_cli/remote_control.py; the two must agree, because the
7
+ // same server hands jobs to both.
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ /**
13
+ * Default workspace root, one directory per automation underneath.
14
+ *
15
+ * The SAME folder Atlas Studio uses, honouring the same env var, so a job runs
16
+ * out of one place no matter who started the runner. Studio also passes --dir
17
+ * explicitly; this is what makes a hand-typed `atlas remote-control` land in the
18
+ * same folder rather than a private one nobody else can see.
19
+ */
20
+ export const DEFAULT_ROOT = '~/AtlasStudio';
21
+ /**
22
+ * The entry script, in preference order. Deliberately ONE file, not a tree: a
23
+ * tree invites imports, which invites a module system, which invites dependency
24
+ * resolution. Python and JavaScript because those are what people write and
25
+ * what a coding agent writes best; the language is chosen by which file exists,
26
+ * so nothing has to be declared anywhere.
27
+ */
28
+ export const ENTRIES = ['main.py', 'main.js', 'main.mjs'];
29
+ /** Hard ceiling on one job, so a hung script cannot hold the runner forever. */
30
+ export const DEFAULT_TIMEOUT_MS = 600_000;
31
+
32
+ export function workspaceRoot(override) {
33
+ const raw = override || process.env.ATLAS_LOCAL_DIR
34
+ || process.env.ATLAS_LIBRARY_DIR // what Studio sets
35
+ || DEFAULT_ROOT;
36
+ return raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(raw);
37
+ }
38
+
39
+ /**
40
+ * A job's id names a DIRECTORY, so it must not be able to escape the root.
41
+ *
42
+ * The server is trusted, but a path traversal here would be a remote write to
43
+ * anywhere on the user's disk, which is exactly the blast radius Local Mode
44
+ * promises it does not have. Anything outside the allowed set becomes '_'.
45
+ */
46
+ export function safeSlug(automationId) {
47
+ const slug = String(automationId || '')
48
+ .replace(/[^A-Za-z0-9\-_.]/g, '_')
49
+ .replace(/^[._]+|[._]+$/g, '');
50
+ return (slug || 'job').slice(0, 120);
51
+ }
52
+
53
+ /**
54
+ * A human label for THIS machine.
55
+ *
56
+ * One account can run jobs on several computers, so every run record names the
57
+ * one that produced it. $ATLAS_COMPUTER wins for anyone who wants a friendlier
58
+ * label than the hostname; the trailing '.local' macOS appends is dropped
59
+ * because nobody calls their laptop that.
60
+ */
61
+ export function computerName() {
62
+ const override = (process.env.ATLAS_COMPUTER || '').trim();
63
+ if (override) return override.slice(0, 200);
64
+ let host = '';
65
+ try { host = (os.hostname() || '').trim(); } catch { host = ''; }
66
+ for (const suffix of ['.local', '.lan', '.home']) {
67
+ if (host.toLowerCase().endsWith(suffix)) { host = host.slice(0, -suffix.length); break; }
68
+ }
69
+ return host.slice(0, 200) || 'this computer';
70
+ }
71
+
72
+ /** The automation's OWN interpreter, if it has one. A venv is a DEPENDENCY
73
+ * boundary, not a security one, so falling back to the ambient python is a
74
+ * convenience, never a safety decision. */
75
+ export function venvPython(dir) {
76
+ const rel = process.platform === 'win32'
77
+ ? ['.venv', 'Scripts', 'python.exe']
78
+ : ['.venv', 'bin', 'python'];
79
+ const p = path.join(dir, ...rel);
80
+ return fs.existsSync(p) ? p : null;
81
+ }
82
+
83
+ /** How to run this entry file. The extension picks the language, so a job never
84
+ * declares one and a folder can switch runtime by swapping the file. Node
85
+ * resolves node_modules from the working directory on its own, which is the
86
+ * JavaScript equivalent of the venv. */
87
+ export function commandFor(entry, dir) {
88
+ if (entry.endsWith('.js') || entry.endsWith('.mjs')) return ['node', entry];
89
+ const venv = venvPython(dir);
90
+ return [venv || 'python3', entry];
91
+ }
92
+
93
+ /** Every job lives under here, one folder each. */
94
+ export const SCRIPTS_DIR = 'scripts';
95
+
96
+ /**
97
+ * KEY=value pairs from a .env, or {} if there is none.
98
+ *
99
+ * Deliberately tiny: no interpolation, no export keyword, no multi-line values.
100
+ * A secrets file people hand-edit should behave exactly as it looks.
101
+ */
102
+ export function readEnvFile(file) {
103
+ const out = {};
104
+ let text;
105
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
106
+ for (const raw of text.split('\n')) {
107
+ const line = raw.trim();
108
+ if (!line || line.startsWith('#') || !line.includes('=')) continue;
109
+ const idx = line.indexOf('=');
110
+ const k = line.slice(0, idx).trim();
111
+ let v = line.slice(idx + 1).trim();
112
+ if (v.length >= 2 && v[0] === v[v.length - 1] && (v[0] === '"' || v[0] === "'")) {
113
+ v = v.slice(1, -1);
114
+ }
115
+ if (k) out[k] = v;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Secrets for one job: the shared file, then the job's own on top.
122
+ *
123
+ * Shared first so a key is typed once; the job's own last so a job that wants
124
+ * its own value gets it. Neither is required.
125
+ */
126
+ export function jobEnv(dir, root) {
127
+ return { ...readEnvFile(path.join(workspaceRoot(root), '.env')), ...readEnvFile(path.join(dir, '.env')) };
128
+ }
129
+
130
+ /** { dir, entry, argv } for one automation. `entry` is whichever of ENTRIES
131
+ * exists; when none does it is the FIRST candidate, so the message names a
132
+ * concrete path to create rather than listing possibilities. */
133
+ export function workspace(automationId, root) {
134
+ const dir = path.join(workspaceRoot(root), SCRIPTS_DIR, safeSlug(automationId));
135
+ let found = null;
136
+ for (const name of ENTRIES) {
137
+ if (fs.existsSync(path.join(dir, name))) { found = path.join(dir, name); break; }
138
+ }
139
+ const entry = found || path.join(dir, ENTRIES[0]);
140
+ return { dir, entry, argv: commandFor(entry, dir) };
141
+ }
142
+
143
+ /**
144
+ * Why this job should NOT run, or null to go ahead.
145
+ *
146
+ * A job can arrive late: the computer was asleep when it came due and the
147
+ * server held it. Whether late still counts is the automation's own call — a
148
+ * morning report at 09:34 is fine, an entry signal at 09:34 is worthless — so
149
+ * the server sends `max_age_s` and this only enforces it.
150
+ */
151
+ export function staleness(job, now) {
152
+ const maxAge = job?.max_age_s;
153
+ if (typeof maxAge !== 'number' || !Number.isFinite(maxAge) || maxAge <= 0) return null;
154
+ const due = job?.scheduled_for_epoch;
155
+ if (typeof due !== 'number' || !Number.isFinite(due)) return null;
156
+ const late = (now ?? Date.now() / 1000) - due;
157
+ if (late <= maxAge) return null;
158
+ return `Came due ${Math.round(late / 60)}m ago, past this setup's `
159
+ + `${Math.round(maxAge / 60)}m limit, so it was not run.`;
160
+ }
161
+
162
+ /** Keep the END of a long log: the traceback is at the bottom. */
163
+ export function tail(text, limit = 20_000) {
164
+ if (!text) return null;
165
+ const s = String(text);
166
+ return s.length <= limit ? s : s.slice(-limit);
167
+ }
168
+
169
+ /** The one plain line the owner reads first. Scripts have no model to write
170
+ * their own summary, so the last non-empty output line stands in. */
171
+ export function summarize(output, ok) {
172
+ const lines = String(output || '').split('\n').map((l) => l.trim()).filter(Boolean);
173
+ if (!lines.length) return ok ? 'Ran and finished.' : null;
174
+ return lines[lines.length - 1].slice(0, 2000);
175
+ }
package/src/util/help.js CHANGED
@@ -31,6 +31,8 @@ export function printRootHelp() {
31
31
  println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
32
32
  println(' [--save-charts <dir>] [--no-chart] [--json]');
33
33
  println(' flow stream --symbol NVDA [--side call] Live options-flow feed (SSE)');
34
+ println(' remote-control [--dir DIR] Let Atlas run this computer\'s jobs');
35
+ println(' update-cli Install the newest Atlas CLI');
34
36
  println(' [--min-premium N] [--min-tag unusual] [--json]');
35
37
  println();
36
38
  println(c.bold('GLOBAL'));