mindvest-atlas 0.68.3 → 0.91.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.68.3",
3
+ "version": "0.91.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": {
package/src/cli.js CHANGED
@@ -20,6 +20,9 @@ import { screenshotCommand } from './commands/screenshot.js';
20
20
  import { runCommand } from './commands/run.js';
21
21
  import { computerUseCommand } from './commands/computer-use.js';
22
22
  import { dashboardCommand } from './commands/dashboard.js';
23
+ import { chatSearchCommand } from './commands/chat-search.js';
24
+ import { connectorsCommand, connectorCommand } from './commands/connectors.js';
25
+ import { memoryCommand } from './commands/memory.js';
23
26
  import { remoteCheckCommand } from './commands/remote-check.js';
24
27
  import { shareCommand } from './commands/share.js';
25
28
  import { historicalContractCommand } from './commands/historical.js';
@@ -130,6 +133,26 @@ export async function main(rawArgs) {
130
133
  // hand (owner, 2026-09-06).
131
134
  case 'dashboard':
132
135
  return dashboardCommand(withoutCommand(args));
136
+ // EVERY PAST CONVERSATION ON THIS COMPUTER, read where it lies (owner,
137
+ // 2026-09-18: "how do I do searching so every agent can search
138
+ // conversations"). Local files, so no cloud tool could do it; the
139
+ // running Studio knows where each agent keeps its own.
140
+ case 'chat-search':
141
+ case 'chats':
142
+ return await chatSearchCommand(withoutCommand(args));
143
+ // THE CONNECTORS ON THIS COMPUTER, brokers among them (owner,
144
+ // 2026-09-30: "add a CLI for these tools"). Their sign-ins never leave
145
+ // this machine, so no cloud tool could reach them.
146
+ case 'connectors':
147
+ return await connectorsCommand(withoutCommand(args));
148
+ case 'connector':
149
+ return await connectorCommand(withoutCommand(args));
150
+ // WHAT ATLAS KEEPS BETWEEN CONVERSATIONS: three tiers, decayed by use,
151
+ // weighted 1-10 by whoever is working (owner, 2026-09-19). Local files,
152
+ // so no cloud tool could do it.
153
+ case 'memory':
154
+ case 'mem':
155
+ return await memoryCommand(withoutCommand(args));
133
156
  // Put the newest CLI on this machine.
134
157
  case 'update-cli':
135
158
  case 'self-update':
@@ -0,0 +1,218 @@
1
+ // `atlas chat-search` — every past conversation on this computer, searched
2
+ // where it lies.
3
+ //
4
+ // Owner, 2026-09-18: "let's say I want to continue from another agent and my
5
+ // sessions are not linked, how do I do searching so every agent can search
6
+ // conversations … even ask() or a normal convo can check previous convo."
7
+ //
8
+ // The engine is in Atlas Studio (`main/core/chat-search.ts`) because that is
9
+ // where the stores are known: the adapters' own transcripts, the chats Studio
10
+ // keeps for every model reached over a port or a key (LM Studio, Ollama, any
11
+ // provider), a project's own `.chats`, and whatever its sweep has found. This
12
+ // command is the shell's way in, for the agent that is sitting at a prompt
13
+ // rather than inside a project script — the same road `atlas screenshot` takes
14
+ // to the dashboard.
15
+ //
16
+ // A HAND-WRITTEN COMMAND, DELIBERATELY. The rule is that a verb has to earn
17
+ // one by doing something a cloud tool call cannot (2026-09-15). This reads
18
+ // files on THIS disk; there is no cloud tool that could.
19
+ //
20
+ // NOTHING IS SHORTENED ON THE WAY THROUGH (owner, 2026-09-18: "DO NOT TRUNCATE
21
+ // OR CUT"). Whatever the engine returns is printed whole; when there is too
22
+ // much, the engine returns FEWER WHOLE TURNS and says so, and that sentence is
23
+ // printed too.
24
+ import { color, println, eprintln } from '../util/ui.js';
25
+ import { askAddress } from '../remote-control.js';
26
+
27
+ function notRunning() {
28
+ eprintln(color.red('Atlas Studio is not running on this computer.'));
29
+ eprintln(color.dim('It is what knows where each agent keeps its conversations. Start it and try again.'));
30
+ return 1;
31
+ }
32
+
33
+ async function post(at, path, body) {
34
+ const res = await fetch(at.url.replace('/ask', path), {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': at.token },
37
+ body: JSON.stringify(body),
38
+ signal: AbortSignal.timeout(180_000),
39
+ });
40
+ const out = await res.json().catch(() => ({}));
41
+ if (!res.ok || !out.ok) throw new Error(out.error || `Chat search failed (${res.status}).`);
42
+ return out;
43
+ }
44
+
45
+ function printRows(rows, agent) {
46
+ for (const row of rows) {
47
+ const who = row.kind === 'user'
48
+ ? 'You'
49
+ : row.kind === 'assistant'
50
+ ? (agent || 'Model')
51
+ : `${row.kind === 'tool_call' ? 'Tool call' : 'Tool result'}: ${row.tool || 'tool'}`;
52
+ println(color.dim(`--- ${who} ---`));
53
+ println(row.text);
54
+ println('');
55
+ }
56
+ }
57
+
58
+ export async function chatSearchCommand(args) {
59
+ const flags = args.flags || {};
60
+ const at = askAddress(flags.root);
61
+ if (!at) return notRunning();
62
+
63
+ const sub = String((args.positional ?? args._)?.[0] || '').trim();
64
+
65
+ // WHAT THIS COMPUTER REMEMBERS about searching: which folders hold
66
+ // conversations, which of them answer, and where recent answers were. Paths
67
+ // and counters, never a conversation (owner, 2026-09-19: "store the search
68
+ // path or recent important stuff, so we can read this to find info quicker
69
+ // next time").
70
+ if (sub === 'forget') {
71
+ try {
72
+ await post(at, '/chat-search/forget', {});
73
+ println('Forgotten. Every conversation is exactly where it was; only the shortcuts are gone.');
74
+ return 0;
75
+ } catch (err) {
76
+ eprintln(color.red(err.message));
77
+ return 1;
78
+ }
79
+ }
80
+ if (sub === 'memory' || sub === 'places') {
81
+ let mem;
82
+ try {
83
+ mem = await post(at, '/chat-search/memory', {});
84
+ } catch (err) {
85
+ eprintln(color.red(err.message));
86
+ return 1;
87
+ }
88
+ if (flags.json) {
89
+ println(JSON.stringify(mem, null, 2));
90
+ return 0;
91
+ }
92
+ println(color.bold(`${mem.places.length} place(s) learned, ${mem.recent.length} pointer(s) kept`));
93
+ for (const p of mem.places) {
94
+ println(` ${p.dir} ${color.dim(`${p.agent} · ${p.parser}${p.hits ? ` · answered ${p.hits}x` : ''}`)}`);
95
+ }
96
+ for (const r of mem.recent.slice(0, 10)) {
97
+ println(` "${r.query}" -> ${r.sessionId} ${color.dim(`(${r.agent} · ${r.store})`)}`);
98
+ }
99
+ return 0;
100
+ }
101
+
102
+ // `chat-search show <session>` — one conversation, as deep as asked. Its own
103
+ // call rather than a flag, because reading a conversation whole is a
104
+ // different act from finding one.
105
+ if (sub === 'show' || flags.show) {
106
+ const id = String(flags.show || (args.positional ?? args._)?.[1] || '').trim();
107
+ if (!id) {
108
+ eprintln(color.red('Which conversation? `atlas chat-search show <session-id>`'));
109
+ return 1;
110
+ }
111
+ let out;
112
+ try {
113
+ out = await post(at, '/chat-search/show', {
114
+ sessionId: id,
115
+ file: flags.file,
116
+ around: flags.around == null ? undefined : Number(flags.around),
117
+ // `--turns 0` is the whole thing; the default keeps it to the part
118
+ // around the start.
119
+ turns: flags.turns == null ? undefined : Number(flags.turns),
120
+ handover: !!flags.handover,
121
+ discover: !!flags.discover,
122
+ });
123
+ } catch (err) {
124
+ eprintln(color.red(err.message));
125
+ return 1;
126
+ }
127
+ if (flags.json) {
128
+ println(JSON.stringify(out, null, 2));
129
+ return 0;
130
+ }
131
+ if (flags.handover) {
132
+ // Exactly what you paste into another model as the first message.
133
+ println(out.handover);
134
+ return 0;
135
+ }
136
+ println(color.bold(out.title));
137
+ println(color.dim(`${out.agent} · ${out.store} · ${out.sessionId}${out.cwd ? ` · ${out.cwd}` : ''}`));
138
+ println(color.dim(`Turns ${out.from}-${out.to} of ${out.total}. Nothing below is shortened.`));
139
+ println('');
140
+ printRows(out.rows, out.agent);
141
+ return 0;
142
+ }
143
+
144
+ const query = String(flags.query || flags.q || (args.positional ?? args._)?.join(' ') || '').trim();
145
+ if (!query) {
146
+ eprintln(color.red('What are you looking for? `atlas chat-search "the words you remember"`'));
147
+ eprintln(color.dim('Then: atlas chat-search show <session> --turns 0 (the whole conversation)'));
148
+ return 1;
149
+ }
150
+
151
+ let out;
152
+ try {
153
+ out = await post(at, '/chat-search', {
154
+ query,
155
+ turns: flags.turns == null ? undefined : Number(flags.turns),
156
+ limit: flags.limit == null ? undefined : Number(flags.limit),
157
+ maxBytes: flags.bytes == null ? undefined : Number(flags.bytes),
158
+ store: flags.store,
159
+ agent: flags.agent,
160
+ since: flags.since ? Date.parse(String(flags.since)) || undefined : undefined,
161
+ matchCase: !!flags['match-case'],
162
+ regex: !!flags.regex,
163
+ // Look for stores nobody wrote a path for, unless told not to. What it
164
+ // finds is remembered as a FOLDER, so the next search already knows.
165
+ discover: flags['no-discover'] ? false : true,
166
+ // The project you are in comes first; everything else still comes back.
167
+ project: flags.project || flags.job || process.cwd(),
168
+ maxMs: flags.ms == null ? undefined : Number(flags.ms),
169
+ });
170
+ } catch (err) {
171
+ eprintln(color.red(err.message));
172
+ return 1;
173
+ }
174
+
175
+ if (flags.json) {
176
+ println(JSON.stringify(out, null, 2));
177
+ return 0;
178
+ }
179
+
180
+ if (!out.hits.length) {
181
+ println(`Nothing on this computer says "${query}".`);
182
+ println(color.dim(`Looked in ${out.scanned.places} place(s), ${out.scanned.files} transcript(s).`));
183
+ for (const note of out.notes) println(color.dim(note));
184
+ return 0;
185
+ }
186
+
187
+ // SEEN BEFORE. Offered beside the live answer, never instead of it: a store
188
+ // can change under a pointer.
189
+ if (out.remembered?.length) {
190
+ println(color.dim('Seen before:'));
191
+ for (const r of out.remembered) {
192
+ println(color.dim(` "${r.query}" -> ${r.sessionId} (${r.agent} · ${r.store})`));
193
+ }
194
+ println('');
195
+ }
196
+
197
+ println(color.bold(`${out.hits.length} of ${out.found} conversation(s)`));
198
+ println('');
199
+ for (const hit of out.hits) {
200
+ println(color.bold(hit.title));
201
+ println(color.dim(`${hit.agent} · ${hit.store} · ${new Date(hit.updatedAt).toLocaleString()} · ${hit.matches} match(es)`));
202
+ println(color.dim(`session ${hit.sessionId}${hit.cwd ? ` · ${hit.cwd}` : ''}`));
203
+ // The matching line verbatim — or, when that ONE line is enormous, where to
204
+ // find it, because showing part of it would be showing part of a value.
205
+ println(hit.line == null
206
+ ? color.dim(` [one line of ${hit.lineIsHuge?.bytes} bytes. Read it whole: atlas chat-search show ${hit.sessionId} --turns 0]`)
207
+ : ` ${hit.line}`);
208
+ if (!flags.quiet) {
209
+ println('');
210
+ printRows(hit.rows, hit.agent);
211
+ if (hit.note) println(color.yellow(hit.note));
212
+ println(color.dim(`Turns ${hit.from}-${hit.to} of ${hit.total}. atlas chat-search show ${hit.sessionId} --turns 0 for all of it.`));
213
+ }
214
+ println('');
215
+ }
216
+ for (const note of out.notes) println(color.dim(note));
217
+ return 0;
218
+ }
@@ -21,13 +21,14 @@
21
21
  // ONE IMPLEMENTATION, NOT TWO: every subcommand runs the SAME atlas_env function
22
22
  // a project's script would call, inside the project, so the switch, the claim,
23
23
  // the action log and standing back for a person are identically the same rules.
24
- import { spawnSync } from 'node:child_process';
24
+ import { spawn, spawnSync } from 'node:child_process';
25
25
  import crypto from 'node:crypto';
26
26
  import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import {
29
29
  workspace, workspaceRoot, venvPython, ambientPython, jobEnv, ensureProjectFiles,
30
30
  computerUseAllowed, computerClaim, releaseComputerClaim, askAddress,
31
+ listComputerSessions, renameComputerSession, writeReplay, reviewPrompt, askModel,
31
32
  } from '../remote-control.js';
32
33
  import { color, println, eprintln } from '../util/ui.js';
33
34
 
@@ -52,8 +53,26 @@ const HELP = `atlas computer-use <command>
52
53
  find "<what>" where something is on screen
53
54
  window "<title>" bring a window to the front
54
55
  cursor where the pointer is
56
+ changed did the screen move since the last look (no picture)
57
+ code "<python>" run a block in the session: loops, ifs, many actions, one call
58
+ controls ["<window>"] the front window's buttons and fields, by name (try this before screen)
59
+ press "<name>" press one of them by name or id (e7), found live [--right] [--double] [--wait <s>]
60
+ type-into "<name>" "<text>" [--enter] fill a field by its name and read it back
61
+ wait-for "<name>" [--gone] [--timeout <s>] until a control or text appears (or goes)
62
+ web-setup fetch Playwright + Chromium once, for the web verbs
63
+ web-open <url> a page in Atlas's browser; your mouse stays yours
64
+ web-click "<what>" press a button or link by what it says (or a selector)
65
+ web-type "<what>" "<text>" [--enter] fill a field by its label
66
+ web-read ["<what>"] the page (or one part) as text
67
+ web-find "<what>" where something is on the page
68
+ web-shot a sharp picture of the page itself
69
+ web-close close Atlas's browser (logins are kept)
55
70
  take-over carry on after a person used it by hand
56
71
  stop give the computer back
72
+ sessions this project's recordings, with their ids
73
+ rename <id> "<name>" name a recording (an empty name takes it off)
74
+ replay <id> write replay.md: every action, with the frame at that moment
75
+ review <id> have the model read the replay and write review.md
57
76
 
58
77
  --job <project> which project's log this belongs to
59
78
  (defaults to $ATLAS_JOB, which a chat already sets)`;
@@ -61,7 +80,7 @@ const HELP = `atlas computer-use <command>
61
80
  // Each subcommand as the atlas_env call it really is, and how many values it
62
81
  // takes. Mapped rather than hand-written so this cannot invent an operation the
63
82
  // helper does not have.
64
- const OPS = {
83
+ export const OPS = {
65
84
  screen: ['screen', 0],
66
85
  screens: ['screens', 0],
67
86
  'use-screen': ['use_screen', 1],
@@ -79,6 +98,16 @@ const OPS = {
79
98
  find: ['find', 1],
80
99
  window: ['focus_window', 1],
81
100
  cursor: ['get_cursor', 0],
101
+ changed: ['changed', 0],
102
+ code: ['run_code', 1],
103
+ 'web-setup': ['web_setup', 0],
104
+ 'web-open': ['web_open', 1],
105
+ 'web-click': ['web_click', 1],
106
+ 'web-type': ['web_type', -1],
107
+ 'web-read': ['web_read', -1],
108
+ 'web-find': ['web_find', 1],
109
+ 'web-shot': ['web_shot', 0],
110
+ 'web-close': ['web_close', 0],
82
111
  'take-over': ['take_over', 0],
83
112
  ready: ['computer_ready', 0],
84
113
  // WAIT ESPECIALLY. A sequence without it cannot pause for a page, which is
@@ -122,6 +151,14 @@ const OPS = {
122
151
  // ── A PAGE THAT IS TALLER THAN THE SCREEN ─────────────────────────────
123
152
  'scroll-to': ['scroll_to', 1],
124
153
  'scan-page': ['scan_page', 0],
154
+ // ── CONTROLS, NOT PIXELS ──────────────────────────────────────────────
155
+ // The app's own list of its buttons and fields, read from the system's
156
+ // accessibility tree: named, found again at the moment of the press, and
157
+ // checked afterwards. No picture to read, no coordinates to guess.
158
+ controls: ['controls', -1],
159
+ press: ['press', -1],
160
+ 'type-into': ['type_into', -1],
161
+ 'wait-for': ['wait_for', -1],
125
162
  };
126
163
  // Which values are numbers. Passing "840" as a string to click() would move the
127
164
  // pointer nowhere and report success. THE SAME SET THE PYTHON CLI USES: a verb
@@ -131,6 +168,14 @@ const NUMERIC = new Set(['use-screen', 'click', 'double-click', 'right-click',
131
168
  'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region',
132
169
  'scroll-x', 'shift-scroll', 'swipe', 'zoom']);
133
170
 
171
+ /** "Last name | Smith" -> ["Last name", "Smith"]; "Quantity 25" -> ["Quantity", "25"]. */
172
+ export function splitTarget(text) {
173
+ const bar = text.indexOf('|');
174
+ if (bar >= 0) return [text.slice(0, bar).trim(), text.slice(bar + 1).trim()];
175
+ const words = text.split(/\s+/).filter(Boolean);
176
+ return [words[0] || '', words.slice(1).join(' ')];
177
+ }
178
+
134
179
  /** This shell's holding id, made once and remembered. Shared with `atlas run`
135
180
  * so everything typed at one prompt lands in ONE session: the log reads as a
136
181
  * session rather than a pile of one-action folders, and the claim names
@@ -201,6 +246,17 @@ export function askServer(job, op, args, root) {
201
246
  /** Run one atlas_env call inside the project, and hand back what it said. */
202
247
  /** What using the computer needs, and what it is called when you import it. */
203
248
  const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
249
+ /** What reads an app's own buttons and fields (controls(), press()). Never a
250
+ * condition of working: without it the screen and the mouse still do, so it is
251
+ * fetched on its own and a failure costs nothing. Linux reads AT-SPI through
252
+ * the system's python3-gi, which pip cannot put there. */
253
+ export function controlPackages(platform = process.platform) {
254
+ if (platform === 'win32') return [['uiautomation', 'uiautomation']];
255
+ if (platform === 'darwin') {
256
+ return [['pyobjc-framework-ApplicationServices', 'ApplicationServices'], ['pyobjc-framework-Cocoa', 'AppKit']];
257
+ }
258
+ return [];
259
+ }
204
260
 
205
261
  /**
206
262
  * Put what is missing into the interpreter that is ABOUT TO BE USED.
@@ -228,20 +284,44 @@ const NEEDED = [['pyautogui', 'pyautogui'], ['mss', 'mss'], ['Pillow', 'PIL']];
228
284
  const HIDDEN = { windowsHide: true };
229
285
 
230
286
  export function ensureDeps(python, cwd, env) {
231
- const check = `import ${NEEDED.map(([, mod]) => mod).join(', ')}`;
232
- const has = () => spawnSync(python[0], [...python.slice(1), '-c', check],
233
- { cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
234
- if (has()) return true;
287
+ const extra = controlPackages();
288
+ // ONE python start answers both: 0 all here, 3 only the control reader
289
+ // missing, 1 the screen and mouse themselves. LOOKED UP, NOT IMPORTED:
290
+ // importing pyautogui on a Linux without tkinter exits the process, so the
291
+ // check failed on every command there and paid a pip run each time.
292
+ const mods = (list) => JSON.stringify(list.map(([, mod]) => mod));
293
+ const check = 'import importlib.util as u, sys\n'
294
+ + `sys.exit(1 if not all(u.find_spec(m) for m in ${mods(NEEDED)}) else 3 if not all(u.find_spec(m) for m in ${mods(extra)}) else 0)\n`;
295
+ const status = () => spawnSync(python[0], [...python.slice(1), '-c', check],
296
+ { cwd, env, stdio: 'ignore', ...HIDDEN }).status;
297
+ const pip = (names, timeout) => {
298
+ const inVenv = spawnSync(python[0],
299
+ [...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
300
+ { cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
301
+ spawnSync(python[0], [...python.slice(1), '-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user']), ...names],
302
+ { cwd, env, stdio: 'ignore', timeout, ...HIDDEN });
303
+ };
304
+ const first = status();
305
+ if (first === 0) return true;
306
+ if (first === 3) {
307
+ // Tried once a day per project, not on every command: a machine where it
308
+ // will not install should not pay a pip run per click.
309
+ const mark = path.join(cwd, '.studio', 'controls-tried');
310
+ let last = 0;
311
+ try { last = fs.statSync(mark).mtimeMs; } catch { /* never tried */ }
312
+ if (Date.now() - last > 24 * 3600_000) {
313
+ try { fs.mkdirSync(path.dirname(mark), { recursive: true }); fs.writeFileSync(mark, ''); } catch { /* best effort */ }
314
+ pip(extra.map(([name]) => name), 180000);
315
+ }
316
+ return true;
317
+ }
235
318
  println(color.dim(' Getting this computer ready (one time)…'));
236
- // --user is wrong inside a venv and right outside one, so ask the interpreter
237
- // which it is in rather than guessing.
238
- const inVenv = spawnSync(python[0],
239
- [...python.slice(1), '-c', 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'],
240
- { cwd, env, stdio: 'ignore', ...HIDDEN }).status === 0;
241
- const args = ['-m', 'pip', 'install', '--quiet', ...(inVenv ? [] : ['--user'])];
242
- spawnSync(python[0], [...python.slice(1), ...args, ...NEEDED.map(([name]) => name)],
243
- { cwd, env, stdio: 'ignore', timeout: 300000, ...HIDDEN });
244
- return has();
319
+ // --user is wrong inside a venv and right outside one, so pip() asks the
320
+ // interpreter which it is in rather than guessing.
321
+ pip(NEEDED.map(([name]) => name), 300000);
322
+ if (extra.length) pip(extra.map(([name]) => name), 180000);
323
+ const now = status();
324
+ return now === 0 || now === 3;
245
325
  }
246
326
 
247
327
  export function runInProject(job, code, root) {
@@ -285,6 +365,63 @@ export function runInProject(job, code, root) {
285
365
  return out.status === null ? 1 : out.status;
286
366
  }
287
367
 
368
+ /**
369
+ * Start a watch that OUTLIVES this command.
370
+ *
371
+ * A watch runs for hours by design: it looks, decides how long to wait, and
372
+ * looks again. Run in the foreground, a shell agent that started one would sit
373
+ * there until it finished — one turn, held open, with nothing to show for it.
374
+ * The Python CLI has detached it since 2026-09-06; this runtime ran it inline,
375
+ * and this runtime is the one Atlas Studio bundles, so the blocking version is
376
+ * the one a chat actually got (owner, 2026-09-15).
377
+ *
378
+ * Detached, with no console and no pipes: what it does is visible in the watch
379
+ * window and the action log, which is where somebody watching already is.
380
+ */
381
+ function startWatch(job, instruction, flags) {
382
+ if (!String(instruction || '').trim()) {
383
+ eprintln(color.red('Say what to watch for.'));
384
+ eprintln(color.dim(' atlas computer-use watch "when SPY breaks the opening range high, buy 1 call"'));
385
+ return 1;
386
+ }
387
+ const { dir } = workspace(job, undefined);
388
+ if (!fs.existsSync(dir)) {
389
+ eprintln(color.red(`There is no project called ${job} on this computer.`));
390
+ return 1;
391
+ }
392
+ try { ensureProjectFiles(dir); } catch { /* it may already be right */ }
393
+ const venv = venvPython(dir);
394
+ const python = venv ? [String(venv)] : [...ambientPython()];
395
+ const env = { ...process.env, ...jobEnv(dir), ATLAS_JOB: path.basename(dir) };
396
+ if (!env.ATLAS_ASK_URL || !env.ATLAS_ASK_TOKEN) {
397
+ const at = askAddress(undefined);
398
+ if (at) { env.ATLAS_ASK_URL = at.url; env.ATLAS_ASK_TOKEN = at.token; }
399
+ }
400
+ if (!env.ATLAS_COMPUTER_SESSION) env.ATLAS_COMPUTER_SESSION = shellSession(undefined);
401
+ env.PYTHONPATH = [dir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
402
+ ensureDeps(python, dir, env);
403
+ const kw = { every: Number(flags.every || 5) };
404
+ if (flags.most) kw.most = Number(flags.most);
405
+ if (flags.hours) kw.hours = Number(flags.hours);
406
+ const code = 'import atlas_env as e, json, sys\n'
407
+ + 'e.watch(json.loads(sys.argv[1]), **json.loads(sys.argv[2]))\n';
408
+ try {
409
+ const child = spawn(
410
+ python[0],
411
+ [...python.slice(1), '-c', code, JSON.stringify(String(instruction)), JSON.stringify(kw)],
412
+ { cwd: dir, env, stdio: 'ignore', detached: true, ...HIDDEN },
413
+ );
414
+ child.unref();
415
+ } catch (err) {
416
+ eprintln(color.red(`Could not start watching: ${err && err.message ? err.message : err}`));
417
+ return 1;
418
+ }
419
+ println(color.dim('Watching started. It looks, decides how long to wait, and looks again.'));
420
+ println(color.dim(' Follow it in the watch window, or: atlas computer-use screen'));
421
+ println(color.dim(' Stop ends it — the whole thing, wait included.'));
422
+ return 0;
423
+ }
424
+
288
425
  export function computerUseCommand(args) {
289
426
  const flags = args.flags || {};
290
427
  const rest = [...(args.positional || args._ || [])];
@@ -351,6 +488,11 @@ export function computerUseCommand(args) {
351
488
  return out;
352
489
  }
353
490
 
491
+ // A WATCH FROM A CHAT MUST COME BACK: detached unless told otherwise.
492
+ if (sub === 'watch' && flags.background !== false) {
493
+ return startWatch(job, rest[0] || '', flags);
494
+ }
495
+
354
496
  // SEVERAL ACTIONS AND ONE PICTURE, in one process. The slow part was never
355
497
  // the clicking: it was a command and a look for every step, when four of them
356
498
  // were visible in the picture you already had.
@@ -361,6 +503,58 @@ export function computerUseCommand(args) {
361
503
  // here — from the runtime Atlas Studio bundles — so a model in a chat that
362
504
  // followed the instructions was told `There is no "do" here` (owner,
363
505
  // 2026-09-15: "see if we can unify things instead of diverging").
506
+ // THE RECORDINGS, AND A NAME FOR ONE (owner, 2026-09-17: "for recordings,
507
+ // allow the CLI the option to rename this"). The id stays what it is — the
508
+ // folder, the log, the parts — and the name sits beside them, the same
509
+ // name.json Atlas Studio's own list reads and writes.
510
+ if (sub === 'sessions') {
511
+ const rows = listComputerSessions(job);
512
+ if (!rows.length) { println(color.dim('No recordings for this project yet.')); return 0; }
513
+ for (const s of rows) {
514
+ const when = new Date(s.started * 1000).toLocaleString();
515
+ println(color.cyan(s.id) + ' ' + (s.name ? color.bold(s.name) + color.dim(' · ' + when) : when)
516
+ + color.dim(` ${s.actions} action${s.actions === 1 ? '' : 's'}${s.video ? ` · ${s.parts} part${s.parts === 1 ? '' : 's'}` : ''}`));
517
+ }
518
+ return 0;
519
+ }
520
+ if (sub === 'rename') {
521
+ const id = String(flags.session || rest.shift() || '').trim();
522
+ const name = String(flags.name ?? rest.join(' ')).trim();
523
+ if (!id) {
524
+ eprintln(color.red('Which recording? `atlas computer-use rename <session-id> "<name>"` (ids: `atlas computer-use sessions`)'));
525
+ return 1;
526
+ }
527
+ const out = renameComputerSession(job, id, name);
528
+ if (!out.ok) { eprintln(color.red(out.error || 'Could not rename that.')); return 1; }
529
+ println(name ? `Named it "${name}".` : 'Name removed.');
530
+ return 0;
531
+ }
532
+
533
+ // A MODEL WATCHES A SESSION AS FRAMES, NOT FILM (owner, 2026-09-18: "it's
534
+ // just a batch of frames where actions were made"). replay.md lists
535
+ // every action with the picture the run took at that moment; review asks
536
+ // the project's model to read it, open the frames, and write review.md —
537
+ // what worked, what a person had to correct, rules for next time.
538
+ if (sub === 'replay' || sub === 'review') {
539
+ const id = String(flags.session || rest.shift() || '').trim();
540
+ if (!id) {
541
+ eprintln(color.red(`Which recording? \`atlas computer-use ${sub} <session-id>\` (ids: \`atlas computer-use sessions\`)`));
542
+ return 1;
543
+ }
544
+ const board = writeReplay(job, id);
545
+ if (!board.ok) { eprintln(color.red(board.error || 'Could not build the replay.')); return 1; }
546
+ println(color.dim(`replay: ${board.path} (${board.frames} actions, ${board.images} frames)`));
547
+ if (sub === 'replay') return 0;
548
+ return askModel(reviewPrompt(id), { job, model: String(flags.model || '') })
549
+ .then((text) => {
550
+ const said = String(text || '').trim();
551
+ try { fs.writeFileSync(path.join(workspace(job).dir, '.computer-use', id, 'review.md'), said + '\n'); } catch { /* the words are printed either way */ }
552
+ println(said);
553
+ return 0;
554
+ })
555
+ .catch((err) => { eprintln(color.red(`The model could not review it: ${err.message || err}`)); return 1; });
556
+ }
557
+
364
558
  if (sub === 'do') {
365
559
  if (!rest.length) {
366
560
  eprintln(color.red('What should it do? e.g. do "click 820 410" "type hello"'));
@@ -378,9 +572,13 @@ export function computerUseCommand(args) {
378
572
  const [name] = OPS[verb];
379
573
  // A quoted phrase is one value: `type "hello there"` means two words
380
574
  // typed, not a word typed twice.
381
- const vals = (verb === 'type' || verb === 'note' || verb === 'find')
575
+ let vals = (['type', 'note', 'find', 'code', 'web-open', 'web-click', 'web-read', 'web-find',
576
+ 'controls', 'press', 'wait-for'].includes(verb))
382
577
  ? (bits.length > 1 ? [bits.slice(1).join(' ')] : [])
383
578
  : bits.slice(1).map((v) => (NUMERIC.has(verb) ? Number(v) : v));
579
+ // A name can be several words, so the text comes after a "|":
580
+ // "type-into Last name | Smith". Without one, the first word is the name.
581
+ if (verb === 'type-into') vals = splitTarget(bits.slice(1).join(' '));
384
582
  steps.push([name, ...vals]);
385
583
  }
386
584
  const code = 'import atlas_env as e, json\n'
@@ -394,20 +592,62 @@ export function computerUseCommand(args) {
394
592
  return 1;
395
593
  }
396
594
  const [name, count] = OPS[sub];
595
+ // `press --double Save` reads "Save" as the flag's value; a switch never has
596
+ // one, so that word goes back where it was meant to go.
597
+ const on = (flag) => {
598
+ const v = flags[flag];
599
+ if (typeof v === 'string') { rest.push(v); return true; }
600
+ return Boolean(v);
601
+ };
602
+ const right = on('right'); const twice = on('double'); const gone = on('gone'); const enter = on('enter');
397
603
  if (count >= 0 && rest.length !== count) {
398
604
  eprintln(color.red(`${sub} takes ${count} value${count === 1 ? '' : 's'}.`));
399
605
  return 1;
400
606
  }
401
- if (count === -1 && !rest.length) {
607
+ if (count === -1 && !rest.length && sub !== 'web-read' && sub !== 'controls') {
402
608
  eprintln(color.red(`${sub} needs at least one.`));
403
609
  return 1;
404
610
  }
405
- const values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
611
+ let values = rest.map((v) => (NUMERIC.has(sub) ? Number(v) : v));
612
+ // A block of code, a sentence to find, an address: the words are one value.
613
+ if (['code', 'note', 'find', 'type', 'web-open', 'web-click', 'web-read', 'web-find'].includes(sub) && rest.length > 1) {
614
+ values = [rest.join(' ')];
615
+ }
616
+ if (sub === 'press' || sub === 'wait-for' || sub === 'controls') {
617
+ values = rest.length ? [rest.join(' ')] : [];
618
+ if (sub === 'press') {
619
+ values.push(right ? 'right' : 'left', twice ? 2 : 1);
620
+ // How long it may wait for the control to appear, stop being grey, or
621
+ // come out from under something (default 5s).
622
+ if (flags.wait !== undefined) values.push(null, 1.0, Number(flags.wait));
623
+ }
624
+ if (sub === 'wait-for') {
625
+ values.push(gone, flags.timeout !== undefined ? Number(flags.timeout) : 10);
626
+ }
627
+ }
628
+ if (sub === 'type-into') {
629
+ if (rest.length < 2) {
630
+ eprintln(color.red('type-into takes the field and the text: type-into "Quantity" "25" [--enter]'));
631
+ return 1;
632
+ }
633
+ values = [rest[0], rest.slice(1).join(' '), enter];
634
+ if (flags.wait !== undefined) values.push(true, null, Number(flags.wait));
635
+ }
636
+ if (sub === 'web-type') {
637
+ if (rest.length < 2) {
638
+ eprintln(color.red('web-type takes the field and the text: web-type "Email" "me@x.com" [--enter]'));
639
+ return 1;
640
+ }
641
+ values = [rest[0], rest.slice(1).join(' '), Boolean(flags.enter)];
642
+ }
406
643
  // THE LIVE PROCESS FIRST: same function, same rules, without a python start.
407
644
  const answer = askServer(job, name, values);
408
645
  if (answer) {
409
646
  if (answer.ok) {
410
- println(JSON.stringify(answer.out, null, 2));
647
+ // THE LIST AS LINES: one control per line is what gets read, and the
648
+ // same list as JSON is five times the words to read it in.
649
+ if (sub === 'controls' && answer.out && answer.out.summary) println(answer.out.summary);
650
+ else println(JSON.stringify(answer.out, null, 2));
411
651
  return 0;
412
652
  }
413
653
  eprintln(color.red(String(answer.error || 'That did not work.')));
@@ -417,6 +657,8 @@ export function computerUseCommand(args) {
417
657
  // pasting is a snippet that breaks on somebody's password.
418
658
  const code = 'import atlas_env as e, json\n'
419
659
  + `out = e.${name}(*json.loads(${JSON.stringify(JSON.stringify(values))}))\n`
420
- + 'print(json.dumps(out, default=str))\n';
660
+ + (sub === 'controls'
661
+ ? 'print(out["summary"] if isinstance(out, dict) and out.get("summary") else json.dumps(out, default=str))\n'
662
+ : 'print(json.dumps(out, default=str))\n');
421
663
  return runInProject(job, code);
422
664
  }