atris 3.82.18 → 3.82.20

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: engines
3
- description: "Dispatch work to an installed terminal agent or named Atris engine profile. Supports Atris Fast, Claude, Codex, Cursor, Fable, Composer, Haiku, Devin, Grok, Antigravity (agy), and opencode. Triggers on: use codex, use cursor, use devin, use grok, use agy, use antigravity, use gemini, gemini session, use fable, use claude, use opencode, use atris, engine, dispatch to, worker agent, second opinion build."
3
+ description: "Dispatch work to an installed terminal agent or named Atris engine profile. Supports Atris Fast, Claude, Codex, Cursor, Fable, Composer, Haiku, Devin, Grok, Muse, Antigravity (agy), and opencode. Triggers on: use codex, use cursor, use devin, use grok, use muse, use agy, use antigravity, use gemini, gemini session, use fable, use claude, use opencode, use atris, engine, dispatch to, worker agent, second opinion build."
4
4
  version: 1.7.0
5
5
  tags:
6
6
  - engines
@@ -12,6 +12,7 @@ tags:
12
12
  - haiku
13
13
  - devin
14
14
  - grok
15
+ - muse
15
16
  - agy
16
17
  - antigravity
17
18
  - gemini
@@ -22,7 +23,7 @@ tags:
22
23
 
23
24
  # Engines: interchangeable terminal workers
24
25
 
25
- One contract, eleven live profiles. The orchestrator writes a bounded task prompt, dispatches it to an engine, then **independently verifies, lands, and pushes** the result. Engines never self-certify.
26
+ One contract, twelve live profiles. The orchestrator writes a bounded task prompt, dispatches it to an engine, then **independently verifies, lands, and pushes** the result. Engines never self-certify.
26
27
 
27
28
  ## Pick the job owner first
28
29
 
@@ -46,6 +47,18 @@ The first worker that is ready and not expired leads; the rest are backups in or
46
47
 
47
48
  **What happens on its own.** A worker that stalls or loses its connection sits out 30 minutes ("cooling") and the next worker takes the job; a failed task (tests failed) is not handed over and does not bench anyone. Every run is recorded; `atris engine roster --runs` lists them and the roster shows each worker's recent record. When the record says the order is wrong, the roster prints one suggestion with the exact command. It never edits the file: run it only when the owner says so.
48
49
 
50
+ **Team members: model and runs on.** A line under `## team` gives one member its own model, and an `on:` part says where it works:
51
+
52
+ ```
53
+ ## team
54
+ - cosmo: claude code, model: opus 5.5
55
+ - vannevar: muse, model: muse wonder
56
+ - walsh: claude code, model: opus 5.5, on: atris computer
57
+ - amara: on: grok bot
58
+ ```
59
+
60
+ No `on:` means this machine. `on: atris computer` is the computer Atris hosts; any other name (`grok bot`, `claude duet`, `dots`) is a bot on its own machine. A member that works elsewhere never runs here: its missions pause with "runs on <place>", and the roster shows a RUNS ON column. Set it with `atris engine roster set --member <name> [<tool> <model>] [--on "<place>"]`; moving a member keeps its engine, and swapping its engine keeps its place. The machine that is that place says so with `ATRIS_RUNS_AS="grok bot"` or one line in `~/.atris/RUNS_AS`; there the member runs, and everywhere else it is skipped.
61
+
49
62
  **Heavy models.** Astra and Fable should judge, not explore. Give their line `prep: search`: the search lead writes a brief of at most 300 lines first, and if prep fails the worker runs as usual.
50
63
 
51
64
  **Team members.** Navigators, researchers, and scouts do search; validators, reviewers, and judges do review; everyone else builds. A `## team` line overrides: `- researcher: search` or `- judge: claude code, model: opus 5.5`. `member run`, owned missions, and autopilot phases use the member's pick; `--engine` still wins. Parked members are skipped; `atris team park <name>` and `atris team unpark <name>` toggle it.
@@ -99,6 +112,7 @@ Smoke: `reply with the word OK`, run from `$HOME`, 120s cap, one at a time on a
99
112
  | Devin (build) | `devin -p --permission-mode dangerous --model swe-2-max --prompt-file <brief>` (run from an isolated worktree) | `swe-2-max` (Free tag in `devin models list`, 2026-09-23) | 2026-09-23 | 14.0 | In `-p` mode the default permission mode rejects shell commands too, so even a read-only audit dies after one step (2026-09-23). Keep the brief outside the worktree. Long runs (5+ min) can print nothing even on success: judge by the worktree diff. `devin cloud` outlives this machine. |
100
113
  | Devin (search) | `devin -p --permission-mode dangerous --model swe-1.7-lightning -- "<prompt>"` | `swe-1.7-lightning` (paid, no Free tag on 2026-09-23) | 2026-09-23 | 13.9 | |
101
114
  | Grok | `grok --always-approve --model grok-4.7 -p "<prompt>"` (run from the target repo) | `grok-4.7` | 2026-09-23 | 11.7 | Unpinned default is now `grok-4.7-build-fast`; `grok models` lists the menu. Uses grok.com login. `--best-of-n <N>` for tricky bounded builds. |
115
+ | Muse | `muse exec --sandbox-network enabled --trust-workspace --model muse-wonder-1.0 "<prompt>"` (run from the target repo) | `muse-wonder-1.0` | 2026-11-11 | - | Meta's Muse Code. Runs only Muse models (`muse wonder`, `muse spark`). Its sandbox proxies the network by default, which blocks atris calls; `enabled` keeps the sandbox and approval judge on. Read-only asks add `--disable-write`. |
102
116
  | Antigravity (agy) | `agy --mode accept-edits --add-dir "$PWD" --model gemini-3.8-flash-high --output-format json -p "<prompt>"` (run from the target repo) | `gemini-3.8-flash-high` | 2026-09-23 | 15.9 | **`--add-dir` is mandatory for writes**: without it agy edits `~/.gemini/antigravity-cli/scratch/` and the project never changes (2026-08-28). `--mode plan --sandbox` for read-only review. Also answers to "gemini". |
103
117
  | opencode | `opencode run -m opencode/muse-spark-1.3-contributor-free "<prompt>"` (read-only: add `--agent plan`) | `opencode/muse-spark-1.3-contributor-free` | 2026-09-23 | 41.1 | Build work needs `--auto` (isolated worktree only). `opencode models` lists the live menu. |
104
118
 
@@ -123,6 +137,7 @@ Headless dispatch permissions (verified 2026-08-11): `codex exec`, `grok`, and `
123
137
  | Devin | `--model <id>` | `swe-2-max`, `swe-2-high`, `swe-2-medium` (Free); `claude-opus-5-5-high`, `gpt-6-sol` (paid) |
124
138
  | Cursor | `--model <id>` | `composer-2.5`, `grok-4.7-xhigh`, `claude-opus-5-thinking-high`; `--list-models` |
125
139
  | Grok | `--model <id>` | `grok-4.7`, `grok-4.7-build-fast` (default), `grok-4.6`, `grok-4.5`; `grok models` |
140
+ | Muse | `--model <id>` | `muse-wonder-1.0`, `muse-spark-1.3`; `muse model-profile show <id>` |
126
141
  | Codex | `-m <id>` | `gpt-6-sol` is the `~/.codex/config.toml` default |
127
142
 
128
143
  Pins expire 30 days after their verified date. Re-verify on a new model: run the CLI's model list, smoke one prompt, update the row.
package/ax CHANGED
@@ -3134,8 +3134,12 @@ function buildPayload(message, options = {}) {
3134
3134
  // /terminal endpoint. The workspace_path is a label, not a local path.
3135
3135
  payload.workspace_path = `/workspace/${options.business.slug}`;
3136
3136
  payload.local_executor = true;
3137
- } else if (local) {
3138
- payload.workspace_path = options.cwd || process.cwd();
3137
+ } else {
3138
+ // Say this is the person's own terminal chat, so the backend gives it their
3139
+ // Atris memory (what they told Atris, and remember/save). Business turns
3140
+ // keep the shape they have today and name no surface.
3141
+ payload.surface = 'cli';
3142
+ if (local) payload.workspace_path = options.cwd || process.cwd();
3139
3143
  }
3140
3144
  if (options.connectionContext) {
3141
3145
  payload.connection_context = options.connectionContext;
package/bin/atris.js CHANGED
@@ -774,6 +774,7 @@ function showHelpAll() {
774
774
  console.log(' switch - Switch account globally (atris switch <name>)');
775
775
  console.log(' use - Set account for this terminal only (atris use <name>)');
776
776
  console.log(' accounts - Manage accounts (list, add, remove)');
777
+ console.log(' film - See film projects and suggest edits (list, show, propose, status; --json)');
777
778
  console.log(' balance - Show credit balance in dollars');
778
779
  console.log(' usage - Show developer API usage');
779
780
  console.log(' api-key - Create, list, rotate, or revoke a developer API key');
@@ -2531,6 +2532,10 @@ if (command === 'guide') {
2531
2532
  Promise.resolve().then(() => require('../commands/memory').memoryCommand(process.argv.slice(3)))
2532
2533
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2533
2534
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2535
+ } else if (command === 'film') {
2536
+ Promise.resolve().then(() => require('../commands/film').filmCommand(process.argv.slice(3)))
2537
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2538
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2534
2539
  } else if (command === 'balance') {
2535
2540
  Promise.resolve().then(() => require('../commands/balance').balanceCommand(process.argv.slice(3)))
2536
2541
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
package/commands/ask.js CHANGED
@@ -7,11 +7,27 @@
7
7
  */
8
8
 
9
9
  const readline = require('readline');
10
- const { getApiBaseUrl, httpRequest, apiRequestJson } = require('../utils/api');
10
+ const api = require('../utils/api');
11
11
  const auth = require('../utils/auth');
12
12
 
13
+ const { getApiBaseUrl, apiRequestJson } = api;
14
+
13
15
  const TIMEOUT_MS = 300000;
14
16
 
17
+ // A request like "run date on my computer" can come back as a started job
18
+ // instead of an answer. These keep the terminal open until the job is done.
19
+ const JOB_FIRST_POLL_MS = 1500;
20
+ const JOB_MAX_POLL_MS = 3000;
21
+ const JOB_WAIT_CAP_MS = 5 * 60 * 1000;
22
+ const JOB_POLL_TIMEOUT_MS = 20000;
23
+ // Consecutive polls that may fail (network drop, 5xx) before we give up.
24
+ const JOB_POLL_FAILURE_LIMIT = 4;
25
+ // Consecutive 404s before we say the job is gone. One is tolerated in case
26
+ // the first read lands before the saved job is visible.
27
+ const JOB_NOT_FOUND_LIMIT = 2;
28
+ const CHECK_COMMAND = 'atris chat --personal "how is it going"';
29
+ const JOB_INTENTS = new Set(['mission_start', 'mission_answer', 'mission_approve', 'mission_correction']);
30
+
15
31
  function stripEvents(text) {
16
32
  // Drop inline [[EVENT]]{...} tool chatter; keep only the spoken reply.
17
33
  // Tool names are surfaced dimmed in the terminal (texts never see them).
@@ -52,13 +68,14 @@ function stripEvents(text) {
52
68
  }
53
69
 
54
70
  function startSpinner(label) {
55
- if (!process.stdout.isTTY) return { stop: () => {} };
71
+ if (!process.stdout.isTTY) return { stop: () => {}, setLabel: () => {} };
56
72
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
57
73
  const started = Date.now();
58
74
  let i = 0;
75
+ let current = label;
59
76
  const timer = setInterval(() => {
60
77
  const secs = Math.floor((Date.now() - started) / 1000);
61
- process.stdout.write(`\r${frames[i % frames.length]} ${label}${secs > 2 ? ` · ${secs}s` : ''} `);
78
+ process.stdout.write(`\r\x1b[2K${frames[i % frames.length]} ${current}${secs > 2 ? ` · ${secs}s` : ''}`);
62
79
  i += 1;
63
80
  }, 90);
64
81
  return {
@@ -66,6 +83,7 @@ function startSpinner(label) {
66
83
  clearInterval(timer);
67
84
  process.stdout.write('\r\x1b[2K');
68
85
  },
86
+ setLabel: (next) => { if (next) current = String(next); },
69
87
  };
70
88
  }
71
89
 
@@ -83,7 +101,7 @@ async function typeOut(text) {
83
101
  }
84
102
 
85
103
  async function sendMessage(token, message) {
86
- const res = await httpRequest(`${getApiBaseUrl()}/message/incoming`, {
104
+ const res = await api.httpRequest(`${getApiBaseUrl()}/message/incoming`, {
87
105
  method: 'POST',
88
106
  headers: {
89
107
  Authorization: `Bearer ${token}`,
@@ -100,7 +118,165 @@ async function sendMessage(token, message) {
100
118
  return JSON.parse(res.body.toString('utf8'));
101
119
  }
102
120
 
103
- async function askAtris(args) {
121
+ // The job id when this reply started (or restarted) a job the person should
122
+ // wait for. Replies that only report on a job (status, stop) carry no wait.
123
+ function startedJobId(body) {
124
+ if (!body || typeof body !== 'object' || body.success === false) return null;
125
+ if (!JOB_INTENTS.has(body.intent)) return null;
126
+ const id = body.card && body.card.mission_id;
127
+ return typeof id === 'string' && id ? id : null;
128
+ }
129
+
130
+ function defaultSleep(ms) {
131
+ return new Promise((resolve) => setTimeout(resolve, ms));
132
+ }
133
+
134
+ async function fetchJob(token, jobId) {
135
+ const url = `${getApiBaseUrl()}/atris2/missions/${encodeURIComponent(jobId)}?details=true`;
136
+ let res;
137
+ try {
138
+ res = await api.httpRequest(url, {
139
+ method: 'GET',
140
+ headers: { Authorization: `Bearer ${token}` },
141
+ timeoutMs: JOB_POLL_TIMEOUT_MS,
142
+ });
143
+ } catch (err) {
144
+ return { transient: true, error: err && err.message ? err.message : String(err) };
145
+ }
146
+ const status = res && res.status;
147
+ if (status === 200) {
148
+ try {
149
+ return { job: JSON.parse(res.body.toString('utf8')) };
150
+ } catch (_) {
151
+ return { transient: true, error: 'unreadable reply' };
152
+ }
153
+ }
154
+ if (status === 404) return { notFound: true };
155
+ if (status === 401 || status === 403) return { signedOut: true };
156
+ return { transient: true, error: `HTTP ${status}` };
157
+ }
158
+
159
+ // What a polled job says right now: done (with its answer), needs the person,
160
+ // stopped, failed, waiting for a computer, or still working.
161
+ function readJob(job) {
162
+ const result = job && typeof job.result === 'object' && job.result ? job.result : {};
163
+ const proof = job && typeof job.proof === 'object' && job.proof ? job.proof : {};
164
+ const state = String((job && job.state) || '').toLowerCase();
165
+ const status = String((job && job.status) || '').toLowerCase();
166
+ const text = [result.response, result.summary, proof.summary, job && job.summary]
167
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
168
+ .find(Boolean) || '';
169
+ if (status === 'completed' || state === 'done') return { kind: 'done', text };
170
+ if (status === 'cancelled') return { kind: 'stopped' };
171
+ const detail = [result.message, result.error]
172
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
173
+ .find(Boolean) || '';
174
+ if (status === 'failed') {
175
+ // A hard-capped job that failed reads "stopped"; any other failure is a failure.
176
+ return state === 'stopped' ? { kind: 'stopped', detail } : { kind: 'failed', detail };
177
+ }
178
+ if (state === 'stopped') return { kind: 'stopped', detail };
179
+ if (state === 'your_turn') {
180
+ const needs = job.needs && typeof job.needs === 'object' ? job.needs : {};
181
+ const question = String(needs.question || job.next || '').trim();
182
+ const options = Array.isArray(needs.options) ? needs.options.map(String).filter(Boolean) : [];
183
+ return { kind: 'needs', question: question || 'The job is waiting on you.', options };
184
+ }
185
+ if (state === 'waiting_for_computer') return { kind: 'no_computer' };
186
+ return { kind: 'working', label: typeof job.working_on === 'string' ? job.working_on : '' };
187
+ }
188
+
189
+ // Poll a started job until it ends, asks a question, or the wait cap passes.
190
+ // Never throws: every way this can go wrong comes back as an outcome.
191
+ async function waitForJob(token, jobId, { spinner, sleep = defaultSleep, now = Date.now, capMs = JOB_WAIT_CAP_MS } = {}) {
192
+ const startedAt = now();
193
+ let attempt = 0;
194
+ let failures = 0;
195
+ let notFound = 0;
196
+ for (;;) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ const polled = await fetchJob(token, jobId);
199
+ if (polled.signedOut) return { kind: 'signed_out' };
200
+ if (polled.notFound) {
201
+ notFound += 1;
202
+ failures = 0;
203
+ if (notFound >= JOB_NOT_FOUND_LIMIT) return { kind: 'lost' };
204
+ } else if (polled.transient) {
205
+ failures += 1;
206
+ notFound = 0;
207
+ if (failures >= JOB_POLL_FAILURE_LIMIT) return { kind: 'disconnected' };
208
+ } else {
209
+ failures = 0;
210
+ notFound = 0;
211
+ const read = readJob(polled.job);
212
+ if (read.kind !== 'working') return read;
213
+ if (spinner && read.label) spinner.setLabel(read.label);
214
+ }
215
+ const remaining = capMs - (now() - startedAt);
216
+ if (remaining <= 0) return { kind: 'timeout' };
217
+ attempt += 1;
218
+ const delay = Math.min(JOB_FIRST_POLL_MS + (attempt - 1) * 500, JOB_MAX_POLL_MS, remaining);
219
+ // eslint-disable-next-line no-await-in-loop
220
+ await sleep(delay);
221
+ }
222
+ }
223
+
224
+ // Plain words for each way a wait can end. `failed` ones are errors for a
225
+ // script (exit code 1); the rest are answers or a request for the person.
226
+ function describeOutcome(outcome, { interactive = false } = {}) {
227
+ switch (outcome.kind) {
228
+ case 'done':
229
+ return { text: stripEvents(outcome.text) || 'The job finished, but it did not leave a written answer.' };
230
+ case 'needs': {
231
+ const lines = [`The job needs your answer: ${outcome.question}`];
232
+ if (outcome.options.length) lines.push(`Choices: ${outcome.options.join(', ')}`);
233
+ lines.push(interactive
234
+ ? 'Type your answer below.'
235
+ : 'Answer with: atris chat --personal "<your answer>"');
236
+ return { text: lines.join('\n') };
237
+ }
238
+ case 'timeout':
239
+ return { text: `Still working after ${Math.round(JOB_WAIT_CAP_MS / 60000)} minutes, and it keeps running. Check on it with: ${CHECK_COMMAND}` };
240
+ case 'no_computer':
241
+ return { text: 'This job is waiting for a computer to run on. It starts once your computer is running.' };
242
+ case 'stopped':
243
+ return { text: `That job was stopped before it finished.${outcome.detail ? ` ${outcome.detail}` : ''}`, failed: true };
244
+ case 'failed':
245
+ return {
246
+ text: `The job did not finish${outcome.detail ? `: ${outcome.detail}` : '.'} Say it a little differently to start a fresh try.`,
247
+ failed: true,
248
+ };
249
+ case 'lost':
250
+ return { text: 'I lost track of that job, so I cannot tell you how it went. Ask again to start a new one.', failed: true };
251
+ case 'signed_out':
252
+ return { text: 'Your sign-in no longer works for this job. Run "atris login", then ask for an update.', failed: true };
253
+ case 'disconnected':
254
+ default:
255
+ return {
256
+ text: `Lost the connection while the job was running. It may still be working. Check with: ${CHECK_COMMAND}`,
257
+ failed: true,
258
+ };
259
+ }
260
+ }
261
+
262
+ // One person's message, start to finish: send it, and when the reply is a
263
+ // started job, wait for the job's real answer instead of stopping at "I'm starting now".
264
+ async function askOnce(token, message, { spinner, interactive = false, wait = {} } = {}) {
265
+ const body = await sendMessage(token, message);
266
+ const jobId = startedJobId(body);
267
+ if (!jobId) return { text: stripEvents(body.response) || JSON.stringify(body) };
268
+ if (spinner) spinner.setLabel('working on it');
269
+ const outcome = await waitForJob(token, jobId, { spinner, ...wait });
270
+ const described = describeOutcome(outcome, { interactive });
271
+ // The same words always map to the same job, so asking again can return a finished one.
272
+ if (outcome.kind === 'done' && body.intent === 'mission_start'
273
+ && body.card && String(body.card.state || '').toLowerCase() === 'done') {
274
+ described.note = 'This is the answer from the last time you asked this. Reword it to run it again.';
275
+ }
276
+ return described;
277
+ }
278
+
279
+ async function askAtris(args, options = {}) {
104
280
  const ensured = await auth.ensureValidCredentials(apiRequestJson);
105
281
  if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
106
282
  console.error('✗ Not logged in. Run "atris login" first.');
@@ -125,15 +301,23 @@ async function askAtris(args) {
125
301
 
126
302
  if (oneShot) {
127
303
  const spinner = startSpinner('thinking');
304
+ let answer;
128
305
  try {
129
- const body = await sendMessage(token, oneShot);
130
- spinner.stop();
131
- await typeOut(stripEvents(body.response) || JSON.stringify(body));
306
+ answer = await askOnce(token, oneShot, { spinner, wait: options });
132
307
  } catch (err) {
133
308
  spinner.stop();
134
309
  console.error(`✗ ${err.message}`);
135
310
  process.exit(1);
311
+ return;
312
+ }
313
+ spinner.stop();
314
+ if (answer.note) console.error(answer.note);
315
+ if (answer.failed) {
316
+ console.error(`✗ ${answer.text}`);
317
+ process.exit(1);
318
+ return;
136
319
  }
320
+ await typeOut(answer.text);
137
321
  return;
138
322
  }
139
323
 
@@ -150,11 +334,16 @@ async function askAtris(args) {
150
334
  busy = true;
151
335
  const spinner = startSpinner('thinking');
152
336
  try {
153
- const body = await sendMessage(token, message);
337
+ const answer = await askOnce(token, message, { spinner, interactive: true, wait: options });
154
338
  spinner.stop();
155
- process.stdout.write('\natris › ');
156
- await typeOut(stripEvents(body.response) || JSON.stringify(body));
157
- process.stdout.write('\n');
339
+ if (answer.note) console.error(answer.note);
340
+ if (answer.failed) {
341
+ console.error(`✗ ${answer.text}\n`);
342
+ } else {
343
+ process.stdout.write('\natris › ');
344
+ await typeOut(answer.text);
345
+ process.stdout.write('\n');
346
+ }
158
347
  } catch (err) {
159
348
  spinner.stop();
160
349
  console.error(`✗ ${err.message}\n`);
@@ -172,4 +361,4 @@ async function askAtris(args) {
172
361
  });
173
362
  }
174
363
 
175
- module.exports = { askAtris };
364
+ module.exports = { askAtris, readJob, startedJobId, waitForJob, describeOutcome };
@@ -1374,12 +1374,22 @@ function teamActivityTag(activity) {
1374
1374
  function renderTeamRoster(rows, { width } = {}) {
1375
1375
  if (!rows.length) return '';
1376
1376
  const showActivity = rows.some((row) => row.activity !== undefined);
1377
- const columns = [{ header: 'MEMBER' }, { header: 'JOB' }, { header: 'ENGINE · MODEL' }, { header: 'SOURCE' }];
1377
+ // RUNS ON shows once any member works on another machine; an all-local
1378
+ // team reads exactly as before.
1379
+ const showPlace = rows.some((row) => row.runs_here === false);
1380
+ const columns = [{ header: 'MEMBER' }, { header: 'JOB' }, { header: 'ENGINE · MODEL' }];
1381
+ if (showPlace) columns.push({ header: 'RUNS ON' });
1382
+ columns.push({ header: 'SOURCE' });
1378
1383
  if (showActivity) columns.push({ header: 'LAST 7 DAYS', clip: true, min: 11 });
1379
1384
  const cells = rows.map((row) => {
1380
- const engine = row.engine ? workerCellText(engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' })) : 'no ready engine';
1385
+ const elsewhere = row.runs_here === false;
1386
+ const engine = row.engine
1387
+ ? workerCellText(engineRunsView(row.engine, { model: row.model || '', effort: row.effort || '' }))
1388
+ : elsewhere ? 'its own model' : 'no ready engine';
1381
1389
  const stale = row.stale_model && row.newer_model ? ` (newer ${modelLabel(row.newer_model)} out)` : '';
1382
- const cell = [row.member, row.job || '-', `${engine}${stale}`, row.source === 'file' ? (row.file || 'roster file') : 'automatic'];
1390
+ const cell = [row.member, row.job || '-', `${engine}${stale}`];
1391
+ if (showPlace) cell.push(row.runs_on_label || row.runs_on || '-');
1392
+ cell.push(row.source === 'file' ? (row.file || 'roster file') : 'automatic');
1383
1393
  if (showActivity) cell.push(teamActivityTag(row.activity));
1384
1394
  return cell;
1385
1395
  });
@@ -1520,7 +1530,7 @@ function runRosterRunsCommand(job, json, root, now) {
1520
1530
  return 0;
1521
1531
  }
1522
1532
 
1523
- const ROSTER_SET_USAGE = 'usage: atris engine roster set <job> <tool> <model>, or atris engine roster set --member <name> <tool> <model>. the tools and models come from atris engine roster --available';
1533
+ const ROSTER_SET_USAGE = 'usage: atris engine roster set <job> <tool> <model>, or atris engine roster set --member <name> [<tool> <model>] [--on "<place>"]. --on is where the member works: "atris computer", "local", or a bot on another machine like "grok bot". the tools and models come from atris engine roster --available';
1524
1534
 
1525
1535
  // The words after set: the job (one quoted argument, or every word before
1526
1536
  // the first one that names a tool), the tool (one word, or two like "claude
@@ -1569,19 +1579,51 @@ function runRosterSetCommand(args, root) {
1569
1579
  return 2;
1570
1580
  }
1571
1581
  }
1582
+ let place = null;
1583
+ const onAt = words.indexOf('--on');
1584
+ if (onAt !== -1) {
1585
+ place = words[onAt + 1] || '';
1586
+ words.splice(onAt, 2);
1587
+ if (!member || !place || place.startsWith('--')) {
1588
+ console.error(ROSTER_SET_USAGE);
1589
+ return 2;
1590
+ }
1591
+ }
1572
1592
  if (words.some((word) => String(word).startsWith('--'))) {
1573
1593
  console.error(ROSTER_SET_USAGE);
1574
1594
  return 2;
1575
1595
  }
1576
- const parts = splitSetWords(words, !member);
1596
+ const parts = place !== null && !words.length ? {} : splitSetWords(words, !member);
1577
1597
  if (!parts || (!member && !parts.job)) {
1578
1598
  console.error(ROSTER_SET_USAGE);
1579
1599
  return 2;
1580
1600
  }
1581
1601
  try {
1582
- const out = member
1602
+ let lineScope = 'project';
1603
+ if (place !== null) {
1604
+ // a bad place or an unknown member is refused before anything is written
1605
+ const { readMemberCard, teamLineFor } = require('../lib/member-engine');
1606
+ const reg = require('../lib/engine-registry');
1607
+ reg.setTeamPlaceCheck(place);
1608
+ // member names are lowercase on disk, the same way swapMember reads them
1609
+ member = member.trim().toLowerCase();
1610
+ const line = teamLineFor(reg.readRosterState(root), member);
1611
+ if (!line && !readMemberCard(root, member).found) throw new Error(`there is no team member named ${member}`);
1612
+ if (line && line.scope) lineScope = line.scope;
1613
+ }
1614
+ let out = member && parts.tool
1583
1615
  ? swapMember(root, member, parts.tool, parts.model)
1584
- : swapJob(root, parts.job, parts.tool, parts.model);
1616
+ : member ? null : swapJob(root, parts.job, parts.tool, parts.model);
1617
+ if (place !== null) {
1618
+ const { setTeamPlace } = require('../lib/engine-registry');
1619
+ // the place goes on the line that decides for this member
1620
+ const scope = out && out.scope ? out.scope : lineScope;
1621
+ const placed = setTeamPlace(member, place, scope === 'session' ? { session: true } : scope === 'machine' ? { everywhere: true } : {}, root);
1622
+ const { runsOnText } = require('../lib/runs-on');
1623
+ const where = runsOnText(placed.place, placed.place.engine || '');
1624
+ out = { ...(out || { member }), runs_on: placed.place.id, runs_on_label: placed.place.label, runs_here: placed.place.here, value: placed.value,
1625
+ line: `${out && out.changed ? `${out.line} ` : ''}${member} now works on ${where}.` };
1626
+ }
1585
1627
  console.log(json ? JSON.stringify(out, null, 2) : out.line);
1586
1628
  return 0;
1587
1629
  } catch (err) {
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ // atris film: see film projects and suggest edits. Agents only suggest; the
4
+ // person approves in their film editor. Backend: /api/film-studio/*.
5
+
6
+ const { apiRequestJson } = require('../utils/api');
7
+ const { loadCredentials } = require('../utils/auth');
8
+ const { NOT_LOGGED_IN, wantsHelp, wantsJson, tokenFrom, oneLine } = require('../lib/developer-api');
9
+
10
+ const BASE = '/film-studio';
11
+ const POLL_MS = 2000;
12
+ const MAX_POLLS = 30; // 60 seconds
13
+ const DONE_STATES = new Set(['done', 'refused', 'expired']);
14
+
15
+ function showFilmHelp(log = console.log) {
16
+ log('usage: atris film [list] [--json]');
17
+ log(' atris film show <slug> [--json]');
18
+ log(' atris film propose <slug> \'<op json>\' --why "<one line>" [--wait] [--json]');
19
+ log(' atris film status <id> [--json]');
20
+ log('see film projects and suggest edits. Times are frames (30 a second).');
21
+ log('proposals change nothing until the person approves them in their editor.');
22
+ }
23
+
24
+ function parseArgs(args) {
25
+ const out = { positional: [], why: '', wait: false };
26
+ for (let i = 0; i < args.length; i += 1) {
27
+ const a = String(args[i]);
28
+ if (a === '--json') continue;
29
+ if (a === '--wait') out.wait = true;
30
+ else if (a === '--why') { out.why = String(args[i + 1] == null ? '' : args[i + 1]); i += 1; }
31
+ else if (a.startsWith('--why=')) out.why = a.slice(6);
32
+ else out.positional.push(a);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ const secs = (f, fps) => (Number(f) / (fps || 30)).toFixed(2);
38
+
39
+ function describeEdit(e) {
40
+ if (!e || typeof e !== 'object') return String(e);
41
+ const who = e.by || 'someone';
42
+ const text = e.summary || (e.op ? JSON.stringify(e.op) : 'an edit');
43
+ const label = e.n != null ? `#${e.n}` : (e.id ? `#${e.id}` : '-');
44
+ const state = e.status ? ` (${e.status})` : '';
45
+ const why = e.why ? ` Why: ${oneLine(e.why)}` : '';
46
+ return `${label} ${who}${state}: ${oneLine(text)}${why}`;
47
+ }
48
+
49
+ function showLines(d) {
50
+ const tl = d.timeline && typeof d.timeline === 'object' ? d.timeline : {};
51
+ const fps = Number(tl.fps) || 30;
52
+ const bubbles = Array.isArray(tl.bubbles) ? tl.bubbles : [];
53
+ const lines = [`${d.title || d.slug} (${d.slug}) version ${d.version == null ? '?' : d.version}`];
54
+ lines.push(`Editor Mac: ${d.mac_online ? 'online' : 'off'}${d.render_status ? `, render: ${d.render_status}` : ''}`);
55
+ lines.push(`${tl.width || '?'}x${tl.height || '?'} at ${fps} fps, ${tl.duration_f || 0} frames (${secs(tl.duration_f || 0, fps)} s), ${bubbles.length} bubbles`);
56
+ const order = (Array.isArray(tl.tracks) ? tl.tracks.map((t) => t.id) : []);
57
+ for (const b of bubbles) if (!order.includes(b.track)) order.push(b.track);
58
+ for (const track of order) {
59
+ const rows = bubbles.filter((b) => b.track === track).sort((a, b) => a.start_f - b.start_f);
60
+ lines.push('', track);
61
+ if (!rows.length) lines.push(' (empty)');
62
+ for (const b of rows) {
63
+ const what = b.label || (b.source && (b.source.file || b.source.kind)) || '';
64
+ lines.push(` ${b.id} frames ${b.start_f}-${b.end_f} ${secs(b.start_f, fps)}-${secs(b.end_f, fps)} s ${what}${b.locked ? ' [locked]' : ''}`);
65
+ }
66
+ }
67
+ const notes = Array.isArray(tl.notes) ? tl.notes : [];
68
+ if (notes.length) {
69
+ lines.push('', 'notes');
70
+ for (const n of notes) lines.push(` ${n.id} frame ${n.t_f} (${secs(n.t_f, fps)} s) ${oneLine(n.text)}`);
71
+ }
72
+ const edits = (Array.isArray(d.edits) ? d.edits : []).slice(-10);
73
+ lines.push('', 'last edits');
74
+ if (!edits.length) lines.push(' none yet');
75
+ for (const e of edits) lines.push(` ${describeEdit(e)}`);
76
+ const pending = Array.isArray(d.pending) ? d.pending : [];
77
+ lines.push('', `pending proposals: ${pending.length}`);
78
+ for (const e of pending) lines.push(` ${describeEdit(e)}`);
79
+ return lines;
80
+ }
81
+
82
+ function commandLines(c, id) {
83
+ const status = c.status || 'unknown';
84
+ if (status === 'done') return ['Done. Your suggestion reached the editor and is waiting for the person to approve it.'];
85
+ if (status === 'refused') return [`Refused: ${oneLine(c.error)}`];
86
+ if (status === 'expired') return ['Expired: the editor Mac did not pick it up in time. Try again when it is on.'];
87
+ return [`${status}. Check later: atris film status ${id}`];
88
+ }
89
+
90
+ async function filmCommand(args = [], deps = {}) {
91
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
92
+ if (wantsHelp(args)) { showFilmHelp(io.log); return 0; }
93
+ const json = wantsJson(args);
94
+ const request = deps.apiRequestJson || apiRequestJson;
95
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
96
+ const fail = (message, extra = {}) => {
97
+ if (json) io.log(JSON.stringify({ ok: false, error: message, ...extra }));
98
+ else io.err(message);
99
+ return 1;
100
+ };
101
+ const ok = (payload, lines) => {
102
+ if (json) io.log(JSON.stringify({ ok: true, ...payload }));
103
+ else for (const l of lines) io.log(l);
104
+ return 0;
105
+ };
106
+ const token = tokenFrom((deps.loadCredentials || loadCredentials)());
107
+ if (!token) return fail(NOT_LOGGED_IN);
108
+ const call = async (path, options = {}) => {
109
+ try {
110
+ const res = await request(`${BASE}${path}`, { token, ...options });
111
+ return res || { ok: false, error: 'request failed' };
112
+ } catch (e) {
113
+ return { ok: false, error: oneLine(e && e.message) };
114
+ }
115
+ };
116
+
117
+ const { positional, why, wait } = parseArgs(args);
118
+ const sub = positional[0] || 'list';
119
+
120
+ if (sub === 'list') {
121
+ const res = await call('/projects');
122
+ if (!res.ok) return fail(oneLine(res.error), { status: res.status });
123
+ const d = res.data || {};
124
+ const projects = Array.isArray(d.projects) ? d.projects : [];
125
+ const lines = [`Editor Mac: ${d.mac_online ? 'online' : 'off'}`];
126
+ if (!projects.length) lines.push('No film projects yet.');
127
+ for (const p of projects) {
128
+ lines.push(`${p.slug} ${p.title || ''} version ${p.version == null ? '?' : p.version} ${p.pending_count || 0} pending updated ${p.updated_at || '?'}`);
129
+ }
130
+ return ok({ mac_online: Boolean(d.mac_online), projects }, lines);
131
+ }
132
+
133
+ if (sub === 'show') {
134
+ const slug = positional[1];
135
+ if (!slug) return fail('usage: atris film show <slug>');
136
+ const res = await call(`/projects/${encodeURIComponent(slug)}`);
137
+ if (!res.ok) return fail(oneLine(res.error), { status: res.status });
138
+ return ok({ project: res.data }, showLines({ slug, ...(res.data || {}) }));
139
+ }
140
+
141
+ if (sub === 'status') {
142
+ const id = positional[1];
143
+ if (!id) return fail('usage: atris film status <id>');
144
+ const res = await call(`/commands/${encodeURIComponent(id)}`);
145
+ if (!res.ok) return fail(oneLine(res.error), { status: res.status });
146
+ return ok({ command_id: id, ...(res.data || {}) }, commandLines(res.data || {}, id));
147
+ }
148
+
149
+ if (sub === 'propose') {
150
+ const [, slug, raw] = positional;
151
+ if (!slug || !raw) return fail('usage: atris film propose <slug> \'<op json>\' --why "<one line>" [--wait]');
152
+ if (!why.trim()) return fail('add --why "<one line>" so the person knows the reason for this edit');
153
+ let op;
154
+ try { op = JSON.parse(raw); } catch { return fail('the edit is not valid JSON. Example: \'{"op":"move","id":"<bubble id>","start_f":30}\''); }
155
+ if (!op || typeof op !== 'object' || Array.isArray(op) || typeof op.op !== 'string') {
156
+ return fail('the edit must be a JSON object with an "op" name, like {"op":"move","id":"<bubble id>","start_f":30}');
157
+ }
158
+ const res = await call(`/projects/${encodeURIComponent(slug)}/commands`, {
159
+ method: 'POST',
160
+ body: { kind: 'op', payload: { op, propose: true }, why: why.trim() },
161
+ });
162
+ if (!res.ok) return fail(oneLine(res.error), { status: res.status });
163
+ const id = res.data && res.data.command_id;
164
+ if (!id) return fail('the server did not return a command id');
165
+ if (!wait) {
166
+ return ok({ command_id: id, status: 'pending' }, [`Queued proposal ${id}. It changes nothing until the person approves it in their editor.`, `Check it: atris film status ${id}`]);
167
+ }
168
+ let last = { status: 'pending' };
169
+ for (let i = 0; i < MAX_POLLS; i += 1) {
170
+ await sleep(POLL_MS);
171
+ const poll = await call(`/commands/${encodeURIComponent(id)}`);
172
+ if (poll.ok && poll.data) {
173
+ last = poll.data;
174
+ if (DONE_STATES.has(last.status)) break;
175
+ }
176
+ }
177
+ const lines = DONE_STATES.has(last.status)
178
+ ? commandLines(last, id)
179
+ : [`Still ${last.status || 'pending'} after 60 s. The editor Mac may be off.`, `Check later: atris film status ${id}`];
180
+ const finished = DONE_STATES.has(last.status);
181
+ const payload = { command_id: id, ...last, status: last.status || 'pending' };
182
+ if (json) io.log(JSON.stringify({ ok: last.status === 'done', ...payload }));
183
+ else for (const l of lines) (last.status === 'done' || !finished ? io.log : io.err)(l);
184
+ if (last.status === 'done') return 0;
185
+ return finished ? 1 : 2;
186
+ }
187
+
188
+ return fail(`unknown film command "${sub}". Try: atris film --help`);
189
+ }
190
+
191
+ module.exports = { filmCommand };