atris 3.34.0 → 3.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +4 -2
- package/ax +475 -17
- package/bin/atris.js +197 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +554 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +250 -9
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
package/ax
CHANGED
|
@@ -8,7 +8,9 @@ const path = require('path');
|
|
|
8
8
|
const readline = require('readline');
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { Readable } = require('stream');
|
|
11
|
+
const permissionGrants = require(path.join(__dirname, 'lib', 'permission-grants.js'));
|
|
11
12
|
const { loadCredentials } = require('./utils/auth');
|
|
13
|
+
const { apiRequestJson, getApiBaseUrl } = require('./utils/api');
|
|
12
14
|
const missionRuntime = require('./lib/mission-runtime-loop');
|
|
13
15
|
|
|
14
16
|
const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
|
|
@@ -29,6 +31,8 @@ const CONNECTION_CAPABILITIES_PATH = '/api/atris2/connection-capabilities';
|
|
|
29
31
|
const ATRIS2_CONNECTION_STATUS_PATH = '/api/atris2/connection-status';
|
|
30
32
|
const ATRIS2_HEALTH_PATH = '/api/atris2/health';
|
|
31
33
|
const APPROVAL_EXECUTE_PATH = '/api/atris2/approvals/execute';
|
|
34
|
+
const BACKEND_API_TOOL_NAME = 'backend_api';
|
|
35
|
+
const BACKEND_API_TOOL_RESULT_PATH = '/api/atris2/turn/tool-result';
|
|
32
36
|
const DEFAULT_APPROVAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
33
37
|
const CONNECTOR_NAMES = {
|
|
34
38
|
gmail: 'Gmail',
|
|
@@ -71,6 +75,18 @@ const ANSI = {
|
|
|
71
75
|
|
|
72
76
|
const TIER_COLORS = { fast: '\x1b[32m', pro: '\x1b[36m', max: '\x1b[35m' };
|
|
73
77
|
|
|
78
|
+
// Extend backend_api by adding one row here. Keep mutating endpoints narrow;
|
|
79
|
+
// any non-get method is approval-gated before the HTTP request is made.
|
|
80
|
+
const BACKEND_API_ALLOWLIST = [
|
|
81
|
+
{ method: 'GET', endpoint: '/api/atris2/health', description: 'Atris2 health' },
|
|
82
|
+
{ method: 'GET', endpoint: '/api/atris2/connection-status', description: 'connection status' },
|
|
83
|
+
{ method: 'GET', endpoint: '/api/atris2/connection-capabilities', description: 'connection capabilities' },
|
|
84
|
+
{ method: 'GET', endpoint: '/api/integrations/status', description: 'integration status' },
|
|
85
|
+
{ method: 'GET', endpoint: '/api/ai-computer/user/status', description: 'personal computer status' },
|
|
86
|
+
{ method: 'GET', endpoint: '/api/business', description: 'business list' },
|
|
87
|
+
{ method: 'POST', endpoint: '/api/improve', description: 'run one improve tick' },
|
|
88
|
+
];
|
|
89
|
+
|
|
74
90
|
function tierColor(mode) {
|
|
75
91
|
return TIER_COLORS[mode] || ANSI.accent;
|
|
76
92
|
}
|
|
@@ -116,12 +132,17 @@ function formatUsage() {
|
|
|
116
132
|
'',
|
|
117
133
|
'Usage:',
|
|
118
134
|
' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
|
|
135
|
+
' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] --print <message>',
|
|
119
136
|
' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
|
|
120
137
|
' ax [--max|--pro|--fast] --business <slug> [<message>|--chat]',
|
|
121
138
|
' ax [--max|--pro|--fast|--code-fast] --doctor',
|
|
122
139
|
' ax --approvals',
|
|
123
140
|
' ax --approve <approval-id>',
|
|
124
141
|
' ax --deny <approval-id>',
|
|
142
|
+
' ax --grant <approval-id> approve and remember this exact command (this workspace)',
|
|
143
|
+
' ax --grants list active permission grants',
|
|
144
|
+
' ax --sync-grants push/pull grants with atrisos-web',
|
|
145
|
+
' ax --revoke-grant <grant-id>',
|
|
125
146
|
' ax --self-test',
|
|
126
147
|
' ax [--max|--fast] --benchmark',
|
|
127
148
|
'',
|
|
@@ -132,6 +153,7 @@ function formatUsage() {
|
|
|
132
153
|
' --code-fast Atris Code Fast public lane',
|
|
133
154
|
' --local opt into local backend/workspace tools',
|
|
134
155
|
' --cloud force authenticated cloud connectors/chat',
|
|
156
|
+
' --print headless JSON result: { ok, model, output, durationMs }',
|
|
135
157
|
' --business <slug> run tools on that business cloud workspace (EC2)',
|
|
136
158
|
' --verify <cmd> gate the turn on this command passing (default: no verifier)',
|
|
137
159
|
'',
|
|
@@ -495,6 +517,201 @@ function postJson(pathname, body, { token = authToken(), timeoutMs = 30000, rout
|
|
|
495
517
|
});
|
|
496
518
|
}
|
|
497
519
|
|
|
520
|
+
function compactSingleLine(value) {
|
|
521
|
+
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function truncateSingleLine(value, limit = 120) {
|
|
525
|
+
const text = compactSingleLine(value);
|
|
526
|
+
if (text.length <= limit) return text;
|
|
527
|
+
if (limit <= 3) return text.slice(0, limit);
|
|
528
|
+
return `${text.slice(0, limit - 3)}...`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function canonicalBackendApiPath(pathname) {
|
|
532
|
+
let value = String(pathname || '').trim();
|
|
533
|
+
if (!value) return '';
|
|
534
|
+
if (!value.startsWith('/')) value = `/${value}`;
|
|
535
|
+
value = value.replace(/\/{2,}/g, '/');
|
|
536
|
+
if (value !== '/' && value.endsWith('/')) value = value.replace(/\/+$/, '');
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function normalizeBackendApiMethod(method) {
|
|
541
|
+
return String(method || 'GET').trim().toUpperCase() || 'GET';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function normalizeBackendApiEndpoint(endpoint) {
|
|
545
|
+
const raw = String(endpoint || '').trim();
|
|
546
|
+
if (!raw || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return null;
|
|
547
|
+
const parsed = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'https://ax.local');
|
|
548
|
+
const pathOnly = canonicalBackendApiPath(parsed.pathname);
|
|
549
|
+
const displayPath = pathOnly === '/api' || pathOnly.startsWith('/api/')
|
|
550
|
+
? pathOnly
|
|
551
|
+
: canonicalBackendApiPath(`/api${pathOnly}`);
|
|
552
|
+
return {
|
|
553
|
+
endpoint: `${displayPath}${parsed.search}`,
|
|
554
|
+
pathname: displayPath,
|
|
555
|
+
search: parsed.search,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function backendApiAllowlistEntry(method, endpointPathname) {
|
|
560
|
+
const normalizedMethod = normalizeBackendApiMethod(method);
|
|
561
|
+
const normalizedPath = canonicalBackendApiPath(endpointPathname);
|
|
562
|
+
return BACKEND_API_ALLOWLIST.find(row =>
|
|
563
|
+
row.method === normalizedMethod && canonicalBackendApiPath(row.endpoint) === normalizedPath
|
|
564
|
+
) || null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function apiRequestPathForBackendEndpoint(displayEndpoint, baseUrl = getApiBaseUrl()) {
|
|
568
|
+
const parsed = new URL(displayEndpoint, 'https://ax.local');
|
|
569
|
+
let pathname = canonicalBackendApiPath(parsed.pathname);
|
|
570
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
571
|
+
if (base.endsWith('/api') && pathname.startsWith('/api/')) {
|
|
572
|
+
pathname = pathname.slice('/api'.length) || '/';
|
|
573
|
+
}
|
|
574
|
+
return `${pathname}${parsed.search}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function summarizeBackendApiPayload(payload) {
|
|
578
|
+
if (payload === undefined || payload === null) return 'none';
|
|
579
|
+
if (typeof payload === 'string') return truncateSingleLine(payload, 120);
|
|
580
|
+
try {
|
|
581
|
+
return truncateSingleLine(JSON.stringify(payload), 120);
|
|
582
|
+
} catch (_) {
|
|
583
|
+
return truncateSingleLine(String(payload), 120);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function summarizeBackendApiResponse(response) {
|
|
588
|
+
if (!response) return '';
|
|
589
|
+
if (response.data !== undefined && response.data !== null) {
|
|
590
|
+
try {
|
|
591
|
+
return truncateSingleLine(JSON.stringify(response.data), 120);
|
|
592
|
+
} catch (_) {
|
|
593
|
+
return truncateSingleLine(String(response.data), 120);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return truncateSingleLine(response.text || response.error || '', 120);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function formatBackendApiReceipt(call) {
|
|
600
|
+
return `backend_api ${String(call.method || 'get').toLowerCase()} ${call.endpoint} status ${call.status}: ${truncateSingleLine(call.summary, 120)}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function formatBackendApiApprovalPrompt(call) {
|
|
604
|
+
return `approve backend_api ${String(call.method || 'post').toLowerCase()} ${call.endpoint} payload ${summarizeBackendApiPayload(call.payload)}? yes/no: `;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function messageApprovesBackendApi(value) {
|
|
608
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[.!?]+$/g, '');
|
|
609
|
+
return normalized === 'yes' || normalized === 'y';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function requestBackendApiApproval(call, options = {}) {
|
|
613
|
+
const prompt = formatBackendApiApprovalPrompt(call);
|
|
614
|
+
if (typeof options.approveBackendApi === 'function') {
|
|
615
|
+
if (options.output && typeof options.output.write === 'function') {
|
|
616
|
+
options.output.write(`${prompt}\n`);
|
|
617
|
+
}
|
|
618
|
+
const decision = await options.approveBackendApi({ ...call, prompt });
|
|
619
|
+
return decision === true || messageApprovesBackendApi(decision);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const input = options.input || process.stdin;
|
|
623
|
+
const output = options.output || process.stdout;
|
|
624
|
+
if (!input || !input.isTTY) return false;
|
|
625
|
+
return new Promise((resolve) => {
|
|
626
|
+
const rl = readline.createInterface({ input, output });
|
|
627
|
+
rl.question(prompt, (answer) => {
|
|
628
|
+
rl.close();
|
|
629
|
+
resolve(messageApprovesBackendApi(answer));
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function backendApiToolDescriptor() {
|
|
635
|
+
return {
|
|
636
|
+
name: BACKEND_API_TOOL_NAME,
|
|
637
|
+
description: 'Call allowlisted AtrisOS backend API endpoints through ax. get calls run directly; non-get calls require explicit user approval.',
|
|
638
|
+
input_schema: {
|
|
639
|
+
type: 'object',
|
|
640
|
+
properties: {
|
|
641
|
+
method: { type: 'string', description: 'HTTP method, for example get or post' },
|
|
642
|
+
endpoint: { type: 'string', description: 'Allowlisted endpoint such as /api/atris2/health or /api/improve' },
|
|
643
|
+
payload: { type: 'object', description: 'JSON payload for non-get calls' },
|
|
644
|
+
},
|
|
645
|
+
required: ['endpoint'],
|
|
646
|
+
},
|
|
647
|
+
allowlist: BACKEND_API_ALLOWLIST.map(row => ({ method: row.method, endpoint: row.endpoint, description: row.description })),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function executeBackendApiTool(args = {}, options = {}) {
|
|
652
|
+
const method = normalizeBackendApiMethod(args.method || args.verb);
|
|
653
|
+
const normalizedEndpoint = normalizeBackendApiEndpoint(args.endpoint || args.path || args.url);
|
|
654
|
+
if (!normalizedEndpoint) {
|
|
655
|
+
return { status: 'error', error: 'backend_api refused: missing or invalid endpoint' };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const allowlisted = backendApiAllowlistEntry(method, normalizedEndpoint.pathname);
|
|
659
|
+
if (!allowlisted) {
|
|
660
|
+
return {
|
|
661
|
+
status: 'error',
|
|
662
|
+
error: `backend_api refused: ${method.toLowerCase()} ${normalizedEndpoint.endpoint} is not allowlisted`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const payload = args.payload !== undefined ? args.payload : args.body;
|
|
667
|
+
const call = { method, endpoint: normalizedEndpoint.endpoint, payload };
|
|
668
|
+
if (method !== 'GET') {
|
|
669
|
+
const approved = await requestBackendApiApproval(call, options);
|
|
670
|
+
if (!approved) {
|
|
671
|
+
return {
|
|
672
|
+
status: 'error',
|
|
673
|
+
error: `backend_api refused: ${method.toLowerCase()} ${normalizedEndpoint.endpoint} was not approved`,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const loadCreds = options.loadCredentials || loadCredentials;
|
|
679
|
+
const credentials = loadCreds();
|
|
680
|
+
const token = credentials && credentials.token ? credentials.token : '';
|
|
681
|
+
if (!token) {
|
|
682
|
+
return { status: 'error', error: 'backend_api refused: not logged in' };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const apiFn = options.apiRequestJson || apiRequestJson;
|
|
686
|
+
const baseFn = options.getApiBaseUrl || getApiBaseUrl;
|
|
687
|
+
const requestPath = apiRequestPathForBackendEndpoint(normalizedEndpoint.endpoint, baseFn());
|
|
688
|
+
const response = await apiFn(requestPath, {
|
|
689
|
+
method,
|
|
690
|
+
token,
|
|
691
|
+
...(method === 'GET' ? {} : { body: payload === undefined ? {} : payload }),
|
|
692
|
+
timeoutMs: options.timeoutMs || 30000,
|
|
693
|
+
});
|
|
694
|
+
const summary = summarizeBackendApiResponse(response);
|
|
695
|
+
const receipt = formatBackendApiReceipt({
|
|
696
|
+
method,
|
|
697
|
+
endpoint: normalizedEndpoint.endpoint,
|
|
698
|
+
status: response && typeof response.status === 'number' ? response.status : 0,
|
|
699
|
+
summary,
|
|
700
|
+
});
|
|
701
|
+
return {
|
|
702
|
+
status: response && response.ok ? 'ok' : 'error',
|
|
703
|
+
method: method.toLowerCase(),
|
|
704
|
+
endpoint: normalizedEndpoint.endpoint,
|
|
705
|
+
status_code: response && typeof response.status === 'number' ? response.status : 0,
|
|
706
|
+
summary,
|
|
707
|
+
receipt,
|
|
708
|
+
response: response && response.data !== undefined && response.data !== null
|
|
709
|
+
? response.data
|
|
710
|
+
: truncateSingleLine(response && response.text, 4000),
|
|
711
|
+
...(response && response.ok ? {} : { error: (response && response.error) || summary || 'request failed' }),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
498
715
|
function defaultAuthority(id) {
|
|
499
716
|
const key = canonicalConnectorId(id);
|
|
500
717
|
if (key === 'gmail') return { list_messages: 'read_only', get_message: 'read_only', send_message: 'approval_required' };
|
|
@@ -867,6 +1084,8 @@ function formatGoalStatusLine(goal, options = {}) {
|
|
|
867
1084
|
const status = String(goal.mission_status || '').toLowerCase();
|
|
868
1085
|
if (!['active', 'running', 'planning', 'in_progress', 'ready'].includes(status)) return '';
|
|
869
1086
|
const elapsed = goalElapsedSeconds(goal);
|
|
1087
|
+
// A goal "running" for >24h is stale state, not a live goal — hide it.
|
|
1088
|
+
if (elapsed !== null && elapsed > 86400) return '';
|
|
870
1089
|
const objective = String(goal.objective).length > 64
|
|
871
1090
|
? `${String(goal.objective).slice(0, 61)}...`
|
|
872
1091
|
: String(goal.objective);
|
|
@@ -1768,12 +1987,58 @@ async function approveWorkspaceApproval(ref, options = {}) {
|
|
|
1768
1987
|
return { response, executed };
|
|
1769
1988
|
}
|
|
1770
1989
|
|
|
1990
|
+
// A persistent grant (ax --grant) lets an exact approved command pattern
|
|
1991
|
+
// redeem itself on later turns instead of re-asking; anything unmatched
|
|
1992
|
+
// falls through to the normal hint. Store: ~/.atris/permission-grants.json.
|
|
1993
|
+
async function autoRedeemGrantedApprovals(cwd, sinceMs, options = {}) {
|
|
1994
|
+
const redeemed = [];
|
|
1995
|
+
for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
|
|
1996
|
+
const record = item.record;
|
|
1997
|
+
if (String(record.action_type || '') !== 'local_command') continue;
|
|
1998
|
+
const command = record.payload && record.payload.command;
|
|
1999
|
+
const grant = permissionGrants.matchGrant({ command, workspaceRoot: cwd });
|
|
2000
|
+
if (!grant) continue;
|
|
2001
|
+
const output = options.output || process.stdout;
|
|
2002
|
+
output.write(`${formatAuxRow('grant', `auto-approved via ${grant.grant_id}: ${grant.pattern.display}`, output)}\n`);
|
|
2003
|
+
try {
|
|
2004
|
+
await approveWorkspaceApproval(record.approval_id, { ...options, cwd, output });
|
|
2005
|
+
permissionGrants.recordUse(grant.grant_id);
|
|
2006
|
+
redeemed.push(record.approval_id);
|
|
2007
|
+
} catch {
|
|
2008
|
+
// Redemption failed: leave the artifact pending so the hint still shows.
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return redeemed;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1771
2014
|
function writeWorkspaceApprovalHints(cwd, sinceMs, output) {
|
|
1772
2015
|
for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
|
|
1773
2016
|
output.write(`${formatAuxRow('approve', formatApprovalCommand(item.record.approval_id), output)}\n`);
|
|
1774
2017
|
}
|
|
1775
2018
|
}
|
|
1776
2019
|
|
|
2020
|
+
function deferWriteWorkspaceApprovalHints(cwd, sinceMs, output) {
|
|
2021
|
+
setImmediate(() => {
|
|
2022
|
+
Promise.resolve()
|
|
2023
|
+
.then(() => writeWorkspaceApprovalHints(cwd, sinceMs, output))
|
|
2024
|
+
.catch(() => {});
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function deferPersistPendingApproval(receipt, approvalRequest, options = {}) {
|
|
2029
|
+
setImmediate(() => {
|
|
2030
|
+
Promise.resolve()
|
|
2031
|
+
.then(() => persistPendingApproval(receipt, approvalRequest, options))
|
|
2032
|
+
.catch(() => {});
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function estimateHistoryContext(history = []) {
|
|
2037
|
+
const turns = history.filter(row => row && row.role === 'user').length;
|
|
2038
|
+
const chars = history.reduce((total, row) => total + String((row && row.content) || '').length, 0);
|
|
2039
|
+
return { turns, chars, tokens: Math.ceil(chars / 4) };
|
|
2040
|
+
}
|
|
2041
|
+
|
|
1777
2042
|
function formatStoredApprovals(store, options = {}) {
|
|
1778
2043
|
const approvals = Array.isArray(store && store.approvals) ? store.approvals : [];
|
|
1779
2044
|
if (!approvals.length) return 'No pending approvals.';
|
|
@@ -2187,8 +2452,9 @@ function buildPayload(message, options = {}) {
|
|
|
2187
2452
|
const payload = {
|
|
2188
2453
|
message: buildMessage(message, options.history || []),
|
|
2189
2454
|
model: modelForMode(mode),
|
|
2190
|
-
max_turns: local ? (mode === 'fast' ?
|
|
2191
|
-
verify_command: verifyCommand || 'true'
|
|
2455
|
+
max_turns: local ? (mode === 'fast' ? 16 : 24) : 1,
|
|
2456
|
+
verify_command: verifyCommand || 'true',
|
|
2457
|
+
local_tools: [backendApiToolDescriptor()]
|
|
2192
2458
|
};
|
|
2193
2459
|
if (previousMessages.length) {
|
|
2194
2460
|
payload.previous_messages = previousMessages;
|
|
@@ -2317,7 +2583,15 @@ function handleEvent(event, state, output) {
|
|
|
2317
2583
|
state.relay.chain = state.relay.chain
|
|
2318
2584
|
.then(() => state.relay.execute(event.name, args))
|
|
2319
2585
|
.catch((err) => ({ status: 'error', error: String(err.message || err).slice(0, 500) }))
|
|
2320
|
-
.then((result) =>
|
|
2586
|
+
.then((result) => {
|
|
2587
|
+
if (result && result.receipt) {
|
|
2588
|
+
flushPendingText(state, output);
|
|
2589
|
+
writeAuxLine(state, output, formatAuxRow('receipt', result.receipt, output));
|
|
2590
|
+
state.lastAux = 'receipt';
|
|
2591
|
+
}
|
|
2592
|
+
return result;
|
|
2593
|
+
})
|
|
2594
|
+
.then((result) => state.relay.post(event.call_id || event.id, result))
|
|
2321
2595
|
.catch((err) => state.errors.push(`tool relay failed: ${err.message}`));
|
|
2322
2596
|
}
|
|
2323
2597
|
return;
|
|
@@ -2351,11 +2625,22 @@ function handleEvent(event, state, output) {
|
|
|
2351
2625
|
return;
|
|
2352
2626
|
}
|
|
2353
2627
|
|
|
2354
|
-
if (event.type === 'result' && event.result
|
|
2628
|
+
if (event.type === 'result' && event.result) {
|
|
2355
2629
|
const content = String(event.result || '');
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2630
|
+
// `result` is the backend's authoritative final answer for the turn.
|
|
2631
|
+
// Local tool-loop turns stream throwaway narration via text_delta before
|
|
2632
|
+
// the model actually does the work ("Acknowledged, let me check that...")
|
|
2633
|
+
// — remember the true final text separately so callers (runHeadlessTurn's
|
|
2634
|
+
// JSON `output`, and chat()'s conversation history) get the real answer
|
|
2635
|
+
// instead of whichever fragment happened to stream first. Terminal
|
|
2636
|
+
// rendering keeps its existing de-dupe: only print `result` live when
|
|
2637
|
+
// nothing was already streamed (avoids double-printing a duplicate).
|
|
2638
|
+
state.resultOutput = content;
|
|
2639
|
+
if (!state.output) {
|
|
2640
|
+
state.output += content;
|
|
2641
|
+
if (output && output.isTTY) writeStreamingText(state, output, content);
|
|
2642
|
+
else state.pendingText += content;
|
|
2643
|
+
}
|
|
2359
2644
|
return;
|
|
2360
2645
|
}
|
|
2361
2646
|
|
|
@@ -2389,7 +2674,7 @@ async function postTurn(message, options = {}) {
|
|
|
2389
2674
|
// SSE traffic in between, so the socket-idle timeout needs more headroom.
|
|
2390
2675
|
const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
|
|
2391
2676
|
let timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
|
|
2392
|
-
// Local workspace tool loops (max_turns
|
|
2677
|
+
// Local workspace tool loops (max_turns 16/24) legitimately run past the fast
|
|
2393
2678
|
// lane's 60s chat wall — SwapBench 2026-07-02: three tool-loop tasks died at
|
|
2394
2679
|
// ~60s as "Atris cloud did not respond". Same headroom as business relays.
|
|
2395
2680
|
if (local) timeoutMs = Math.max(timeoutMs, 180000);
|
|
@@ -2411,11 +2696,34 @@ async function postTurn(message, options = {}) {
|
|
|
2411
2696
|
markdownMode: 'normal',
|
|
2412
2697
|
markdownBuffer: '',
|
|
2413
2698
|
markdownCarry: '',
|
|
2414
|
-
relay:
|
|
2699
|
+
relay: {
|
|
2415
2700
|
chain: Promise.resolve(),
|
|
2416
|
-
execute:
|
|
2417
|
-
|
|
2418
|
-
|
|
2701
|
+
execute: async (name, args) => {
|
|
2702
|
+
if (name === BACKEND_API_TOOL_NAME) {
|
|
2703
|
+
return executeBackendApiTool(args, {
|
|
2704
|
+
input: options.input,
|
|
2705
|
+
output,
|
|
2706
|
+
approveBackendApi: options.approveBackendApi,
|
|
2707
|
+
apiRequestJson: options.apiRequestJson,
|
|
2708
|
+
loadCredentials: options.loadCredentials,
|
|
2709
|
+
getApiBaseUrl: options.getApiBaseUrl,
|
|
2710
|
+
timeoutMs: options.backendApiTimeoutMs,
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
if (options.business) return options.business.executor(name, args);
|
|
2714
|
+
return { status: 'error', error: `unsupported relayed tool: ${name || '?'}` };
|
|
2715
|
+
},
|
|
2716
|
+
post: (callId, result) => {
|
|
2717
|
+
if (options.business) {
|
|
2718
|
+
return options.business.postToolResult(callId, result, backendBaseUrl({ route: 'cloud' }));
|
|
2719
|
+
}
|
|
2720
|
+
return postJson(BACKEND_API_TOOL_RESULT_PATH, { call_id: callId, result }, {
|
|
2721
|
+
token,
|
|
2722
|
+
route: endpointRoute,
|
|
2723
|
+
timeoutMs: 30000,
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2419
2727
|
};
|
|
2420
2728
|
|
|
2421
2729
|
return new Promise((resolve, reject) => {
|
|
@@ -2495,6 +2803,11 @@ async function postTurn(message, options = {}) {
|
|
|
2495
2803
|
}
|
|
2496
2804
|
|
|
2497
2805
|
flushPendingText(state, output);
|
|
2806
|
+
// Terminal display already streamed/de-duped above; the RETURNED
|
|
2807
|
+
// output must be the backend's authoritative final answer, not
|
|
2808
|
+
// whichever text_delta fragment happened to arrive first (see the
|
|
2809
|
+
// `result` branch in handleEvent).
|
|
2810
|
+
if (state.resultOutput) state.output = state.resultOutput;
|
|
2498
2811
|
finish(null, state);
|
|
2499
2812
|
});
|
|
2500
2813
|
});
|
|
@@ -2622,6 +2935,39 @@ function turnFunctionForMode(mode) {
|
|
|
2622
2935
|
return normalizeMode(mode) === 'code-fast' ? postCodeFastTurn : postTurn;
|
|
2623
2936
|
}
|
|
2624
2937
|
|
|
2938
|
+
async function runHeadlessTurn(message, options = {}) {
|
|
2939
|
+
const mode = normalizeMode(options.mode || 'fast');
|
|
2940
|
+
const startedAt = Date.now();
|
|
2941
|
+
const sink = options.output || bufferedOutput();
|
|
2942
|
+
const turnFunction = options.turnFunction || turnFunctionForMode(mode);
|
|
2943
|
+
|
|
2944
|
+
try {
|
|
2945
|
+
const result = await turnFunction(message, {
|
|
2946
|
+
mode,
|
|
2947
|
+
cwd: options.cwd || process.cwd(),
|
|
2948
|
+
route: options.route,
|
|
2949
|
+
business: options.business,
|
|
2950
|
+
verify: options.verify,
|
|
2951
|
+
output: sink,
|
|
2952
|
+
showProgress: false,
|
|
2953
|
+
});
|
|
2954
|
+
return {
|
|
2955
|
+
ok: true,
|
|
2956
|
+
model: modelForMode(mode),
|
|
2957
|
+
output: String((result && result.output) || '').trim(),
|
|
2958
|
+
durationMs: Number((result && result.durationMs) || 0) || (Date.now() - startedAt),
|
|
2959
|
+
};
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
return {
|
|
2962
|
+
ok: false,
|
|
2963
|
+
model: modelForMode(mode),
|
|
2964
|
+
output: '',
|
|
2965
|
+
durationMs: Date.now() - startedAt,
|
|
2966
|
+
error: String((error && error.message) || error || 'headless turn failed'),
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2625
2971
|
async function chat(options = {}) {
|
|
2626
2972
|
let mode = normalizeMode(options.mode);
|
|
2627
2973
|
const cwd = options.cwd || process.cwd();
|
|
@@ -2781,7 +3127,22 @@ async function chat(options = {}) {
|
|
|
2781
3127
|
lastCompactionNotice = compactedTurns;
|
|
2782
3128
|
output.write(`${paint(`· context: ${compactedTurns} earlier turn${compactedTurns === 1 ? '' : 's'} compacted into a digest`, [ANSI.muted], output)}\n`);
|
|
2783
3129
|
}
|
|
2784
|
-
const result = await turnFunction(trimmed, {
|
|
3130
|
+
const result = await turnFunction(trimmed, {
|
|
3131
|
+
mode,
|
|
3132
|
+
cwd,
|
|
3133
|
+
history,
|
|
3134
|
+
input,
|
|
3135
|
+
output,
|
|
3136
|
+
route,
|
|
3137
|
+
business: options.business,
|
|
3138
|
+
verify: options.verify,
|
|
3139
|
+
conversationId,
|
|
3140
|
+
turnId,
|
|
3141
|
+
approveBackendApi: options.approveBackendApi,
|
|
3142
|
+
apiRequestJson: options.apiRequestJson,
|
|
3143
|
+
loadCredentials: options.loadCredentials,
|
|
3144
|
+
getApiBaseUrl: options.getApiBaseUrl,
|
|
3145
|
+
});
|
|
2785
3146
|
if (result.output && !result.output.endsWith('\n')) output.write('\n');
|
|
2786
3147
|
const approvalExecution = normalizeMode(mode) === 'code-fast'
|
|
2787
3148
|
? null
|
|
@@ -3600,6 +3961,7 @@ async function main() {
|
|
|
3600
3961
|
const selfTest = args.includes('--self-test');
|
|
3601
3962
|
const benchmark = args.includes('--benchmark');
|
|
3602
3963
|
const approvals = args.includes('--approvals');
|
|
3964
|
+
const printMode = args.includes('--print') || args.includes('--headless');
|
|
3603
3965
|
const forceCloud = args.includes('--cloud');
|
|
3604
3966
|
const forceLocal = args.includes('--local');
|
|
3605
3967
|
|
|
@@ -3614,6 +3976,34 @@ async function main() {
|
|
|
3614
3976
|
args.splice(approveIdx, 2);
|
|
3615
3977
|
}
|
|
3616
3978
|
|
|
3979
|
+
let grantId = '';
|
|
3980
|
+
const grantIdx = args.indexOf('--grant');
|
|
3981
|
+
if (grantIdx !== -1) {
|
|
3982
|
+
grantId = String(args[grantIdx + 1] || '').trim();
|
|
3983
|
+
if (!grantId) {
|
|
3984
|
+
console.error('Usage: ax --grant <approval-id>');
|
|
3985
|
+
process.exit(1);
|
|
3986
|
+
}
|
|
3987
|
+
args.splice(grantIdx, 2);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
const listGrants = args.includes('--grants');
|
|
3991
|
+
if (listGrants) args.splice(args.indexOf('--grants'), 1);
|
|
3992
|
+
|
|
3993
|
+
const syncGrantsFlag = args.includes('--sync-grants');
|
|
3994
|
+
if (syncGrantsFlag) args.splice(args.indexOf('--sync-grants'), 1);
|
|
3995
|
+
|
|
3996
|
+
let revokeGrantId = '';
|
|
3997
|
+
const revokeGrantIdx = args.indexOf('--revoke-grant');
|
|
3998
|
+
if (revokeGrantIdx !== -1) {
|
|
3999
|
+
revokeGrantId = String(args[revokeGrantIdx + 1] || '').trim();
|
|
4000
|
+
if (!revokeGrantId) {
|
|
4001
|
+
console.error('Usage: ax --revoke-grant <grant-id>');
|
|
4002
|
+
process.exit(1);
|
|
4003
|
+
}
|
|
4004
|
+
args.splice(revokeGrantIdx, 2);
|
|
4005
|
+
}
|
|
4006
|
+
|
|
3617
4007
|
let denyId = '';
|
|
3618
4008
|
const denyIdx = args.indexOf('--deny');
|
|
3619
4009
|
if (denyIdx !== -1) {
|
|
@@ -3627,11 +4017,11 @@ async function main() {
|
|
|
3627
4017
|
|
|
3628
4018
|
const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
|
|
3629
4019
|
const prompt = args
|
|
3630
|
-
.filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--local', '--cloud', '--help', '-h'].includes(arg))
|
|
4020
|
+
.filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h'].includes(arg))
|
|
3631
4021
|
.join(' ')
|
|
3632
4022
|
.trim();
|
|
3633
4023
|
|
|
3634
|
-
const missionIntent = missionRunIntentFromMessage(prompt);
|
|
4024
|
+
const missionIntent = printMode ? null : missionRunIntentFromMessage(prompt);
|
|
3635
4025
|
if (missionIntent) {
|
|
3636
4026
|
const result = await runLocalMission(missionIntent, {
|
|
3637
4027
|
cwd: process.cwd(),
|
|
@@ -3677,7 +4067,7 @@ async function main() {
|
|
|
3677
4067
|
executor: makeCloudExecutor({ token: creds.token, businessId: biz.businessId, workspaceId: biz.workspaceId, slug: businessSlug }),
|
|
3678
4068
|
postToolResult
|
|
3679
4069
|
};
|
|
3680
|
-
console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
|
|
4070
|
+
if (!printMode) console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
|
|
3681
4071
|
}
|
|
3682
4072
|
|
|
3683
4073
|
try {
|
|
@@ -3720,6 +4110,65 @@ async function main() {
|
|
|
3720
4110
|
return;
|
|
3721
4111
|
}
|
|
3722
4112
|
|
|
4113
|
+
if (grantId) {
|
|
4114
|
+
const found = findWorkspaceApproval(grantId, process.cwd());
|
|
4115
|
+
if (!found) {
|
|
4116
|
+
console.error(`Pending approval not found: ${grantId}`);
|
|
4117
|
+
process.exit(1);
|
|
4118
|
+
}
|
|
4119
|
+
const command = found.record.payload && found.record.payload.command;
|
|
4120
|
+
const added = permissionGrants.addGrant({ command, workspaceRoot: process.cwd() });
|
|
4121
|
+
if (!added.ok) {
|
|
4122
|
+
console.error(`Not grantable: ${added.reason}`);
|
|
4123
|
+
process.exit(1);
|
|
4124
|
+
}
|
|
4125
|
+
console.log(`granted ${added.grant.grant_id}: ${added.grant.pattern.display} (this workspace, expires ${added.grant.constraints.expires_at.slice(0, 10)})`);
|
|
4126
|
+
await approveWorkspaceApproval(grantId, { cwd: process.cwd(), mode, output: process.stdout });
|
|
4127
|
+
return;
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
if (listGrants) {
|
|
4131
|
+
const store = permissionGrants.loadGrants();
|
|
4132
|
+
const rows = store.grants.filter(g => g.status === 'active');
|
|
4133
|
+
if (!rows.length) {
|
|
4134
|
+
console.log('no active grants');
|
|
4135
|
+
} else {
|
|
4136
|
+
for (const g of rows) {
|
|
4137
|
+
console.log(`${g.grant_id} ${g.pattern.display} ${g.scope.workspace_root} uses:${g.audit?.use_count || 0} expires:${String(g.constraints?.expires_at || '').slice(0, 10)}`);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
return;
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
if (revokeGrantId) {
|
|
4144
|
+
const revoked = permissionGrants.revokeGrant(revokeGrantId);
|
|
4145
|
+
if (!revoked.ok) {
|
|
4146
|
+
console.error(revoked.reason);
|
|
4147
|
+
process.exit(1);
|
|
4148
|
+
}
|
|
4149
|
+
console.log(`revoked ${revoked.grant.grant_id}: ${revoked.grant.pattern.display}`);
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
if (syncGrantsFlag) {
|
|
4154
|
+
const { apiRequestJson } = require('./utils/api');
|
|
4155
|
+
const result = await permissionGrants.syncGrants({ apiRequestJson, token: authToken() });
|
|
4156
|
+
if (!result.ok) {
|
|
4157
|
+
console.error(`grant sync failed: ${result.reason}`);
|
|
4158
|
+
process.exit(1);
|
|
4159
|
+
}
|
|
4160
|
+
console.log(`synced grants: pushed:${result.pushed} pulled:${result.pulled} revoked:${result.revoked}${result.rejected ? ` rejected:${result.rejected}` : ''}`);
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
if (printMode) {
|
|
4165
|
+
const payload = prompt
|
|
4166
|
+
? await runHeadlessTurn(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify })
|
|
4167
|
+
: { ok: false, model: modelForMode(mode), output: '', durationMs: 0, error: 'missing prompt' };
|
|
4168
|
+
console.log(JSON.stringify(payload));
|
|
4169
|
+
process.exit(payload.ok ? 0 : 1);
|
|
4170
|
+
}
|
|
4171
|
+
|
|
3723
4172
|
if (!prompt || args.includes('--chat')) {
|
|
3724
4173
|
await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
|
|
3725
4174
|
return;
|
|
@@ -3737,7 +4186,9 @@ async function main() {
|
|
|
3737
4186
|
: null;
|
|
3738
4187
|
if (approvalRecord) console.log(formatAuxRow('approve', formatApprovalCommand(approvalRecord.id), process.stdout));
|
|
3739
4188
|
// A one-shot has no next turn, so "reply yes to approve" is a dead end;
|
|
3740
|
-
//
|
|
4189
|
+
// granted patterns redeem themselves, everything else gets the exact
|
|
4190
|
+
// redeem command for what the turn staged on disk.
|
|
4191
|
+
await autoRedeemGrantedApprovals(process.cwd(), turnStartedAt, { mode, output: process.stdout });
|
|
3741
4192
|
writeWorkspaceApprovalHints(process.cwd(), turnStartedAt, process.stdout);
|
|
3742
4193
|
console.log('');
|
|
3743
4194
|
console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
|
|
@@ -3767,10 +4218,13 @@ module.exports = {
|
|
|
3767
4218
|
approvalStorePath,
|
|
3768
4219
|
approveStoredApproval,
|
|
3769
4220
|
approveWorkspaceApproval,
|
|
4221
|
+
autoRedeemGrantedApprovals,
|
|
3770
4222
|
findWorkspaceApproval,
|
|
3771
4223
|
listWorkspaceApprovals,
|
|
3772
4224
|
pendingWorkspaceApprovalsSince,
|
|
3773
4225
|
backendBaseUrl,
|
|
4226
|
+
backendApiAllowlistEntry,
|
|
4227
|
+
backendApiToolDescriptor,
|
|
3774
4228
|
backendStartCommand,
|
|
3775
4229
|
backendUrl,
|
|
3776
4230
|
buildCodeFastPayload,
|
|
@@ -3793,6 +4247,9 @@ module.exports = {
|
|
|
3793
4247
|
denyStoredApproval,
|
|
3794
4248
|
executeApprovalRequest,
|
|
3795
4249
|
executeApprovalFromState,
|
|
4250
|
+
executeBackendApiTool,
|
|
4251
|
+
formatBackendApiApprovalPrompt,
|
|
4252
|
+
formatBackendApiReceipt,
|
|
3796
4253
|
formatDoneLine,
|
|
3797
4254
|
formatDuration,
|
|
3798
4255
|
formatApprovalExecutionResult,
|
|
@@ -3824,6 +4281,7 @@ module.exports = {
|
|
|
3824
4281
|
renderStreamingMarkdown,
|
|
3825
4282
|
renderTerminalMarkdown,
|
|
3826
4283
|
resolveRoute,
|
|
4284
|
+
runHeadlessTurn,
|
|
3827
4285
|
runBenchmark,
|
|
3828
4286
|
runSelfTest,
|
|
3829
4287
|
runtimeReadyForChat,
|