atris 3.64.0 → 3.66.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.
@@ -0,0 +1,430 @@
1
+ 'use strict';
2
+
3
+ // Who is working right now: engine runs alive on this machine, read from one
4
+ // `ps` call. A run belongs to a member when its prompt says "acting as
5
+ // <member>" (the dispatch convention); otherwise it shows as unattached.
6
+ // Nothing here starts or stops anything.
7
+ //
8
+ // ps joins a launch's words with spaces, so where the prompt starts and ends
9
+ // is a guess. The rules: a known engine program is always a row unless it is
10
+ // plainly an interactive session; settings are read only where atris's own
11
+ // launchers put them (test/roster-team-tables.test.js builds every launch
12
+ // from those launchers, so a new flag that breaks this fails a test); and
13
+ // when the words cannot be read with confidence the model shows as unknown
14
+ // rather than a guess.
15
+
16
+ const path = require('path');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ const { engineModelText } = require('./roster-models');
20
+ const { EFFORT_WORDS } = require('./runner-command');
21
+ const { readMemberLaunch } = require('./member-identity');
22
+
23
+ const PS_ARGS = ['-axo', 'pid=,ppid=,etime=,command='];
24
+
25
+ // Each engine CLI's settings: the flags that take the next word as a value
26
+ // and the flags that stand alone. A flag in neither list makes the reading
27
+ // unsure.
28
+ const SHARED_VALUES = ['-m', '--model', '--effort', '--add-dir', '--prompt-file', '--output-format'];
29
+ const SHARED_SWITCHES = ['-p', '--print', '--help', '-h', '--version', '--verbose', '--json'];
30
+
31
+ const ENGINE_FLAGS = {
32
+ codex: {
33
+ values: ['-c', '--config', '-s', '--sandbox', '-o', '--output-last-message', '-C', '--cd', '-p', '--profile',
34
+ '-i', '--image', '--color', '--output-schema', '-a', '--ask-for-approval', '--enable', '--disable'],
35
+ switches: ['--ephemeral', '--ignore-user-config', '--ignore-rules', '--dangerously-bypass-approvals-and-sandbox',
36
+ '--skip-git-repo-check', '--full-auto', '--oss'],
37
+ },
38
+ claude: {
39
+ values: ['--tools', '--permission-mode', '--input-format', '--allowedTools', '--allowed-tools', '--disallowedTools',
40
+ '--disallowed-tools', '--append-system-prompt', '--system-prompt', '--session-id', '--resume', '-r', '--max-turns',
41
+ '--mcp-config', '--settings', '--agents', '--fallback-model'],
42
+ switches: ['--no-session-persistence', '--dangerously-skip-permissions', '--safe-mode', '--include-partial-messages',
43
+ '--strict-mcp-config', '-c', '--continue'],
44
+ },
45
+ cursor: {
46
+ values: ['--mode', '--sandbox', '--api-key', '--resume'],
47
+ switches: ['--trust', '--force', '-f', '--stream-partial-output'],
48
+ },
49
+ devin: {
50
+ values: ['--permission-mode', '--respect-workspace-trust'],
51
+ switches: ['--sandbox'],
52
+ },
53
+ grok: {
54
+ values: ['--permission-mode', '--sandbox', '--reasoning-effort', '--best-of-n'],
55
+ switches: ['--always-approve', '--no-memory', '--no-subagents'],
56
+ },
57
+ agy: {
58
+ values: ['--mode', '--print-timeout'],
59
+ switches: ['--sandbox', '--dangerously-skip-permissions'],
60
+ },
61
+ droid: {
62
+ values: ['--reasoning-effort', '--append-system-prompt', '--cwd'],
63
+ switches: ['--auto', '--skip-permissions-unsafe'],
64
+ },
65
+ opencode: {
66
+ values: ['--variant', '--agent', '-s', '--session', '--format', '-f', '--file'],
67
+ switches: ['--auto'],
68
+ },
69
+ 'atris-fast': {
70
+ values: ['--business', '--verify', '--approve', '--deny', '--grant', '--revoke-grant'],
71
+ switches: ['--auto', '--max', '--pro', '--fast', '--rapid', '--alpha', '--code-fast', '--local', '--cloud', '--chat',
72
+ '--doctor', '--approvals', '--grants', '--sync-grants', '--auto-approve', '--self-test', '--benchmark'],
73
+ },
74
+ commandcode: {
75
+ values: [],
76
+ switches: ['--yolo', '--trust'],
77
+ },
78
+ atris: {
79
+ values: ['--engine', '--member', '--minutes', '--job', '--timeout', '--jobs', '--scope'],
80
+ switches: ['--personal'],
81
+ },
82
+ };
83
+ for (const spec of Object.values(ENGINE_FLAGS)) {
84
+ spec.values = new Set([...SHARED_VALUES, ...spec.values]);
85
+ spec.switches = new Set([...SHARED_SWITCHES, ...spec.switches]);
86
+ }
87
+
88
+ const AX_CONTROL_FLAGS = new Set([
89
+ '--chat', '--doctor', '--approvals', '--approve', '--deny', '--grant', '--grants', '--sync-grants',
90
+ '--revoke-grant', '--auto-approve', '--self-test', '--benchmark', '--help', '-h', '--version',
91
+ ]);
92
+ const ATRIS_RUN_VERBS = ['ask', 'dispatch', 'validate', 'bench', 'test'];
93
+
94
+ const printing = ({ flags }) => hasFlag(flags, ['-p', '--print']);
95
+
96
+ // What makes a launch a run. Where an interactive session looks different
97
+ // from a headless one (codex exec, claude -p) the difference is required;
98
+ // anywhere else a launch with work in it is a run. A string names the engine
99
+ // when the launcher is atris itself.
100
+ const RUN_RULES = {
101
+ codex: ({ command }) => command[0] === 'exec',
102
+ droid: ({ command }) => command[0] === 'exec',
103
+ claude: printing,
104
+ // `atris agent spawn` launches `cursor-agent <prompt>` with no -p.
105
+ cursor: (split) => printing(split) || split.prompt.length > 0,
106
+ devin: printing,
107
+ grok: printing,
108
+ agy: printing,
109
+ opencode: ({ command }) => command[0] === 'run',
110
+ 'atris-fast': ({ flags, prompt }) => !flags.some(([name]) => AX_CONTROL_FLAGS.has(name)) && prompt.length > 0,
111
+ commandcode: printing,
112
+ atris: atrisRunEngine,
113
+ };
114
+
115
+ // Subcommand words ahead of the settings.
116
+ function subcommandCount(engine, args) {
117
+ if (engine === 'codex' || engine === 'droid' || engine === 'opencode') return 1;
118
+ if (engine === 'atris') return args[0] === 'engine' ? 2 : 1;
119
+ return 0;
120
+ }
121
+
122
+ // `atris engine ask|dispatch|validate ...` runs the engine it names;
123
+ // `atris engine fable <question>` asks fable; `atris chat --print` is Atris
124
+ // Fast. Anything else atris does is not an engine run.
125
+ function atrisRunEngine({ command, flags, prompt }) {
126
+ if (command[0] === 'chat') return printing({ flags }) && prompt.length > 0 ? 'atris-fast' : false;
127
+ if (command[0] !== 'engine') return false;
128
+ const named = canonicalEngine(flagValue(flags, ['--engine']));
129
+ if (ATRIS_RUN_VERBS.includes(command[1])) return named || 'atris';
130
+ return canonicalEngine(command[1]) || false;
131
+ }
132
+
133
+ function canonicalEngine(name) {
134
+ if (!name) return '';
135
+ const { canonicalEngineName } = require('./engine-registry');
136
+ try { return canonicalEngineName(name) || ''; } catch { return ''; }
137
+ }
138
+
139
+ // The program at the front of a launch -> the engine name the roster uses.
140
+ const BINARY_ENGINES = Object.freeze({
141
+ claude: 'claude', 'cursor-agent': 'cursor', droid: 'droid', devin: 'devin', grok: 'grok', agy: 'agy', opencode: 'opencode',
142
+ ax: 'atris-fast', cmd: 'commandcode', atris: 'atris',
143
+ });
144
+
145
+ // ax picks its model by mode flag; --fast is the atris-fast profile's model.
146
+ const AX_MODES = ['auto', 'max', 'pro', 'fast', 'rapid', 'alpha', 'code-fast'];
147
+
148
+ const INTERPRETERS = new Set(['node', 'bun', 'deno']);
149
+
150
+ function engineOf(token) {
151
+ const text = String(token || '');
152
+ const base = path.basename(text).replace(/\.(c?js|mjs)$/, '');
153
+ // The native codex binary can carry a platform suffix; codex-watchdog is
154
+ // atris's wrapper, not codex.
155
+ if (/^codex(-(?:aarch64|arm64|x86_64|x64)[a-z0-9_-]*)?$/.test(base)) return 'codex';
156
+ if (Object.prototype.hasOwnProperty.call(BINARY_ENGINES, base)) return BINARY_ENGINES[base];
157
+ // Command Code run straight from its package file.
158
+ if (/[\\/]command-code[\\/]dist[\\/]index\.m?js$/.test(text)) return 'commandcode';
159
+ return '';
160
+ }
161
+
162
+ // "1-02:03:04", "02:03:04", "03:04" -> seconds.
163
+ function parseElapsed(text) {
164
+ const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(String(text || '').trim());
165
+ if (!match) return null;
166
+ const [, days, hours, minutes, seconds] = match.map((part) => Number(part || 0));
167
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
168
+ }
169
+
170
+ // "now", "6m", "2h", "3d".
171
+ function shortElapsed(seconds) {
172
+ const value = Number(seconds);
173
+ if (!Number.isFinite(value) || value < 0) return '-';
174
+ if (value < 60) return 'now';
175
+ if (value < 3600) return `${Math.floor(value / 60)}m`;
176
+ if (value < 86400) return `${Math.floor(value / 3600)}h`;
177
+ return `${Math.floor(value / 86400)}d`;
178
+ }
179
+
180
+ function parsePsLine(line) {
181
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(String(line || ''));
182
+ if (!match) return null;
183
+ return { pid: Number(match[1]), ppid: Number(match[2]), elapsed_seconds: parseElapsed(match[3]), command: match[4] };
184
+ }
185
+
186
+ // The one place a launch is split. argv -> the subcommand words, the
187
+ // settings before the prompt, the prompt, and the settings after it.
188
+ // Leading settings stop at "--" or at the first word that is neither a flag
189
+ // nor a flag's value. Atris puts some settings after the prompt (claude
190
+ // --model, devin --model, fleet's sandbox flags); those count only when every
191
+ // word to the end is a known setting, any model named is a real model id, and
192
+ // there was no "--". `unsure` is set when a flag the engine list does not
193
+ // know came before the prompt, so its value could not be told from prose.
194
+ function splitArgs(engine, args) {
195
+ const spec = ENGINE_FLAGS[engine];
196
+ const command = args.slice(0, subcommandCount(engine, args));
197
+ const flags = [];
198
+ let unsure = false;
199
+ let i = command.length;
200
+ for (; i < args.length; i += 1) {
201
+ const arg = args[i];
202
+ if (arg === '--') return { command, flags, prompt: args.slice(i + 1), dashed: true, unsure, tail: [] };
203
+ if (!arg.startsWith('-') || arg === '-') break;
204
+ const eq = arg.indexOf('=');
205
+ if (arg.startsWith('--') && eq > 0) {
206
+ flags.push([arg.slice(0, eq), arg.slice(eq + 1)]);
207
+ if (!spec.values.has(arg.slice(0, eq)) && !spec.switches.has(arg.slice(0, eq))) unsure = true;
208
+ } else if (spec.values.has(arg) && i + 1 < args.length) {
209
+ flags.push([arg, args[i + 1]]);
210
+ i += 1;
211
+ } else {
212
+ flags.push([arg, true]);
213
+ if (!spec.switches.has(arg)) unsure = true;
214
+ }
215
+ }
216
+ const rest = args.slice(i);
217
+ const tail = trailingSettings(spec, rest);
218
+ return {
219
+ command,
220
+ flags,
221
+ prompt: tail ? rest.slice(0, tail.at) : rest,
222
+ dashed: false,
223
+ unsure,
224
+ tail: tail ? tail.flags : [],
225
+ };
226
+ }
227
+
228
+ function looksLikeModel(value) {
229
+ const text = String(value || '');
230
+ if (/\d|\//.test(text)) return true;
231
+ const { isClaudeFamilyModel } = require('./engine-registry');
232
+ return isClaudeFamilyModel(text);
233
+ }
234
+
235
+ const EFFORT_VALUES = new Set([...EFFORT_WORDS, 'off', 'none', 'minimal']);
236
+
237
+ // The settings at the very end of a launch, read right to left: each step
238
+ // takes a known setting (with its value) off the end, and stops at the first
239
+ // word that is not one, at a setting already taken (a launcher never says a
240
+ // setting twice, so a repeat belongs to the prompt), or at a model or effort
241
+ // value that is not real. At least one prompt word always stays. So a prompt
242
+ // ending "Explain --model <some model>" keeps its words, and the launcher's
243
+ // own "--model <its model> ..." after it is what counts.
244
+ function trailingSettings(spec, words) {
245
+ const known = (name) => spec.values.has(name) || spec.switches.has(name);
246
+ const flags = [];
247
+ const seen = new Set();
248
+ let at = words.length;
249
+ while (at > 1) {
250
+ const word = words[at - 1];
251
+ const before = words[at - 2];
252
+ let item = null;
253
+ let width = 1;
254
+ const eq = word.startsWith('--') ? word.indexOf('=') : -1;
255
+ if (eq > 0 && known(word.slice(0, eq))) item = [word.slice(0, eq), word.slice(eq + 1)];
256
+ else if (!word.startsWith('-') && spec.values.has(before) && at > 2) { item = [before, word]; width = 2; }
257
+ else if (spec.switches.has(word) && word !== '-p' && word !== '--print') item = [word, true];
258
+ if (!item || seen.has(item[0])) break;
259
+ if ((item[0] === '--model' || item[0] === '-m') && !looksLikeModel(item[1])) break;
260
+ if ((item[0] === '--effort' || item[0] === '--reasoning-effort') && !EFFORT_VALUES.has(item[1])) break;
261
+ seen.add(item[0]);
262
+ if (item[0] === '--model') seen.add('-m');
263
+ if (item[0] === '-m') seen.add('--model');
264
+ flags.unshift(item);
265
+ at -= width;
266
+ }
267
+ return flags.length ? { at, flags } : null;
268
+ }
269
+
270
+ function hasFlag(flags, names) {
271
+ return flags.some(([name]) => names.includes(name));
272
+ }
273
+
274
+ function flagValue(flags, names) {
275
+ const found = flags.find(([name, value]) => names.includes(name) && typeof value === 'string');
276
+ return found ? found[1] : '';
277
+ }
278
+
279
+ function effortOf(flags) {
280
+ const direct = flagValue(flags, ['--effort', '--reasoning-effort', '--variant']);
281
+ if (direct) return direct;
282
+ for (const [name, value] of flags) {
283
+ if (name !== '-c' && name !== '--config') continue;
284
+ const match = /^model_reasoning_effort=["']?([a-z]+)/.exec(String(value));
285
+ if (match) return match[1];
286
+ }
287
+ return '';
288
+ }
289
+
290
+ function promptText(split) {
291
+ if (split.prompt.length) return split.prompt.join(' ');
292
+ const file = flagValue(split.flags, ['--prompt-file']);
293
+ return file ? `prompt file ${path.basename(file)}` : '';
294
+ }
295
+
296
+ // A prompt can run to pages; the view needs its first line.
297
+ const DOING_MAX = 240;
298
+
299
+ // What the run is for, on one line, without the dispatch preamble
300
+ // ("Read <path>/MEMBER.md first.").
301
+ function doingText(text) {
302
+ const doing = String(text || '').replace(/^(?:first,?\s+)?read\s+\S+\s+first[.,]?\s*/i, '');
303
+ return doing.length > DOING_MAX ? `${doing.slice(0, DOING_MAX - 1).trimEnd()}…` : doing;
304
+ }
305
+
306
+ function axModel(engine, flags) {
307
+ if (engine !== 'atris-fast') return '';
308
+ const mode = AX_MODES.find((name) => hasFlag(flags, [`--${name}`]));
309
+ return mode ? `atris:${mode}` : '';
310
+ }
311
+
312
+ // An unsure reading: an unknown flag came before the prompt, so the words
313
+ // after it may be its value or the prompt. Read every flag-looking word
314
+ // before "--" as a setting just to decide whether this is a run, and take the
315
+ // prompt from after the -p when there is one.
316
+ function looseSplit(engine, args, split) {
317
+ const dash = args.indexOf('--');
318
+ const head = dash === -1 ? args : args.slice(0, dash);
319
+ const flags = head.filter((word) => word.startsWith('-')).map((word) => [word, true]);
320
+ const print = head.findIndex((word) => word === '-p' || word === '--print');
321
+ const after = print === -1 ? null : splitArgs(engine, [...split.command, ...args.slice(print + 1)]);
322
+ return { ...split, flags, prompt: after && after.prompt.length ? after.prompt : split.prompt };
323
+ }
324
+
325
+ // One ps line -> a run, or null when it is not an engine run at all.
326
+ function parseEngineProcess(entry) {
327
+ if (!entry || !entry.command) return null;
328
+ const tokens = entry.command.trim().split(/\s+/);
329
+ let at = 0;
330
+ if (INTERPRETERS.has(path.basename(tokens[0] || ''))) at = 1;
331
+ // The orchestrator daemon is skipped by what it runs, never by what a
332
+ // prompt mentions ("Review orchestrator.py" is real work).
333
+ if (tokens.slice(0, at + 1).some((token) => /orchestrator/i.test(path.basename(token)))) return null;
334
+ const binary = engineOf(tokens[at]);
335
+ if (!binary) return null;
336
+ const args = tokens.slice(at + 1);
337
+ let split = splitArgs(binary, args);
338
+ let verdict = RUN_RULES[binary](split);
339
+ if (!verdict && split.unsure) {
340
+ split = looseSplit(binary, args, split);
341
+ verdict = RUN_RULES[binary](split);
342
+ }
343
+ if (!verdict) return null;
344
+ const engine = typeof verdict === 'string' ? verdict : binary;
345
+ const prompt = promptText(split);
346
+ const { member, doing } = readMemberLaunch(prompt);
347
+ const settings = split.unsure ? [] : [...split.flags, ...split.tail];
348
+ const model = flagValue(settings, ['-m', '--model']) || (split.unsure ? '' : axModel(binary, split.flags));
349
+ return {
350
+ pid: entry.pid,
351
+ ppid: entry.ppid,
352
+ engine,
353
+ model: model || null,
354
+ effort: effortOf(settings) || null,
355
+ member,
356
+ doing: doingText(doing) || '-',
357
+ elapsed_seconds: entry.elapsed_seconds,
358
+ };
359
+ }
360
+
361
+ // Every matched run above this one in the process tree. A shell may sit
362
+ // between a dispatcher and the tool it runs, so the walk goes all the way up.
363
+ function matchedAncestors(run, all, byPid) {
364
+ const out = [];
365
+ const seen = new Set();
366
+ let pid = run.ppid;
367
+ while (all.has(pid) && !seen.has(pid)) {
368
+ seen.add(pid);
369
+ if (byPid.has(pid)) out.push(byPid.get(pid));
370
+ pid = all.get(pid).ppid;
371
+ }
372
+ return out;
373
+ }
374
+
375
+ // Keep one run per real job. A node wrapper and the native binary it spawns
376
+ // carry the same words, and a dispatcher (atris engine) sits above the tool
377
+ // it runs: the deepest run wins, and an unnamed run takes the member of the
378
+ // nearest named run above it. Only runs in one process line fold together:
379
+ // two separate processes asking the same thing are two jobs.
380
+ function liveEngineRuns(entries, { selfPid = process.pid } = {}) {
381
+ const all = new Map(entries.filter(Boolean).map((entry) => [entry.pid, entry]));
382
+ const runs = entries
383
+ .filter((entry) => entry && entry.pid !== selfPid)
384
+ .map(parseEngineProcess)
385
+ .filter(Boolean);
386
+ const byPid = new Map(runs.map((run) => [run.pid, run]));
387
+ const above = new Map(runs.map((run) => [run.pid, matchedAncestors(run, all, byPid)]));
388
+ for (const run of runs) {
389
+ if (run.member) continue;
390
+ const named = above.get(run.pid).find((item) => item.member);
391
+ if (named) run.member = named.member;
392
+ }
393
+ const hidden = new Set();
394
+ for (const run of runs) {
395
+ for (const item of above.get(run.pid)) {
396
+ if (!item.member || item.member === run.member) hidden.add(item.pid);
397
+ }
398
+ }
399
+ return runs
400
+ .filter((run) => !hidden.has(run.pid))
401
+ .map(({ ppid, ...run }) => run)
402
+ .sort((a, b) => (a.elapsed_seconds ?? Infinity) - (b.elapsed_seconds ?? Infinity));
403
+ }
404
+
405
+ // The live runs on this machine. deps.runPs returns { status, stdout } like
406
+ // spawnSync; tests pass their own. A ps that fails means no runs, never an
407
+ // error.
408
+ function listLiveEngineRuns(deps = {}) {
409
+ const run = deps.runPs || (() => spawnSync('ps', PS_ARGS, { encoding: 'utf8', timeout: 3000, maxBuffer: 16 * 1024 * 1024 }));
410
+ let result;
411
+ try { result = run(PS_ARGS); } catch { return []; }
412
+ if (!result || result.status !== 0 || typeof result.stdout !== 'string') return [];
413
+ const entries = result.stdout.split(/\r?\n/).map(parsePsLine).filter(Boolean);
414
+ return liveEngineRuns(entries, { selfPid: deps.selfPid || process.pid });
415
+ }
416
+
417
+ // "<engine> · <model label>" for a live run.
418
+ function liveRunEngineText(run) {
419
+ return (run && engineModelText(run.engine, run.model || '')) || '-';
420
+ }
421
+
422
+ module.exports = {
423
+ listLiveEngineRuns,
424
+ liveEngineRuns,
425
+ liveRunEngineText,
426
+ parseEngineProcess,
427
+ parsePsLine,
428
+ splitArgs,
429
+ shortElapsed,
430
+ };