atris 3.30.8 → 3.31.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 (59) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +4 -2
  4. package/atris/atris.md +51 -19
  5. package/atris/skills/README.md +1 -0
  6. package/atris/skills/blocks/SKILL.md +134 -0
  7. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  8. package/atris/skills/youtube/SKILL.md +31 -11
  9. package/atris.md +7 -0
  10. package/ax +367 -93
  11. package/bin/atris.js +317 -155
  12. package/commands/autoland.js +319 -0
  13. package/commands/autopilot.js +94 -4
  14. package/commands/business.js +1 -1
  15. package/commands/clean.js +72 -9
  16. package/commands/codex-goal.js +72 -22
  17. package/commands/computer.js +48 -3
  18. package/commands/gm.js +1 -1
  19. package/commands/harvest.js +179 -0
  20. package/commands/init.js +1 -1
  21. package/commands/land.js +442 -0
  22. package/commands/loop-front.js +122 -4
  23. package/commands/member.js +519 -19
  24. package/commands/mission.js +3659 -282
  25. package/commands/play.js +1 -1
  26. package/commands/pulse.js +65 -7
  27. package/commands/run.js +3 -3
  28. package/commands/strings.js +301 -0
  29. package/commands/task.js +575 -102
  30. package/commands/truth.js +170 -0
  31. package/commands/xp.js +32 -8
  32. package/commands/youtube.js +72 -5
  33. package/decks/README.md +6 -12
  34. package/lib/auto-accept-certified.js +10 -0
  35. package/lib/autoland.js +283 -0
  36. package/lib/context-gatherer.js +0 -8
  37. package/lib/mission-artifact.js +504 -0
  38. package/lib/mission-room.js +846 -0
  39. package/lib/mission-runtime-loop.js +320 -0
  40. package/lib/next-moves.js +212 -6
  41. package/lib/pulse.js +74 -1
  42. package/lib/runner-command.js +20 -8
  43. package/lib/runs-prune.js +242 -0
  44. package/lib/task-proof.js +1 -1
  45. package/package.json +3 -3
  46. package/decks/atris-seed-pitch-v3.json +0 -118
  47. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  48. package/decks/atris-seed-pitch-v5.json +0 -109
  49. package/decks/atris-seed-pitch-v6.json +0 -137
  50. package/decks/atris-seed-pitch-v7.json +0 -133
  51. package/decks/mark-pincus-narrative.json +0 -102
  52. package/decks/mark-pincus-sourcery.json +0 -94
  53. package/decks/yash-applied-compute-detailed.json +0 -150
  54. package/decks/yash-applied-compute-generalist.json +0 -82
  55. package/decks/yash-applied-compute-narrative.json +0 -54
  56. package/lib/ax-chat-input.js +0 -164
  57. package/lib/ax-goal.js +0 -307
  58. package/lib/ax-prefs.js +0 -63
  59. package/lib/ax-shimmer.js +0 -63
package/ax CHANGED
@@ -9,6 +9,7 @@ const readline = require('readline');
9
9
  const crypto = require('crypto');
10
10
  const { Readable } = require('stream');
11
11
  const { loadCredentials } = require('./utils/auth');
12
+ const missionRuntime = require('./lib/mission-runtime-loop');
12
13
 
13
14
  const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
14
15
  const BACKEND = {
@@ -16,8 +17,8 @@ const BACKEND = {
16
17
  port: 8000,
17
18
  path: '/api/atris2/turn'
18
19
  };
19
- const DEFAULT_BACKEND_BASE = `http://${BACKEND.host}:${BACKEND.port}`;
20
20
  const CLOUD_BACKEND_BASE = 'https://api.atris.ai';
21
+ const LOCAL_BACKEND_BASE = `http://${BACKEND.host}:${BACKEND.port}`;
21
22
  const CODE_FAST = {
22
23
  path: '/api/cursor/turn',
23
24
  model: 'composer-2-5-fast',
@@ -125,11 +126,11 @@ function formatUsage() {
125
126
  ' ax [--max|--fast] --benchmark',
126
127
  '',
127
128
  'Modes:',
128
- ' --max highest reasoning, slowest turns',
129
- ' --pro deeper tool loop',
130
- ' --fast faster low-latency turns',
129
+ ' --max hosted Atris 2, highest reasoning, slowest turns',
130
+ ' --pro hosted Atris 2, deeper tool loop',
131
+ ' --fast hosted Atris 2, faster low-latency turns',
131
132
  ' --code-fast Atris Code Fast public lane',
132
- ' --local force local workspace tools with your own AtrisOS backend',
133
+ ' --local opt into local backend/workspace tools',
133
134
  ' --cloud force authenticated cloud connectors/chat',
134
135
  ' --business <slug> run tools on that business cloud workspace (EC2)',
135
136
  ' --verify <cmd> gate the turn on this command passing (default: no verifier)',
@@ -211,11 +212,12 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
211
212
  }
212
213
 
213
214
  function backendBaseUrl(options = {}) {
214
- const explicitBase = process.env.AX_BACKEND_URL
215
- || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL
216
- || process.env.OBELISK_ATRIS2_BACKEND_URL;
217
- if (explicitBase) return explicitBase.replace(/\/$/, '');
218
- return options.route === 'cloud' ? CLOUD_BACKEND_BASE : DEFAULT_BACKEND_BASE;
215
+ const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
216
+ if (process.env.AX_BACKEND_URL) return process.env.AX_BACKEND_URL.replace(/\/$/, '');
217
+ if (route === 'local') {
218
+ return (process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL || LOCAL_BACKEND_BASE).replace(/\/$/, '');
219
+ }
220
+ return (process.env.OBELISK_ATRIS2_BACKEND_URL || process.env.ATRIS_API_BASE || CLOUD_BACKEND_BASE).replace(/\/$/, '');
219
221
  }
220
222
 
221
223
  function backendUrl(options = {}) {
@@ -268,9 +270,13 @@ function buildRunProfile(options = {}) {
268
270
  reasoning: 'Composer 2.5 fast lane; charges 10 credits per public turn'
269
271
  };
270
272
  }
271
- const profileMessage = options.message || 'what files are here?';
272
- const route = resolveRoute(profileMessage, options);
273
- const payload = buildPayload(profileMessage, { mode, cwd, route });
273
+ const route = resolveRoute(options.message || 'doctor', options);
274
+ const payload = buildPayload(options.message || 'doctor', { mode, cwd, route });
275
+ const reasoningByMode = mode === 'max'
276
+ ? 'Max workspace tool loop uses high reasoning effort'
277
+ : mode === 'pro'
278
+ ? 'Pro workspace tool loop uses API default medium'
279
+ : 'Fast workspace tool loop uses provider default';
274
280
  return {
275
281
  endpoint: backendUrl({ route }),
276
282
  mode,
@@ -278,13 +284,11 @@ function buildRunProfile(options = {}) {
278
284
  model: payload.model,
279
285
  workspace_path: payload.workspace_path || 'cloud',
280
286
  max_turns: payload.max_turns,
287
+ member_slug: 'ax',
288
+ bypass_permissions: false,
281
289
  streaming: true,
282
290
  runtime: route === 'cloud' ? 'authenticated cloud connectors/chat' : 'local workspace',
283
- reasoning: mode === 'max'
284
- ? 'backend reports run row; Max workspace tool loop uses high reasoning effort'
285
- : mode === 'pro'
286
- ? 'backend reports run row; Pro workspace tool loop uses API default medium'
287
- : 'backend reports run row; Fast workspace tool loop uses provider default'
291
+ reasoning: `${route === 'cloud' ? 'Atris cloud service' : 'local backend'}; ${reasoningByMode}`
288
292
  };
289
293
  }
290
294
 
@@ -303,18 +307,23 @@ function formatRunProfile(profile, options = {}) {
303
307
  }
304
308
 
305
309
  async function buildRuntimeHealth(options = {}) {
310
+ const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
306
311
  const healthRes = options.healthRes
307
312
  ? await Promise.resolve(options.healthRes)
308
- : await requestJson(ATRIS2_HEALTH_PATH, { token: '', timeoutMs: options.timeoutMs || 1500, route: options.route || 'local' });
313
+ : await requestJson(ATRIS2_HEALTH_PATH, { token: route === 'cloud' ? authToken() : '', timeoutMs: options.timeoutMs || 1500, route });
309
314
  const data = healthRes.ok && healthRes.data && typeof healthRes.data === 'object' ? healthRes.data : {};
310
315
  const models = Array.isArray(data.models) ? data.models : [];
311
316
  const fast = models.find(row => row && row.id === 'atris:fast') || null;
317
+ const status = Number(healthRes.status || 0);
318
+ const authRequired = status === 401 || status === 403;
312
319
  return {
313
320
  schema: 'ax.runtime_health.v1',
321
+ route,
314
322
  backend: {
315
323
  ready: Boolean(healthRes.ok && data.ready),
316
- reachable: Boolean(healthRes.ok),
317
- status: healthRes.status || 0,
324
+ reachable: Boolean(healthRes.ok || status > 0),
325
+ auth_required: authRequired,
326
+ status,
318
327
  error: healthRes.error || '',
319
328
  },
320
329
  fast: {
@@ -331,14 +340,19 @@ async function buildRuntimeHealth(options = {}) {
331
340
  function formatRuntimeHealth(health, options = {}) {
332
341
  const backendReady = health && health.backend && health.backend.ready;
333
342
  const backendReachable = health && health.backend && health.backend.reachable;
343
+ const authRequired = health && health.backend && health.backend.auth_required;
334
344
  const fastReady = health && health.fast && health.fast.ready;
335
345
  const permissionReady = health && health.permissions && health.permissions.ready;
336
346
  const rows = [
337
- ['backend', backendReady ? 'ready' : backendReachable ? 'not ready' : 'offline'],
347
+ ['backend', authRequired ? 'auth required' : backendReady ? 'ready' : backendReachable ? 'not ready' : 'offline'],
338
348
  ['fast', fastReady ? 'ready' : 'not ready'],
339
- ['approvals', permissionReady ? 'ready' : 'offline'],
349
+ ['approvals', authRequired ? 'auth required' : permissionReady ? 'ready' : 'offline'],
340
350
  ];
341
- if (!backendReachable) rows.push(['fix', 'local backend offline; use hosted mode or start your AtrisOS backend']);
351
+ if (authRequired) {
352
+ rows.push(['fix', 'run atris login, then rerun ax --doctor']);
353
+ } else if (!backendReachable) {
354
+ rows.push(['fix', health?.route === 'local' ? 'start local backend, then rerun ax --doctor' : 'Atris cloud unavailable; retry or run ax --doctor']);
355
+ }
342
356
  return rows.map(([label, value]) => formatAuxRow(label, value, options)).join('\n');
343
357
  }
344
358
 
@@ -527,7 +541,7 @@ function cachedIntegrationStatus(options = {}) {
527
541
 
528
542
  async function buildConnectionContext(options = {}) {
529
543
  const token = options.token || authToken();
530
- const localStatusUserId = isLoopbackBackend({ route: options.route }) ? authUserId() : '';
544
+ const localStatusUserId = isLoopbackBackend() ? authUserId() : '';
531
545
  const statusPath = localStatusUserId
532
546
  ? `${ATRIS2_CONNECTION_STATUS_PATH}?connection_user_id=${encodeURIComponent(localStatusUserId)}`
533
547
  : CONNECTION_STATUS_PATH;
@@ -535,9 +549,9 @@ async function buildConnectionContext(options = {}) {
535
549
  options.statusRes
536
550
  ? Promise.resolve(options.statusRes)
537
551
  : localStatusUserId || token
538
- ? requestJson(statusPath, { token: localStatusUserId ? '' : token, route: options.route })
552
+ ? requestJson(statusPath, { token: localStatusUserId ? '' : token })
539
553
  : Promise.resolve({ ok: false, data: null }),
540
- options.contractRes ? Promise.resolve(options.contractRes) : requestJson(CONNECTION_CAPABILITIES_PATH, { token: '', route: options.route })
554
+ options.contractRes ? Promise.resolve(options.contractRes) : requestJson(CONNECTION_CAPABILITIES_PATH, { token: '' })
541
555
  ]);
542
556
  const statusData = statusRes.ok && statusRes.data && typeof statusRes.data === 'object' ? statusRes.data : {};
543
557
  const backendStatuses = statusData.statuses && typeof statusData.statuses === 'object' ? statusData.statuses : statusData;
@@ -598,15 +612,7 @@ function connectorWriteIntent(message) {
598
612
  }
599
613
 
600
614
  function workspaceIntent(message) {
601
- return Boolean(workspaceIntentReason(message));
602
- }
603
-
604
- function workspaceIntentReason(message) {
605
- const text = String(message || '');
606
- if (/\b(files?|folders?|repo|workspace|project|directory|tree|src|source|code|diff|git|backend|frontend|atris task|atris xp|xp game|career xp|agentxp|todo|map|tests?)\b/i.test(text)) return 'prompt mentions workspace, file, code, or repo terms';
607
- if (/(^|\s|["'`])(?:\.{0,2}\/)?[\w.-]+\/[\w./-]+|[\w.-]+\.(?:js|jsx|ts|tsx|mjs|cjs|json|md|py|rb|go|rs|java|css|scss|html|yml|yaml|toml|env|txt)\b/i.test(text)) return 'prompt names a file path or filename';
608
- if (/\b(read|open|inspect|search|grep|find|locate|where|edit|write|change|modify|patch|fix|test|build|refactor)\b.*\b(this|file|folder|repo|workspace|project|directory|module|component|package|readme|source|code|branch|suite|build|bug)\b/i.test(text)) return 'prompt asks to operate on workspace material';
609
- return '';
615
+ return /\b(files?|folders?|repo|workspace|project|directory|tree|read|open|inspect|search|grep|find|locate|where|edit|write|change|modify|patch|fix|test|tests?|build|src|source|code|diff|git|backend|frontend|atris task|atris xp|xp game|career xp|agentxp|todo|map)\b/i.test(message || '');
610
616
  }
611
617
 
612
618
  function githubWorkspaceIntent(message) {
@@ -616,22 +622,9 @@ function githubWorkspaceIntent(message) {
616
622
  }
617
623
 
618
624
  function resolveRoute(message, options = {}) {
619
- return routeDecision(message, options).route;
620
- }
621
-
622
- function routeDecision(message, options = {}) {
623
- if (options.route === 'local' || options.forceLocal) return { route: 'local', reason: 'explicit --local' };
624
- if (options.route === 'cloud' || options.forceCloud) return { route: 'cloud', reason: 'explicit --cloud' };
625
- if (githubWorkspaceIntent(message)) return { route: 'local', reason: 'prompt asks for GitHub repo mutation' };
626
- const reason = workspaceIntentReason(message);
627
- if (mentionsConnector(message) && !reason) return { route: 'cloud', reason: 'connector request uses hosted Atris' };
628
- if (reason) return { route: 'local', reason };
629
- return { route: 'cloud', reason: 'default hosted chat' };
630
- }
631
-
632
- function shouldPreflightRuntime(route, options = {}, mode = 'fast') {
633
- if (options.skipRuntimePreflight || options.turnFunction || options.business || normalizeMode(mode) === 'code-fast') return false;
634
- return route !== 'cloud';
625
+ if (options.route === 'local' || options.forceLocal) return 'local';
626
+ if (options.route === 'cloud' || options.forceCloud) return 'cloud';
627
+ return 'cloud';
635
628
  }
636
629
 
637
630
  function normalizeMode(mode) {
@@ -705,6 +698,161 @@ function formatDoneLine(ms, credits) {
705
698
  return `${base} —`;
706
699
  }
707
700
 
701
+ function stripMissionQuotes(value) {
702
+ const text = String(value || '').trim();
703
+ if (text.length >= 2) {
704
+ const first = text[0];
705
+ const last = text[text.length - 1];
706
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
707
+ return text.slice(1, -1).trim();
708
+ }
709
+ }
710
+ return text;
711
+ }
712
+
713
+ function extractMissionFlag(text, flagName) {
714
+ const source = String(text || '');
715
+ const inline = new RegExp(`(^|\\s)${flagName}=([^\\s]+)(?=\\s|$)`).exec(source);
716
+ if (inline) {
717
+ return {
718
+ value: inline[2],
719
+ text: `${source.slice(0, inline.index)}${inline[1] || ''}${source.slice(inline.index + inline[0].length)}`.replace(/\s+/g, ' ').trim(),
720
+ };
721
+ }
722
+ const split = new RegExp(`(^|\\s)${flagName}\\s+([^\\s]+)(?=\\s|$)`).exec(source);
723
+ if (split) {
724
+ return {
725
+ value: split[2],
726
+ text: `${source.slice(0, split.index)}${split[1] || ''}${source.slice(split.index + split[0].length)}`.replace(/\s+/g, ' ').trim(),
727
+ };
728
+ }
729
+ return { value: null, text: source.trim() };
730
+ }
731
+
732
+ function missionRunIntentFromMessage(message) {
733
+ return missionRuntime.missionRunIntentFromMessage(message);
734
+ }
735
+
736
+ function loadMissionRecord(cwd, missionId) {
737
+ if (!missionId) return null;
738
+ try {
739
+ const statePath = path.join(cwd || process.cwd(), '.atris', 'state', 'missions.jsonl');
740
+ if (!fs.existsSync(statePath)) return null;
741
+ const lines = fs.readFileSync(statePath, 'utf8').split('\n').filter(Boolean);
742
+ for (let i = lines.length - 1; i >= 0; i--) {
743
+ let record;
744
+ try {
745
+ record = JSON.parse(lines[i]);
746
+ } catch (_) {
747
+ continue;
748
+ }
749
+ const id = record && (record.id || record.mission_id);
750
+ if (id === missionId) return record;
751
+ }
752
+ } catch (_) {
753
+ return null;
754
+ }
755
+ return null;
756
+ }
757
+
758
+ function currentAtrisGoal(cwd = process.cwd()) {
759
+ try {
760
+ const statePath = path.join(cwd, '.atris', 'state', 'atris_goal.json');
761
+ if (!fs.existsSync(statePath)) return null;
762
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
763
+ const goal = state && state.goal;
764
+ if (!goal || !goal.objective) return null;
765
+ if (String(goal.runner || '').trim().toLowerCase() === 'codex_goal') return null;
766
+ const mission = loadMissionRecord(cwd, goal.mission_id);
767
+ return {
768
+ ...goal,
769
+ mission_status: mission?.status || goal.mission_status,
770
+ created_at: goal.created_at || mission?.created_at || null,
771
+ updated_at: goal.updated_at || mission?.updated_at || null,
772
+ completed_at: goal.completed_at || mission?.completed_at || null,
773
+ next_command: goal.next_command || mission?.next_action || null,
774
+ };
775
+ } catch (_) {
776
+ return null;
777
+ }
778
+ }
779
+
780
+ function goalElapsedSeconds(goal) {
781
+ const started = Date.parse(goal?.created_at || '');
782
+ if (!Number.isFinite(started)) return null;
783
+ const ended = Date.parse(goal?.completed_at || '') || Date.now();
784
+ return Math.max(0, Math.floor((ended - started) / 1000));
785
+ }
786
+
787
+ function goalAchieved(goal) {
788
+ return ['complete', 'completed', 'achieved'].includes(String(goal?.mission_status || goal?.status || '').toLowerCase());
789
+ }
790
+
791
+ function formatAtrisGoal(goal) {
792
+ if (!goal) return '';
793
+ const objective = String(goal.objective || '').length > 92
794
+ ? `${String(goal.objective).slice(0, 89)}...`
795
+ : String(goal.objective || '');
796
+ const elapsed = goalElapsedSeconds(goal);
797
+ const missionParts = [
798
+ goal.mission_id || '?',
799
+ goal.mission_status || '?',
800
+ goal.runner || 'mission',
801
+ ];
802
+ if (elapsed !== null) missionParts.push(`elapsed ${formatSeconds(elapsed)}`);
803
+ missionParts.push(`achieved ${goalAchieved(goal) ? 'yes' : 'no'}`);
804
+ return [
805
+ `Atris goal: ${objective}`,
806
+ `Mission: ${missionParts.join(' · ')}`,
807
+ goal.next_command ? `Next: ${goal.next_command}` : '',
808
+ ].filter(Boolean).join('\n');
809
+ }
810
+
811
+ function writeAtrisGoalBanner(output, cwd = process.cwd()) {
812
+ const text = formatAtrisGoal(currentAtrisGoal(cwd));
813
+ if (!text) return false;
814
+ output.write(`${text}\n\n`);
815
+ return true;
816
+ }
817
+
818
+ function maybeWriteAtrisGoalBanner(output, cwd = process.cwd()) {
819
+ if (process.env.AX_SHOW_GOAL_BANNER !== '1') return false;
820
+ return writeAtrisGoalBanner(output, cwd);
821
+ }
822
+
823
+ function formatMissionStartReceipt(payload) {
824
+ const mission = payload?.mission || {};
825
+ const goal = payload?.atris_goal_state?.goal || {};
826
+ const receiptGoal = {
827
+ ...goal,
828
+ objective: goal.objective || mission.objective,
829
+ mission_id: goal.mission_id || mission.id,
830
+ mission_status: goal.mission_status || mission.status,
831
+ runner: goal.runner || mission.runner,
832
+ created_at: goal.created_at || mission.created_at,
833
+ updated_at: goal.updated_at || mission.updated_at,
834
+ completed_at: goal.completed_at || mission.completed_at,
835
+ next_command: goal.next_command || payload?.next_command || mission.next_action,
836
+ };
837
+ const elapsed = goalElapsedSeconds(receiptGoal);
838
+ return [
839
+ 'Atris mission started',
840
+ `Goal: ${receiptGoal.objective || '?'}`,
841
+ `Mission: ${receiptGoal.mission_id || '?'} · ${receiptGoal.mission_status || '?'} · ${receiptGoal.runner || 'atris2'}`,
842
+ `Elapsed: ${elapsed === null ? '0s' : formatSeconds(elapsed)}`,
843
+ `Achieved: ${goalAchieved(receiptGoal) ? 'yes' : 'no'}`,
844
+ receiptGoal.next_command ? `Next: ${receiptGoal.next_command}` : '',
845
+ ].filter(Boolean).join('\n');
846
+ }
847
+
848
+ async function runLocalMission(intent, options = {}) {
849
+ return missionRuntime.runRuntimeMissionLoop(intent, {
850
+ cwd: options.cwd || process.cwd(),
851
+ cliPath: path.join(__dirname, 'bin', 'atris.js'),
852
+ onProgress: options.onProgress,
853
+ });
854
+ }
855
+
708
856
  function useColor(options = {}) {
709
857
  if (options.color === false) return false;
710
858
  if (process.env.NO_COLOR) return false;
@@ -1017,6 +1165,51 @@ function formatApprovalTime(value) {
1017
1165
  return `${months[Number(month) - 1] || month} ${Number(day)}, ${year} at ${hour12}:${minute} ${ampm}`;
1018
1166
  }
1019
1167
 
1168
+ function previewText(value, limit = 80) {
1169
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
1170
+ }
1171
+
1172
+ function previewContact(value) {
1173
+ if (!value) return '';
1174
+ if (typeof value === 'string') return previewText(value, 120);
1175
+ if (typeof value !== 'object') return previewText(value, 120);
1176
+ return previewText(value.email || value.address || value.value || value.name || '', 120);
1177
+ }
1178
+
1179
+ function previewContacts(value) {
1180
+ const values = Array.isArray(value)
1181
+ ? value
1182
+ : String(value || '').includes(',')
1183
+ ? String(value).split(',')
1184
+ : [value];
1185
+ return values
1186
+ .map(previewContact)
1187
+ .filter(Boolean)
1188
+ .slice(0, 3)
1189
+ .join(', ');
1190
+ }
1191
+
1192
+ function gmailApprovalDetails(receipt, request, approvalEvent = null) {
1193
+ const payload = request && request.payload && typeof request.payload === 'object' ? request.payload : {};
1194
+ const taskPreview = receipt && receipt.task_preview && typeof receipt.task_preview === 'object' ? receipt.task_preview : {};
1195
+ const slots = taskPreview.slots && typeof taskPreview.slots === 'object' ? taskPreview.slots : {};
1196
+ const preview = approvalEvent && approvalEvent.preview && typeof approvalEvent.preview === 'object' ? approvalEvent.preview : {};
1197
+ return {
1198
+ to: previewContacts(payload.to || payload.recipient || payload.recipients || payload.email || slots.to || slots.recipient || preview.to),
1199
+ subject: previewText(payload.subject || payload.title || slots.subject || preview.subject, 80),
1200
+ };
1201
+ }
1202
+
1203
+ function isGmailApproval(request, approvalEvent = null, accept = {}) {
1204
+ const connector = canonicalConnectorId((request && request.connector) || (approvalEvent && approvalEvent.tool));
1205
+ const action = String(
1206
+ (request && (request.executor_action_type || request.action_type || request.action))
1207
+ || accept.task
1208
+ || ''
1209
+ ).toLowerCase().replace(/_/g, ' ');
1210
+ return connector === 'gmail' && /\b(send|reply|compose)\b/.test(action);
1211
+ }
1212
+
1020
1213
  function formatApprovalReceipt(receipt) {
1021
1214
  if (!receipt || typeof receipt !== 'object') return null;
1022
1215
  const events = Array.isArray(receipt.tool_events) ? receipt.tool_events : [];
@@ -1034,6 +1227,15 @@ function formatApprovalReceipt(receipt) {
1034
1227
  return `Approval needed before creation: calendar event "${String(title).slice(0, 80)}"${when ? ` ${when}` : ''}`;
1035
1228
  }
1036
1229
 
1230
+ if (isGmailApproval(request, approvalEvent, accept)) {
1231
+ const details = gmailApprovalDetails(receipt, request, approvalEvent);
1232
+ const parts = [
1233
+ details.to ? `to ${details.to}` : '',
1234
+ details.subject ? `subject "${details.subject}"` : '',
1235
+ ].filter(Boolean);
1236
+ return `Approval needed before sending Gmail${parts.length ? `: ${parts.join(' ')}` : ''} (body hidden until approved)`;
1237
+ }
1238
+
1037
1239
  const connector = String(request.connector || approvalEvent.tool || 'connector').replace(/_/g, ' ');
1038
1240
  const action = String(request.action || actionType || 'action').replace(/_/g, ' ');
1039
1241
  return `Approval needed before ${action}: ${connector}`;
@@ -1062,6 +1264,16 @@ function formatTaskPreviewPlan(receipt) {
1062
1264
  if (!taskPreview) return null;
1063
1265
  const task = String(taskPreview.task || '').trim();
1064
1266
  const slots = taskPreview.slots && typeof taskPreview.slots === 'object' ? taskPreview.slots : {};
1267
+ const approvalEvent = taskPreviewApprovalEvent(receipt);
1268
+ const request = approvalEvent && approvalEvent.approval_request;
1269
+ if (isGmailApproval(request, approvalEvent, receipt.task_accept_receipt || {}) || task === 'gmail.send_message') {
1270
+ const details = gmailApprovalDetails(receipt, request || { payload: slots }, approvalEvent);
1271
+ return [
1272
+ 'send Gmail',
1273
+ details.to ? `to ${details.to}` : '',
1274
+ details.subject ? `subject "${details.subject}"` : '',
1275
+ ].filter(Boolean).join(' ');
1276
+ }
1065
1277
  if (task === 'calendar.create_event') {
1066
1278
  const title = taskPreviewTitle(receipt);
1067
1279
  const start = slots.start || (receipt.task_accept_receipt && receipt.task_accept_receipt.start);
@@ -1345,11 +1557,11 @@ function creditsFromApprovalExecution(response) {
1345
1557
  return Number.isFinite(credits) && credits > 0 ? credits : null;
1346
1558
  }
1347
1559
 
1348
- async function postApprovalExecution(approvalRequest, { model = 'atris:fast', token = authToken(), route = 'cloud' } = {}) {
1560
+ async function postApprovalExecution(approvalRequest, { model = 'atris:fast', token = authToken() } = {}) {
1349
1561
  return postJson(APPROVAL_EXECUTE_PATH, {
1350
1562
  model,
1351
1563
  approval_request: approvalRequest,
1352
- }, { token, route });
1564
+ }, { token });
1353
1565
  }
1354
1566
 
1355
1567
  async function executeApprovalRequest(approvalRequest, {
@@ -1564,12 +1776,30 @@ function buildMessage(message, history = []) {
1564
1776
  return String(message || '').trim();
1565
1777
  }
1566
1778
 
1779
+ function createTurnId() {
1780
+ return `axt_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
1781
+ }
1782
+
1783
+ function connectorTurnPolicy(message, options = {}) {
1784
+ if (!connectorWriteIntent(message)) return null;
1785
+ const policy = {
1786
+ schema: 'ax.connector_turn.v1',
1787
+ scope: 'current_turn_only',
1788
+ policy: 'preview_then_explicit_approval',
1789
+ writes_require_approval: true,
1790
+ };
1791
+ if (options.turnId) policy.turn_id = String(options.turnId);
1792
+ if (options.conversationId) policy.conversation_id = String(options.conversationId);
1793
+ return policy;
1794
+ }
1795
+
1567
1796
  function buildPayload(message, options = {}) {
1568
1797
  const mode = normalizeMode(options.mode);
1569
1798
  const route = options.business ? 'local' : resolveRoute(message, options);
1570
1799
  const local = route !== 'cloud';
1571
1800
  const verifyCommand = String(options.verify || '').trim();
1572
1801
  const previousMessages = structuredHistory(options.history || []);
1802
+ const turnId = String(options.turnId || '').trim();
1573
1803
  const payload = {
1574
1804
  message: buildMessage(message, options.history || []),
1575
1805
  model: modelForMode(mode),
@@ -1582,6 +1812,14 @@ function buildPayload(message, options = {}) {
1582
1812
  if (options.conversationId) {
1583
1813
  payload.conversation_id = String(options.conversationId);
1584
1814
  }
1815
+ if (turnId) {
1816
+ payload.turn_id = turnId;
1817
+ }
1818
+ const connectorPolicy = !local ? connectorTurnPolicy(message, { ...options, turnId }) : null;
1819
+ if (connectorPolicy) {
1820
+ payload.connector_turn = connectorPolicy;
1821
+ payload.external_action_policy = 'preview_then_explicit_approval';
1822
+ }
1585
1823
  if (options.business) {
1586
1824
  // Business cloud workspace: the model loop stays on the backend and every
1587
1825
  // file/bash tool call relays here, executing on the business EC2 via the
@@ -1732,23 +1970,25 @@ function handleEvent(event, state, output) {
1732
1970
  async function postTurn(message, options = {}) {
1733
1971
  const route = options.business ? 'local' : resolveRoute(message, options);
1734
1972
  const local = route !== 'cloud';
1973
+ const endpointRoute = options.business ? 'cloud' : route;
1735
1974
  const token = authToken();
1975
+ const turnId = options.turnId || createTurnId();
1736
1976
  const shouldSendConnectionContext = options.connectionContext
1737
1977
  || route === 'cloud'
1738
1978
  || mentionsConnector(message)
1739
1979
  || /\b(can you use|can you access|connected|connections?|integrations?|tools?|capabilities)\b/i.test(message || '');
1740
1980
  const connectionContext = options.connectionContext || (shouldSendConnectionContext
1741
- ? await buildConnectionContext({ token, localWorkspace: local, route })
1981
+ ? await buildConnectionContext({ token, localWorkspace: local })
1742
1982
  : null);
1743
- const connectionUserId = !local && isLoopbackBackend({ route }) ? authUserId() : '';
1744
- const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId });
1983
+ const connectionUserId = !local && isLoopbackBackend({ route: endpointRoute }) ? authUserId() : '';
1984
+ const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId, turnId });
1745
1985
  const postData = JSON.stringify(payload);
1746
1986
  const output = options.output || process.stdout;
1747
1987
  // Relayed business turns wait on EC2 terminal calls (up to 60s each) with no
1748
1988
  // SSE traffic in between, so the socket-idle timeout needs more headroom.
1749
1989
  const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
1750
1990
  const timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
1751
- const turnUrl = new URL(backendUrl({ route }));
1991
+ const turnUrl = new URL(backendUrl({ route: endpointRoute }));
1752
1992
  const transport = turnUrl.protocol === 'https:' ? https : http;
1753
1993
  const state = {
1754
1994
  events: [],
@@ -1769,7 +2009,7 @@ async function postTurn(message, options = {}) {
1769
2009
  relay: options.business ? {
1770
2010
  chain: Promise.resolve(),
1771
2011
  execute: options.business.executor,
1772
- post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl())
2012
+ post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl({ route: 'cloud' }))
1773
2013
  } : null
1774
2014
  };
1775
2015
 
@@ -1989,23 +2229,27 @@ async function chat(options = {}) {
1989
2229
 
1990
2230
  output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
1991
2231
  if (logger) output.write(`${formatAuxRow('log', formatPathSubject(logger.path, output), output)}\n\n`);
1992
-
1993
- const runtimePreflight = { localReady: false };
1994
- const ensureRuntimeReady = async (routeForTurn = 'local', decision = {}) => {
1995
- if (!shouldPreflightRuntime(routeForTurn, options, mode)) {
2232
+ maybeWriteAtrisGoalBanner(output, cwd);
2233
+
2234
+ const runtimePreflight = new Set();
2235
+ const ensureRuntimeReady = async (routeForTurn = 'cloud') => {
2236
+ const endpointRoute = routeForTurn === 'local' ? 'local' : 'cloud';
2237
+ const key = `${normalizeMode(mode)}:${endpointRoute}`;
2238
+ if (runtimePreflight.has(key)) return true;
2239
+ if (options.skipRuntimePreflight || options.turnFunction || options.business || normalizeMode(mode) === 'code-fast') {
2240
+ runtimePreflight.add(key);
1996
2241
  return true;
1997
2242
  }
1998
- if (runtimePreflight.localReady) return true;
1999
2243
  const startedAt = Date.now();
2000
2244
  const health = options.runtimeHealth
2001
- ? await Promise.resolve(options.runtimeHealth({ mode }))
2002
- : await buildRuntimeHealth({ timeoutMs: options.preflightTimeoutMs || 1200, route: 'local' });
2245
+ ? await Promise.resolve(options.runtimeHealth({ mode, route: endpointRoute }))
2246
+ : await buildRuntimeHealth({ timeoutMs: options.preflightTimeoutMs || 1200, route: endpointRoute });
2003
2247
  if (runtimeReadyForChat(health, mode)) {
2004
- runtimePreflight.localReady = true;
2248
+ runtimePreflight.add(key);
2005
2249
  return true;
2006
2250
  }
2007
2251
  output.write(`${formatRuntimeHealth(health, output)}\n\n`);
2008
- output.write(`${formatBackendHint({ routeReason: decision.reason, explicitLocal: decision.reason === 'explicit --local' })}\n`);
2252
+ output.write(`${formatBackendHint({ route: endpointRoute })}\n`);
2009
2253
  output.write(`${formatDoneLine(Date.now() - startedAt)}\n\n`);
2010
2254
  return false;
2011
2255
  };
@@ -2035,6 +2279,26 @@ async function chat(options = {}) {
2035
2279
 
2036
2280
  if (logger) logger.write(`${formatPrompt(mode)}${trimmed}\n`);
2037
2281
  output.write('\n');
2282
+
2283
+ const missionIntent = missionRunIntentFromMessage(trimmed);
2284
+ if (missionIntent) {
2285
+ const result = await runLocalMission(missionIntent, {
2286
+ cwd,
2287
+ onProgress: (line) => {
2288
+ if (/^pursuing /.test(line)) output.write('Pursuing goal...\n');
2289
+ else if (/^still_running /.test(line)) {
2290
+ const seconds = Number(String(line).split(/\s+/)[1]);
2291
+ output.write(`Still pursuing... ${formatSeconds(seconds)}\n`);
2292
+ } else if (/^landing /.test(line)) output.write('Landing goal...\n');
2293
+ else if (/^\[tick /.test(line)) output.write(`${line}\n`);
2294
+ },
2295
+ });
2296
+ output.write(`${result.text || 'Mission command failed.'}\n\n`);
2297
+ history.push({ role: 'user', content: trimmed });
2298
+ history.push({ role: 'assistant', content: result.text || 'Mission command failed.' });
2299
+ return false;
2300
+ }
2301
+
2038
2302
  const pendingApprovalRequest = latestPendingApprovalRequest(history);
2039
2303
  if (pendingApprovalRequest && normalizeMode(mode) !== 'code-fast' && messageCancelsPendingApproval(trimmed)) {
2040
2304
  const startedAt = Date.now();
@@ -2091,13 +2355,13 @@ async function chat(options = {}) {
2091
2355
  }
2092
2356
 
2093
2357
  const pendingTaskPreview = latestPendingTaskPreview(history);
2094
- const decision = pendingTaskPreview
2095
- ? { route: 'cloud', reason: 'pending approval stays on hosted Atris' }
2096
- : routeDecision(trimmed, { route: options.route });
2097
- const route = decision.route;
2098
- if (!(await ensureRuntimeReady(route, decision))) return false;
2358
+ const route = pendingTaskPreview ? 'cloud' : options.route;
2359
+ maybeWriteAtrisGoalBanner(output, cwd);
2360
+ const turnRoute = options.business ? 'cloud' : resolveRoute(trimmed, { route });
2361
+ if (!(await ensureRuntimeReady(turnRoute))) return false;
2099
2362
  const turnFunction = options.turnFunction || turnFunctionForMode(mode);
2100
- const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId });
2363
+ const turnId = options.turnId || createTurnId();
2364
+ const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId, turnId });
2101
2365
  if (result.output && !result.output.endsWith('\n')) output.write('\n');
2102
2366
  const approvalExecution = normalizeMode(mode) === 'code-fast'
2103
2367
  ? null
@@ -2158,16 +2422,14 @@ function printBackendHint(options = {}) {
2158
2422
  console.log(formatBackendHint(options));
2159
2423
  }
2160
2424
 
2161
- function backendStartCommand(options = {}) {
2162
- if (options.explicitLocal) return 'Start your AtrisOS backend, or retry with --cloud for hosted chat.';
2163
- return 'Start your AtrisOS backend for local workspace, or retry with --cloud for hosted cloud scratch.';
2425
+ function backendStartCommand() {
2426
+ return `cd ${process.env.HOME}/arena/atrisos-backend/backend && ATRIS2_ALLOW_LOCAL_WORKSPACE=1 ENVIRONMENT=development ENV=development ../venv/bin/uvicorn main:app --host ${BACKEND.host} --port ${BACKEND.port}`;
2164
2427
  }
2165
2428
 
2166
2429
  function formatBackendHint(options = {}) {
2167
- const lines = ['This requires local workspace mode, but no AtrisOS backend is running.'];
2168
- if (options.routeReason && !options.explicitLocal) lines.push(`Routed local because ${options.routeReason}.`);
2169
- lines.push(backendStartCommand(options));
2170
- return lines.join('\n');
2430
+ const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
2431
+ if (route === 'local') return `Start backend:\n${backendStartCommand()}`;
2432
+ return 'Atris cloud did not respond. Check login/network, then rerun ax --doctor.';
2171
2433
  }
2172
2434
 
2173
2435
  function bufferedOutput() {
@@ -2270,7 +2532,7 @@ async function runSelfTest(options = {}) {
2270
2532
  assertSelfTest(!/atris:|fireworks|glm|secret|token/i.test(text), 'doctor output leaked provider/debug text');
2271
2533
  }, results, output);
2272
2534
 
2273
- await runSelfTestCase('chat backend offline preflight', async () => {
2535
+ await runSelfTestCase('chat cloud offline preflight', async () => {
2274
2536
  const previousAuto = process.env.AX_AUTO_LOG;
2275
2537
  process.env.AX_AUTO_LOG = '0';
2276
2538
  const chunks = [];
@@ -2285,7 +2547,6 @@ async function runSelfTest(options = {}) {
2285
2547
  try {
2286
2548
  await chat({
2287
2549
  mode: 'fast',
2288
- route: 'local',
2289
2550
  cwd: os.tmpdir(),
2290
2551
  input: Readable.from(['hi\n', 'exit\n']),
2291
2552
  output: chatOutput,
@@ -2306,9 +2567,8 @@ async function runSelfTest(options = {}) {
2306
2567
  const text = chunks.join('');
2307
2568
  assertSelfTest(healthCalls === 1, 'offline preflight repeated unexpectedly');
2308
2569
  assertSelfTest(/backend\s+offline/.test(text), 'offline backend status missing');
2309
- assertSelfTest(/This requires local workspace mode, but no AtrisOS backend is running\./.test(text), 'local backend hint missing');
2310
- assertSelfTest(/retry with --cloud for hosted chat/.test(text), 'cloud retry hint missing');
2311
- assertSelfTest(!/\/Users\/keshavrao/.test(text), 'local backend hint leaked a developer path');
2570
+ assertSelfTest(/Atris cloud unavailable/.test(text), 'cloud offline guidance missing');
2571
+ assertSelfTest(!/Start backend:|uvicorn main:app/.test(text), 'cloud default showed local backend instructions');
2312
2572
  assertSelfTest(!/secret-token/.test(text), 'offline preflight leaked error detail');
2313
2573
  }, results, output);
2314
2574
 
@@ -2947,6 +3207,24 @@ async function main() {
2947
3207
  .join(' ')
2948
3208
  .trim();
2949
3209
 
3210
+ const missionIntent = missionRunIntentFromMessage(prompt);
3211
+ if (missionIntent) {
3212
+ const result = await runLocalMission(missionIntent, {
3213
+ cwd: process.cwd(),
3214
+ onProgress: (line) => {
3215
+ if (/^pursuing /.test(line)) console.log('Pursuing goal...');
3216
+ else if (/^still_running /.test(line)) {
3217
+ const seconds = Number(String(line).split(/\s+/)[1]);
3218
+ console.log(`Still pursuing... ${formatSeconds(seconds)}`);
3219
+ } else if (/^landing /.test(line)) console.log('Landing goal...');
3220
+ else if (/^\[tick /.test(line)) console.log(line);
3221
+ },
3222
+ });
3223
+ console.log(result.text || 'Mission command failed.');
3224
+ if (!result.ok) process.exit(result.status || 1);
3225
+ return;
3226
+ }
3227
+
2950
3228
  let business = null;
2951
3229
  if (businessSlug) {
2952
3230
  if (normalizeMode(mode) === 'code-fast') {
@@ -2994,7 +3272,7 @@ async function main() {
2994
3272
  console.log('');
2995
3273
  console.log(formatRunProfile(buildRunProfile({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route }), process.stdout));
2996
3274
  console.log('');
2997
- console.log(formatRuntimeHealth(await buildRuntimeHealth(), process.stdout));
3275
+ console.log(formatRuntimeHealth(await buildRuntimeHealth({ route: route === 'auto' ? undefined : route }), process.stdout));
2998
3276
  return;
2999
3277
  }
3000
3278
 
@@ -3021,6 +3299,7 @@ async function main() {
3021
3299
 
3022
3300
  console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
3023
3301
  console.log('');
3302
+ maybeWriteAtrisGoalBanner(process.stdout, process.cwd());
3024
3303
  const result = await turnFunctionForMode(mode)(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
3025
3304
  const receipt = receiptFromState(result);
3026
3305
  const approvalRequest = normalizeMode(mode) === 'code-fast' ? null : approvalRequestFromReceipt(receipt);
@@ -3032,11 +3311,7 @@ async function main() {
3032
3311
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
3033
3312
  } catch (error) {
3034
3313
  console.error(`x ${error.message}`);
3035
- const decision = prompt
3036
- ? routeDecision(prompt, { route: route === 'auto' ? undefined : route })
3037
- : { route: forceLocal ? 'local' : 'cloud', reason: forceLocal ? 'explicit --local' : 'default hosted chat' };
3038
- const shouldShowBackendHint = decision.route === 'local';
3039
- if (shouldShowBackendHint) printBackendHint({ routeReason: decision.reason, explicitLocal: forceLocal || decision.reason === 'explicit --local' });
3314
+ printBackendHint({ route: route === 'auto' ? undefined : route });
3040
3315
  process.exit(1);
3041
3316
  }
3042
3317
  }
@@ -3106,7 +3381,6 @@ module.exports = {
3106
3381
  renderStreamingMarkdown,
3107
3382
  renderTerminalMarkdown,
3108
3383
  resolveRoute,
3109
- shouldPreflightRuntime,
3110
3384
  runBenchmark,
3111
3385
  runSelfTest,
3112
3386
  runtimeReadyForChat,