atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
package/ax CHANGED
@@ -8,8 +8,11 @@ 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');
15
+ const { pickLane } = require('./lib/ax-auto-lane');
13
16
 
14
17
  const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
15
18
  const BACKEND = {
@@ -29,7 +32,12 @@ const CONNECTION_CAPABILITIES_PATH = '/api/atris2/connection-capabilities';
29
32
  const ATRIS2_CONNECTION_STATUS_PATH = '/api/atris2/connection-status';
30
33
  const ATRIS2_HEALTH_PATH = '/api/atris2/health';
31
34
  const APPROVAL_EXECUTE_PATH = '/api/atris2/approvals/execute';
35
+ const BACKEND_API_TOOL_NAME = 'backend_api';
36
+ const BACKEND_API_TOOL_RESULT_PATH = '/api/atris2/turn/tool-result';
32
37
  const DEFAULT_APPROVAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
38
+ const DEFAULT_TURN_TIMEOUT_MS = 60000;
39
+ const PRO_TURN_TIMEOUT_MS = 180000;
40
+ const MAX_TURN_TIMEOUT_MS = 300000;
33
41
  const CONNECTOR_NAMES = {
34
42
  gmail: 'Gmail',
35
43
  google_calendar: 'Google Calendar',
@@ -71,6 +79,18 @@ const ANSI = {
71
79
 
72
80
  const TIER_COLORS = { fast: '\x1b[32m', pro: '\x1b[36m', max: '\x1b[35m' };
73
81
 
82
+ // Extend backend_api by adding one row here. Keep mutating endpoints narrow;
83
+ // any non-get method is approval-gated before the HTTP request is made.
84
+ const BACKEND_API_ALLOWLIST = [
85
+ { method: 'GET', endpoint: '/api/atris2/health', description: 'Atris2 health' },
86
+ { method: 'GET', endpoint: '/api/atris2/connection-status', description: 'connection status' },
87
+ { method: 'GET', endpoint: '/api/atris2/connection-capabilities', description: 'connection capabilities' },
88
+ { method: 'GET', endpoint: '/api/integrations/status', description: 'integration status' },
89
+ { method: 'GET', endpoint: '/api/ai-computer/user/status', description: 'personal computer status' },
90
+ { method: 'GET', endpoint: '/api/business', description: 'business list' },
91
+ { method: 'POST', endpoint: '/api/improve', description: 'run one improve tick' },
92
+ ];
93
+
74
94
  function tierColor(mode) {
75
95
  return TIER_COLORS[mode] || ANSI.accent;
76
96
  }
@@ -81,6 +101,33 @@ function modelForMode(mode) {
81
101
  return mode === 'fast' ? 'atris:fast' : 'atris:pro';
82
102
  }
83
103
 
104
+ function payloadCanRunTools(payload = {}, options = {}) {
105
+ return Boolean(
106
+ options.business
107
+ || options.local
108
+ || payload.local_executor
109
+ || (Array.isArray(payload.local_tools) && payload.local_tools.length > 0)
110
+ );
111
+ }
112
+
113
+ function postTurnTimeoutMs(payload = {}, options = {}) {
114
+ const model = String(payload.model || '');
115
+ let timeoutMs = DEFAULT_TURN_TIMEOUT_MS;
116
+ if (model === 'atris:max') timeoutMs = MAX_TURN_TIMEOUT_MS;
117
+ else if (model === 'atris:pro') timeoutMs = PRO_TURN_TIMEOUT_MS;
118
+
119
+ // AX postTurn is the tool-capable Atris2 path. Fast tool turns need the same
120
+ // headroom as pro because the backend can spend long stretches inside tools
121
+ // without SSE traffic. Plain tool-free one-shot fast chat stays at 60s.
122
+ if (model === 'atris:fast' && payloadCanRunTools(payload, options)) {
123
+ timeoutMs = Math.max(timeoutMs, PRO_TURN_TIMEOUT_MS);
124
+ }
125
+ if (options.business || options.local) {
126
+ timeoutMs = Math.max(timeoutMs, PRO_TURN_TIMEOUT_MS);
127
+ }
128
+ return timeoutMs;
129
+ }
130
+
84
131
  function formatDuration(ms) {
85
132
  const value = Number(ms) || 0;
86
133
  if (value < 1000) return `${Math.max(0, Math.round(value))}ms`;
@@ -115,23 +162,30 @@ function formatUsage() {
115
162
  'ax - Atris local/code agent',
116
163
  '',
117
164
  'Usage:',
118
- ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
119
- ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
165
+ ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
166
+ ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] --print <message>',
167
+ ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
120
168
  ' ax [--max|--pro|--fast] --business <slug> [<message>|--chat]',
121
169
  ' ax [--max|--pro|--fast|--code-fast] --doctor',
122
170
  ' ax --approvals',
123
171
  ' ax --approve <approval-id>',
124
172
  ' ax --deny <approval-id>',
173
+ ' ax --grant <approval-id> approve and remember this exact command (this workspace)',
174
+ ' ax --grants list active permission grants',
175
+ ' ax --sync-grants push/pull grants with atrisos-web',
176
+ ' ax --revoke-grant <grant-id>',
125
177
  ' ax --self-test',
126
178
  ' ax [--max|--fast] --benchmark',
127
179
  '',
128
180
  'Modes:',
181
+ ' --auto pick a lane for each message and say why',
129
182
  ' --max hosted Atris 2, highest reasoning, slowest turns',
130
183
  ' --pro hosted Atris 2, deeper tool loop',
131
184
  ' --fast hosted Atris 2, faster low-latency turns',
132
185
  ' --code-fast Atris Code Fast public lane',
133
186
  ' --local opt into local backend/workspace tools',
134
187
  ' --cloud force authenticated cloud connectors/chat',
188
+ ' --print headless JSON result, including auto_lane and auto_reason with --auto',
135
189
  ' --business <slug> run tools on that business cloud workspace (EC2)',
136
190
  ' --verify <cmd> gate the turn on this command passing (default: no verifier)',
137
191
  '',
@@ -177,10 +231,29 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
177
231
  ].join('\n'));
178
232
 
179
233
  let redactedChars = 0;
234
+ let logBuffer = '';
235
+ let logFlushTimer = null;
236
+ const flushLogBuffer = () => {
237
+ if (logFlushTimer) {
238
+ clearTimeout(logFlushTimer);
239
+ logFlushTimer = null;
240
+ }
241
+ if (!logBuffer) return;
242
+ fs.appendFileSync(logPath, logBuffer);
243
+ logBuffer = '';
244
+ };
245
+ const scheduleLogFlush = () => {
246
+ if (logFlushTimer) return;
247
+ logFlushTimer = setTimeout(() => {
248
+ logFlushTimer = null;
249
+ flushLogBuffer();
250
+ }, 500);
251
+ };
180
252
  const writeLog = (chunk) => {
181
253
  const text = stripAnsi(chunk);
182
254
  if (fullTranscript) {
183
- fs.appendFileSync(logPath, text);
255
+ logBuffer += text;
256
+ scheduleLogFlush();
184
257
  return;
185
258
  }
186
259
  redactedChars += text.length;
@@ -200,6 +273,7 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
200
273
  output: teeOutput,
201
274
  write: writeLog,
202
275
  close(exitCode = 0) {
276
+ flushLogBuffer();
203
277
  if (!fullTranscript) {
204
278
  fs.appendFileSync(logPath, `redacted_chars: ${redactedChars}\n`);
205
279
  fs.appendFileSync(logPath, 'note: full transcript disabled by default; set AX_LOG_FULL=1 to opt in.\n');
@@ -207,6 +281,7 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
207
281
  return;
208
282
  }
209
283
  writeLog(`\nexit_code: ${exitCode}\nfinished_at: ${new Date().toISOString()}\n`);
284
+ flushLogBuffer();
210
285
  }
211
286
  };
212
287
  }
@@ -495,6 +570,201 @@ function postJson(pathname, body, { token = authToken(), timeoutMs = 30000, rout
495
570
  });
496
571
  }
497
572
 
573
+ function compactSingleLine(value) {
574
+ return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
575
+ }
576
+
577
+ function truncateSingleLine(value, limit = 120) {
578
+ const text = compactSingleLine(value);
579
+ if (text.length <= limit) return text;
580
+ if (limit <= 3) return text.slice(0, limit);
581
+ return `${text.slice(0, limit - 3)}...`;
582
+ }
583
+
584
+ function canonicalBackendApiPath(pathname) {
585
+ let value = String(pathname || '').trim();
586
+ if (!value) return '';
587
+ if (!value.startsWith('/')) value = `/${value}`;
588
+ value = value.replace(/\/{2,}/g, '/');
589
+ if (value !== '/' && value.endsWith('/')) value = value.replace(/\/+$/, '');
590
+ return value;
591
+ }
592
+
593
+ function normalizeBackendApiMethod(method) {
594
+ return String(method || 'GET').trim().toUpperCase() || 'GET';
595
+ }
596
+
597
+ function normalizeBackendApiEndpoint(endpoint) {
598
+ const raw = String(endpoint || '').trim();
599
+ if (!raw || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return null;
600
+ const parsed = new URL(raw.startsWith('/') ? raw : `/${raw}`, 'https://ax.local');
601
+ const pathOnly = canonicalBackendApiPath(parsed.pathname);
602
+ const displayPath = pathOnly === '/api' || pathOnly.startsWith('/api/')
603
+ ? pathOnly
604
+ : canonicalBackendApiPath(`/api${pathOnly}`);
605
+ return {
606
+ endpoint: `${displayPath}${parsed.search}`,
607
+ pathname: displayPath,
608
+ search: parsed.search,
609
+ };
610
+ }
611
+
612
+ function backendApiAllowlistEntry(method, endpointPathname) {
613
+ const normalizedMethod = normalizeBackendApiMethod(method);
614
+ const normalizedPath = canonicalBackendApiPath(endpointPathname);
615
+ return BACKEND_API_ALLOWLIST.find(row =>
616
+ row.method === normalizedMethod && canonicalBackendApiPath(row.endpoint) === normalizedPath
617
+ ) || null;
618
+ }
619
+
620
+ function apiRequestPathForBackendEndpoint(displayEndpoint, baseUrl = getApiBaseUrl()) {
621
+ const parsed = new URL(displayEndpoint, 'https://ax.local');
622
+ let pathname = canonicalBackendApiPath(parsed.pathname);
623
+ const base = String(baseUrl || '').replace(/\/+$/, '');
624
+ if (base.endsWith('/api') && pathname.startsWith('/api/')) {
625
+ pathname = pathname.slice('/api'.length) || '/';
626
+ }
627
+ return `${pathname}${parsed.search}`;
628
+ }
629
+
630
+ function summarizeBackendApiPayload(payload) {
631
+ if (payload === undefined || payload === null) return 'none';
632
+ if (typeof payload === 'string') return truncateSingleLine(payload, 120);
633
+ try {
634
+ return truncateSingleLine(JSON.stringify(payload), 120);
635
+ } catch (_) {
636
+ return truncateSingleLine(String(payload), 120);
637
+ }
638
+ }
639
+
640
+ function summarizeBackendApiResponse(response) {
641
+ if (!response) return '';
642
+ if (response.data !== undefined && response.data !== null) {
643
+ try {
644
+ return truncateSingleLine(JSON.stringify(response.data), 120);
645
+ } catch (_) {
646
+ return truncateSingleLine(String(response.data), 120);
647
+ }
648
+ }
649
+ return truncateSingleLine(response.text || response.error || '', 120);
650
+ }
651
+
652
+ function formatBackendApiReceipt(call) {
653
+ return `backend_api ${String(call.method || 'get').toLowerCase()} ${call.endpoint} status ${call.status}: ${truncateSingleLine(call.summary, 120)}`;
654
+ }
655
+
656
+ function formatBackendApiApprovalPrompt(call) {
657
+ return `approve backend_api ${String(call.method || 'post').toLowerCase()} ${call.endpoint} payload ${summarizeBackendApiPayload(call.payload)}? yes/no: `;
658
+ }
659
+
660
+ function messageApprovesBackendApi(value) {
661
+ const normalized = String(value || '').trim().toLowerCase().replace(/[.!?]+$/g, '');
662
+ return normalized === 'yes' || normalized === 'y';
663
+ }
664
+
665
+ async function requestBackendApiApproval(call, options = {}) {
666
+ const prompt = formatBackendApiApprovalPrompt(call);
667
+ if (typeof options.approveBackendApi === 'function') {
668
+ if (options.output && typeof options.output.write === 'function') {
669
+ options.output.write(`${prompt}\n`);
670
+ }
671
+ const decision = await options.approveBackendApi({ ...call, prompt });
672
+ return decision === true || messageApprovesBackendApi(decision);
673
+ }
674
+
675
+ const input = options.input || process.stdin;
676
+ const output = options.output || process.stdout;
677
+ if (!input || !input.isTTY) return false;
678
+ return new Promise((resolve) => {
679
+ const rl = readline.createInterface({ input, output });
680
+ rl.question(prompt, (answer) => {
681
+ rl.close();
682
+ resolve(messageApprovesBackendApi(answer));
683
+ });
684
+ });
685
+ }
686
+
687
+ function backendApiToolDescriptor() {
688
+ return {
689
+ name: BACKEND_API_TOOL_NAME,
690
+ description: 'Call allowlisted AtrisOS backend API endpoints through ax. get calls run directly; non-get calls require explicit user approval.',
691
+ input_schema: {
692
+ type: 'object',
693
+ properties: {
694
+ method: { type: 'string', description: 'HTTP method, for example get or post' },
695
+ endpoint: { type: 'string', description: 'Allowlisted endpoint such as /api/atris2/health or /api/improve' },
696
+ payload: { type: 'object', description: 'JSON payload for non-get calls' },
697
+ },
698
+ required: ['endpoint'],
699
+ },
700
+ allowlist: BACKEND_API_ALLOWLIST.map(row => ({ method: row.method, endpoint: row.endpoint, description: row.description })),
701
+ };
702
+ }
703
+
704
+ async function executeBackendApiTool(args = {}, options = {}) {
705
+ const method = normalizeBackendApiMethod(args.method || args.verb);
706
+ const normalizedEndpoint = normalizeBackendApiEndpoint(args.endpoint || args.path || args.url);
707
+ if (!normalizedEndpoint) {
708
+ return { status: 'error', error: 'backend_api refused: missing or invalid endpoint' };
709
+ }
710
+
711
+ const allowlisted = backendApiAllowlistEntry(method, normalizedEndpoint.pathname);
712
+ if (!allowlisted) {
713
+ return {
714
+ status: 'error',
715
+ error: `backend_api refused: ${method.toLowerCase()} ${normalizedEndpoint.endpoint} is not allowlisted`,
716
+ };
717
+ }
718
+
719
+ const payload = args.payload !== undefined ? args.payload : args.body;
720
+ const call = { method, endpoint: normalizedEndpoint.endpoint, payload };
721
+ if (method !== 'GET') {
722
+ const approved = await requestBackendApiApproval(call, options);
723
+ if (!approved) {
724
+ return {
725
+ status: 'error',
726
+ error: `backend_api refused: ${method.toLowerCase()} ${normalizedEndpoint.endpoint} was not approved`,
727
+ };
728
+ }
729
+ }
730
+
731
+ const loadCreds = options.loadCredentials || loadCredentials;
732
+ const credentials = loadCreds();
733
+ const token = credentials && credentials.token ? credentials.token : '';
734
+ if (!token) {
735
+ return { status: 'error', error: 'backend_api refused: not logged in' };
736
+ }
737
+
738
+ const apiFn = options.apiRequestJson || apiRequestJson;
739
+ const baseFn = options.getApiBaseUrl || getApiBaseUrl;
740
+ const requestPath = apiRequestPathForBackendEndpoint(normalizedEndpoint.endpoint, baseFn());
741
+ const response = await apiFn(requestPath, {
742
+ method,
743
+ token,
744
+ ...(method === 'GET' ? {} : { body: payload === undefined ? {} : payload }),
745
+ timeoutMs: options.timeoutMs || 30000,
746
+ });
747
+ const summary = summarizeBackendApiResponse(response);
748
+ const receipt = formatBackendApiReceipt({
749
+ method,
750
+ endpoint: normalizedEndpoint.endpoint,
751
+ status: response && typeof response.status === 'number' ? response.status : 0,
752
+ summary,
753
+ });
754
+ return {
755
+ status: response && response.ok ? 'ok' : 'error',
756
+ method: method.toLowerCase(),
757
+ endpoint: normalizedEndpoint.endpoint,
758
+ status_code: response && typeof response.status === 'number' ? response.status : 0,
759
+ summary,
760
+ receipt,
761
+ response: response && response.data !== undefined && response.data !== null
762
+ ? response.data
763
+ : truncateSingleLine(response && response.text, 4000),
764
+ ...(response && response.ok ? {} : { error: (response && response.error) || summary || 'request failed' }),
765
+ };
766
+ }
767
+
498
768
  function defaultAuthority(id) {
499
769
  const key = canonicalConnectorId(id);
500
770
  if (key === 'gmail') return { list_messages: 'read_only', get_message: 'read_only', send_message: 'approval_required' };
@@ -687,10 +957,38 @@ function normalizeMode(mode) {
687
957
  return mode === 'fast' ? 'fast' : 'pro';
688
958
  }
689
959
 
960
+ function appendAutoPick(message, picked, options = {}) {
961
+ try {
962
+ const dir = path.join(os.homedir(), '.atris');
963
+ fs.mkdirSync(dir, { recursive: true });
964
+ fs.appendFileSync(path.join(dir, 'ax-auto-picks.jsonl'), `${JSON.stringify({
965
+ at: new Date().toISOString(),
966
+ lane: picked.lane,
967
+ reason: picked.reason,
968
+ message_chars: String(message || '').length,
969
+ print: Boolean(options.print),
970
+ })}\n`);
971
+ } catch (_) {
972
+ // Lane telemetry is best-effort and must never block a turn.
973
+ }
974
+ }
975
+
976
+ function autoLaneForMessage(message, options = {}) {
977
+ const picked = pickLane(message);
978
+ appendAutoPick(message, picked, options);
979
+ const errorOutput = options.errorOutput || process.stderr;
980
+ errorOutput.write(`${picked.reason}\n`);
981
+ return picked;
982
+ }
983
+
690
984
  function formatPrompt(mode, options = {}) {
691
985
  if (!mode) return '› ';
692
986
  const tier = normalizeMode(mode);
693
- return `${paint(tier, [ANSI.bold, tierColor(tier)], options)} › `;
987
+ const label = paint(tier, [ANSI.bold, tierColor(tier)], options);
988
+ if (options.approveMode === 'auto') {
989
+ return `${label} ${paint('[auto-approve]', [ANSI.muted], options)} › `;
990
+ }
991
+ return `${label} › `;
694
992
  }
695
993
 
696
994
  const TIER_COMMANDS = new Map([
@@ -707,6 +1005,8 @@ const CHAT_COMMANDS = [
707
1005
  ['/fast', 'quick answers, lowest latency'],
708
1006
  ['/pro', 'deeper tool loop for real work'],
709
1007
  ['/max', 'highest reasoning for the hardest jobs'],
1008
+ ['/clear', 'wipe chat history and reset conversation id'],
1009
+ ['/context', 'show turn count and rough token estimate'],
710
1010
  ['/help', 'show this menu'],
711
1011
  ['exit', 'leave chat'],
712
1012
  ];
@@ -867,6 +1167,8 @@ function formatGoalStatusLine(goal, options = {}) {
867
1167
  const status = String(goal.mission_status || '').toLowerCase();
868
1168
  if (!['active', 'running', 'planning', 'in_progress', 'ready'].includes(status)) return '';
869
1169
  const elapsed = goalElapsedSeconds(goal);
1170
+ // A goal "running" for >24h is stale state, not a live goal — hide it.
1171
+ if (elapsed !== null && elapsed > 86400) return '';
870
1172
  const objective = String(goal.objective).length > 64
871
1173
  ? `${String(goal.objective).slice(0, 61)}...`
872
1174
  : String(goal.objective);
@@ -1768,12 +2070,58 @@ async function approveWorkspaceApproval(ref, options = {}) {
1768
2070
  return { response, executed };
1769
2071
  }
1770
2072
 
2073
+ // A persistent grant (ax --grant) lets an exact approved command pattern
2074
+ // redeem itself on later turns instead of re-asking; anything unmatched
2075
+ // falls through to the normal hint. Store: ~/.atris/permission-grants.json.
2076
+ async function autoRedeemGrantedApprovals(cwd, sinceMs, options = {}) {
2077
+ const redeemed = [];
2078
+ for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
2079
+ const record = item.record;
2080
+ if (String(record.action_type || '') !== 'local_command') continue;
2081
+ const command = record.payload && record.payload.command;
2082
+ const grant = permissionGrants.matchGrant({ command, workspaceRoot: cwd });
2083
+ if (!grant) continue;
2084
+ const output = options.output || process.stdout;
2085
+ output.write(`${formatAuxRow('grant', `auto-approved via ${grant.grant_id}: ${grant.pattern.display}`, output)}\n`);
2086
+ try {
2087
+ await approveWorkspaceApproval(record.approval_id, { ...options, cwd, output });
2088
+ permissionGrants.recordUse(grant.grant_id);
2089
+ redeemed.push(record.approval_id);
2090
+ } catch {
2091
+ // Redemption failed: leave the artifact pending so the hint still shows.
2092
+ }
2093
+ }
2094
+ return redeemed;
2095
+ }
2096
+
1771
2097
  function writeWorkspaceApprovalHints(cwd, sinceMs, output) {
1772
2098
  for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
1773
2099
  output.write(`${formatAuxRow('approve', formatApprovalCommand(item.record.approval_id), output)}\n`);
1774
2100
  }
1775
2101
  }
1776
2102
 
2103
+ function deferWriteWorkspaceApprovalHints(cwd, sinceMs, output) {
2104
+ setImmediate(() => {
2105
+ Promise.resolve()
2106
+ .then(() => writeWorkspaceApprovalHints(cwd, sinceMs, output))
2107
+ .catch(() => {});
2108
+ });
2109
+ }
2110
+
2111
+ function deferPersistPendingApproval(receipt, approvalRequest, options = {}) {
2112
+ setImmediate(() => {
2113
+ Promise.resolve()
2114
+ .then(() => persistPendingApproval(receipt, approvalRequest, options))
2115
+ .catch(() => {});
2116
+ });
2117
+ }
2118
+
2119
+ function estimateHistoryContext(history = []) {
2120
+ const turns = history.filter(row => row && row.role === 'user').length;
2121
+ const chars = history.reduce((total, row) => total + String((row && row.content) || '').length, 0);
2122
+ return { turns, chars, tokens: Math.ceil(chars / 4) };
2123
+ }
2124
+
1777
2125
  function formatStoredApprovals(store, options = {}) {
1778
2126
  const approvals = Array.isArray(store && store.approvals) ? store.approvals : [];
1779
2127
  if (!approvals.length) return 'No pending approvals.';
@@ -2187,8 +2535,9 @@ function buildPayload(message, options = {}) {
2187
2535
  const payload = {
2188
2536
  message: buildMessage(message, options.history || []),
2189
2537
  model: modelForMode(mode),
2190
- max_turns: local ? (mode === 'fast' ? 8 : 14) : 1,
2191
- verify_command: verifyCommand || 'true'
2538
+ max_turns: local ? (mode === 'fast' ? 16 : 24) : 1,
2539
+ verify_command: verifyCommand || 'true',
2540
+ local_tools: [backendApiToolDescriptor()]
2192
2541
  };
2193
2542
  if (previousMessages.length) {
2194
2543
  payload.previous_messages = previousMessages;
@@ -2317,7 +2666,15 @@ function handleEvent(event, state, output) {
2317
2666
  state.relay.chain = state.relay.chain
2318
2667
  .then(() => state.relay.execute(event.name, args))
2319
2668
  .catch((err) => ({ status: 'error', error: String(err.message || err).slice(0, 500) }))
2320
- .then((result) => state.relay.post(event.call_id, result))
2669
+ .then((result) => {
2670
+ if (result && result.receipt) {
2671
+ flushPendingText(state, output);
2672
+ writeAuxLine(state, output, formatAuxRow('receipt', result.receipt, output));
2673
+ state.lastAux = 'receipt';
2674
+ }
2675
+ return result;
2676
+ })
2677
+ .then((result) => state.relay.post(event.call_id || event.id, result))
2321
2678
  .catch((err) => state.errors.push(`tool relay failed: ${err.message}`));
2322
2679
  }
2323
2680
  return;
@@ -2351,11 +2708,22 @@ function handleEvent(event, state, output) {
2351
2708
  return;
2352
2709
  }
2353
2710
 
2354
- if (event.type === 'result' && event.result && !state.output) {
2711
+ if (event.type === 'result' && event.result) {
2355
2712
  const content = String(event.result || '');
2356
- state.output += content;
2357
- if (output && output.isTTY) writeStreamingText(state, output, content);
2358
- else state.pendingText += content;
2713
+ // `result` is the backend's authoritative final answer for the turn.
2714
+ // Local tool-loop turns stream throwaway narration via text_delta before
2715
+ // the model actually does the work ("Acknowledged, let me check that...")
2716
+ // — remember the true final text separately so callers (runHeadlessTurn's
2717
+ // JSON `output`, and chat()'s conversation history) get the real answer
2718
+ // instead of whichever fragment happened to stream first. Terminal
2719
+ // rendering keeps its existing de-dupe: only print `result` live when
2720
+ // nothing was already streamed (avoids double-printing a duplicate).
2721
+ state.resultOutput = content;
2722
+ if (!state.output) {
2723
+ state.output += content;
2724
+ if (output && output.isTTY) writeStreamingText(state, output, content);
2725
+ else state.pendingText += content;
2726
+ }
2359
2727
  return;
2360
2728
  }
2361
2729
 
@@ -2385,14 +2753,7 @@ async function postTurn(message, options = {}) {
2385
2753
  const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId, turnId });
2386
2754
  const postData = JSON.stringify(payload);
2387
2755
  const output = options.output || process.stdout;
2388
- // Relayed business turns wait on EC2 terminal calls (up to 60s each) with no
2389
- // SSE traffic in between, so the socket-idle timeout needs more headroom.
2390
- const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
2391
- let timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
2392
- // Local workspace tool loops (max_turns 8/14) legitimately run past the fast
2393
- // lane's 60s chat wall — SwapBench 2026-07-02: three tool-loop tasks died at
2394
- // ~60s as "Atris cloud did not respond". Same headroom as business relays.
2395
- if (local) timeoutMs = Math.max(timeoutMs, 180000);
2756
+ const timeoutMs = postTurnTimeoutMs(payload, { business: options.business, local });
2396
2757
  const turnUrl = new URL(backendUrl({ route: endpointRoute }));
2397
2758
  const transport = turnUrl.protocol === 'https:' ? https : http;
2398
2759
  const state = {
@@ -2411,11 +2772,34 @@ async function postTurn(message, options = {}) {
2411
2772
  markdownMode: 'normal',
2412
2773
  markdownBuffer: '',
2413
2774
  markdownCarry: '',
2414
- relay: options.business ? {
2775
+ relay: {
2415
2776
  chain: Promise.resolve(),
2416
- execute: options.business.executor,
2417
- post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl({ route: 'cloud' }))
2418
- } : null
2777
+ execute: async (name, args) => {
2778
+ if (name === BACKEND_API_TOOL_NAME) {
2779
+ return executeBackendApiTool(args, {
2780
+ input: options.input,
2781
+ output,
2782
+ approveBackendApi: options.approveBackendApi,
2783
+ apiRequestJson: options.apiRequestJson,
2784
+ loadCredentials: options.loadCredentials,
2785
+ getApiBaseUrl: options.getApiBaseUrl,
2786
+ timeoutMs: options.backendApiTimeoutMs,
2787
+ });
2788
+ }
2789
+ if (options.business) return options.business.executor(name, args);
2790
+ return { status: 'error', error: `unsupported relayed tool: ${name || '?'}` };
2791
+ },
2792
+ post: (callId, result) => {
2793
+ if (options.business) {
2794
+ return options.business.postToolResult(callId, result, backendBaseUrl({ route: 'cloud' }));
2795
+ }
2796
+ return postJson(BACKEND_API_TOOL_RESULT_PATH, { call_id: callId, result }, {
2797
+ token,
2798
+ route: endpointRoute,
2799
+ timeoutMs: 30000,
2800
+ });
2801
+ }
2802
+ }
2419
2803
  };
2420
2804
 
2421
2805
  return new Promise((resolve, reject) => {
@@ -2495,6 +2879,11 @@ async function postTurn(message, options = {}) {
2495
2879
  }
2496
2880
 
2497
2881
  flushPendingText(state, output);
2882
+ // Terminal display already streamed/de-duped above; the RETURNED
2883
+ // output must be the backend's authoritative final answer, not
2884
+ // whichever text_delta fragment happened to arrive first (see the
2885
+ // `result` branch in handleEvent).
2886
+ if (state.resultOutput) state.output = state.resultOutput;
2498
2887
  finish(null, state);
2499
2888
  });
2500
2889
  });
@@ -2622,6 +3011,44 @@ function turnFunctionForMode(mode) {
2622
3011
  return normalizeMode(mode) === 'code-fast' ? postCodeFastTurn : postTurn;
2623
3012
  }
2624
3013
 
3014
+ async function runHeadlessTurn(message, options = {}) {
3015
+ const mode = normalizeMode(options.mode || 'fast');
3016
+ const startedAt = Date.now();
3017
+ const sink = options.output || bufferedOutput();
3018
+ const turnFunction = options.turnFunction || turnFunctionForMode(mode);
3019
+ const autoFields = options.autoLane && options.autoReason
3020
+ ? { auto_lane: normalizeMode(options.autoLane), auto_reason: String(options.autoReason) }
3021
+ : {};
3022
+
3023
+ try {
3024
+ const result = await turnFunction(message, {
3025
+ mode,
3026
+ cwd: options.cwd || process.cwd(),
3027
+ route: options.route,
3028
+ business: options.business,
3029
+ verify: options.verify,
3030
+ output: sink,
3031
+ showProgress: false,
3032
+ });
3033
+ return {
3034
+ ok: true,
3035
+ model: modelForMode(mode),
3036
+ output: String((result && result.output) || '').trim(),
3037
+ durationMs: Number((result && result.durationMs) || 0) || (Date.now() - startedAt),
3038
+ ...autoFields,
3039
+ };
3040
+ } catch (error) {
3041
+ return {
3042
+ ok: false,
3043
+ model: modelForMode(mode),
3044
+ output: '',
3045
+ durationMs: Date.now() - startedAt,
3046
+ error: String((error && error.message) || error || 'headless turn failed'),
3047
+ ...autoFields,
3048
+ };
3049
+ }
3050
+ }
3051
+
2625
3052
  async function chat(options = {}) {
2626
3053
  let mode = normalizeMode(options.mode);
2627
3054
  const cwd = options.cwd || process.cwd();
@@ -2631,7 +3058,9 @@ async function chat(options = {}) {
2631
3058
  const output = logger ? logger.output : baseOutput;
2632
3059
  const history = [];
2633
3060
  let lastCompactionNotice = 0;
2634
- const conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
3061
+ let conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
3062
+ const session = { approveMode: 'stage' };
3063
+ let turnInFlight = false;
2635
3064
 
2636
3065
  output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
2637
3066
  if (logger) output.write(`${formatAuxRow('log', formatPathSubject(logger.path, output), output)}\n\n`);
@@ -2678,11 +3107,31 @@ async function chat(options = {}) {
2678
3107
  return false;
2679
3108
  }
2680
3109
 
3110
+ if (trimmed === '/clear') {
3111
+ history.length = 0;
3112
+ conversationId = `ax-${process.pid}-${Date.now().toString(36)}`;
3113
+ output.write(`${paint('context cleared', [ANSI.muted], output)}\n\n`);
3114
+ return false;
3115
+ }
3116
+
3117
+ if (trimmed === '/context') {
3118
+ const ctx = estimateHistoryContext(history);
3119
+ output.write(`${paint(`· ${ctx.turns} turn${ctx.turns === 1 ? '' : 's'} · ~${ctx.chars} chars · ~${ctx.tokens} tokens`, [ANSI.muted], output)}\n\n`);
3120
+ return false;
3121
+ }
3122
+
2681
3123
  if (trimmed.startsWith('/')) {
2682
3124
  output.write(`${chatMenu(output)}\n\n`);
2683
3125
  return false;
2684
3126
  }
2685
3127
 
3128
+ if (options.auto) {
3129
+ mode = autoLaneForMessage(trimmed, {
3130
+ errorOutput: options.errorOutput,
3131
+ print: false,
3132
+ }).lane;
3133
+ }
3134
+
2686
3135
  if (logger) logger.write(`${formatPrompt(mode)}${trimmed}\n`);
2687
3136
  output.write('\n');
2688
3137
 
@@ -2781,7 +3230,22 @@ async function chat(options = {}) {
2781
3230
  lastCompactionNotice = compactedTurns;
2782
3231
  output.write(`${paint(`· context: ${compactedTurns} earlier turn${compactedTurns === 1 ? '' : 's'} compacted into a digest`, [ANSI.muted], output)}\n`);
2783
3232
  }
2784
- const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId, turnId });
3233
+ const result = await turnFunction(trimmed, {
3234
+ mode,
3235
+ cwd,
3236
+ history,
3237
+ input,
3238
+ output,
3239
+ route,
3240
+ business: options.business,
3241
+ verify: options.verify,
3242
+ conversationId,
3243
+ turnId,
3244
+ approveBackendApi: options.approveBackendApi,
3245
+ apiRequestJson: options.apiRequestJson,
3246
+ loadCredentials: options.loadCredentials,
3247
+ getApiBaseUrl: options.getApiBaseUrl,
3248
+ });
2785
3249
  if (result.output && !result.output.endsWith('\n')) output.write('\n');
2786
3250
  const approvalExecution = normalizeMode(mode) === 'code-fast'
2787
3251
  ? null
@@ -3595,11 +4059,22 @@ async function main() {
3595
4059
  args.splice(verifyIdx, 2);
3596
4060
  }
3597
4061
 
3598
- const mode = args.includes('--code-fast') || args.includes('--code') ? 'code-fast' : args.includes('--max') ? 'max' : args.includes('--fast') ? 'fast' : 'pro';
4062
+ const explicitLane = args.includes('--code-fast') || args.includes('--code')
4063
+ ? 'code-fast'
4064
+ : args.includes('--max')
4065
+ ? 'max'
4066
+ : args.includes('--fast')
4067
+ ? 'fast'
4068
+ : args.includes('--pro')
4069
+ ? 'pro'
4070
+ : null;
4071
+ const autoEnabled = args.includes('--auto') && !explicitLane;
4072
+ let mode = explicitLane || 'pro';
3599
4073
  const doctor = args.includes('--doctor');
3600
4074
  const selfTest = args.includes('--self-test');
3601
4075
  const benchmark = args.includes('--benchmark');
3602
4076
  const approvals = args.includes('--approvals');
4077
+ const printMode = args.includes('--print') || args.includes('--headless');
3603
4078
  const forceCloud = args.includes('--cloud');
3604
4079
  const forceLocal = args.includes('--local');
3605
4080
 
@@ -3614,6 +4089,34 @@ async function main() {
3614
4089
  args.splice(approveIdx, 2);
3615
4090
  }
3616
4091
 
4092
+ let grantId = '';
4093
+ const grantIdx = args.indexOf('--grant');
4094
+ if (grantIdx !== -1) {
4095
+ grantId = String(args[grantIdx + 1] || '').trim();
4096
+ if (!grantId) {
4097
+ console.error('Usage: ax --grant <approval-id>');
4098
+ process.exit(1);
4099
+ }
4100
+ args.splice(grantIdx, 2);
4101
+ }
4102
+
4103
+ const listGrants = args.includes('--grants');
4104
+ if (listGrants) args.splice(args.indexOf('--grants'), 1);
4105
+
4106
+ const syncGrantsFlag = args.includes('--sync-grants');
4107
+ if (syncGrantsFlag) args.splice(args.indexOf('--sync-grants'), 1);
4108
+
4109
+ let revokeGrantId = '';
4110
+ const revokeGrantIdx = args.indexOf('--revoke-grant');
4111
+ if (revokeGrantIdx !== -1) {
4112
+ revokeGrantId = String(args[revokeGrantIdx + 1] || '').trim();
4113
+ if (!revokeGrantId) {
4114
+ console.error('Usage: ax --revoke-grant <grant-id>');
4115
+ process.exit(1);
4116
+ }
4117
+ args.splice(revokeGrantIdx, 2);
4118
+ }
4119
+
3617
4120
  let denyId = '';
3618
4121
  const denyIdx = args.indexOf('--deny');
3619
4122
  if (denyIdx !== -1) {
@@ -3627,11 +4130,11 @@ async function main() {
3627
4130
 
3628
4131
  const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
3629
4132
  const prompt = args
3630
- .filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--local', '--cloud', '--help', '-h'].includes(arg))
4133
+ .filter(arg => !['--auto', '--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h'].includes(arg))
3631
4134
  .join(' ')
3632
4135
  .trim();
3633
4136
 
3634
- const missionIntent = missionRunIntentFromMessage(prompt);
4137
+ const missionIntent = printMode ? null : missionRunIntentFromMessage(prompt);
3635
4138
  if (missionIntent) {
3636
4139
  const result = await runLocalMission(missionIntent, {
3637
4140
  cwd: process.cwd(),
@@ -3649,6 +4152,12 @@ async function main() {
3649
4152
  return;
3650
4153
  }
3651
4154
 
4155
+ const startsChat = !printMode && (!prompt || args.includes('--chat'));
4156
+ const autoPick = autoEnabled && prompt && !startsChat
4157
+ ? autoLaneForMessage(prompt, { print: printMode })
4158
+ : null;
4159
+ if (autoPick) mode = autoPick.lane;
4160
+
3652
4161
  let business = null;
3653
4162
  if (businessSlug) {
3654
4163
  if (normalizeMode(mode) === 'code-fast') {
@@ -3677,7 +4186,7 @@ async function main() {
3677
4186
  executor: makeCloudExecutor({ token: creds.token, businessId: biz.businessId, workspaceId: biz.workspaceId, slug: businessSlug }),
3678
4187
  postToolResult
3679
4188
  };
3680
- console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
4189
+ if (!printMode) console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
3681
4190
  }
3682
4191
 
3683
4192
  try {
@@ -3720,8 +4229,75 @@ async function main() {
3720
4229
  return;
3721
4230
  }
3722
4231
 
4232
+ if (grantId) {
4233
+ const found = findWorkspaceApproval(grantId, process.cwd());
4234
+ if (!found) {
4235
+ console.error(`Pending approval not found: ${grantId}`);
4236
+ process.exit(1);
4237
+ }
4238
+ const command = found.record.payload && found.record.payload.command;
4239
+ const added = permissionGrants.addGrant({ command, workspaceRoot: process.cwd() });
4240
+ if (!added.ok) {
4241
+ console.error(`Not grantable: ${added.reason}`);
4242
+ process.exit(1);
4243
+ }
4244
+ console.log(`granted ${added.grant.grant_id}: ${added.grant.pattern.display} (this workspace, expires ${added.grant.constraints.expires_at.slice(0, 10)})`);
4245
+ await approveWorkspaceApproval(grantId, { cwd: process.cwd(), mode, output: process.stdout });
4246
+ return;
4247
+ }
4248
+
4249
+ if (listGrants) {
4250
+ const store = permissionGrants.loadGrants();
4251
+ const rows = store.grants.filter(g => g.status === 'active');
4252
+ if (!rows.length) {
4253
+ console.log('no active grants');
4254
+ } else {
4255
+ for (const g of rows) {
4256
+ 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)}`);
4257
+ }
4258
+ }
4259
+ return;
4260
+ }
4261
+
4262
+ if (revokeGrantId) {
4263
+ const revoked = permissionGrants.revokeGrant(revokeGrantId);
4264
+ if (!revoked.ok) {
4265
+ console.error(revoked.reason);
4266
+ process.exit(1);
4267
+ }
4268
+ console.log(`revoked ${revoked.grant.grant_id}: ${revoked.grant.pattern.display}`);
4269
+ return;
4270
+ }
4271
+
4272
+ if (syncGrantsFlag) {
4273
+ const { apiRequestJson } = require('./utils/api');
4274
+ const result = await permissionGrants.syncGrants({ apiRequestJson, token: authToken() });
4275
+ if (!result.ok) {
4276
+ console.error(`grant sync failed: ${result.reason}`);
4277
+ process.exit(1);
4278
+ }
4279
+ console.log(`synced grants: pushed:${result.pushed} pulled:${result.pulled} revoked:${result.revoked}${result.rejected ? ` rejected:${result.rejected}` : ''}`);
4280
+ return;
4281
+ }
4282
+
4283
+ if (printMode) {
4284
+ const payload = prompt
4285
+ ? await runHeadlessTurn(prompt, {
4286
+ mode,
4287
+ cwd: process.cwd(),
4288
+ route: route === 'auto' ? undefined : route,
4289
+ business,
4290
+ verify,
4291
+ autoLane: autoPick && autoPick.lane,
4292
+ autoReason: autoPick && autoPick.reason,
4293
+ })
4294
+ : { ok: false, model: modelForMode(mode), output: '', durationMs: 0, error: 'missing prompt' };
4295
+ console.log(JSON.stringify(payload));
4296
+ process.exit(payload.ok ? 0 : 1);
4297
+ }
4298
+
3723
4299
  if (!prompt || args.includes('--chat')) {
3724
- await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
4300
+ await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify, auto: autoEnabled });
3725
4301
  return;
3726
4302
  }
3727
4303
 
@@ -3737,7 +4313,9 @@ async function main() {
3737
4313
  : null;
3738
4314
  if (approvalRecord) console.log(formatAuxRow('approve', formatApprovalCommand(approvalRecord.id), process.stdout));
3739
4315
  // 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.
4316
+ // granted patterns redeem themselves, everything else gets the exact
4317
+ // redeem command for what the turn staged on disk.
4318
+ await autoRedeemGrantedApprovals(process.cwd(), turnStartedAt, { mode, output: process.stdout });
3741
4319
  writeWorkspaceApprovalHints(process.cwd(), turnStartedAt, process.stdout);
3742
4320
  console.log('');
3743
4321
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
@@ -3767,10 +4345,13 @@ module.exports = {
3767
4345
  approvalStorePath,
3768
4346
  approveStoredApproval,
3769
4347
  approveWorkspaceApproval,
4348
+ autoRedeemGrantedApprovals,
3770
4349
  findWorkspaceApproval,
3771
4350
  listWorkspaceApprovals,
3772
4351
  pendingWorkspaceApprovalsSince,
3773
4352
  backendBaseUrl,
4353
+ backendApiAllowlistEntry,
4354
+ backendApiToolDescriptor,
3774
4355
  backendStartCommand,
3775
4356
  backendUrl,
3776
4357
  buildCodeFastPayload,
@@ -3793,6 +4374,9 @@ module.exports = {
3793
4374
  denyStoredApproval,
3794
4375
  executeApprovalRequest,
3795
4376
  executeApprovalFromState,
4377
+ executeBackendApiTool,
4378
+ formatBackendApiApprovalPrompt,
4379
+ formatBackendApiReceipt,
3796
4380
  formatDoneLine,
3797
4381
  formatDuration,
3798
4382
  formatApprovalExecutionResult,
@@ -3819,11 +4403,13 @@ module.exports = {
3819
4403
  postApprovalExecution,
3820
4404
  postCodeFastTurn,
3821
4405
  postTurn,
4406
+ postTurnTimeoutMs,
3822
4407
  readApprovalStore,
3823
4408
  removeStoredApproval,
3824
4409
  renderStreamingMarkdown,
3825
4410
  renderTerminalMarkdown,
3826
4411
  resolveRoute,
4412
+ runHeadlessTurn,
3827
4413
  runBenchmark,
3828
4414
  runSelfTest,
3829
4415
  runtimeReadyForChat,