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.
- package/atris/skills/engines/SKILL.md +17 -2
- package/ax +6 -2
- package/bin/atris.js +5 -0
- package/commands/ask.js +202 -13
- package/commands/engine.js +49 -7
- package/commands/film.js +191 -0
- package/commands/mission.js +11 -1
- package/commands/probe.js +108 -11
- package/lib/engine-ask.js +10 -1
- package/lib/engine-processes.js +7 -2
- package/lib/engine-registry.js +58 -2
- package/lib/fleet.js +1 -1
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +66 -9
- package/lib/mission-core.js +7 -0
- package/lib/model-names.js +13 -0
- package/lib/runner-command.js +10 -0
- package/lib/runs-on.js +134 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
3138
|
-
|
|
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
|
|
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]} ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
337
|
+
const answer = await askOnce(token, message, { spinner, interactive: true, wait: options });
|
|
154
338
|
spinner.stop();
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 };
|
package/commands/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
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) {
|
package/commands/film.js
ADDED
|
@@ -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 };
|