atris 3.33.3 → 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.
Files changed (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. 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' ? 8 : 14) : 1,
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) => state.relay.post(event.call_id, 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 && !state.output) {
2628
+ if (event.type === 'result' && event.result) {
2355
2629
  const content = String(event.result || '');
2356
- state.output += content;
2357
- if (output && output.isTTY) writeStreamingText(state, output, content);
2358
- else state.pendingText += content;
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 8/14) legitimately run past the fast
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: options.business ? {
2699
+ relay: {
2415
2700
  chain: Promise.resolve(),
2416
- execute: options.business.executor,
2417
- post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl({ route: 'cloud' }))
2418
- } : null
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, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId, turnId });
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
- // surface the exact redeem command for anything the turn staged on disk.
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,