atris 3.71.0 → 3.71.1
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/bin/atris.js +12 -2
- package/commands/campaign.js +22 -2
- package/commands/integrations.js +17 -5
- package/lib/account-bound.js +13 -0
- package/lib/campaign-loop.js +5 -1
- package/lib/campaign-run-lock.js +59 -0
- package/package.json +1 -1
package/bin/atris.js
CHANGED
|
@@ -3003,7 +3003,12 @@ if (command === 'guide') {
|
|
|
3003
3003
|
const mailbox = extractGmailMailboxAccount(raw);
|
|
3004
3004
|
// Mailbox --account <id> is a selector, not the workspace opt-in.
|
|
3005
3005
|
// Scratch folders refuse every gmail verb the same way as slack/inbox.
|
|
3006
|
-
|
|
3006
|
+
// Read verbs also run from a project folder (atris/, no business.json).
|
|
3007
|
+
const GMAIL_READ_VERBS = new Set(['inbox', 'list', 'read', 'triage', 'verdicts', 'accounts']);
|
|
3008
|
+
const gate = requireAccountBound(
|
|
3009
|
+
mailbox.args.filter((arg) => arg !== '--account'),
|
|
3010
|
+
{ read: GMAIL_READ_VERBS.has(mailbox.args[0]) },
|
|
3011
|
+
);
|
|
3007
3012
|
if (!gate.ok) {
|
|
3008
3013
|
process.exit(refuseAccountGlobal());
|
|
3009
3014
|
return;
|
|
@@ -3042,7 +3047,12 @@ if (command === 'guide') {
|
|
|
3042
3047
|
const account = extractGmailMailboxAccount(raw);
|
|
3043
3048
|
// Account --account <id> is a selector, not the workspace opt-in.
|
|
3044
3049
|
// Scratch folders refuse every slack verb the same way as gmail/inbox.
|
|
3045
|
-
|
|
3050
|
+
// Read verbs also run from a project folder (atris/, no business.json).
|
|
3051
|
+
const SLACK_READ_VERBS = new Set(['channels', 'list', 'dms', 'messages', 'read', 'search']);
|
|
3052
|
+
const gate = requireAccountBound(
|
|
3053
|
+
account.args.filter((arg) => arg !== '--account'),
|
|
3054
|
+
{ read: SLACK_READ_VERBS.has(account.args[0]) },
|
|
3055
|
+
);
|
|
3046
3056
|
if (!gate.ok) {
|
|
3047
3057
|
process.exit(refuseAccountGlobal());
|
|
3048
3058
|
return;
|
package/commands/campaign.js
CHANGED
|
@@ -46,7 +46,7 @@ function printHelp() {
|
|
|
46
46
|
console.log(' atris campaign answer <id> <ask-id> "<answer>" a person answers; agents cannot');
|
|
47
47
|
console.log(' atris campaign pause|resume|stop <id>');
|
|
48
48
|
console.log(' atris campaign event <id> <type> [--data \'<json>\'] append any event by hand');
|
|
49
|
-
console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--runner auto]');
|
|
49
|
+
console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--mission-minutes 40] [--runner auto]');
|
|
50
50
|
console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
|
|
51
51
|
console.log(' add --work 50m --break 10m [--rounds N] to work in rounds; each break judges and writes lessons the next round plans from');
|
|
52
52
|
console.log(' atris campaign step <id> one step of that loop');
|
|
@@ -79,7 +79,7 @@ const NO_LIMIT_BUDGET = 1e9;
|
|
|
79
79
|
const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
|
|
80
80
|
|
|
81
81
|
// The flags this command understands, so positional words are what is left.
|
|
82
|
-
const VALUE_FLAGS = ['--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
|
|
82
|
+
const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
|
|
83
83
|
|
|
84
84
|
function positionals(args) {
|
|
85
85
|
const out = [];
|
|
@@ -639,6 +639,25 @@ const cmdEvent = (args, ctx) => {
|
|
|
639
639
|
async function cmdLoop(args, ctx, verb) {
|
|
640
640
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
641
641
|
if (error !== undefined) return error;
|
|
642
|
+
// One driver per campaign: a second loop would plan and start missions
|
|
643
|
+
// against the same budget and files (lib/campaign-run-lock.js).
|
|
644
|
+
let lock = null;
|
|
645
|
+
if (ctx.store.path && ctx.campaignHome) {
|
|
646
|
+
lock = require('../lib/campaign-run-lock').acquireRunLock(ctx.campaignHome, view.id);
|
|
647
|
+
if (!lock.ok) {
|
|
648
|
+
const holder = lock.holder || {};
|
|
649
|
+
return ctx.fail(`another run is driving ${view.id} (pid ${holder.pid || '?'} in ${holder.cwd || '?'} since ${holder.started_at || '?'}); wait for it to stop or stop that process`, 1);
|
|
650
|
+
}
|
|
651
|
+
process.once('exit', lock.release);
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
return await driveLoop(args, ctx, verb, view);
|
|
655
|
+
} finally {
|
|
656
|
+
if (lock) lock.release();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function driveLoop(args, ctx, verb, view) {
|
|
642
661
|
const { makeLoop } = require('../lib/campaign-loop');
|
|
643
662
|
// A synced campaign trades events around every step. Failures warn and the
|
|
644
663
|
// loop runs on offline; a refusal (403, 409) stops syncing for this run
|
|
@@ -666,6 +685,7 @@ async function cmdLoop(args, ctx, verb) {
|
|
|
666
685
|
const loop = makeLoop(process.cwd(), {
|
|
667
686
|
parallel: readFlag(args, '--parallel', '') || readFlag(args, '--count', ''),
|
|
668
687
|
maxTicks: readFlag(args, '--max-ticks', ''),
|
|
688
|
+
missionMinutes: readFlag(args, '--mission-minutes', ''),
|
|
669
689
|
runner: readFlag(args, '--runner', ''),
|
|
670
690
|
actor: ctx.actor,
|
|
671
691
|
}, {
|
package/commands/integrations.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* atris gmail archive <id> [...] [--account <id>] - Archive messages
|
|
8
8
|
* atris gmail triage [--account <id>] [--limit N] - Record keep or archive verdicts
|
|
9
9
|
* atris gmail verdicts [--summary] [--account <id>] [--limit N] - List recent Gmail verdicts
|
|
10
|
-
* atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
|
|
10
|
+
* atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - Send an email
|
|
11
11
|
* atris gmail voice [account] [--clear] - Edit an account's writing voice
|
|
12
12
|
* atris gmail connect [name] - Connect or reconnect a Gmail account
|
|
13
13
|
* atris gmail accounts - List connected Gmail accounts and the active account
|
|
@@ -41,7 +41,7 @@ const { sleepSync } = require('../lib/sleep-sync');
|
|
|
41
41
|
const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
|
|
42
42
|
const GMAIL_CONNECT_POLL_MS = 3000;
|
|
43
43
|
const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
|
|
44
|
-
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
|
|
44
|
+
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
|
|
45
45
|
const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
|
|
46
46
|
const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
|
|
47
47
|
const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
|
|
@@ -560,10 +560,15 @@ async function gmailArchive(messageIds, options = {}) {
|
|
|
560
560
|
function parseGmailSendArgs(args = []) {
|
|
561
561
|
let accountId = null;
|
|
562
562
|
let bodyFile = null;
|
|
563
|
+
let approved = false;
|
|
563
564
|
const positional = [];
|
|
564
565
|
|
|
565
566
|
for (let i = 0; i < args.length; i += 1) {
|
|
566
567
|
const arg = args[i];
|
|
568
|
+
if (arg === '--approved' || arg === '--confirm-approved') {
|
|
569
|
+
approved = true;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
567
572
|
if (arg === '--account' || arg === '--body-file') {
|
|
568
573
|
const value = String(args[i + 1] || '').trim();
|
|
569
574
|
if (!value || value.startsWith('--')) {
|
|
@@ -596,10 +601,17 @@ function parseGmailSendArgs(args = []) {
|
|
|
596
601
|
}
|
|
597
602
|
}
|
|
598
603
|
|
|
599
|
-
return { to, subject, body, accountId };
|
|
604
|
+
return { to, subject, body, accountId, approved };
|
|
600
605
|
}
|
|
601
606
|
|
|
602
607
|
async function gmailSend(to, subject, body, options = {}) {
|
|
608
|
+
// Approval is checked before any token lookup or network call so an
|
|
609
|
+
// unapproved send can never reach the server.
|
|
610
|
+
if (!options.approved) {
|
|
611
|
+
console.error('Refusing to send without --approved after the exact recipient, subject, and body are confirmed.');
|
|
612
|
+
console.error(GMAIL_SEND_USAGE);
|
|
613
|
+
process.exit(2);
|
|
614
|
+
}
|
|
603
615
|
const token = await getAuthToken();
|
|
604
616
|
const accountId = resolveGmailAccountId(options.accountId);
|
|
605
617
|
const accounts = await fetchGmailAccounts(token);
|
|
@@ -864,7 +876,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
864
876
|
}
|
|
865
877
|
case 'send': {
|
|
866
878
|
const parsed = parseGmailSendArgs(args);
|
|
867
|
-
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined });
|
|
879
|
+
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined, approved: parsed.approved });
|
|
868
880
|
break;
|
|
869
881
|
}
|
|
870
882
|
case 'voice': {
|
|
@@ -888,7 +900,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
888
900
|
console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
|
|
889
901
|
console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
|
|
890
902
|
console.log(' atris gmail verdicts [--summary] [--account <id>] [--limit N] - list recent gmail verdicts');
|
|
891
|
-
console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
|
|
903
|
+
console.log(' atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - send an email');
|
|
892
904
|
console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
|
|
893
905
|
console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
|
|
894
906
|
console.log(' atris gmail accounts - list connected gmail accounts and the active account');
|
package/lib/account-bound.js
CHANGED
|
@@ -20,6 +20,14 @@ function isProvisionedAgentWorkspace(cwd = process.cwd()) {
|
|
|
20
20
|
return fs.existsSync(path.join(root, '.atris', 'agent-token.json'));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A Desktop project folder carries an atris/ directory but no business.json.
|
|
24
|
+
// It is a real workspace, not a scratch folder, so account reads may run
|
|
25
|
+
// there. Writes (send, dm, archive, connect) still need the business binding.
|
|
26
|
+
function isProjectWorkspace(cwd = process.cwd()) {
|
|
27
|
+
const root = path.resolve(cwd);
|
|
28
|
+
return fs.existsSync(path.join(root, 'atris'));
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
function isHelpArg(arg) {
|
|
24
32
|
return arg === '--help' || arg === '-h' || arg === 'help';
|
|
25
33
|
}
|
|
@@ -33,6 +41,8 @@ function looksLikeBusinessSlug(token) {
|
|
|
33
41
|
/**
|
|
34
42
|
* Account-scoped verbs dump live CRM/cloud state. Outside a bound business
|
|
35
43
|
* workspace they must opt in with --account. Help always passes.
|
|
44
|
+
* Pass { read: true } for read-only verbs: a project folder with atris/ (no
|
|
45
|
+
* business.json) then counts as bound. Writes never get that relaxation.
|
|
36
46
|
* Returns { ok, args } where args has --account stripped when continuing.
|
|
37
47
|
*/
|
|
38
48
|
function requireAccountBound(args = [], options = {}) {
|
|
@@ -44,6 +54,9 @@ function requireAccountBound(args = [], options = {}) {
|
|
|
44
54
|
if (isBoundBusinessWorkspace(cwd) || isProvisionedAgentWorkspace(cwd)) {
|
|
45
55
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
|
|
46
56
|
}
|
|
57
|
+
if (options.read === true && isProjectWorkspace(cwd)) {
|
|
58
|
+
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true, project: true };
|
|
59
|
+
}
|
|
47
60
|
if (list.includes('--account')) {
|
|
48
61
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
|
|
49
62
|
}
|
package/lib/campaign-loop.js
CHANGED
|
@@ -105,6 +105,9 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
105
105
|
const log = deps.log || ((line) => console.log(line));
|
|
106
106
|
const parallel = Math.max(1, Number(options.parallel) || 3);
|
|
107
107
|
const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
|
|
108
|
+
// Every mission gets an explicit time limit. Without one, a number in the
|
|
109
|
+
// objective ("437 tests, 475s") was read as the mission's time budget.
|
|
110
|
+
const missionMinutes = Math.max(1, Number(options.missionMinutes) || 40);
|
|
108
111
|
const runner = options.runner || 'auto';
|
|
109
112
|
const actor = options.actor || 'mission-lead';
|
|
110
113
|
const ask = deps.ask || askOwner;
|
|
@@ -155,7 +158,8 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
155
158
|
const reserve = work.estimate_usd || defaultReserve(current);
|
|
156
159
|
const started = await cli([
|
|
157
160
|
'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
|
|
158
|
-
'--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
|
|
161
|
+
'--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
|
|
162
|
+
'--max-wall', String(missionMinutes * 60), '--json',
|
|
159
163
|
]);
|
|
160
164
|
if (started.code !== 0) {
|
|
161
165
|
const why = (started.out + started.err).trim().split('\n').pop();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One driver per campaign on a computer. Two `campaign run` loops on the same
|
|
4
|
+
// campaign each planned and started missions, so they raced for the same
|
|
5
|
+
// budget and edited the same files (2026-10-15, cmp-suite-3min). The lock is
|
|
6
|
+
// a file beside the store naming the process that holds it; a lock whose
|
|
7
|
+
// process is gone is stale and is taken over.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
function lockPath(home, campaignId) {
|
|
14
|
+
return path.join(home, 'campaigns', campaignId, 'run.lock');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function alive(pid) {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return error.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readHolder(file) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Returns { ok: true, release } or { ok: false, holder } when a live run holds it.
|
|
36
|
+
function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostname(), cwd = process.cwd(), isAlive = alive } = {}) {
|
|
37
|
+
const file = lockPath(home, campaignId);
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
const mine = { pid, host, cwd, started_at: new Date().toISOString() };
|
|
40
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(file, `${JSON.stringify(mine)}\n`, { flag: 'wx' });
|
|
43
|
+
const release = () => {
|
|
44
|
+
const holder = readHolder(file);
|
|
45
|
+
if (holder && holder.pid === pid && holder.host === host) fs.rmSync(file, { force: true });
|
|
46
|
+
};
|
|
47
|
+
return { ok: true, release };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== 'EEXIST') throw error;
|
|
50
|
+
}
|
|
51
|
+
const holder = readHolder(file);
|
|
52
|
+
if (holder && holder.host === host && isAlive(holder.pid)) return { ok: false, holder };
|
|
53
|
+
if (holder && holder.host !== host) return { ok: false, holder };
|
|
54
|
+
fs.rmSync(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, holder: readHolder(file) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { acquireRunLock, lockPath };
|