mindvest-atlas 0.68.4 → 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.4",
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
+ }
@@ -28,6 +28,7 @@ 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) {
@@ -423,6 +503,58 @@ export function computerUseCommand(args) {
423
503
  // here — from the runtime Atlas Studio bundles — so a model in a chat that
424
504
  // followed the instructions was told `There is no "do" here` (owner,
425
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
+
426
558
  if (sub === 'do') {
427
559
  if (!rest.length) {
428
560
  eprintln(color.red('What should it do? e.g. do "click 820 410" "type hello"'));
@@ -440,9 +572,13 @@ export function computerUseCommand(args) {
440
572
  const [name] = OPS[verb];
441
573
  // A quoted phrase is one value: `type "hello there"` means two words
442
574
  // typed, not a word typed twice.
443
- 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))
444
577
  ? (bits.length > 1 ? [bits.slice(1).join(' ')] : [])
445
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(' '));
446
582
  steps.push([name, ...vals]);
447
583
  }
448
584
  const code = 'import atlas_env as e, json\n'
@@ -456,20 +592,62 @@ export function computerUseCommand(args) {
456
592
  return 1;
457
593
  }
458
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');
459
603
  if (count >= 0 && rest.length !== count) {
460
604
  eprintln(color.red(`${sub} takes ${count} value${count === 1 ? '' : 's'}.`));
461
605
  return 1;
462
606
  }
463
- if (count === -1 && !rest.length) {
607
+ if (count === -1 && !rest.length && sub !== 'web-read' && sub !== 'controls') {
464
608
  eprintln(color.red(`${sub} needs at least one.`));
465
609
  return 1;
466
610
  }
467
- 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
+ }
468
643
  // THE LIVE PROCESS FIRST: same function, same rules, without a python start.
469
644
  const answer = askServer(job, name, values);
470
645
  if (answer) {
471
646
  if (answer.ok) {
472
- 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));
473
651
  return 0;
474
652
  }
475
653
  eprintln(color.red(String(answer.error || 'That did not work.')));
@@ -479,6 +657,8 @@ export function computerUseCommand(args) {
479
657
  // pasting is a snippet that breaks on somebody's password.
480
658
  const code = 'import atlas_env as e, json\n'
481
659
  + `out = e.${name}(*json.loads(${JSON.stringify(JSON.stringify(values))}))\n`
482
- + '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');
483
663
  return runInProject(job, code);
484
664
  }