mindvest-atlas 0.14.2 → 0.14.4

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.14.2",
3
+ "version": "0.14.4",
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": {
@@ -9,7 +9,7 @@
9
9
  // A job that could NOT run is reported too: silence is indistinguishable from
10
10
  // a schedule that quietly broke, which is the failure the record exists to make
11
11
  // visible.
12
- import { spawn } from 'node:child_process';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
13
  import { API, baseUrl } from '../config.js';
14
14
  import { requestJson, jobToken } from '../api/client.js';
15
15
  import { runStream } from '../util/stream-loop.js';
@@ -17,8 +17,9 @@ import { color, eprintln } from '../util/ui.js';
17
17
  import {
18
18
  DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
19
19
  osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
20
- setPaused, writeHelpers, askModel, listModels, chatModel,
20
+ setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission,
21
21
  createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
22
+ readSchedule, cronMatches, dueMinutes, SCRIPTS_DIR, projectsForAlert, readMode,
22
23
  } from '../remote-control.js';
23
24
  import fs from 'node:fs';
24
25
  import os from 'node:os';
@@ -67,14 +68,57 @@ async function report() {
67
68
  return undefined;
68
69
  }
69
70
 
70
- function runOnce(argv, dir, env, timeoutMs) {
71
+ /** The run going on in each project right now, by folder name, so Stop can
72
+ * reach it. One run per project means one entry per project. */
73
+ const running = new Map();
74
+
75
+ /** Kill a run's whole process tree. On Windows a plain kill leaves the
76
+ * script's own children going; taskkill /T takes the tree. */
77
+ function killTree(child) {
78
+ if (!child || child.pid === undefined) return;
79
+ if (process.platform === 'win32') {
80
+ try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); return; } catch { /* fall through */ }
81
+ }
82
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
83
+ const pid = child.pid;
84
+ setTimeout(() => { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }, 3_000).unref();
85
+ }
86
+
87
+ /** Stop the run in one project, if there is one. Returns whether there was. */
88
+ /** Listeners the owner stopped by hand: not restarted until Run now or a
89
+ * relaunch. Pressing Stop on something that comes straight back is a lie. */
90
+ const userStopped = new Set();
91
+ /** Per-listener restart pacing: when it last exited, and how many times in a
92
+ * row it died young. A crash loop backs off to a minute, not a spin. */
93
+ const listenerExits = new Map();
94
+
95
+ export function stopRun(slug) {
96
+ const entry = running.get(slug);
97
+ if (!entry) return false;
98
+ entry.stopped = true;
99
+ userStopped.add(slug);
100
+ killTree(entry.child);
101
+ return true;
102
+ }
103
+
104
+ export function runOnce(argv, dir, env, timeoutMs) {
71
105
  return new Promise((resolve) => {
72
106
  let out = '';
73
107
  let done = false;
74
108
  const child = spawn(argv[0], argv.slice(1), { cwd: dir, env });
75
- const finish = (r) => { if (!done) { done = true; resolve(r); } };
76
- const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ }
77
- finish({ timedOut: true, out }); }, timeoutMs);
109
+ const entry = { child, stopped: false };
110
+ running.set(path.basename(dir), entry);
111
+ const finish = (r) => {
112
+ if (done) return;
113
+ done = true;
114
+ running.delete(path.basename(dir));
115
+ resolve(entry.stopped ? { stopped: true, out } : r);
116
+ };
117
+ // No timer at all for a listener: it is meant to stay up.
118
+ const timer = timeoutMs > 0
119
+ ? setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ }
120
+ finish({ timedOut: true, out }); }, timeoutMs)
121
+ : null;
78
122
  const cap = (c) => { out += String(c); if (out.length > 200_000) out = out.slice(-200_000); };
79
123
  child.stdout.on('data', cap);
80
124
  child.stderr.on('data', cap);
@@ -124,6 +168,13 @@ async function handleAsk(ask, root) {
124
168
  return answer(ask.request_id, { ok: true, paused: setPaused(dir, Boolean(ask.paused)) });
125
169
  }
126
170
  if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
171
+ if (ask.op === 'stop') {
172
+ // Stop replaces Pause on the web (owner, 2026-09-03): what a person wants
173
+ // from the button is the run going right now to end, not a flag for later.
174
+ const { dir } = workspace(ask.job || '', root);
175
+ const had = stopRun(path.basename(dir));
176
+ return answer(ask.request_id, had ? { ok: true, stopped: true } : { ok: true, stopped: false, error: 'Nothing is running.' });
177
+ }
127
178
  if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
128
179
  if (ask.op === 'chat') {
129
180
  // A person typing at the web, talking to the model on this machine. Each
@@ -160,6 +211,14 @@ async function handleAsk(ask, root) {
160
211
  });
161
212
  return undefined;
162
213
  }
214
+ if (ask.op === 'permission') {
215
+ // The person's answer to a question the model asked mid-turn. Its own ask,
216
+ // not part of the chat stream: the stream is one-way. `permission_id` is
217
+ // the question; `request_id` is this ask, which is what gets the ack.
218
+ void answerPermission(ask.permission_id || '', ask.option_id ?? null)
219
+ .then((r) => answer(ask.request_id, r));
220
+ return undefined;
221
+ }
163
222
  if (ask.op === 'create') return answer(ask.request_id, createProject(ask.job || '', root));
164
223
  if (ask.op === 'chats') return answer(ask.request_id, { ok: true, chats: listChats(ask.job || '', root) });
165
224
  if (ask.op === 'chat_read') return answer(ask.request_id, readChat(ask.job || '', ask.chat_id || '', root));
@@ -237,6 +296,11 @@ async function runJob(job, root, timeoutMs) {
237
296
  }
238
297
 
239
298
  const { dir, entry, argv } = workspace(job.automation_id, root);
299
+ // A listener has no run timeout, and the owner pressing Run now on one they
300
+ // stopped means "bring it back".
301
+ const listen = readMode(dir) === 'listen';
302
+ if (listen) timeoutMs = 0;
303
+ if (job.source === 'manual') userStopped.delete(path.basename(dir));
240
304
  // Paused is the owner saying "not now". A scheduled run that fires anyway
241
305
  // would make the switch meaningless; a MANUAL run is the owner asking
242
306
  // directly, so it goes through.
@@ -286,6 +350,20 @@ async function runJob(job, root, timeoutMs) {
286
350
  };
287
351
  const r = await runOnce(argv, dir, env, timeoutMs);
288
352
  const finished = Date.now();
353
+ if (listen) {
354
+ // How it ended paces the restart: a listener that lived a while is
355
+ // restarted at once; one that died young backs off.
356
+ const prev = listenerExits.get(path.basename(dir)) || { exits: 0 };
357
+ const young = finished - started < 5 * 60_000;
358
+ listenerExits.set(path.basename(dir), { at: finished, exits: young ? prev.exits + 1 : 0 });
359
+ }
360
+ if (r.stopped) {
361
+ // The owner pressed Stop. Not a failure: they asked, it stopped.
362
+ eprintln(color.yellow(`■ ${name} — stopped`));
363
+ writeLog(dir, job, 'stopped', started, finished, `Stopped by you.\n${tail(r.out) || ''}`);
364
+ releaseRun(dir);
365
+ return report(job, 'stopped', started, finished);
366
+ }
289
367
  if (r.timedOut) {
290
368
  eprintln(color.red(`✗ ${name} — timed out`));
291
369
  writeLog(dir, job, 'failed', started, finished,
@@ -307,6 +385,156 @@ async function runJob(job, root, timeoutMs) {
307
385
  return report(job, ok ? 'completed' : 'failed', started, finished);
308
386
  }
309
387
 
388
+ /**
389
+ * The local clock (owner decision 2026-09-03: schedule on the computer, not the
390
+ * server).
391
+ *
392
+ * Every 20s, for each project whose atlas.json names a `schedule`, look at the
393
+ * minutes since this project was last checked. The current minute firing starts
394
+ * a run through the same path a queued job takes (one run per project, same
395
+ * logs, same paused rule). Any EARLIER minute that fired while nobody was
396
+ * checking — the app was closed, the laptop asleep — is written down as one
397
+ * "Computer was off" row, because a schedule that misses in silence is the one
398
+ * failure the owner can never see. The last-checked minute lives in
399
+ * runs/.schedule so a restart knows what it slept through.
400
+ */
401
+ const SCHEDULE_STATE = '.schedule';
402
+ const TICK_MS = 20_000;
403
+ const warnedSchedules = new Set();
404
+
405
+ function lastChecked(dir) {
406
+ try {
407
+ const n = parseInt(fs.readFileSync(path.join(dir, 'runs', SCHEDULE_STATE), 'utf8'), 10);
408
+ return Number.isFinite(n) ? n : null;
409
+ } catch { return null; }
410
+ }
411
+
412
+ function markChecked(dir, minute) {
413
+ try {
414
+ fs.mkdirSync(path.join(dir, 'runs'), { recursive: true });
415
+ fs.writeFileSync(path.join(dir, 'runs', SCHEDULE_STATE), `${minute}\n`);
416
+ } catch { /* the next tick tries again */ }
417
+ }
418
+
419
+ export function scheduleTick(root, timeoutMs, nowSec = Date.now() / 1000) {
420
+ const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
421
+ let dirs = [];
422
+ try {
423
+ dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
424
+ } catch { return; }
425
+ const now = Math.floor(nowSec / 60) * 60;
426
+ for (const slug of dirs) {
427
+ const dir = path.join(base, slug);
428
+ const sched = readSchedule(dir);
429
+ if (!sched) continue;
430
+ if (sched.error) {
431
+ if (!warnedSchedules.has(slug)) {
432
+ warnedSchedules.add(slug);
433
+ eprintln(color.yellow(`· ${slug} — ${sched.error}`));
434
+ }
435
+ continue;
436
+ }
437
+ const last = lastChecked(dir);
438
+ if (last !== null && now <= last) continue; // this minute is already handled
439
+ markChecked(dir, now);
440
+ const job = { automation_id: slug, name: slug, source: 'schedule', scheduled_for_epoch: now };
441
+ if (last === null) {
442
+ // First sight of this schedule: start from now. Nothing before it is a
443
+ // gap, because nothing before it was promised.
444
+ if (cronMatches(sched.cron, now, sched.tz)) spawnJob(job, root, timeoutMs);
445
+ continue;
446
+ }
447
+ const due = dueMinutes(sched.cron, last, now, sched.tz);
448
+ const missed = due.filter((t) => t !== now);
449
+ if (missed.length && !isPaused(dir)) {
450
+ const first = missed[0] * 1000;
451
+ const lastMissed = missed[missed.length - 1] * 1000;
452
+ eprintln(color.yellow(`· ${slug} — ${missed.length} scheduled run(s) missed while this was not running`));
453
+ writeLog(dir, job, 'skipped_offline', first, lastMissed,
454
+ `Computer was off or asleep. ${missed.length} run(s) due between `
455
+ + `${iso(first)} and ${iso(lastMissed)} did not happen.\n`);
456
+ }
457
+ if (due.includes(now)) spawnJob(job, root, timeoutMs);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Keep every `mode: listen` project up.
463
+ *
464
+ * Runs on the same 20s beat as the schedule. A listener that is not running,
465
+ * not paused and not stopped by hand is started through the normal job path
466
+ * (one run per project, the same logs); one that keeps dying young is retried
467
+ * on a growing delay, 5s to 60s, so a broken adapter shows up as a row of
468
+ * failures and not as a busy loop.
469
+ */
470
+ export function listenerTick(root, timeoutMs, nowMs = Date.now(), spawn = spawnJob) {
471
+ const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
472
+ let dirs = [];
473
+ try {
474
+ dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
475
+ } catch { return []; }
476
+ const started = [];
477
+ for (const slug of dirs) {
478
+ const dir = path.join(base, slug);
479
+ if (readMode(dir) !== 'listen') continue;
480
+ if (running.has(slug) || userStopped.has(slug) || isPaused(dir)) continue;
481
+ if (!fs.existsSync(path.join(dir, 'main.py')) && !fs.existsSync(path.join(dir, 'main.js')) && !fs.existsSync(path.join(dir, 'main.mjs'))) continue;
482
+ const last = listenerExits.get(slug);
483
+ if (last) {
484
+ const delay = Math.min(60_000, 5_000 * 2 ** Math.max(0, last.exits - 1));
485
+ if (last.exits > 0 && nowMs - last.at < delay) continue;
486
+ }
487
+ started.push(slug);
488
+ spawn({ automation_id: slug, name: slug, source: 'listen', scheduled_for_epoch: Math.floor(nowMs / 1000) }, root, timeoutMs);
489
+ }
490
+ return started;
491
+ }
492
+
493
+ function startLocalSchedule(root, timeoutMs) {
494
+ scheduleTick(root, timeoutMs);
495
+ listenerTick(root, timeoutMs);
496
+ const t = setInterval(() => { scheduleTick(root, timeoutMs); listenerTick(root, timeoutMs); }, TICK_MS);
497
+ t.unref?.();
498
+ return () => clearInterval(t);
499
+ }
500
+
501
+ /**
502
+ * The owner's alerts, as a trigger (owner decision 2026-09-03: triggers kept
503
+ * local, like the schedule).
504
+ *
505
+ * This computer already holds one outbound stream for jobs; this is a second,
506
+ * to the same alert feed `atlas alerts` shows, with charts left out. When an
507
+ * alert fires, every project whose atlas.json `on_alert` names it runs through
508
+ * the same path a queued job takes, with the whole alert event as the job's
509
+ * payload — so a script can read what fired and ask the model what to do about
510
+ * it. Nothing is registered on the server: the feed was always the owner's.
511
+ */
512
+ export function onAlertEvent(event, root, timeoutMs, spawn = spawnJob) {
513
+ if (!event || typeof event !== 'object') return [];
514
+ const slugs = projectsForAlert(root, event);
515
+ const now = Math.floor(Date.now() / 1000);
516
+ for (const slug of slugs) {
517
+ eprintln(color.dim(`⚡ ${event.label || event.name || 'alert'}${event.symbol ? ` ${event.symbol}` : ''} → ${slug}`));
518
+ spawn({ automation_id: slug, name: slug, source: 'alert', scheduled_for_epoch: now,
519
+ payload: { alert: event } }, root, timeoutMs);
520
+ }
521
+ return slugs;
522
+ }
523
+
524
+ function startAlertListener(root, timeoutMs) {
525
+ // Deliberately NOT awaited: it reconnects on its own for as long as the
526
+ // process lives, beside the jobs stream, and a computer with no on_alert
527
+ // projects costs the server one idle subscriber.
528
+ void runStream(`${API.alerts}?chart=0`, {
529
+ label: 'alerts',
530
+ reconnect: true,
531
+ onEvent: ({ event, payload }) => {
532
+ if (event === 'meta' || event === 'error') return;
533
+ onAlertEvent(payload, root, timeoutMs);
534
+ },
535
+ }).catch((err) => eprintln(color.dim(`● alert feed: ${err.message}`)));
536
+ }
537
+
310
538
  export async function remoteControlCommand(args) {
311
539
  const flags = args.flags;
312
540
  const root = typeof flags.dir === 'string' ? flags.dir : undefined;
@@ -315,6 +543,13 @@ export async function remoteControlCommand(args) {
315
543
  eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
316
544
  eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
317
545
 
546
+ // The schedules are kept HERE, on this computer, and keep going whether or
547
+ // not the server can be reached: the stream below only carries manual runs
548
+ // and the web's questions.
549
+ const stopSchedule = startLocalSchedule(root, timeoutMs);
550
+ process.once('exit', stopSchedule);
551
+ startAlertListener(root, timeoutMs);
552
+
318
553
  // Identity is the machine id; the name and OS are how it is DISPLAYED. Two
319
554
  // processes on one computer therefore share one row instead of splitting it.
320
555
  const q = new URLSearchParams({ computer: computerName(), machine_id: machineId(), os: osLabel() });
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.14.2';
5
+ export const VERSION = '0.14.4';
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';
@@ -0,0 +1,154 @@
1
+ // Secrets for this job. Written by Atlas — edits are overwritten.
2
+ import fs from "node:fs";
3
+ //
4
+ // Values come from the environment Atlas set up before starting this script. Do
5
+ // NOT open .env: this module is the supported way to reach a secret, and the
6
+ // file itself is not something a script or a model needs to read.
7
+
8
+ /** The value of one secret, for USE — never for printing or logging. */
9
+ export const secret = (name, fallback) => process.env[name] ?? fallback;
10
+
11
+ /** Whether a secret has been filled in. True/false only, never the value. */
12
+ export const hasSecret = (name) => Boolean(process.env[name]);
13
+
14
+ /**
15
+ * Call an Atlas tool over the REST API. Returns the parsed JSON.
16
+ *
17
+ * This is the way to do anything real — quotes, chains, orders, alerts. The
18
+ * `atlas` CLI shells out a whole process per call and hands back text to
19
+ * re-parse; this is one HTTPS request that returns data, which is what you
20
+ * actually want inside a loop over twenty tickers.
21
+ *
22
+ * The token and base URL are already in this job's environment. Do not read
23
+ * them, print them, or pass them anywhere else.
24
+ *
25
+ * const quote = await atlas("stock-quote", { symbol: "SPY" });
26
+ */
27
+ export async function atlas(tool, params = {}) {
28
+ const base = (process.env.ATLAS_BASE_URL || "https://atlasmcp.finmanagerai.com").replace(/\/+$/, "");
29
+ const token = process.env.ATLAS_TOKEN;
30
+ if (!token) throw new Error("This job has no Atlas token. Is Local Mode signed in?");
31
+ const res = await fetch(`${base}/api/v1/tools/${tool}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
34
+ body: JSON.stringify(params),
35
+ });
36
+ if (!res.ok) throw new Error(`Atlas ${tool} failed (${res.status}): ${(await res.text()).slice(0, 500)}`);
37
+ return res.json();
38
+ }
39
+
40
+ /**
41
+ * Ask YOUR OWN model a question and get its answer back as text.
42
+ *
43
+ * For the steps only a model can do: reading a filing and pulling out the
44
+ * guidance, judging whether a headline is about the company or its sector,
45
+ * summarising what changed since yesterday. Everything else - fetching,
46
+ * counting, comparing, placing orders - is faster and more reliable as plain
47
+ * code, so use this for judgement, not for arithmetic.
48
+ *
49
+ * It runs on this computer, through the model already connected in Atlas
50
+ * Remote Control. The prompt never reaches Atlas.
51
+ *
52
+ * const verdict = await ask(`Is this headline about AAPL itself or the sector?\n${line}`);
53
+ *
54
+ * tools:true lets the model reach Atlas data while it answers; false is faster
55
+ * for pure text. fresh:false keeps ONE conversation for this job (the model
56
+ * starts up once, later questions can refer to earlier ones); fresh:true is for
57
+ * independent questions that should not pile into one context.
58
+ */
59
+ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000 } = {}) {
60
+ const url = process.env.ATLAS_ASK_URL;
61
+ const token = process.env.ATLAS_ASK_TOKEN;
62
+ if (!url || !token) {
63
+ throw new Error(
64
+ "No model is connected on this computer, so this script cannot ask one. " +
65
+ "Connect a model in Atlas Remote Control, or do this step in code."
66
+ );
67
+ }
68
+ const res = await fetch(url, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
71
+ body: JSON.stringify({ job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh }),
72
+ signal: AbortSignal.timeout(timeoutMs),
73
+ });
74
+ const out = await res.json().catch(() => ({}));
75
+ if (!res.ok || !out.ok) throw new Error(`The model could not answer: ${out.error || res.status}`);
76
+ return out.answer || "";
77
+ }
78
+
79
+ /**
80
+ * Call a tool on one of YOUR connected MCP servers.
81
+ *
82
+ * Robinhood, Stripe, whatever is linked to the Atlas account. One credential
83
+ * does it: this job already carries an Atlas token, and Atlas holds the
84
+ * connector's own sign-in, does its handshake and keeps the session warm. The
85
+ * script never sees that server's credentials and never speaks JSON-RPC.
86
+ *
87
+ * const quotes = await mcp("Robinhood", "get_equity_quotes", { symbols: ["SPY"] });
88
+ *
89
+ * Use mcpTools() to see what is connected and what each tool takes.
90
+ */
91
+ export async function mcp(server, tool, args = {}) {
92
+ return atlas("mcp-call", { server, tool, arguments: args });
93
+ }
94
+
95
+ /**
96
+ * What is connected, and what each tool takes. Names and input schemas only —
97
+ * narrow it with `server` or `search` rather than reading a catalogue of sixty.
98
+ */
99
+ export async function mcpTools({ server = "", search = "" } = {}) {
100
+ return atlas("mcp-tools", { server, search });
101
+ }
102
+
103
+ /**
104
+ * Stop cleanly when a secret has not been filled in yet.
105
+ *
106
+ * Names what is missing and nothing else: a blank key is the owner's to fill in
107
+ * Atlas Remote Control, not something a script should work around.
108
+ */
109
+ export function require_(...names) {
110
+ const missing = names.filter((n) => !process.env[n]);
111
+ if (missing.length) {
112
+ console.log(`Missing: ${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.`);
113
+ process.exit(2);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Write one CLOSED trade into this project's Performance page.
119
+ *
120
+ * Appends to trades.json beside the script; Atlas draws the equity curve, the
121
+ * win/loss bar and the calendar from it. Same fields as every Atlas performance
122
+ * row, so it looks exactly like a workflow's record:
123
+ *
124
+ * recordTrade({ symbol: "SPY", quantity: 2, entry_price: 1.20, exit_price: 1.85,
125
+ * opened_at: "2026-09-03T13:35:00Z", closed_at: "2026-09-03T14:10:00Z",
126
+ * option_type: "CALL", strike: 560, expiration: "2026-09-05" });
127
+ *
128
+ * option_type is "CALL" / "PUT" for a contract (P&L is per 100), null for
129
+ * shares. pnl is worked out when not given. The first real trade replaces the
130
+ * example row the project started with.
131
+ */
132
+ export function recordTrade(t) {
133
+ const mult = t.option_type ? 100 : 1;
134
+ const qty = Number(t.quantity || 0);
135
+ const entry = Number(t.entry_price || 0);
136
+ const exit = Number(t.exit_price || 0);
137
+ const pnl = t.pnl_dollars ?? Math.round((exit - entry) * qty * mult * 100) / 100;
138
+ const cost = Math.abs(entry * qty * mult);
139
+ const row = {
140
+ id: Math.random().toString(36).slice(2) + Date.now().toString(36),
141
+ symbol: String(t.symbol).toUpperCase(),
142
+ option_type: t.option_type ? String(t.option_type).toUpperCase() : null,
143
+ strike: t.strike ?? null, expiration: t.expiration ?? null, quantity: qty,
144
+ entry_price: entry, exit_price: exit, pnl_dollars: pnl,
145
+ pnl_pct: t.pnl_pct ?? (cost ? Math.round((pnl / cost) * 10000) / 100 : 0),
146
+ opened_at: t.opened_at, closed_at: t.closed_at,
147
+ };
148
+ let data = {};
149
+ try { data = JSON.parse(fs.readFileSync("trades.json", "utf8")); } catch { data = {}; }
150
+ const trades = (data.trades || []).filter((x) => !x.example);
151
+ trades.push(row);
152
+ fs.writeFileSync("trades.json", JSON.stringify({ trades }, null, 2));
153
+ return row;
154
+ }
@@ -0,0 +1,191 @@
1
+ """Secrets for this job. Written by Atlas — edits are overwritten.
2
+
3
+ Values come from the environment Atlas set up before starting this script. Do
4
+ NOT open .env: this module is the supported way to reach a secret, and the file
5
+ itself is not something a script or a model needs to read.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+
11
+ def secret(name, default=None):
12
+ """The value of one secret, for USE — never for printing or logging."""
13
+ return os.environ.get(name, default)
14
+
15
+
16
+ def has_secret(name):
17
+ """Whether a secret has been filled in. True/False only, never the value."""
18
+ return bool(os.environ.get(name))
19
+
20
+
21
+ def atlas(tool, **params):
22
+ """Call an Atlas tool over the REST API. Returns the parsed JSON.
23
+
24
+ This is the way to do anything real — quotes, chains, orders, alerts. The
25
+ `atlas` CLI shells out a whole process per call and hands back text to
26
+ re-parse; this is one HTTPS request that returns data, which is what you
27
+ actually want inside a loop over twenty tickers.
28
+
29
+ The token and the base URL are already in this job's environment. Do not
30
+ read them, print them, or pass them anywhere else.
31
+
32
+ quote = atlas("stock-quote", symbol="SPY")
33
+ print(quote["price"])
34
+ """
35
+ import json as _json
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ base = os.environ.get("ATLAS_BASE_URL", "https://atlasmcp.finmanagerai.com").rstrip("/")
40
+ token = os.environ.get("ATLAS_TOKEN", "")
41
+ if not token:
42
+ raise RuntimeError("This job has no Atlas token. Is Local Mode signed in?")
43
+ req = urllib.request.Request(
44
+ "%s/api/v1/tools/%s" % (base, tool),
45
+ data=_json.dumps(params).encode("utf-8"),
46
+ headers={"Content-Type": "application/json", "Authorization": "Bearer " + token},
47
+ method="POST",
48
+ )
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=120) as r:
51
+ return _json.loads(r.read().decode("utf-8"))
52
+ except urllib.error.HTTPError as e:
53
+ body = e.read().decode("utf-8", "replace")[:500]
54
+ raise RuntimeError("Atlas %s failed (%d): %s" % (tool, e.code, body))
55
+
56
+
57
+ def ask(prompt, tools=True, fresh=False, timeout=600):
58
+ """Ask YOUR OWN model a question and get its answer back as text.
59
+
60
+ For the steps only a model can do: reading a filing and pulling out the
61
+ guidance, judging whether a headline is about the company or its sector,
62
+ summarising what changed since yesterday. Everything else - fetching,
63
+ counting, comparing, placing orders - is faster and more reliable as plain
64
+ code, so use this for judgement, not for arithmetic.
65
+
66
+ It runs on this computer, through the model already connected in Atlas
67
+ Remote Control. The prompt never reaches Atlas.
68
+
69
+ verdict = ask("Is this headline about AAPL itself or the sector?\n" + line)
70
+ if "itself" in verdict.lower():
71
+ ...
72
+
73
+ tools=True lets the model reach Atlas data while it answers; pass
74
+ tools=False for pure text work, which is faster.
75
+
76
+ fresh=False keeps ONE conversation for this job, so later questions can
77
+ refer to earlier ones and the model starts up only once. Pass fresh=True
78
+ when each question is independent - twenty classifications should not pile
79
+ into one enormous context.
80
+ """
81
+ import json as _json
82
+ import urllib.error
83
+ import urllib.request
84
+
85
+ url = os.environ.get("ATLAS_ASK_URL", "")
86
+ token = os.environ.get("ATLAS_ASK_TOKEN", "")
87
+ if not url or not token:
88
+ raise RuntimeError(
89
+ "No model is connected on this computer, so this script cannot ask one. "
90
+ "Connect a model in Atlas Remote Control, or do this step in code."
91
+ )
92
+ body = {"job": os.environ.get("ATLAS_JOB", ""), "prompt": str(prompt),
93
+ "tools": bool(tools), "fresh": bool(fresh)}
94
+ req = urllib.request.Request(
95
+ url, data=_json.dumps(body).encode("utf-8"),
96
+ headers={"Content-Type": "application/json", "X-Atlas-Token": token},
97
+ method="POST",
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(req, timeout=timeout) as r:
101
+ out = _json.loads(r.read().decode("utf-8"))
102
+ except urllib.error.HTTPError as e:
103
+ body_text = e.read().decode("utf-8", "replace")[:500]
104
+ try:
105
+ body_text = _json.loads(body_text).get("error") or body_text
106
+ except Exception:
107
+ pass
108
+ raise RuntimeError("The model could not answer: %s" % body_text)
109
+ if not out.get("ok"):
110
+ raise RuntimeError("The model could not answer: %s" % (out.get("error") or "unknown"))
111
+ return out.get("answer") or ""
112
+
113
+
114
+ def mcp(server, tool, **arguments):
115
+ """Call a tool on one of YOUR connected MCP servers.
116
+
117
+ Robinhood, Stripe, whatever is linked to the Atlas account. One credential
118
+ does it: this job already carries an Atlas token, and Atlas holds the
119
+ connector's own sign-in, does its handshake and keeps the session warm. The
120
+ script never sees that server's credentials and never speaks JSON-RPC.
121
+
122
+ quotes = mcp("Robinhood", "get_equity_quotes", symbols=["SPY"])
123
+
124
+ Use mcp_tools() to see what is connected and what each tool takes.
125
+ """
126
+ return atlas("mcp-call", server=server, tool=tool, arguments=arguments)
127
+
128
+
129
+ def mcp_tools(server="", search=""):
130
+ """What is connected, and what each tool takes.
131
+
132
+ Names and input schemas only. Narrow it with `server` or `search` rather
133
+ than reading a catalogue of sixty.
134
+ """
135
+ return atlas("mcp-tools", server=server or "", search=search or "")
136
+
137
+
138
+ def require(*names):
139
+ """Stop cleanly when a secret has not been filled in yet.
140
+
141
+ Names what is missing and nothing else: a blank key is the owner's to fill
142
+ in Atlas Remote Control, not something a script should work around.
143
+ """
144
+ missing = [n for n in names if not os.environ.get(n)]
145
+ if missing:
146
+ print("Missing: %s. Add the value in Atlas Remote Control, then run again."
147
+ % ", ".join(missing))
148
+ sys.exit(2)
149
+
150
+
151
+ def record_trade(symbol, quantity, entry_price, exit_price, opened_at, closed_at,
152
+ option_type=None, strike=None, expiration=None, pnl_dollars=None, pnl_pct=None):
153
+ """Write one CLOSED trade into this project's Performance page.
154
+
155
+ Appends to trades.json beside the script; Atlas draws the equity curve, the
156
+ win/loss bar and the calendar from it. Same fields as every Atlas
157
+ performance row, so it looks exactly like a workflow's record:
158
+
159
+ record_trade("SPY", 2, 1.20, 1.85, "2026-09-03T13:35:00Z", "2026-09-03T14:10:00Z",
160
+ option_type="CALL", strike=560, expiration="2026-09-05")
161
+
162
+ option_type is "CALL" / "PUT" for a contract (P&L is per 100), None for
163
+ shares. pnl is worked out when not given. The first real trade replaces the
164
+ example row the project started with.
165
+ """
166
+ import json as _json
167
+ import uuid as _uuid
168
+ mult = 100 if option_type else 1
169
+ qty = float(quantity or 0)
170
+ if pnl_dollars is None:
171
+ pnl_dollars = round((float(exit_price or 0) - float(entry_price or 0)) * qty * mult, 2)
172
+ if pnl_pct is None:
173
+ cost = abs(float(entry_price or 0) * qty * mult)
174
+ pnl_pct = round(pnl_dollars / cost * 100, 2) if cost else 0.0
175
+ row = {
176
+ "id": _uuid.uuid4().hex, "symbol": str(symbol).upper(),
177
+ "option_type": (str(option_type).upper() if option_type else None),
178
+ "strike": strike, "expiration": expiration, "quantity": qty,
179
+ "entry_price": float(entry_price or 0), "exit_price": float(exit_price or 0),
180
+ "pnl_dollars": pnl_dollars, "pnl_pct": pnl_pct,
181
+ "opened_at": opened_at, "closed_at": closed_at,
182
+ }
183
+ try:
184
+ data = _json.load(open("trades.json", encoding="utf-8"))
185
+ except Exception:
186
+ data = {}
187
+ trades = [t for t in (data.get("trades") or []) if not t.get("example")]
188
+ trades.append(row)
189
+ with open("trades.json", "w", encoding="utf-8") as fh:
190
+ _json.dump({"trades": trades}, fh, indent=2)
191
+ return row
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { randomUUID } from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
12
13
  import { configDir } from './config.js';
13
14
 
14
15
  /**
@@ -195,6 +196,195 @@ export function readManifest(dir) {
195
196
  }
196
197
  }
197
198
 
199
+ // ---- the schedule, kept IN the project ---------------------------------------
200
+ //
201
+ // LOCAL CRON (owner decision 2026-09-03, reversing 2026-08-29). The schedule
202
+ // lives in atlas.json on the computer that runs it, and this process keeps the
203
+ // time; the server is not in the loop. "Make things as local as possible": a
204
+ // project is a folder that knows how to start itself AND when. The one thing a
205
+ // local clock cannot do is record a run the computer slept through, so the
206
+ // ticker writes a "Computer was off" row for any gap it finds when it wakes.
207
+
208
+ const CRON_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
209
+
210
+ /**
211
+ * Parse a five-field cron line into sets, or null when it is not one.
212
+ *
213
+ * Supports what people actually write: `*`, `n`, `a-b`, `* /n` (no space),
214
+ * `a-b/n` and comma lists. Day-of-week 7 is Sunday like 0. No names.
215
+ */
216
+ export function parseCron(expr) {
217
+ const parts = String(expr || '').trim().split(/\s+/);
218
+ if (parts.length !== 5) return null;
219
+ const fields = [];
220
+ for (let i = 0; i < 5; i += 1) {
221
+ const set = new Set();
222
+ for (const piece of parts[i].split(',')) {
223
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(piece);
224
+ if (!m) return null;
225
+ let [lo, hi] = CRON_RANGES[i];
226
+ if (m[1] !== '*') {
227
+ const [a, b] = m[1].split('-').map(Number);
228
+ lo = a; hi = b === undefined ? a : b;
229
+ }
230
+ const step = m[2] ? Number(m[2]) : 1;
231
+ if (!(step > 0) || lo < CRON_RANGES[i][0] || hi > CRON_RANGES[i][1] || lo > hi) return null;
232
+ for (let v = lo; v <= hi; v += step) set.add(i === 4 && v === 7 ? 0 : v);
233
+ }
234
+ fields.push(set);
235
+ }
236
+ return {
237
+ minute: fields[0], hour: fields[1], dom: fields[2], month: fields[3], dow: fields[4],
238
+ domStar: parts[2] === '*', dowStar: parts[4] === '*',
239
+ };
240
+ }
241
+
242
+ const DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
243
+
244
+ /** The wall-clock fields of a moment in a time zone (or this machine's). */
245
+ export function localFields(epochSec, tz) {
246
+ const date = new Date(epochSec * 1000);
247
+ let fmt = null;
248
+ if (tz) {
249
+ try {
250
+ fmt = new Intl.DateTimeFormat('en-US', {
251
+ timeZone: tz, hour12: false, minute: 'numeric', hour: 'numeric',
252
+ day: 'numeric', month: 'numeric', weekday: 'short',
253
+ });
254
+ } catch { fmt = null; /* an unknown zone falls back to local time */ }
255
+ }
256
+ if (!fmt) {
257
+ return { minute: date.getMinutes(), hour: date.getHours(), dom: date.getDate(),
258
+ month: date.getMonth() + 1, dow: date.getDay() };
259
+ }
260
+ const out = {};
261
+ for (const part of fmt.formatToParts(date)) {
262
+ if (part.type === 'minute') out.minute = Number(part.value);
263
+ else if (part.type === 'hour') out.hour = Number(part.value) % 24;
264
+ else if (part.type === 'day') out.dom = Number(part.value);
265
+ else if (part.type === 'month') out.month = Number(part.value);
266
+ else if (part.type === 'weekday') out.dow = DOW[part.value] ?? 0;
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /** Does this cron fire at this minute? Standard rule: when BOTH the day-of-month
272
+ * and the day-of-week are restricted, either one matching is enough. */
273
+ export function cronMatches(cron, epochSec, tz) {
274
+ if (!cron) return false;
275
+ const f = localFields(epochSec, tz);
276
+ if (!cron.minute.has(f.minute) || !cron.hour.has(f.hour) || !cron.month.has(f.month)) return false;
277
+ const domOk = cron.dom.has(f.dom);
278
+ const dowOk = cron.dow.has(f.dow);
279
+ if (cron.domStar && cron.dowStar) return true;
280
+ if (cron.domStar) return dowOk;
281
+ if (cron.dowStar) return domOk;
282
+ return domOk || dowOk;
283
+ }
284
+
285
+ /**
286
+ * Every minute in (from, to] at which the cron fires, as epoch seconds.
287
+ *
288
+ * Bounded to the last 24 hours so a laptop shut for a fortnight comes back to
289
+ * one honest gap row, not a loop over twenty thousand minutes.
290
+ */
291
+ export function dueMinutes(cron, fromEpoch, toEpoch, tz) {
292
+ const out = [];
293
+ if (!cron) return out;
294
+ const end = Math.floor(toEpoch / 60) * 60;
295
+ let t = Math.floor(fromEpoch / 60) * 60 + 60;
296
+ if (t < end - 86400) t = end - 86400;
297
+ for (; t <= end; t += 60) if (cronMatches(cron, t, tz)) out.push(t);
298
+ return out;
299
+ }
300
+
301
+ /** The schedule a project declares in atlas.json, or null when it has none.
302
+ * `{ expr, cron, tz, error }` — `error` names a line that could not be read,
303
+ * so the owner is told instead of the project silently never running. */
304
+ export function readSchedule(dir) {
305
+ const m = readManifest(dir);
306
+ const expr = typeof m.schedule === 'string' ? m.schedule.trim() : '';
307
+ if (!expr) return null;
308
+ const tz = typeof m.timezone === 'string' && m.timezone.trim() ? m.timezone.trim() : '';
309
+ const cron = parseCron(expr);
310
+ return { expr, cron, tz, error: cron ? null : `schedule "${expr}" is not a cron line I understand` };
311
+ }
312
+
313
+ /**
314
+ * How a project runs: 'once' (one pass, exits, the schedule or a trigger brings
315
+ * it back) or 'listen' (an ADAPTER the model wrote — a broker websocket, a
316
+ * webhook port the owner exposes themselves — that this computer keeps running:
317
+ * no run timeout, restarted when it exits, ended by Stop). Owner rule
318
+ * 2026-09-03: nothing passes through the Atlas server; the builder builds the
319
+ * adapters, so the runner has to be able to hold one open.
320
+ */
321
+ export function readMode(dir) {
322
+ const m = readManifest(dir);
323
+ if (m.mode === 'listen' || m.keep_running === true) return 'listen';
324
+ return 'once';
325
+ }
326
+
327
+ /**
328
+ * The alerts a project wants to run on, from atlas.json `on_alert`.
329
+ *
330
+ * A string or a list. Each entry names an alert the owner already has: its
331
+ * label ("SPY DEX flip"), its id, its symbol ("SPY"), or its kind ("king_node").
332
+ * "*" means any alert of theirs. Matching is case-insensitive. Empty = none:
333
+ * the project runs on its schedule or Run now only. The server is not told
334
+ * about any of this; the computer listens to the owner's own alert feed and
335
+ * decides for itself (owner, 2026-09-03: triggers, kept local).
336
+ */
337
+ export function readAlertTriggers(dir) {
338
+ const raw = readManifest(dir).on_alert;
339
+ const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw.trim() ? [raw] : []);
340
+ return list.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
341
+ }
342
+
343
+ /** Does a fired alert (the /api/v1/alerts event) match a project's on_alert list? */
344
+ export function alertMatches(triggers, event) {
345
+ if (!triggers || !triggers.length || !event || typeof event !== 'object') return false;
346
+ const facets = [event.label, event.id, event.symbol, event.name, event.alert_type]
347
+ .map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
348
+ return triggers.some((t) => t === '*' || facets.includes(t));
349
+ }
350
+
351
+ /** Every project on this computer that wants this alert, as folder names. */
352
+ export function projectsForAlert(root, event) {
353
+ const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
354
+ let dirs = [];
355
+ try {
356
+ dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
357
+ } catch { return []; }
358
+ return dirs.filter((slug) => alertMatches(readAlertTriggers(path.join(base, slug)), event));
359
+ }
360
+
361
+ /**
362
+ * What the running script last said about where it is, from the progress file
363
+ * the manifest names (runs/progress.json by default). Null when there is none.
364
+ * Clipped, typed and confined to the project: this is read for the web.
365
+ */
366
+ export function readProgress(dir) {
367
+ const rel = readManifest(dir).progress;
368
+ const want = typeof rel === 'string' && rel.trim() ? rel.trim() : 'runs/progress.json';
369
+ const file = path.resolve(dir, want);
370
+ if (!file.startsWith(path.resolve(dir) + path.sep)) return null;
371
+ try {
372
+ const st = fs.statSync(file);
373
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'));
374
+ if (!d || typeof d !== 'object') return null;
375
+ const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
376
+ return {
377
+ step: typeof d.step === 'string' ? d.step.slice(0, 200) : '',
378
+ done: num(d.done),
379
+ total: num(d.total),
380
+ message: typeof d.message === 'string' ? d.message.slice(0, 500) : '',
381
+ at: st.mtimeMs / 1000,
382
+ };
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
198
388
  /** The command the agent declared, split for spawning, or null. */
199
389
  export function manifestCommand(dir) {
200
390
  const run = readManifest(dir).run;
@@ -277,59 +467,16 @@ export function readEnvFile(file) {
277
467
  * changes nothing.
278
468
  */
279
469
  export function writeHelpers(dir) {
280
- const py = `"""Secrets for this job. Written by Atlas — edits are overwritten.
281
-
282
- Values come from the environment Atlas set up before starting this script. Do
283
- NOT open .env: this module is the supported way to reach a secret, and the file
284
- itself is not something a script or a model needs to read.
285
- """
286
- import os
287
- import sys
288
-
289
-
290
- def secret(name, default=None):
291
- """The value of one secret, for USE — never for printing or logging."""
292
- return os.environ.get(name, default)
293
-
294
-
295
- def has_secret(name):
296
- """Whether a secret has been filled in. True/False only, never the value."""
297
- return bool(os.environ.get(name))
298
-
299
-
300
- def require(*names):
301
- """Stop cleanly when a secret has not been filled in yet."""
302
- missing = [n for n in names if not os.environ.get(n)]
303
- if missing:
304
- print("Missing: %s. Add the value in Atlas Remote Control, then run again."
305
- % ", ".join(missing))
306
- sys.exit(2)
307
- `;
308
- const js = `// Secrets for this job. Written by Atlas — edits are overwritten.
309
- //
310
- // Values come from the environment Atlas set up before starting this script. Do
311
- // NOT open .env: this module is the supported way to reach a secret, and the
312
- // file itself is not something a script or a model needs to read.
313
-
314
- /** The value of one secret, for USE — never for printing or logging. */
315
- export const secret = (name, fallback) => process.env[name] ?? fallback;
316
-
317
- /** Whether a secret has been filled in. True/false only, never the value. */
318
- export const hasSecret = (name) => Boolean(process.env[name]);
319
-
320
- /** Stop cleanly when a secret has not been filled in yet. */
321
- export function require_(...names) {
322
- const missing = names.filter((n) => !process.env[n]);
323
- if (missing.length) {
324
- console.log(\`Missing: \${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.\`);
325
- process.exit(2);
326
- }
327
- }
328
- `;
470
+ // ONE helper text for both CLIs. The python CLI keeps the source of truth
471
+ // inline (HELPER_PY / HELPER_JS in remote_control.py); these files are that
472
+ // text, shipped inside the npm package. They used to be a shorter copy with
473
+ // no ask()/atlas()/mcp(), so a script written to the build prompt failed on
474
+ // a computer running THIS CLI (which is every Studio install, 2026-09-03).
475
+ const here = path.dirname(fileURLToPath(import.meta.url));
329
476
  try {
330
- fs.writeFileSync(path.join(dir, 'atlas_env.py'), py);
331
- fs.writeFileSync(path.join(dir, 'atlas_env.js'), js);
332
- } catch { /* a helper is a courtesy, never a failure */ }
477
+ fs.writeFileSync(path.join(dir, 'atlas_env.py'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.py'), 'utf8'));
478
+ fs.writeFileSync(path.join(dir, 'atlas_env.js'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.js'), 'utf8'));
479
+ } catch { /* best effort: a run without helpers still runs */ }
333
480
  }
334
481
 
335
482
  export function jobEnv(dir, root) {
@@ -581,10 +728,15 @@ export function listRuns(root, limit = 50) {
581
728
  } catch { /* mid-write */ }
582
729
  runs.push({ job, name: nm, status: 'running', started_at: started, duration_s: null, log: '' });
583
730
  }
731
+ // "If we come back later we see the progress" (owner, 2026-09-03): the
732
+ // newest row of a project carries what its progress file last said, so a
733
+ // run that is going shows a bar and a finished one shows where it ended.
734
+ const newest = runs.length;
735
+ const progress = readProgress(path.join(base, job));
584
736
  let files = [];
585
737
  try {
586
738
  files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();
587
- } catch { continue; }
739
+ } catch { if (progress && runs[newest]) runs[newest].progress = progress; continue; }
588
740
  for (const f of files.slice(0, limit)) {
589
741
  const stem = f.replace(/\.log$/, '');
590
742
  const parts = stem.split('-');
@@ -604,6 +756,9 @@ export function listRuns(root, limit = 50) {
604
756
  } catch { /* unreadable: the filename still tells the story */ }
605
757
  runs.push({ job, name, status, started_at: Number.isFinite(started) ? started : 0, duration_s: took, log: f });
606
758
  }
759
+ // Only when the file was touched during or after that run: a stale bar
760
+ // from last week under today's row would be a lie.
761
+ if (progress && runs[newest] && progress.at >= runs[newest].started_at - 5) runs[newest].progress = progress;
607
762
  }
608
763
  runs.sort((a, b) => b.started_at - a.started_at);
609
764
  return runs.slice(0, limit);
@@ -713,6 +868,35 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', timeout
713
868
  }
714
869
  }
715
870
 
871
+ /**
872
+ * Hand the model the person's answer to a question it asked mid-turn.
873
+ *
874
+ * With "Ask before the model uses a tool" on, the bridge streams the question
875
+ * as a `permission` event and the model waits. The answer arrives from the web
876
+ * as its own ask (a stream is one-way), and this passes it to the bridge. `ok`
877
+ * false with a reason when nothing was waiting: the turn ended, it timed out,
878
+ * or the id was never asked.
879
+ */
880
+ export async function answerPermission(requestId, optionId, timeoutMs = 15000) {
881
+ const url = process.env.ATLAS_ASK_URL;
882
+ const token = process.env.ATLAS_ASK_TOKEN;
883
+ if (!url || !token) return { ok: false, error: 'No model is connected on this computer.' };
884
+ if (!requestId) return { ok: false, error: 'Which question?' };
885
+ try {
886
+ const res = await fetch(url.replace('/ask', '/chat/permission'), {
887
+ method: 'POST',
888
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
889
+ body: JSON.stringify({ request_id: String(requestId), option_id: optionId ?? null }),
890
+ signal: AbortSignal.timeout(timeoutMs),
891
+ });
892
+ const out = await res.json().catch(() => ({}));
893
+ if (!res.ok || !out.ok) return { ok: false, error: out.error || `The model did not take the answer (${res.status}).` };
894
+ return { ok: true };
895
+ } catch (err) {
896
+ return { ok: false, error: String(err?.message || err) };
897
+ }
898
+ }
899
+
716
900
  // The starter a project gets the moment it is created, so an agent opening the
717
901
  // folder finds a shape to work in rather than an empty directory. Nothing here
718
902
  // runs: it is scaffolding, the model replaces main.* on the first build.
@@ -725,8 +909,14 @@ const STARTER_README = (name) => [
725
909
  '- `.env` your keys. Values stay on this computer and are never read',
726
910
  ' back by anything, including the model.',
727
911
  '- `atlas_env.py` helpers: secret(), require(), atlas(), ask().',
728
- '- `flow.md` a diagram of what happens, written after the first build.', '',
729
- 'Nothing here is stored on Atlas servers.', '',
912
+ '- `flow.md` a diagram of what happens, written after the first build.',
913
+ '- `atlas.json` how to start it, and WHEN: `schedule` is a cron line this',
914
+ ' computer keeps to. Empty = only when you press Run now.',
915
+ '- `runs/` one log per run, and progress.json while it works.',
916
+ '- `trades.json` the trades this project made, shown on its Performance',
917
+ ' page. record_trade() in atlas_env writes one.', '',
918
+ 'Nothing here is stored on Atlas servers. The schedule runs on this computer,',
919
+ 'while Atlas Remote Control is open.', '',
730
920
  ].join('\n');
731
921
 
732
922
  const STARTER_MAIN = (name) => [
@@ -762,11 +952,38 @@ const STARTER_FLOW = (name) => [
762
952
 
763
953
  const STARTER_MANIFEST = JSON.stringify({
764
954
  run: '',
955
+ schedule: '',
956
+ timezone: '',
957
+ on_alert: [],
958
+ mode: 'once',
765
959
  progress: 'runs/progress.json',
766
960
  _note: 'run: how to start this project, e.g. "python main.py" or "py -3 -m mypkg". '
767
- + 'Empty means Atlas looks for main.py itself. progress: a file you overwrite with '
768
- + '{step, done, total, message} while you work, and Atlas draws a bar from it. '
769
- + 'Neither is required.',
961
+ + 'Empty means Atlas looks for main.py itself. '
962
+ + 'schedule: WHEN to run it, as a five-field cron line kept by this computer, e.g. '
963
+ + '"*/5 9-16 * * 1-5" (every 5 minutes, 9am-4pm, weekdays). Empty means only when '
964
+ + 'someone presses Run now. timezone: the zone the schedule is read in, e.g. '
965
+ + '"America/New_York"; empty means this computer\'s own clock. '
966
+ + 'on_alert: run when one of the owner\'s Atlas alerts fires - a list of alert labels, '
967
+ + 'ids, symbols or kinds, or ["*"] for any; the alert arrives in $ATLAS_JOB_PAYLOAD as '
968
+ + '{"alert": {label, symbol, message, result, ...}}. '
969
+ + 'mode: "once" (default: one pass, then exit) or "listen" (an adapter that stays up - a '
970
+ + 'websocket, a webhook port - which this computer keeps running and restarts if it exits). '
971
+ + 'progress: a file you overwrite with {step, done, total, message} while you work, '
972
+ + 'and Atlas draws a bar from it. None of these is required.',
973
+ }, null, 2) + '\n';
974
+
975
+ // A project's Performance page starts with one EXAMPLE trade, so the model can
976
+ // see every field it must fill and the owner sees what the page looks like
977
+ // before anything real has happened. The first real trade replaces it.
978
+ const STARTER_TRADES = JSON.stringify({
979
+ _note: 'One closed trade per row, written by record_trade() / recordTrade() in atlas_env. '
980
+ + 'Fields are exactly the Atlas performance row. option_type CALL/PUT for a contract '
981
+ + '(P&L per 100), null for shares. The example row is dropped by the first real trade.',
982
+ trades: [{
983
+ id: 'example', example: true, symbol: 'SPY', option_type: 'CALL', strike: 560, expiration: '2026-09-05',
984
+ quantity: 2, entry_price: 1.20, exit_price: 1.85, pnl_dollars: 130, pnl_pct: 54.17,
985
+ opened_at: '2026-09-03T13:35:00Z', closed_at: '2026-09-03T14:10:00Z',
986
+ }],
770
987
  }, null, 2) + '\n';
771
988
 
772
989
  /**
@@ -783,6 +1000,7 @@ export function starterFiles(slug) {
783
1000
  ['main.py', STARTER_MAIN(slug)],
784
1001
  ['flow.md', STARTER_FLOW(slug)],
785
1002
  ['atlas.json', STARTER_MANIFEST],
1003
+ ['trades.json', STARTER_TRADES],
786
1004
  ['.env', '# KEY=value - one per line. Values stay on this computer.\n'],
787
1005
  ];
788
1006
  }
package/src/util/help.js CHANGED
@@ -55,6 +55,7 @@ export function printRootHelp() {
55
55
  println(c.dim(' atlas create-task --name "Close SPY 3:55" --instruction "Close my SPY position" --schedule-times 15:55'));
56
56
  println(c.dim(' atlas run-task --task-id <id>'));
57
57
  println(c.dim(' atlas workflow-create --name "GEX signal" --schedule "30 9 * * 1-5" --tools Stock-Quote --instruction "…"'));
58
+ println(c.dim(' atlas workflow-create --name "FOMC day" --run-once-at "2026-09-17 14:00" --tools Stock-Quote --instruction "…" # once, at specific dates/times; --trigger-source manual = Run now only'));
58
59
  println(c.dim(' atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts'));
59
60
  println(c.dim(' atlas flow stream --symbol NVDA'));
60
61
  }