atris 3.30.1 → 3.30.3

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 (67) hide show
  1. package/AGENTS.md +6 -0
  2. package/atris/AGENTS.md +11 -0
  3. package/atris/CLAUDE.md +5 -0
  4. package/atris/atris.md +19 -4
  5. package/atris/policies/atris-design.md +71 -0
  6. package/atris/policies/design-seed.md +187 -0
  7. package/atris/skills/atris/SKILL.md +26 -3
  8. package/atris/skills/design/SKILL.md +3 -2
  9. package/atris/skills/loop/SKILL.md +5 -3
  10. package/atris/team/_template/MEMBER.md +19 -0
  11. package/atris.md +63 -23
  12. package/ax +1434 -1698
  13. package/bin/atris.js +5 -1
  14. package/commands/agent-spawn.js +2 -2
  15. package/commands/brain.js +92 -7
  16. package/commands/brainstorm.js +62 -22
  17. package/commands/business-sync.js +92 -8
  18. package/commands/business.js +13 -7
  19. package/commands/chat-scan.js +102 -0
  20. package/commands/computer.js +758 -15
  21. package/commands/deck.js +505 -105
  22. package/commands/init.js +6 -1
  23. package/commands/launchpad.js +638 -0
  24. package/commands/log-sync.js +44 -13
  25. package/commands/loop-front.js +290 -0
  26. package/commands/member.js +124 -3
  27. package/commands/mission.js +717 -32
  28. package/commands/pull.js +37 -28
  29. package/commands/pulse.js +11 -8
  30. package/commands/run.js +79 -39
  31. package/commands/sync.js +36 -17
  32. package/commands/task.js +342 -66
  33. package/commands/xp.js +3 -0
  34. package/commands/youtube.js +221 -7
  35. package/decks/README.md +89 -0
  36. package/decks/archetype-catalog.json +180 -0
  37. package/decks/atris-antislop-pitch.json +48 -0
  38. package/decks/atris-archetypes-v2.json +83 -0
  39. package/decks/atris-one-loop-pitch.json +80 -0
  40. package/decks/atris-seed-pitch-v3.json +118 -0
  41. package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
  42. package/decks/atris-seed-pitch-v5.json +109 -0
  43. package/decks/atris-seed-pitch-v6.json +137 -0
  44. package/decks/atris-seed-pitch-v7.json +133 -0
  45. package/decks/atris-single-shot-proof.json +74 -0
  46. package/decks/mark-pincus-narrative.json +102 -0
  47. package/decks/mark-pincus-sourcery.json +94 -0
  48. package/decks/yash-applied-compute-detailed.json +150 -0
  49. package/decks/yash-applied-compute-generalist.json +82 -0
  50. package/decks/yash-applied-compute-narrative.json +54 -0
  51. package/lib/ax-prefs.js +5 -12
  52. package/lib/chat-log-scan.js +377 -0
  53. package/lib/context-gatherer.js +35 -1
  54. package/lib/deck-compose.js +145 -0
  55. package/lib/deck-history.js +64 -0
  56. package/lib/deck-layout.js +169 -0
  57. package/lib/deck-review.js +431 -0
  58. package/lib/deck-schema.js +154 -0
  59. package/lib/file-ops.js +2 -2
  60. package/lib/functional-owner.js +189 -0
  61. package/lib/slides-deck.js +512 -58
  62. package/lib/task-db.js +109 -2
  63. package/package.json +2 -1
  64. package/templates/business-starter/team/START_HERE.md +12 -8
  65. package/utils/auth.js +4 -0
  66. package/utils/config.js +4 -0
  67. package/atris/atrisDev.md +0 -717
package/ax CHANGED
@@ -6,46 +6,15 @@ const https = require('https');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
  const readline = require('readline');
9
- const { spawnSync } = require('child_process');
9
+ const crypto = require('crypto');
10
10
  const { Readable } = require('stream');
11
11
  const { loadCredentials } = require('./utils/auth');
12
- const {
13
- MEMBER_SLUG,
14
- loadAxPrefs,
15
- setBypassPermissions,
16
- resolveBypassPermissions,
17
- permissionsLabel,
18
- } = require('./lib/ax-prefs');
19
- const {
20
- renderShimmerText,
21
- } = require('./lib/ax-shimmer');
22
- const {
23
- attachMultilineChatInput,
24
- stripMultilineCsiText,
25
- } = require('./lib/ax-chat-input');
26
- const {
27
- accumulateGoalUsage,
28
- buildGoalDirective,
29
- clearGoalState,
30
- createGoalState,
31
- evaluateGoalTurn,
32
- finishGoalAchieved,
33
- formatGoalAchieved,
34
- formatGoalActiveBanner,
35
- formatGoalContinue,
36
- formatGoalStatus,
37
- formatGoalStopped,
38
- goalBudgetExceeded,
39
- goalTurnLimitReached,
40
- parseGoalCommand,
41
- } = require('./lib/ax-goal');
42
12
 
43
13
  const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
44
14
  const BACKEND = {
45
15
  host: '127.0.0.1',
46
16
  port: 8000,
47
- path: '/api/atris2/turn',
48
- publicBase: 'https://api.atris.ai'
17
+ path: '/api/atris2/turn'
49
18
  };
50
19
  const DEFAULT_BACKEND_BASE = `http://${BACKEND.host}:${BACKEND.port}`;
51
20
  const CODE_FAST = {
@@ -56,6 +25,9 @@ const CODE_FAST = {
56
25
  const CONNECTION_STATUS_PATH = '/api/integrations/status';
57
26
  const CONNECTION_CAPABILITIES_PATH = '/api/atris2/connection-capabilities';
58
27
  const ATRIS2_CONNECTION_STATUS_PATH = '/api/atris2/connection-status';
28
+ const ATRIS2_HEALTH_PATH = '/api/atris2/health';
29
+ const APPROVAL_EXECUTE_PATH = '/api/atris2/approvals/execute';
30
+ const DEFAULT_APPROVAL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
59
31
  const CONNECTOR_NAMES = {
60
32
  gmail: 'Gmail',
61
33
  google_calendar: 'Google Calendar',
@@ -88,22 +60,14 @@ const CONNECTOR_SCOPES = {
88
60
  const ANSI = {
89
61
  reset: '\x1b[0m',
90
62
  bold: '\x1b[1m',
91
- italic: '\x1b[3m',
92
63
  dim: '\x1b[2m',
93
64
  muted: '\x1b[90m',
94
65
  accent: '\x1b[36m',
95
66
  ok: '\x1b[32m',
96
- safe: '\x1b[34m',
97
- warn: '\x1b[33m',
98
- magenta: '\x1b[35m',
99
- underline: '\x1b[4m',
100
- strike: '\x1b[9m',
67
+ magenta: '\x1b[35m'
101
68
  };
102
69
 
103
70
  const TIER_COLORS = { fast: '\x1b[32m', pro: '\x1b[36m', max: '\x1b[35m' };
104
- const PROGRESS_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
105
- const PROGRESS_START_DELAY_MS = 350;
106
- const PROGRESS_FRAME_MS = 100;
107
71
 
108
72
  function tierColor(mode) {
109
73
  return TIER_COLORS[mode] || ANSI.accent;
@@ -136,23 +100,11 @@ function tierLabel(mode) {
136
100
  return 'Atris 2 Pro';
137
101
  }
138
102
 
139
- function formatHeader({ mode = 'fast', cwd = process.cwd(), chat = false, bypassPermissions, goal } = {}, options = {}) {
140
- const goalLine = chat && goal ? formatGoalActiveBanner(goal, {
141
- paint: (text, codes) => paint(text, codes, options),
142
- bold: ANSI.bold,
143
- magenta: ANSI.magenta,
144
- muted: ANSI.muted,
145
- accent: ANSI.accent,
146
- ...options,
147
- }) : '';
148
- const home = os.homedir();
149
- const shortCwd = cwd && home && (cwd === home || cwd.startsWith(`${home}/`)) ? `~${cwd.slice(home.length)}` : cwd;
150
- const title = paint(`${tierLabel(mode)}${chat ? ' chat' : ''}`, [ANSI.bold, tierColor(normalizeMode(mode))], options);
151
- const titleLine = shortCwd ? `${title}${paint(` · ${shortCwd}`, [ANSI.muted], options)}` : title;
103
+ function formatHeader({ mode = 'pro', cwd = process.cwd(), chat = false } = {}, options = {}) {
152
104
  return [
153
- titleLine,
154
- goalLine,
155
- chat ? paint('shift+tab permissions · shift+enter newline · ctrl-c · / · exit', [ANSI.muted], options) : '',
105
+ paint(`${tierLabel(mode)}${chat ? ' chat' : ''}`, [ANSI.bold, tierColor(normalizeMode(mode))], options),
106
+ cwd,
107
+ chat ? paint('type / for the menu · /fast /pro /max swap tiers · exit to leave', [ANSI.muted], options) : '',
156
108
  ].filter(Boolean).join('\n');
157
109
  }
158
110
 
@@ -161,57 +113,34 @@ function formatUsage() {
161
113
  'ax - Atris local/code agent',
162
114
  '',
163
115
  'Usage:',
164
- ' ax [--fast|--pro|--max|--code-fast] [--local|--cloud] [--bypass|--safe] <message>',
165
- ' ax [--fast|--pro|--max|--code-fast] [--local|--cloud] [--bypass|--safe] --chat',
166
- ' ax chat [ax] [--fast|--pro|--max]',
167
- ' ax spawn <role> --task "..." [--engine manual|codex|claude|cursor|devin]',
168
- ' ax spawns [--json]',
169
- ' ax youtube <url> [--query "..."] [--json]',
170
- ' ax --dogfood-chat [--loops 25] [--json]',
171
- ' ax --dogfood-status [--json]',
116
+ ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
117
+ ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
172
118
  ' ax [--max|--pro|--fast] --business <slug> [<message>|--chat]',
173
119
  ' ax [--max|--pro|--fast|--code-fast] --doctor',
120
+ ' ax --approvals',
121
+ ' ax --approve <approval-id>',
122
+ ' ax --deny <approval-id>',
123
+ ' ax --self-test',
174
124
  ' ax [--max|--fast] --benchmark',
175
125
  '',
176
126
  'Modes:',
177
- ' --max hosted Atris 2, highest reasoning, slowest turns',
178
- ' --pro hosted Atris 2, deeper tool loop',
179
- ' --fast hosted Atris 2, faster low-latency turns',
127
+ ' --max local workspace agent, highest reasoning, slowest turns',
128
+ ' --pro local workspace agent, deeper tool loop',
129
+ ' --fast local workspace agent, faster low-latency turns',
180
130
  ' --code-fast Atris Code Fast public lane',
181
131
  ' --local force local workspace tools',
182
132
  ' --cloud force authenticated cloud connectors/chat',
183
133
  ' --business <slug> run tools on that business cloud workspace (EC2)',
184
134
  ' --verify <cmd> gate the turn on this command passing (default: no verifier)',
185
- ' --bypass allow external side effects for this run (default: safe / approval-only)',
186
- ' --safe force approval-only mode for this run',
187
- ' --dogfood-chat run the safe ax fast chat checklist harness',
188
- ' --dogfood-status summarize the overnight dogfood mission progress',
189
- '',
190
- 'Chat:',
191
- ' /goal <condition> work autonomously until the condition is met',
192
- ' /goal show active goal, turns, and evaluator reason',
193
- ' /goal clear stop the active goal',
194
- ' /bypass persist bypass mode (external actions allowed when backend supports it)',
195
- ' /safe persist safe mode (default; approval-only external actions)',
196
- '',
197
- 'Delegation:',
198
- ' ax spawn <role> --task "..." create a worker request from ax',
199
- ' atris agent spawn <role> --task "..." same request ledger from the Atris CLI',
200
- ' ax youtube <url> process a YouTube video through Atris',
201
- ' ax --dogfood-chat --loops 25 exercise chat UI and routing without spending credits',
202
- ' ax --dogfood-status show overnight dogfood proof and remaining loops',
203
135
  '',
204
136
  'Examples:',
205
- ' ax spawn worker --task "Fix the failing smoke test" --engine codex',
206
- ' ax youtube https://www.youtube.com/watch?v=VIDEO_ID',
207
- ' ax --dogfood-chat --loops 25',
208
- ' ax --dogfood-status',
209
137
  ' ax --pro find the config file and explain it',
210
138
  ' ax --fast what files are here',
211
139
  ' ax --max refactor this module and verify the tests',
212
140
  ' ax --max --verify "npm test" fix the failing suite',
213
141
  ' ax --code-fast explain this error',
214
142
  ' ax --fast what is on my calendar today',
143
+ ' ax --approvals',
215
144
  ' ax --pro --chat',
216
145
  ].join('\n');
217
146
  }
@@ -226,9 +155,10 @@ function stripAnsi(value) {
226
155
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
227
156
  }
228
157
 
229
- function createRunLogger({ cwd = process.cwd(), mode = 'fast', kind = 'play', output = process.stdout } = {}) {
158
+ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', output = process.stdout } = {}) {
230
159
  if (process.env.AX_AUTO_LOG === '0') return null;
231
160
  if (output !== process.stdout && output !== process.stderr && !output.isTTY) return null;
161
+ const fullTranscript = process.env.AX_LOG_FULL === '1';
232
162
 
233
163
  const baseDir = fs.existsSync(path.join(cwd, 'atris'))
234
164
  ? path.join(cwd, 'atris', 'runs')
@@ -236,15 +166,22 @@ function createRunLogger({ cwd = process.cwd(), mode = 'fast', kind = 'play', ou
236
166
  fs.mkdirSync(baseDir, { recursive: true });
237
167
  const logPath = path.join(baseDir, `ax-${kind}-${timestampForFile()}.log`);
238
168
  fs.appendFileSync(logPath, [
239
- `command: ${process.argv.join(' ')}`,
169
+ `command: ${fullTranscript ? process.argv.join(' ') : 'ax <redacted>'}`,
240
170
  `cwd: ${cwd}`,
241
171
  `mode: ${mode}`,
172
+ `transcript: ${fullTranscript ? 'full' : 'redacted'}`,
242
173
  `started_at: ${new Date().toISOString()}`,
243
174
  '',
244
175
  ].join('\n'));
245
176
 
177
+ let redactedChars = 0;
246
178
  const writeLog = (chunk) => {
247
- fs.appendFileSync(logPath, stripAnsi(chunk));
179
+ const text = stripAnsi(chunk);
180
+ if (fullTranscript) {
181
+ fs.appendFileSync(logPath, text);
182
+ return;
183
+ }
184
+ redactedChars += text.length;
248
185
  };
249
186
  const teeOutput = {
250
187
  isTTY: Boolean(output && output.isTTY),
@@ -261,56 +198,41 @@ function createRunLogger({ cwd = process.cwd(), mode = 'fast', kind = 'play', ou
261
198
  output: teeOutput,
262
199
  write: writeLog,
263
200
  close(exitCode = 0) {
201
+ if (!fullTranscript) {
202
+ fs.appendFileSync(logPath, `redacted_chars: ${redactedChars}\n`);
203
+ fs.appendFileSync(logPath, 'note: full transcript disabled by default; set AX_LOG_FULL=1 to opt in.\n');
204
+ fs.appendFileSync(logPath, `exit_code: ${exitCode}\nfinished_at: ${new Date().toISOString()}\n`);
205
+ return;
206
+ }
264
207
  writeLog(`\nexit_code: ${exitCode}\nfinished_at: ${new Date().toISOString()}\n`);
265
208
  }
266
209
  };
267
210
  }
268
211
 
269
- function backendBaseUrl(options = {}) {
270
- const route = options.route === 'local' || options.forceLocal
271
- ? 'local'
272
- : options.route === 'cloud' || options.forceCloud
273
- ? 'cloud'
274
- : 'auto';
275
- const localOverride = process.env.AX_BACKEND_URL || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL;
276
- const cloudOverride = process.env.OBELISK_ATRIS2_BACKEND_URL || process.env.ATRIS_API_BASE || BACKEND.publicBase;
277
- if (route === 'cloud') {
278
- if (localOverride && !isLoopbackUrl(localOverride)) return localOverride.replace(/\/$/, '');
279
- return cloudOverride.replace(/\/$/, '');
280
- }
281
- if (route === 'auto' && !localOverride) {
282
- return cloudOverride.replace(/\/$/, '');
283
- }
284
- return (localOverride
212
+ function backendBaseUrl() {
213
+ return (process.env.AX_BACKEND_URL
214
+ || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL
285
215
  || process.env.OBELISK_ATRIS2_BACKEND_URL
286
216
  || DEFAULT_BACKEND_BASE).replace(/\/$/, '');
287
217
  }
288
218
 
289
- function backendUrl(options = {}) {
290
- return new URL(BACKEND.path, backendBaseUrl(options)).toString();
219
+ function backendUrl() {
220
+ return new URL(BACKEND.path, backendBaseUrl()).toString();
291
221
  }
292
222
 
293
- function backendPathUrl(pathname, options = {}) {
294
- return new URL(pathname, backendBaseUrl(options)).toString();
223
+ function backendPathUrl(pathname) {
224
+ return new URL(pathname, backendBaseUrl()).toString();
295
225
  }
296
226
 
297
- function codeFastBaseUrl(options = {}) {
298
- const route = options.route === 'local' || options.forceLocal
299
- ? 'local'
300
- : options.route === 'cloud' || options.forceCloud
301
- ? 'cloud'
302
- : 'auto';
303
- const backendOverride = process.env.AX_CODE_FAST_BACKEND_URL;
304
- const useBackendOverride = backendOverride && !(route === 'cloud' && isLoopbackUrl(backendOverride));
305
- return (useBackendOverride
306
- ? backendOverride
307
- : process.env.AX_CODE_FAST_API_BASE
308
- || process.env.ATRIS_API_BASE
309
- || CODE_FAST.publicBase).replace(/\/$/, '');
227
+ function codeFastBaseUrl() {
228
+ return (process.env.AX_CODE_FAST_BACKEND_URL
229
+ || process.env.AX_CODE_FAST_API_BASE
230
+ || process.env.ATRIS_API_BASE
231
+ || CODE_FAST.publicBase).replace(/\/$/, '');
310
232
  }
311
233
 
312
- function codeFastUrl(options = {}) {
313
- return new URL(CODE_FAST.path, codeFastBaseUrl(options)).toString();
234
+ function codeFastUrl() {
235
+ return new URL(CODE_FAST.path, codeFastBaseUrl()).toString();
314
236
  }
315
237
 
316
238
  function isLoopbackUrl(value) {
@@ -322,15 +244,6 @@ function isLoopbackUrl(value) {
322
244
  }
323
245
  }
324
246
 
325
- function configuredLocalBackendUrl() {
326
- return process.env.AX_BACKEND_URL || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL || '';
327
- }
328
-
329
- function hasLocalWorkspaceBackend(options = {}) {
330
- if (options.localWorkspaceBackend === true) return true;
331
- return Boolean(configuredLocalBackendUrl());
332
- }
333
-
334
247
  function isCodeFastLocal(options = {}) {
335
248
  const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
336
249
  return route === 'local' && isLoopbackUrl(options.codeFastBaseUrl || codeFastBaseUrl());
@@ -342,44 +255,39 @@ function buildRunProfile(options = {}) {
342
255
  if (mode === 'code-fast') {
343
256
  const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
344
257
  return {
345
- endpoint: codeFastUrl({ route }),
258
+ endpoint: codeFastUrl(),
346
259
  mode,
347
260
  route,
348
261
  model: CODE_FAST.model,
349
262
  workspace_path: isCodeFastLocal(options) ? cwd : 'cloud scratch',
350
263
  max_turns: 1,
351
264
  streaming: false,
352
- runtime: isCodeFastLocal(options) ? 'local Cursor SDK bridge' : 'authenticated Code Fast cloud scratch',
265
+ runtime: isCodeFastLocal(options) ? 'local Cursor SDK through backend' : 'authenticated Code Fast cloud scratch',
353
266
  reasoning: 'Composer 2.5 fast lane; charges 10 credits per public turn'
354
267
  };
355
268
  }
356
- const profileMessage = options.message || 'what files are here?';
357
- const route = resolveRoute(profileMessage, options);
358
- const payload = buildPayload(profileMessage, { mode, cwd, route, bypassPermissions: options.bypassPermissions });
269
+ const route = resolveRoute(options.message || 'doctor', options);
270
+ const payload = buildPayload(options.message || 'doctor', { mode, cwd, route });
359
271
  return {
360
- endpoint: backendUrl({ route }),
272
+ endpoint: backendUrl(),
361
273
  mode,
362
274
  route,
363
275
  model: payload.model,
364
276
  workspace_path: payload.workspace_path || 'cloud',
365
277
  max_turns: payload.max_turns,
366
- member_slug: payload.member_slug,
367
- bypass_permissions: payload.bypass_permissions,
368
278
  streaming: true,
369
279
  runtime: route === 'cloud' ? 'authenticated cloud connectors/chat' : 'local workspace',
370
280
  reasoning: mode === 'max'
371
- ? 'Atris cloud service; Max workspace tool loop uses high reasoning effort'
281
+ ? 'backend reports run row; Max workspace tool loop uses high reasoning effort'
372
282
  : mode === 'pro'
373
- ? 'Atris cloud service; Pro workspace tool loop uses API default medium'
374
- : 'Atris cloud service; Fast workspace tool loop uses provider default'
283
+ ? 'backend reports run row; Pro workspace tool loop uses API default medium'
284
+ : 'backend reports run row; Fast workspace tool loop uses provider default'
375
285
  };
376
286
  }
377
287
 
378
288
  function formatRunProfile(profile, options = {}) {
379
289
  const rows = [
380
290
  ['mode', profile.mode],
381
- ['agent', profile.member_slug || MEMBER_SLUG],
382
- ['permissions', profile.bypass_permissions ? 'bypass' : 'safe'],
383
291
  ['endpoint', profile.endpoint],
384
292
  ['route', profile.route || 'auto'],
385
293
  ['workspace', formatPathSubject(profile.workspace_path, options)],
@@ -391,6 +299,53 @@ function formatRunProfile(profile, options = {}) {
391
299
  return rows.map(([label, value]) => formatAuxRow(label, value, options)).join('\n');
392
300
  }
393
301
 
302
+ async function buildRuntimeHealth(options = {}) {
303
+ const healthRes = options.healthRes
304
+ ? await Promise.resolve(options.healthRes)
305
+ : await requestJson(ATRIS2_HEALTH_PATH, { token: '', timeoutMs: options.timeoutMs || 1500 });
306
+ const data = healthRes.ok && healthRes.data && typeof healthRes.data === 'object' ? healthRes.data : {};
307
+ const models = Array.isArray(data.models) ? data.models : [];
308
+ const fast = models.find(row => row && row.id === 'atris:fast') || null;
309
+ return {
310
+ schema: 'ax.runtime_health.v1',
311
+ backend: {
312
+ ready: Boolean(healthRes.ok && data.ready),
313
+ reachable: Boolean(healthRes.ok),
314
+ status: healthRes.status || 0,
315
+ error: healthRes.error || '',
316
+ },
317
+ fast: {
318
+ ready: Boolean(fast && fast.ready),
319
+ route_ready: Boolean(fast && fast.ready),
320
+ },
321
+ permissions: {
322
+ approval_execution_path: APPROVAL_EXECUTE_PATH,
323
+ ready: Boolean(healthRes.ok),
324
+ },
325
+ };
326
+ }
327
+
328
+ function formatRuntimeHealth(health, options = {}) {
329
+ const backendReady = health && health.backend && health.backend.ready;
330
+ const backendReachable = health && health.backend && health.backend.reachable;
331
+ const fastReady = health && health.fast && health.fast.ready;
332
+ const permissionReady = health && health.permissions && health.permissions.ready;
333
+ const rows = [
334
+ ['backend', backendReady ? 'ready' : backendReachable ? 'not ready' : 'offline'],
335
+ ['fast', fastReady ? 'ready' : 'not ready'],
336
+ ['approvals', permissionReady ? 'ready' : 'offline'],
337
+ ];
338
+ if (!backendReachable) rows.push(['fix', 'start local backend, then rerun ax --doctor']);
339
+ return rows.map(([label, value]) => formatAuxRow(label, value, options)).join('\n');
340
+ }
341
+
342
+ function runtimeReadyForChat(health, mode = 'fast') {
343
+ if (!health || !health.backend || !health.permissions) return false;
344
+ if (!health.backend.ready || !health.permissions.ready) return false;
345
+ if (normalizeMode(mode) === 'fast' && (!health.fast || health.fast.ready === false)) return false;
346
+ return true;
347
+ }
348
+
394
349
  function canonicalConnectorId(value) {
395
350
  return String(value || '').trim().toLowerCase().replace(/-/g, '_');
396
351
  }
@@ -423,9 +378,9 @@ function authUserId() {
423
378
  }
424
379
  }
425
380
 
426
- function isLoopbackBackend(options = {}) {
381
+ function isLoopbackBackend() {
427
382
  try {
428
- const parsed = new URL(backendBaseUrl(options));
383
+ const parsed = new URL(backendBaseUrl());
429
384
  return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
430
385
  } catch (_) {
431
386
  return false;
@@ -467,6 +422,48 @@ function requestJson(pathname, { token = authToken(), timeoutMs = 6000 } = {}) {
467
422
  });
468
423
  }
469
424
 
425
+ function postJson(pathname, body, { token = authToken(), timeoutMs = 30000 } = {}) {
426
+ const url = backendPathUrl(pathname);
427
+ const parsed = new URL(url);
428
+ const transport = parsed.protocol === 'https:' ? https : http;
429
+ const postData = JSON.stringify(body || {});
430
+ return new Promise((resolve) => {
431
+ const req = transport.request({
432
+ method: 'POST',
433
+ hostname: parsed.hostname,
434
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
435
+ path: `${parsed.pathname}${parsed.search}`,
436
+ headers: {
437
+ 'Content-Type': 'application/json',
438
+ 'Content-Length': Buffer.byteLength(postData),
439
+ Accept: 'application/json',
440
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
441
+ }
442
+ }, (res) => {
443
+ const chunks = [];
444
+ res.on('data', chunk => chunks.push(chunk));
445
+ res.on('end', () => {
446
+ const text = Buffer.concat(chunks).toString('utf8');
447
+ let data = null;
448
+ try {
449
+ data = text ? JSON.parse(text) : null;
450
+ } catch (_) {
451
+ resolve({ ok: false, status: res.statusCode, data: null, text });
452
+ return;
453
+ }
454
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data, text });
455
+ });
456
+ });
457
+ req.on('error', error => resolve({ ok: false, status: 0, data: null, text: '', error: error.message }));
458
+ req.setTimeout(timeoutMs, () => {
459
+ req.destroy();
460
+ resolve({ ok: false, status: 0, data: null, text: '', error: `timeout after ${timeoutMs}ms` });
461
+ });
462
+ req.write(postData);
463
+ req.end();
464
+ });
465
+ }
466
+
470
467
  function defaultAuthority(id) {
471
468
  const key = canonicalConnectorId(id);
472
469
  if (key === 'gmail') return { list_messages: 'read_only', get_message: 'read_only', send_message: 'approval_required' };
@@ -594,7 +591,7 @@ function mentionsConnector(message) {
594
591
  }
595
592
 
596
593
  function connectorWriteIntent(message) {
597
- return mentionsConnector(message) && /\b(send|post|dm|message|reply|draft|compose|schedule|book|create|update|delete|archive|move|share|comment|invite)\b/i.test(message || '');
594
+ return mentionsConnector(message) && /\b(add|send|post|dm|message|reply|draft|compose|schedule|book|create|update|delete|archive|move|share|comment|invite)\b/i.test(message || '');
598
595
  }
599
596
 
600
597
  function workspaceIntent(message) {
@@ -607,220 +604,24 @@ function githubWorkspaceIntent(message) {
607
604
  return /\b(push|commit|commits?|branch|branches|checkout|merge|rebase|tag|release|pr|pull request|pull-request|repo change|code change|small change)\b/i.test(text);
608
605
  }
609
606
 
610
- function casualChatIntent(message) {
611
- const text = String(message || '').trim();
612
- if (!text) return false;
613
- if (workspaceIntent(text) || mentionsConnector(text) || githubWorkspaceIntent(text)) return false;
614
- if (/^(hi+|hello+|hey+|yo+|sup|gm|good morning|good afternoon|good evening|thanks?|thank you|ok+|okay|k|cool|nice|lol|lmao|haha|why\??|what\??|huh\??|yes|no|nah|yep|nope)[.!?]*$/i.test(text)) return true;
615
- if (/^(hi|hello|hey)\b/i.test(text)) return true;
616
- return /^[a-z]{1,8}[.!?]*$/i.test(text);
617
- }
618
-
619
607
  function resolveRoute(message, options = {}) {
620
608
  if (options.route === 'local' || options.forceLocal) return 'local';
621
609
  if (options.route === 'cloud' || options.forceCloud) return 'cloud';
622
- if (casualChatIntent(message)) return 'cloud';
623
- const localWorkspace = hasLocalWorkspaceBackend(options);
624
- if (githubWorkspaceIntent(message) && localWorkspace) return 'local';
610
+ if (githubWorkspaceIntent(message)) return 'local';
625
611
  if (mentionsConnector(message) && !workspaceIntent(message)) return 'cloud';
626
- if (!localWorkspace) return 'cloud';
627
612
  return 'local';
628
613
  }
629
614
 
630
615
  function normalizeMode(mode) {
631
616
  if (mode === 'code-fast' || mode === 'code') return 'code-fast';
632
617
  if (mode === 'max') return 'max';
633
- if (mode === 'pro') return 'pro';
634
- return 'fast';
618
+ return mode === 'fast' ? 'fast' : 'pro';
635
619
  }
636
620
 
637
621
  function formatPrompt(mode, options = {}) {
622
+ if (!mode) return '› ';
638
623
  const tier = normalizeMode(mode);
639
- const goalHint = options.goal && options.goal.active
640
- ? `${paint('◎ ', [ANSI.bold, ANSI.magenta], options)}`
641
- : '';
642
- if (!mode) return `${goalHint}› `;
643
- return `${goalHint}${paint(tier, [ANSI.bold, tierColor(tier)], options)} › `;
644
- }
645
-
646
- function terminalWidth(options = {}) {
647
- const cols = Number(options.columns || process.stdout.columns || 0);
648
- if (Number.isFinite(cols) && cols >= 48) return cols;
649
- return 72;
650
- }
651
-
652
- function inputBoxPlainWidth(options = {}) {
653
- const cols = Number(options.columns || process.stdout.columns || 0);
654
- if (Number.isFinite(cols) && cols >= 48) return cols - 1;
655
- return 71;
656
- }
657
-
658
- function inputBoxLayoutOptions(stream, overrides = {}) {
659
- const cols = Number(stream?.columns || process.stdout.columns || 0);
660
- return {
661
- ...overrides,
662
- columns: Number.isFinite(cols) && cols >= 48 ? cols - 1 : 71,
663
- isTTY: stream?.isTTY ?? process.stdout.isTTY,
664
- };
665
- }
666
-
667
- function buildInputBoxTopPlain(options = {}) {
668
- const width = inputBoxPlainWidth(options);
669
- return `╭${'─'.repeat(Math.max(0, width - 2))}╮`;
670
- }
671
-
672
- // The mode + newline affordance lives on its own line BELOW the box (claude
673
- // style), so the rules above/below stay clean and we never say "enter send".
674
- function buildInputBoxStatusPlain(options = {}) {
675
- return ` ${formatPermissionModeBrief(options)} · shift+enter`;
676
- }
677
-
678
- function buildInputBoxBottomPlain(options = {}) {
679
- const width = inputBoxPlainWidth(options);
680
- return `╰${'─'.repeat(Math.max(0, width - 2))}╯`;
681
- }
682
-
683
- function formatInputBoxInputRow(prompt, line, options = {}) {
684
- const width = inputBoxPlainWidth(options);
685
- const promptPlain = stripAnsi(prompt);
686
- const linePlain = stripAnsi(String(line || ''));
687
- const rightBar = paint('│', [ANSI.dim, ANSI.muted], options);
688
- const rightPlain = '│';
689
- const pad = Math.max(0, width - promptPlain.length - linePlain.length - rightPlain.length);
690
- return `${prompt}${line}${' '.repeat(pad)}${rightBar}`;
691
- }
692
-
693
- function formatPermissionModeBrief(options = {}) {
694
- return resolveBypassPermissions(options) ? 'full access' : 'approve';
695
- }
696
-
697
- function permissionAccentCodes(options = {}) {
698
- return resolveBypassPermissions(options) ? [ANSI.dim, ANSI.warn] : [ANSI.dim, ANSI.safe];
699
- }
700
-
701
- function formatPermissionToggleMessage(bypassPermissions, options = {}) {
702
- const persistPrefs = options.persistPrefs !== false;
703
- const suffix = persistPrefs ? ' (saved)' : ' (session)';
704
- if (bypassPermissions) {
705
- return paint(`· Full access${suffix} — external actions can run without approval`, [ANSI.warn], options);
706
- }
707
- return paint(`· Approve mode${suffix} — external actions ask before they run`, [ANSI.safe], options);
708
- }
709
-
710
- function isPermissionToggleKey(key) {
711
- if (!key) return false;
712
- if (key.shift && key.name === 'tab') return true;
713
- const sequence = String(key.sequence || '');
714
- return sequence === '\x1b[Z' || sequence === '\x1b[1;2Z';
715
- }
716
-
717
- function isMultilineInsertKey(str, key) {
718
- if (!key) return str === '\n';
719
- if (key.shift && (key.name === 'return' || key.name === 'enter')) return true;
720
- if (key.meta && (key.name === 'return' || key.name === 'enter')) return true;
721
- if (str === '\n' && key.name !== 'return') return true;
722
- return false;
723
- }
724
-
725
- function insertMultilineBreak(rl) {
726
- if (!rl) return;
727
- // Insert a literal newline at the cursor. rl.write('\n') would SUBMIT the
728
- // line (readline treats it as Enter) and also re-enter our _ttyWrite hook.
729
- if (typeof rl._insertString === 'function') {
730
- rl._insertString('\n');
731
- return;
732
- }
733
- if (typeof rl.line === 'string') {
734
- const cursor = Number.isFinite(rl.cursor) ? rl.cursor : rl.line.length;
735
- rl.line = rl.line.slice(0, cursor) + '\n' + rl.line.slice(cursor);
736
- rl.cursor = cursor + 1;
737
- if (typeof rl._refreshLine === 'function') rl._refreshLine();
738
- }
739
- }
740
-
741
- function formatInputBoxTop(options = {}) {
742
- return paint(buildInputBoxTopPlain(options), [ANSI.dim, ANSI.muted], options);
743
- }
744
-
745
- function formatInputBoxBottom(options = {}) {
746
- return paint(buildInputBoxBottomPlain(options), [ANSI.dim, ANSI.muted], options);
747
- }
748
-
749
- // Status line shown below the box: permission mode + the shift+enter newline
750
- // affordance, mode-colored. Drawn by the repaint, cleared on submit.
751
- function formatInputBoxStatus(options = {}) {
752
- const plain = buildInputBoxStatusPlain(options);
753
- const modeLabel = formatPermissionModeBrief(options);
754
- const idx = plain.indexOf(modeLabel);
755
- if (idx === -1) return paint(plain, [ANSI.dim, ANSI.muted], options);
756
- const modePainted = paint(modeLabel, permissionAccentCodes(options), options);
757
- const dim = [ANSI.dim, ANSI.muted];
758
- return `${paint(plain.slice(0, idx), dim, options)}${modePainted}${paint(plain.slice(idx + modeLabel.length), dim, options)}`;
759
- }
760
-
761
- function closeInputBox(output, options = {}) {
762
- // On submit the cursor sits on the bottom-rule row: redraw the rule, then
763
- // clear the status line the repaint left below it and leave the cursor on a
764
- // fresh row for the turn output.
765
- output.write(`\r\x1b[2K${formatInputBoxBottom(options)}\n\x1b[2K\r`);
766
- }
767
-
768
- // Keep the input box closed while the user is typing: after each readline
769
- // render, repaint the bottom rule one row below the (possibly wrapped/multiline)
770
- // input plus a status line below that, then return the cursor. readline clears
771
- // to end-of-display on every refresh, so both must be redrawn each time. Writes
772
- // to the raw stream readline renders to (not the logger) since these are
773
- // transient cursor moves.
774
- function repaintInputBoxBottom(rl, output, options = {}) {
775
- if (!rl || !output || typeof output.write !== 'function') return;
776
- if (typeof rl._getDisplayPos !== 'function' || typeof rl.getCursorPos !== 'function') return;
777
- let total;
778
- let cur;
779
- try {
780
- total = rl._getDisplayPos(`${rl._prompt || ''}${rl.line || ''}`);
781
- cur = rl.getCursorPos();
782
- } catch {
783
- return;
784
- }
785
- if (!total || !cur) return;
786
- // When the input is taller than the viewport the terminal scrolls and the
787
- // relative cursor math below can't land the rule reliably (it would corrupt
788
- // the scrollback). Degrade gracefully: skip the closing rule for a giant
789
- // input rather than scramble it. readline still renders the text correctly.
790
- const rows = Number(output.rows || (typeof process !== 'undefined' && process.stdout && process.stdout.rows) || 0);
791
- if (rows && total.rows + 3 >= rows) return;
792
- const down = Math.max(1, (total.rows - cur.rows) + 1);
793
- let seq = `\r\x1b[${down}B\x1b[2K${formatInputBoxBottom(options)}`;
794
- seq += `\r\x1b[1B\x1b[2K${formatInputBoxStatus(options)}`;
795
- seq += `\x1b[${down + 1}A\r`;
796
- if (cur.cols > 0) seq += `\x1b[${cur.cols}C`;
797
- output.write(seq);
798
- }
799
-
800
- function formatChatInputInnerPrefix(mode, options = {}) {
801
- const tier = normalizeMode(mode);
802
- const goalHint = options.goal && options.goal.active
803
- ? `${paint('◎ ', [ANSI.bold, ANSI.magenta], options)}`
804
- : '';
805
- const tierPart = mode
806
- ? `${goalHint}${paint(tier, [ANSI.bold, tierColor(tier)], options)} › `
807
- : `${goalHint}› `;
808
- return `${paint('→ ', [ANSI.muted], options)}${tierPart}`;
809
- }
810
-
811
- function formatChatInputPrompt(mode, options = {}) {
812
- // Two-space indent (no left bar) to match the plain rules and the status line.
813
- return ` ${formatChatInputInnerPrefix(mode, options)}`;
814
- }
815
-
816
- const PERMISSION_COMMANDS = new Map([
817
- ['/bypass', true],
818
- ['/safe', false],
819
- ]);
820
-
821
- function chatPermissionCommand(line) {
822
- const key = String(line || '').trim().toLowerCase();
823
- return PERMISSION_COMMANDS.has(key) ? PERMISSION_COMMANDS.get(key) : null;
624
+ return `${paint(tier, [ANSI.bold, tierColor(tier)], options)} `;
824
625
  }
825
626
 
826
627
  const TIER_COMMANDS = new Map([
@@ -834,70 +635,21 @@ function chatTierCommand(line) {
834
635
  }
835
636
 
836
637
  const CHAT_COMMANDS = [
837
- ['/goal', 'set/show autonomous completion condition'],
838
638
  ['/fast', 'quick answers, lowest latency'],
839
639
  ['/pro', 'deeper tool loop for real work'],
840
640
  ['/max', 'highest reasoning for the hardest jobs'],
841
- ['/bypass', 'allow external side effects (persisted)'],
842
- ['/safe', 'approval-only external actions (default, persisted)'],
843
641
  ['/help', 'show this menu'],
844
642
  ['exit', 'leave chat'],
845
643
  ];
846
644
 
847
- const CHAT_SHORTCUTS = [
848
- ['shift+tab', 'toggle approve ↔ full access'],
849
- ['shift+enter', 'new line (enter sends)'],
850
- ];
851
-
852
- const CHAT_DOGFOOD_BASE_CASES = [
853
- { input: '/help', kind: 'command', coverage: 'menu' },
854
- { input: '/fast', kind: 'command', coverage: 'tier_fast' },
855
- { input: 'hi', kind: 'turn', expectRoute: 'cloud', coverage: 'small_talk' },
856
- { input: 'pop', kind: 'turn', expectRoute: 'cloud', coverage: 'short_noise' },
857
- { input: 'hello why', kind: 'turn', expectRoute: 'cloud', coverage: 'greeting_phrase' },
858
- { input: 'what files are here?', kind: 'turn', expectRoute: 'cloud', coverage: 'workspace_read' },
859
- { input: 'search src for the input component', kind: 'turn', expectRoute: 'cloud', coverage: 'workspace_search' },
860
- { input: 'fix the xp game tests', kind: 'turn', expectRoute: 'cloud', coverage: 'workspace_fix' },
861
- { input: 'what is on my calendar today?', kind: 'turn', expectRoute: 'cloud', coverage: 'connector_read' },
862
- { input: 'which integrations are connected?', kind: 'turn', expectRoute: 'cloud', coverage: 'connector_status' },
863
- { input: 'what github repos do I have?', kind: 'turn', expectRoute: 'cloud', coverage: 'github_read' },
864
- { input: 'push something to github', kind: 'turn', expectRoute: 'cloud', coverage: 'github_workspace_mutation' },
865
- { input: 'https://www.youtube.com/watch?v=gBukk9LIklc', kind: 'youtube', coverage: 'youtube_url' },
866
- { input: '/max', kind: 'command', coverage: 'tier_max' },
867
- { input: 'refactor this module and verify the tests', kind: 'turn', expectRoute: 'cloud', expectMode: 'max', coverage: 'max_cloud' },
868
- { input: '/pro', kind: 'command', coverage: 'tier_pro' },
869
- { input: 'send a slack message to the team', kind: 'turn', expectRoute: 'cloud', expectMode: 'pro', coverage: 'connector_write_safe' },
870
- { input: '/bypass', kind: 'command', coverage: 'permission_bypass' },
871
- { input: 'send a slack message to the team', kind: 'turn', expectRoute: 'cloud', expectBypass: true, coverage: 'connector_write_bypass' },
872
- { input: '/safe', kind: 'command', coverage: 'permission_safe' },
873
- { input: 'send a slack message to the team', kind: 'turn', expectRoute: 'cloud', expectBypass: false, coverage: 'connector_write_resafe' },
874
- { input: '/wat', kind: 'command', coverage: 'unknown_slash' },
875
- { input: '/goal', kind: 'command', coverage: 'goal_status' },
876
- { input: 'oj', kind: 'turn', expectRoute: 'cloud', coverage: 'short_noise_repeat' },
877
- { input: 'read MAP.md and do not edit', kind: 'turn', expectRoute: 'cloud', coverage: 'workspace_read_specific' },
878
- ];
879
-
880
- function buildChatDogfoodCases(loopCount = 25) {
881
- const count = Math.max(1, Number(loopCount) || 25);
882
- const cases = [];
883
- for (let i = 0; i < count; i += 1) {
884
- cases.push({ ...CHAT_DOGFOOD_BASE_CASES[i % CHAT_DOGFOOD_BASE_CASES.length], index: i + 1 });
885
- }
886
- return cases;
887
- }
888
-
889
645
  function chatMenu(options = {}) {
890
- const commands = CHAT_COMMANDS
646
+ return CHAT_COMMANDS
891
647
  .map(([name, desc]) => {
892
648
  const tier = TIER_COMMANDS.get(name);
893
649
  const color = tier ? tierColor(tier) : ANSI.accent;
894
650
  return ` ${paint(name.padEnd(6), [color], options)} ${paint(desc, [ANSI.muted], options)}`;
895
651
  })
896
652
  .join('\n');
897
- const shortcuts = CHAT_SHORTCUTS
898
- .map(([name, desc]) => ` ${paint(name.padEnd(6), [ANSI.ok], options)} ${paint(desc, [ANSI.muted], options)}`)
899
- .join('\n');
900
- return `${commands}\n${shortcuts}`;
901
653
  }
902
654
 
903
655
  function chatCompleter(line) {
@@ -908,132 +660,10 @@ function chatCompleter(line) {
908
660
  return [hits.length ? hits : names, trimmed];
909
661
  }
910
662
 
911
- function parsePermissionFlags(args) {
912
- let bypassPermissions;
913
- if (args.includes('--bypass')) bypassPermissions = true;
914
- if (args.includes('--safe')) bypassPermissions = false;
915
- return bypassPermissions;
916
- }
917
-
918
- function stripPermissionFlags(args) {
919
- return args.filter(arg => arg !== '--bypass' && arg !== '--safe');
920
- }
921
-
922
- function normalizeChatCommandArgs(args = []) {
923
- const normalized = [...args];
924
- let firstPositional = -1;
925
- const flagsWithValue = new Set(['--business', '--verify', '--loops']);
926
-
927
- for (let i = 0; i < normalized.length; i += 1) {
928
- const arg = String(normalized[i] || '');
929
- if (flagsWithValue.has(arg)) {
930
- i += 1;
931
- continue;
932
- }
933
- if (arg.startsWith('-')) continue;
934
- firstPositional = i;
935
- break;
936
- }
937
-
938
- let chatRequested = normalized.includes('--chat');
939
- if (firstPositional !== -1 && normalized[firstPositional] === 'chat') {
940
- chatRequested = true;
941
- normalized.splice(firstPositional, 1, '--chat');
942
- const target = String(normalized[firstPositional + 1] || '').toLowerCase();
943
- if (target === MEMBER_SLUG || target === 'ax') {
944
- normalized.splice(firstPositional + 1, 1);
945
- }
946
- }
947
-
948
- return { args: normalized, chatRequested };
949
- }
950
-
951
- function isAxSpawnCommand(command) {
952
- return ['spawn', 'spawns', 'spawn-list', 'list-spawns', 'spawn-status', 'spawn-show'].includes(String(command || ''));
953
- }
954
-
955
- function runAxSpawnCommand(args = [], deps = {}) {
956
- const [command, ...rest] = args;
957
- const root = deps.root || process.cwd();
958
- const output = deps.output || ((line = '') => console.log(line));
959
- const {
960
- agentSpawnCommand,
961
- agentSpawnListCommand,
962
- agentSpawnStatusCommand,
963
- } = require('./commands/agent-spawn');
964
-
965
- if (command === 'spawn') return agentSpawnCommand(rest, { root, output, commandName: 'ax spawn' });
966
- if (command === 'spawns' || command === 'spawn-list' || command === 'list-spawns') {
967
- return agentSpawnListCommand(rest, { root, output });
968
- }
969
- if (command === 'spawn-status' || command === 'spawn-show') {
970
- return agentSpawnStatusCommand(rest, { root, output });
971
- }
972
- throw new Error('Unknown ax spawn command');
973
- }
974
-
975
- const YOUTUBE_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?(?:youtube\.com\/watch\?[^\s]+|youtu\.be\/[^\s]+|youtube\.com\/shorts\/[^\s]+)/i;
976
-
977
- function cleanYoutubeUrl(url) {
978
- return String(url || '').replace(/[),.;\]]+$/g, '');
979
- }
980
-
981
- function extractYoutubeUrl(text) {
982
- const match = String(text || '').match(YOUTUBE_URL_PATTERN);
983
- return match ? cleanYoutubeUrl(match[0]) : null;
984
- }
985
-
986
- function youtubeQueryFromPrompt(prompt, youtubeUrl) {
987
- const withoutUrl = String(prompt || '').replace(youtubeUrl, '').trim();
988
- return withoutUrl.replace(/^[-:,\s]+|[-:,\s]+$/g, '').trim();
989
- }
990
-
991
- function buildAxYoutubeArgs(prompt, extraArgs = []) {
992
- const text = Array.isArray(prompt) ? prompt.join(' ') : String(prompt || '');
993
- const youtubeUrl = extractYoutubeUrl(text);
994
- if (!youtubeUrl) return null;
995
- const query = youtubeQueryFromPrompt(text, youtubeUrl);
996
- const args = [youtubeUrl];
997
- if (query) args.push('--query', query);
998
- return args.concat(extraArgs || []);
999
- }
1000
-
1001
- async function runAxYoutubeCommand(args = [], deps = {}) {
1002
- const output = deps.output || ((line = '') => console.log(line));
1003
- const commandArgs = args[0] === 'youtube' ? args.slice(1) : args;
1004
- const inferred = args[0] === 'youtube' ? null : buildAxYoutubeArgs(commandArgs);
1005
- const finalArgs = inferred || commandArgs;
1006
- const youtubeCommand = deps.youtubeCommand || require('./commands/youtube').youtubeCommand;
1007
- const commandName = args[0] === 'youtube' ? 'ax youtube' : deps.commandName;
1008
- return youtubeCommand(finalArgs, { ...deps, output, commandName });
1009
- }
1010
-
1011
- function normalizeWorkingOptions(frameOrOptions, maybeOptions = {}) {
1012
- if (typeof frameOrOptions === 'string') {
1013
- return { ...maybeOptions, frameChar: frameOrOptions };
1014
- }
1015
- if (frameOrOptions && typeof frameOrOptions === 'object') {
1016
- return frameOrOptions;
1017
- }
1018
- return maybeOptions;
1019
- }
1020
-
1021
- function formatWorkingLine(ms, verb, frameOrOptions, maybeOptions = {}) {
1022
- const options = normalizeWorkingOptions(frameOrOptions, maybeOptions);
663
+ function formatWorkingLine(ms, verb, frame) {
1023
664
  const totalSeconds = Math.max(1, Math.round((Number(ms) || 0) / 1000));
1024
- const tick = Number.isFinite(options.tick) ? options.tick : null;
1025
- const labelVerb = String(verb || 'Thinking').trim() || 'Thinking';
1026
- const meta = paint(
1027
- `(${formatSeconds(totalSeconds)} · ctrl-c to interrupt)`,
1028
- [ANSI.dim, ANSI.muted],
1029
- options
1030
- );
1031
- const label = tick === null
1032
- ? paint(labelVerb, [ANSI.muted], options)
1033
- : renderShimmerText(labelVerb, tick ?? 0, options);
1034
- const suffix = paint('…', [ANSI.dim, ANSI.muted], options);
1035
- const prefix = paint('•', [ANSI.muted], options);
1036
- return `${prefix} ${label}${suffix} ${meta}`;
665
+ if (verb) return `${frame || '•'} ${verb}… (${formatSeconds(totalSeconds)} · ctrl-c to interrupt)`;
666
+ return `• Working (${formatSeconds(totalSeconds)} ctrl-c to interrupt)`;
1037
667
  }
1038
668
 
1039
669
  function verbForTool(tool) {
@@ -1042,8 +672,7 @@ function verbForTool(tool) {
1042
672
  if (name.includes('grep') || name.includes('glob') || name.includes('search')) return 'Searching';
1043
673
  if (name.includes('bash') || name.includes('command') || name.includes('run')) return 'Running';
1044
674
  if (name.includes('write') || name.includes('edit')) return 'Editing';
1045
- if (name.includes('think') || name.includes('reason') || name === 'task') return 'Thinking';
1046
- return 'Thinking';
675
+ return 'Working';
1047
676
  }
1048
677
 
1049
678
  function formatDoneLine(ms, credits) {
@@ -1065,18 +694,7 @@ function paint(text, codes, options = {}) {
1065
694
  return `${codes.join('')}${text}${ANSI.reset}`;
1066
695
  }
1067
696
 
1068
- function formatMarkdownLink(label, url, options = {}) {
1069
- const display = String(label || '').trim();
1070
- const href = String(url || '').trim();
1071
- if (!href) return display;
1072
- const colored = paint(display, [ANSI.accent, ANSI.underline], options);
1073
- if (useColor(options) && (/^(https?:\/\/|file:\/\/)/i.test(href) || href.startsWith('/'))) {
1074
- return hyperlinkPath(colored, href, options);
1075
- }
1076
- return `${display} (${href})`;
1077
- }
1078
-
1079
- function renderTerminalInline(text, options = {}) {
697
+ function renderTerminalMarkdown(text, options = {}) {
1080
698
  let rendered = String(text || '');
1081
699
  const codeSpans = [];
1082
700
  rendered = rendered.replace(/`([^`\n]+)`/g, (_, code) => {
@@ -1084,131 +702,23 @@ function renderTerminalInline(text, options = {}) {
1084
702
  codeSpans.push(code);
1085
703
  return token;
1086
704
  });
1087
- rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => formatMarkdownLink(label, url, options));
1088
- rendered = rendered.replace(/\*\*\*([^*\n]+)\*\*\*/g, (_, value) => paint(value, [ANSI.bold, ANSI.italic], options));
705
+ rendered = rendered.replace(/^#{1,6}\s+(.+)$/gm, (_, title) => paint(title, [ANSI.bold], options));
1089
706
  rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, (_, value) => paint(value, [ANSI.bold], options));
1090
707
  rendered = rendered.replace(/__([^_\n]+)__/g, (_, value) => paint(value, [ANSI.bold], options));
1091
- rendered = rendered.replace(/~~([^~\n]+)~~/g, (_, value) => paint(value, [ANSI.strike], options));
1092
- rendered = rendered.replace(/\*([^*\n]+)\*/g, (_, value) => paint(value, [ANSI.italic], options));
1093
- rendered = rendered.replace(/_([^_\n]+)_/g, (_, value) => paint(value, [ANSI.italic], options));
1094
708
  rendered = rendered.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => paint(codeSpans[Number(index)] || '', [ANSI.accent], options));
1095
709
  return rendered;
1096
710
  }
1097
711
 
1098
- function renderTerminalBlockLine(line, options = {}) {
1099
- const header = line.match(/^(#{1,6})\s+(.+)$/);
1100
- if (header) return paint(header[2], [ANSI.bold], options);
1101
-
1102
- const list = line.match(/^(\s*)(?:[*+-]|\d+\.)\s+(.*)$/);
1103
- if (list) {
1104
- const bullet = paint('•', [ANSI.accent], options);
1105
- return `${list[1]}${bullet} ${renderTerminalInline(list[2], options)}`;
1106
- }
1107
-
1108
- const quote = line.match(/^>\s?(.*)$/);
1109
- if (quote) {
1110
- return `${paint('▏ ', [ANSI.muted], options)}${renderTerminalInline(quote[1], options)}`;
1111
- }
1112
-
1113
- if (/^(\*{3,}|-{3,}|_{3,})$/.test(line.trim())) {
1114
- const width = Math.min(Math.max(16, terminalWidth(options) - 4), 48);
1115
- return paint('─'.repeat(width), [ANSI.dim, ANSI.muted], options);
1116
- }
1117
-
1118
- return renderTerminalInline(line, options);
1119
- }
1120
-
1121
- function renderTerminalMarkdown(text, options = {}) {
1122
- const source = String(text || '');
1123
- const parts = [];
1124
- const fence = /```[^\n]*\n([\s\S]*?)```/g;
1125
- let last = 0;
1126
- let match = fence.exec(source);
1127
- while (match) {
1128
- if (match.index > last) {
1129
- parts.push({ kind: 'text', value: source.slice(last, match.index) });
1130
- }
1131
- parts.push({ kind: 'code', value: match[1] || '' });
1132
- last = match.index + match[0].length;
1133
- match = fence.exec(source);
1134
- }
1135
- if (last < source.length) parts.push({ kind: 'text', value: source.slice(last) });
1136
-
1137
- return parts.map((part) => {
1138
- if (part.kind === 'code') {
1139
- return part.value
1140
- .replace(/\n$/, '')
1141
- .split('\n')
1142
- .map((line) => paint(` ${line}`, [ANSI.muted], options))
1143
- .join('\n');
1144
- }
1145
- return part.value
1146
- .split('\n')
1147
- .map((line) => renderTerminalBlockLine(line, options))
1148
- .join('\n');
1149
- }).join('\n');
1150
- }
1151
-
1152
- function renderFenceBuffer(buffer, options = {}) {
1153
- return String(buffer || '')
1154
- .replace(/\n$/, '')
1155
- .split('\n')
1156
- .map((line) => paint(` ${line}`, [ANSI.muted], options))
1157
- .join('\n');
1158
- }
1159
-
1160
712
  function resetMarkdownState(state) {
1161
713
  state.markdownMode = 'normal';
1162
714
  state.markdownBuffer = '';
1163
715
  state.markdownCarry = '';
1164
- state.markdownDelim = '';
1165
- state.atLineStart = true;
1166
716
  }
1167
717
 
1168
718
  function ensureMarkdownState(state) {
1169
719
  if (!state.markdownMode) state.markdownMode = 'normal';
1170
720
  if (typeof state.markdownBuffer !== 'string') state.markdownBuffer = '';
1171
721
  if (typeof state.markdownCarry !== 'string') state.markdownCarry = '';
1172
- if (typeof state.markdownDelim !== 'string') state.markdownDelim = '';
1173
- if (typeof state.atLineStart !== 'boolean') state.atLineStart = true;
1174
- }
1175
-
1176
- function streamingListMarkerAt(input, index) {
1177
- const rest = input.slice(index);
1178
- const match = rest.match(/^(\s*)(?:[*+-]|\d+\.)\s+/);
1179
- if (!match) return null;
1180
- return { length: match[0].length, indent: match[1] || '' };
1181
- }
1182
-
1183
- function streamingHeadingAt(input, index) {
1184
- const rest = input.slice(index);
1185
- const match = rest.match(/^#{1,6}\s+([^\n]*)/);
1186
- if (!match) return null;
1187
- return { length: match[0].length, title: match[1] || '' };
1188
- }
1189
-
1190
- function streamingBlockquoteAt(input, index) {
1191
- const rest = input.slice(index);
1192
- const match = rest.match(/^>\s?/);
1193
- if (!match) return null;
1194
- return { length: match[0].length };
1195
- }
1196
-
1197
- function streamingHorizontalRuleAt(input, index) {
1198
- const rest = input.slice(index);
1199
- const match = rest.match(/^(-{3,}|\*{3,}|_{3,})(?=\n|$)/);
1200
- if (!match) return null;
1201
- return { length: match[0].length };
1202
- }
1203
-
1204
- function streamingLinkAt(input, index, options) {
1205
- const rest = input.slice(index);
1206
- const match = rest.match(/^\[([^\]]+)\]\(([^)]+)\)/);
1207
- if (!match) return null;
1208
- return {
1209
- length: match[0].length,
1210
- rendered: formatMarkdownLink(match[1], match[2], options),
1211
- };
1212
722
  }
1213
723
 
1214
724
  function renderStreamingMarkdown(state, text, options = {}) {
@@ -1218,239 +728,43 @@ function renderStreamingMarkdown(state, text, options = {}) {
1218
728
  let out = '';
1219
729
 
1220
730
  for (let i = 0; i < input.length;) {
1221
- if (state.markdownMode === 'fence') {
1222
- if (state.atLineStart) {
1223
- const rest = input.slice(i);
1224
- if (/^```/.test(rest)) {
1225
- out += renderFenceBuffer(state.markdownBuffer, options);
1226
- state.markdownBuffer = '';
1227
- state.markdownMode = 'normal';
1228
- const nl = rest.indexOf('\n');
1229
- if (nl === -1) { i = input.length; state.atLineStart = true; continue; }
1230
- out += '\n';
1231
- i += nl + 1;
1232
- state.atLineStart = true;
1233
- continue;
1234
- }
1235
- if (/^`{1,3}$/.test(rest)) { state.markdownCarry = rest; break; }
1236
- }
1237
- const c = input[i];
1238
- state.markdownBuffer += c;
1239
- state.atLineStart = c === '\n';
1240
- i += 1;
1241
- continue;
1242
- }
1243
-
1244
- if (state.markdownMode === 'normal' && state.atLineStart) {
1245
- const rest = input.slice(i);
1246
- // Code fence ```lang … ``` — wait for the full opening line, then stream
1247
- // the body as an indented block (handled by the 'fence' branch above).
1248
- if (/^```/.test(rest)) {
1249
- const nl = rest.indexOf('\n');
1250
- if (nl === -1) { state.markdownCarry = rest; break; }
1251
- state.markdownMode = 'fence';
1252
- state.markdownBuffer = '';
1253
- state.atLineStart = true;
1254
- i += nl + 1;
1255
- continue;
1256
- }
1257
- // Incomplete block marker at the end of this delta (heading ##, fence ```,
1258
- // bullet - / +, numbered 1.) — carry it so the next delta completes the
1259
- // marker instead of leaking it as plain text.
1260
- if (/^(?:#{1,6}|`{1,3}|[-+]|\d{1,9}\.)$/.test(rest)) { state.markdownCarry = rest; break; }
1261
-
1262
- const heading = streamingHeadingAt(input, i);
1263
- if (heading) {
1264
- out += paint(heading.title, [ANSI.bold], options);
1265
- i += heading.length;
1266
- if (input[i] === '\n') {
1267
- out += '\n';
1268
- i += 1;
1269
- state.atLineStart = true;
1270
- } else {
1271
- state.atLineStart = false;
1272
- }
1273
- continue;
1274
- }
1275
- const list = streamingListMarkerAt(input, i);
1276
- if (list) {
1277
- out += `${list.indent}${paint('•', [ANSI.accent], options)} `;
1278
- i += list.length;
1279
- state.atLineStart = false;
1280
- continue;
1281
- }
1282
- const quote = streamingBlockquoteAt(input, i);
1283
- if (quote) {
1284
- out += paint('▏ ', [ANSI.muted], options);
1285
- i += quote.length;
1286
- state.atLineStart = false;
1287
- continue;
1288
- }
1289
- const hr = streamingHorizontalRuleAt(input, i);
1290
- if (hr) {
1291
- const width = Math.min(Math.max(16, terminalWidth(options) - 4), 48);
1292
- out += paint('─'.repeat(width), [ANSI.dim, ANSI.muted], options);
1293
- i += hr.length;
1294
- if (input[i] === '\n') {
1295
- out += '\n';
1296
- i += 1;
1297
- state.atLineStart = true;
1298
- } else {
1299
- state.atLineStart = false;
1300
- }
1301
- continue;
1302
- }
1303
- }
1304
-
1305
731
  const char = input[i];
1306
732
  const next = input[i + 1];
1307
- const third = input[i + 2];
1308
733
 
1309
734
  if (state.markdownMode === 'normal') {
1310
- const link = streamingLinkAt(input, i, options);
1311
- if (link) {
1312
- out += link.rendered;
1313
- i += link.length;
1314
- state.atLineStart = false;
1315
- continue;
1316
- }
1317
- if (char === '[') {
1318
- state.markdownCarry = '[';
735
+ if (char === '*' && next === undefined) {
736
+ state.markdownCarry = '*';
1319
737
  break;
1320
738
  }
1321
- if (char === '*' && next === '*' && third === '*') {
1322
- state.markdownMode = 'bold_italic';
1323
- state.markdownBuffer = '';
1324
- state.markdownDelim = '***';
1325
- i += 3;
1326
- continue;
1327
- }
1328
739
  if (char === '*' && next === '*') {
1329
740
  state.markdownMode = 'bold';
1330
741
  state.markdownBuffer = '';
1331
- state.markdownDelim = '**';
1332
- i += 2;
1333
- continue;
1334
- }
1335
- if (char === '*') {
1336
- if (next === undefined) {
1337
- state.markdownCarry = '*';
1338
- break;
1339
- }
1340
- state.markdownMode = 'italic';
1341
- state.markdownBuffer = '';
1342
- state.markdownDelim = '*';
1343
- i += 1;
1344
- continue;
1345
- }
1346
- if (char === '_' && next === '_') {
1347
- state.markdownMode = 'bold';
1348
- state.markdownBuffer = '';
1349
- state.markdownDelim = '__';
1350
- i += 2;
1351
- continue;
1352
- }
1353
- if (char === '_') {
1354
- if (next === undefined) {
1355
- state.markdownCarry = '_';
1356
- break;
1357
- }
1358
- state.markdownMode = 'italic';
1359
- state.markdownBuffer = '';
1360
- state.markdownDelim = '_';
1361
- i += 1;
1362
- continue;
1363
- }
1364
- if (char === '~' && next === '~') {
1365
- state.markdownMode = 'strike';
1366
- state.markdownBuffer = '';
1367
- state.markdownDelim = '~~';
1368
742
  i += 2;
1369
743
  continue;
1370
744
  }
1371
745
  if (char === '`') {
1372
746
  state.markdownMode = 'code';
1373
747
  state.markdownBuffer = '';
1374
- state.markdownDelim = '`';
1375
748
  i += 1;
1376
749
  continue;
1377
750
  }
1378
751
  out += char;
1379
- if (char === '\n') state.atLineStart = true;
1380
- else state.atLineStart = false;
1381
752
  i += 1;
1382
753
  continue;
1383
754
  }
1384
755
 
1385
- if (state.markdownMode === 'bold_italic') {
1386
- if (char === '*' && next === '*' && third === '*') {
1387
- out += paint(state.markdownBuffer, [ANSI.bold, ANSI.italic], options);
1388
- state.markdownMode = 'normal';
1389
- state.markdownBuffer = '';
1390
- state.markdownDelim = '';
1391
- i += 3;
1392
- continue;
1393
- }
756
+ if (state.markdownMode === 'bold') {
1394
757
  if (char === '*' && next === undefined) {
1395
758
  state.markdownCarry = '*';
1396
759
  break;
1397
760
  }
1398
- state.markdownBuffer += char;
1399
- i += 1;
1400
- continue;
1401
- }
1402
-
1403
- if (state.markdownMode === 'bold') {
1404
- const close = state.markdownDelim === '__' ? (char === '_' && next === '_') : (char === '*' && next === '*');
1405
- if (close) {
761
+ if (char === '*' && next === '*') {
1406
762
  out += paint(state.markdownBuffer, [ANSI.bold], options);
1407
763
  state.markdownMode = 'normal';
1408
764
  state.markdownBuffer = '';
1409
- state.markdownDelim = '';
1410
765
  i += 2;
1411
766
  continue;
1412
767
  }
1413
- if ((char === '*' || char === '_') && next === undefined) {
1414
- state.markdownCarry = char;
1415
- break;
1416
- }
1417
- state.markdownBuffer += char;
1418
- i += 1;
1419
- continue;
1420
- }
1421
-
1422
- if (state.markdownMode === 'italic') {
1423
- const close = char === state.markdownDelim && next !== state.markdownDelim;
1424
- if (close) {
1425
- out += paint(state.markdownBuffer, [ANSI.italic], options);
1426
- state.markdownMode = 'normal';
1427
- state.markdownBuffer = '';
1428
- state.markdownDelim = '';
1429
- i += 1;
1430
- continue;
1431
- }
1432
- if (char === state.markdownDelim && next === undefined) {
1433
- state.markdownCarry = char;
1434
- break;
1435
- }
1436
- state.markdownBuffer += char;
1437
- i += 1;
1438
- continue;
1439
- }
1440
-
1441
- if (state.markdownMode === 'strike') {
1442
- if (char === '~' && next === '~') {
1443
- out += paint(state.markdownBuffer, [ANSI.strike], options);
1444
- state.markdownMode = 'normal';
1445
- state.markdownBuffer = '';
1446
- state.markdownDelim = '';
1447
- i += 2;
1448
- continue;
1449
- }
1450
- if (char === '~' && next === undefined) {
1451
- state.markdownCarry = '~';
1452
- break;
1453
- }
1454
768
  state.markdownBuffer += char;
1455
769
  i += 1;
1456
770
  continue;
@@ -1476,19 +790,10 @@ function renderStreamingMarkdown(state, text, options = {}) {
1476
790
  function flushStreamingMarkdown(state, output) {
1477
791
  ensureMarkdownState(state);
1478
792
  let out = state.markdownCarry || '';
1479
- state.markdownCarry = '';
1480
- if (state.markdownMode === 'bold_italic' && state.markdownBuffer) {
1481
- out += paint(state.markdownBuffer, [ANSI.bold, ANSI.italic], output);
1482
- } else if (state.markdownMode === 'bold' && state.markdownBuffer) {
793
+ if (state.markdownMode === 'bold' && state.markdownBuffer) {
1483
794
  out += paint(state.markdownBuffer, [ANSI.bold], output);
1484
- } else if (state.markdownMode === 'italic' && state.markdownBuffer) {
1485
- out += paint(state.markdownBuffer, [ANSI.italic], output);
1486
- } else if (state.markdownMode === 'strike' && state.markdownBuffer) {
1487
- out += paint(state.markdownBuffer, [ANSI.strike], output);
1488
795
  } else if (state.markdownMode === 'code' && state.markdownBuffer) {
1489
796
  out += paint(state.markdownBuffer, [ANSI.accent], output);
1490
- } else if (state.markdownMode === 'fence' && state.markdownBuffer) {
1491
- out += renderFenceBuffer(state.markdownBuffer, output);
1492
797
  }
1493
798
  resetMarkdownState(state);
1494
799
  if (out) output.write(out);
@@ -1538,26 +843,21 @@ function createProgressReporter(output, options = {}) {
1538
843
  const enabled = options.showProgress !== false;
1539
844
  const isTty = Boolean(output && output.isTTY);
1540
845
  const startedAt = Date.now();
846
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
1541
847
  let frameIndex = 0;
1542
- let verb = 'Thinking';
848
+ let verb = 'Working';
1543
849
  let interval = null;
1544
850
  let timeout = null;
1545
851
  let shown = false;
1546
852
  let closed = false;
1547
- const renderOptions = () => ({
1548
- ...output,
1549
- color: options.color,
1550
- tick: frameIndex,
1551
- frameIndex,
1552
- });
1553
853
 
1554
854
  const render = () => {
1555
855
  if (!enabled || closed) return;
1556
856
  if (isTty) {
1557
- output.write(`\r${formatWorkingLine(Date.now() - startedAt, verb, renderOptions())}\x1b[K`);
1558
- frameIndex += 1;
857
+ const frame = paint(frames[frameIndex++ % frames.length], [ANSI.accent], output);
858
+ output.write(`\r${formatWorkingLine(Date.now() - startedAt, verb, frame)}\x1b[K`);
1559
859
  } else if (!shown) {
1560
- output.write(`${formatWorkingLine(Date.now() - startedAt, verb, renderOptions())}\n`);
860
+ output.write(`${formatWorkingLine(Date.now() - startedAt)}\n`);
1561
861
  }
1562
862
  shown = true;
1563
863
  };
@@ -1567,11 +867,11 @@ function createProgressReporter(output, options = {}) {
1567
867
  if (!enabled) return;
1568
868
  timeout = setTimeout(() => {
1569
869
  render();
1570
- if (isTty) interval = setInterval(render, PROGRESS_FRAME_MS);
1571
- }, PROGRESS_START_DELAY_MS);
870
+ if (isTty) interval = setInterval(render, 120);
871
+ }, 700);
1572
872
  },
1573
873
  setVerb(next) {
1574
- verb = next || 'Thinking';
874
+ verb = next || 'Working';
1575
875
  },
1576
876
  clear() {
1577
877
  if (!isTty || !shown || closed) return;
@@ -1588,17 +888,6 @@ function createProgressReporter(output, options = {}) {
1588
888
  };
1589
889
  }
1590
890
 
1591
- function manageTurnInterrupt(onInterrupt, options = {}) {
1592
- const trigger = () => onInterrupt();
1593
- const register = options.registerAbort;
1594
- if (typeof register === 'function') {
1595
- register(trigger);
1596
- return () => register(null);
1597
- }
1598
- process.once('SIGINT', trigger);
1599
- return () => process.removeListener('SIGINT', trigger);
1600
- }
1601
-
1602
891
  function parseSseBlock(block) {
1603
892
  const data = String(block || '')
1604
893
  .split(/\r?\n/)
@@ -1695,6 +984,428 @@ function formatSystemInit(event, options = {}) {
1695
984
  return parts.length ? parts.join(' ') : null;
1696
985
  }
1697
986
 
987
+ function formatApprovalTime(value) {
988
+ const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
989
+ if (!match) return '';
990
+ const [, year, month, day, rawHour, minute] = match;
991
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
992
+ const hour24 = Number(rawHour);
993
+ const hour12 = hour24 % 12 || 12;
994
+ const ampm = hour24 >= 12 ? 'PM' : 'AM';
995
+ return `${months[Number(month) - 1] || month} ${Number(day)}, ${year} at ${hour12}:${minute} ${ampm}`;
996
+ }
997
+
998
+ function formatApprovalReceipt(receipt) {
999
+ if (!receipt || typeof receipt !== 'object') return null;
1000
+ const events = Array.isArray(receipt.tool_events) ? receipt.tool_events : [];
1001
+ const approvalEvent = events.find(event => event && event.approval_request);
1002
+ const request = approvalEvent && approvalEvent.approval_request;
1003
+ if (!request || request.status !== 'approval_required') return null;
1004
+
1005
+ const accept = receipt.task_accept_receipt || {};
1006
+ const actionType = request.executor_action_type || request.action_type || '';
1007
+ if (actionType === 'google_calendar_create_event' || accept.task === 'calendar.create_event') {
1008
+ const payload = request.payload || {};
1009
+ const start = accept.start || (payload.start && payload.start.dateTime) || (approvalEvent.preview && approvalEvent.preview.start);
1010
+ const when = formatApprovalTime(start);
1011
+ const title = taskPreviewTitle(receipt) || accept.title || (approvalEvent.preview && approvalEvent.preview.summary) || 'calendar event';
1012
+ return `Approval needed before creation: calendar event "${String(title).slice(0, 80)}"${when ? ` ${when}` : ''}`;
1013
+ }
1014
+
1015
+ const connector = String(request.connector || approvalEvent.tool || 'connector').replace(/_/g, ' ');
1016
+ const action = String(request.action || actionType || 'action').replace(/_/g, ' ');
1017
+ return `Approval needed before ${action}: ${connector}`;
1018
+ }
1019
+
1020
+ function taskPreviewApprovalEvent(receipt) {
1021
+ const events = Array.isArray(receipt && receipt.tool_events) ? receipt.tool_events : [];
1022
+ return events.find(event => event && event.approval_request) || null;
1023
+ }
1024
+
1025
+ function taskPreviewTitle(receipt) {
1026
+ const taskPreview = receipt && receipt.task_preview && typeof receipt.task_preview === 'object' ? receipt.task_preview : {};
1027
+ const slots = taskPreview.slots && typeof taskPreview.slots === 'object' ? taskPreview.slots : {};
1028
+ const accept = receipt && receipt.task_accept_receipt && typeof receipt.task_accept_receipt === 'object' ? receipt.task_accept_receipt : {};
1029
+ const approvalEvent = taskPreviewApprovalEvent(receipt);
1030
+ return String(
1031
+ slots.title
1032
+ || accept.title
1033
+ || (approvalEvent && approvalEvent.preview && approvalEvent.preview.summary)
1034
+ || ''
1035
+ ).trim();
1036
+ }
1037
+
1038
+ function formatTaskPreviewPlan(receipt) {
1039
+ const taskPreview = receipt && receipt.task_preview && typeof receipt.task_preview === 'object' ? receipt.task_preview : null;
1040
+ if (!taskPreview) return null;
1041
+ const task = String(taskPreview.task || '').trim();
1042
+ const slots = taskPreview.slots && typeof taskPreview.slots === 'object' ? taskPreview.slots : {};
1043
+ if (task === 'calendar.create_event') {
1044
+ const title = taskPreviewTitle(receipt);
1045
+ const start = slots.start || (receipt.task_accept_receipt && receipt.task_accept_receipt.start);
1046
+ const when = formatApprovalTime(start);
1047
+ return ['create calendar event', title ? `"${title.slice(0, 80)}"` : '', when].filter(Boolean).join(' ');
1048
+ }
1049
+ return task ? task.replace(/[._-]/g, ' ') : null;
1050
+ }
1051
+
1052
+ function formatTaskPreviewCheck(receipt) {
1053
+ const taskPreview = receipt && receipt.task_preview && typeof receipt.task_preview === 'object' ? receipt.task_preview : null;
1054
+ if (!taskPreview) return null;
1055
+ const status = String(taskPreview.status || '').trim();
1056
+ const missing = Array.isArray(taskPreview.missing)
1057
+ ? taskPreview.missing.map(item => String(item || '').trim()).filter(Boolean)
1058
+ : [];
1059
+ if (missing.length) return `needs ${missing.join(', ')}`;
1060
+ if (status === 'approval_required') return 'approval required';
1061
+ if (status === 'needs_input') return 'needs input';
1062
+ return status ? status.replace(/_/g, ' ') : null;
1063
+ }
1064
+
1065
+ function formatTaskPreviewRows(receipt, options = {}) {
1066
+ const taskPreview = receipt && receipt.task_preview && typeof receipt.task_preview === 'object' ? receipt.task_preview : null;
1067
+ if (!taskPreview) return [];
1068
+ const owner = String(taskPreview.owner_member || taskPreview.owner || '').trim();
1069
+ const plan = formatTaskPreviewPlan(receipt);
1070
+ const check = formatTaskPreviewCheck(receipt);
1071
+ return [
1072
+ owner ? formatAuxRow('owner', owner, options) : null,
1073
+ plan ? formatAuxRow('plan', plan, options) : null,
1074
+ check ? formatAuxRow('check', check, options) : null,
1075
+ ].filter(Boolean);
1076
+ }
1077
+
1078
+ function approvalStorePath(options = {}) {
1079
+ return options.storePath || process.env.AX_APPROVAL_STORE || path.join(os.homedir(), '.atris', 'ax-approvals.json');
1080
+ }
1081
+
1082
+ function approvalMaxAgeMs(options = {}) {
1083
+ const value = Number(options.maxAgeMs);
1084
+ return Number.isFinite(value) && value > 0 ? value : DEFAULT_APPROVAL_MAX_AGE_MS;
1085
+ }
1086
+
1087
+ function approvalRecordAgeMs(record, options = {}) {
1088
+ const created = Date.parse(record && record.created_at ? record.created_at : '');
1089
+ if (!Number.isFinite(created)) return Infinity;
1090
+ const now = options.now instanceof Date ? options.now.getTime() : Number(options.now || Date.now());
1091
+ return Math.max(0, now - created);
1092
+ }
1093
+
1094
+ function storedApprovalIsStale(record, options = {}) {
1095
+ return approvalRecordAgeMs(record, options) > approvalMaxAgeMs(options);
1096
+ }
1097
+
1098
+ function emptyApprovalStore() {
1099
+ return { schema: 'ax.pending_approvals.v1', approvals: [] };
1100
+ }
1101
+
1102
+ function readApprovalStore(options = {}) {
1103
+ const file = approvalStorePath(options);
1104
+ try {
1105
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1106
+ const approvals = Array.isArray(parsed.approvals) ? parsed.approvals.filter(row => row && row.id && row.approval_request) : [];
1107
+ return { schema: 'ax.pending_approvals.v1', approvals };
1108
+ } catch (_) {
1109
+ return emptyApprovalStore();
1110
+ }
1111
+ }
1112
+
1113
+ function writeApprovalStore(store, options = {}) {
1114
+ const file = approvalStorePath(options);
1115
+ const dir = path.dirname(file);
1116
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1117
+ try {
1118
+ fs.chmodSync(dir, 0o700);
1119
+ } catch (_) {}
1120
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
1121
+ const data = JSON.stringify({
1122
+ schema: 'ax.pending_approvals.v1',
1123
+ approvals: Array.isArray(store && store.approvals) ? store.approvals : [],
1124
+ }, null, 2);
1125
+ fs.writeFileSync(tmp, data, { mode: 0o600 });
1126
+ fs.renameSync(tmp, file);
1127
+ try {
1128
+ fs.chmodSync(file, 0o600);
1129
+ } catch (_) {}
1130
+ return file;
1131
+ }
1132
+
1133
+ function approvalRecordFromReceipt(receipt, approvalRequest, options = {}) {
1134
+ const request = approvalRequestWithStableId(approvalRequest);
1135
+ const id = request && (request.approval_request_id || request.approval_id);
1136
+ if (!id) return null;
1137
+ const approvalLine = formatApprovalReceipt({
1138
+ ...receipt,
1139
+ tool_events: [{
1140
+ ...(taskPreviewApprovalEvent(receipt) || {}),
1141
+ approval_request: request,
1142
+ }],
1143
+ });
1144
+ return {
1145
+ id,
1146
+ created_at: options.createdAt || new Date().toISOString(),
1147
+ updated_at: options.updatedAt || new Date().toISOString(),
1148
+ cwd: options.cwd || '',
1149
+ mode: normalizeMode(options.mode || 'fast'),
1150
+ approval_request: request,
1151
+ display_rows: [
1152
+ ...formatTaskPreviewRows(receipt, { color: false }),
1153
+ approvalLine ? formatAuxRow('wait', approvalLine, { color: false }) : null,
1154
+ ].filter(Boolean),
1155
+ };
1156
+ }
1157
+
1158
+ function persistPendingApproval(receipt, approvalRequest, options = {}) {
1159
+ if (options.disableApprovalStore) return null;
1160
+ const record = approvalRecordFromReceipt(receipt || {}, approvalRequest, options);
1161
+ if (!record) return null;
1162
+ const store = readApprovalStore(options);
1163
+ const existing = store.approvals.find(row => row.id === record.id);
1164
+ if (existing && existing.created_at) record.created_at = existing.created_at;
1165
+ store.approvals = [
1166
+ record,
1167
+ ...store.approvals.filter(row => row.id !== record.id),
1168
+ ].slice(0, 50);
1169
+ writeApprovalStore(store, options);
1170
+ return record;
1171
+ }
1172
+
1173
+ function findStoredApproval(ref, options = {}) {
1174
+ const target = String(ref || '').trim();
1175
+ if (!target) return null;
1176
+ const store = readApprovalStore(options);
1177
+ const matches = store.approvals.filter(row => String(row.id || '').startsWith(target));
1178
+ if (matches.length !== 1) return null;
1179
+ return matches[0];
1180
+ }
1181
+
1182
+ function removeStoredApproval(ref, options = {}) {
1183
+ const record = findStoredApproval(ref, options);
1184
+ if (!record) return null;
1185
+ const store = readApprovalStore(options);
1186
+ store.approvals = store.approvals.filter(row => row.id !== record.id);
1187
+ writeApprovalStore(store, options);
1188
+ return record;
1189
+ }
1190
+
1191
+ function formatStoredApprovals(store, options = {}) {
1192
+ const approvals = Array.isArray(store && store.approvals) ? store.approvals : [];
1193
+ if (!approvals.length) return 'No pending approvals.';
1194
+ const blocks = ['Pending approvals'];
1195
+ for (const approval of approvals) {
1196
+ blocks.push(formatAuxRow('id', approval.id, options));
1197
+ blocks.push(formatAuxRow('approve', formatApprovalCommand(approval.id), options));
1198
+ for (const row of Array.isArray(approval.display_rows) ? approval.display_rows : []) {
1199
+ blocks.push(row);
1200
+ }
1201
+ }
1202
+ return blocks.join('\n');
1203
+ }
1204
+
1205
+ function formatApprovalCommand(id) {
1206
+ return `ax --approve ${String(id || '').trim()}`;
1207
+ }
1208
+
1209
+ function stableStringify(value) {
1210
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
1211
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
1212
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
1213
+ }
1214
+
1215
+ function approvalRequestWithStableId(request) {
1216
+ if (!request || typeof request !== 'object') return null;
1217
+ const copy = JSON.parse(JSON.stringify(request));
1218
+ if (!copy.approval_request_id && !copy.approval_id) {
1219
+ const fingerprint = crypto
1220
+ .createHash('sha256')
1221
+ .update(stableStringify({
1222
+ action_type: copy.executor_action_type || copy.action_type || copy.action || '',
1223
+ connector: copy.connector || '',
1224
+ payload: copy.payload || null,
1225
+ }))
1226
+ .digest('hex')
1227
+ .slice(0, 20);
1228
+ copy.approval_request_id = `apreq_ax_${fingerprint}`;
1229
+ }
1230
+ return copy;
1231
+ }
1232
+
1233
+ function approvalRequestFromReceipt(receipt) {
1234
+ if (!receipt || typeof receipt !== 'object') return null;
1235
+ const events = Array.isArray(receipt.tool_events) ? receipt.tool_events : [];
1236
+ const approvalEvent = events.find(event => event && event.approval_request);
1237
+ const request = approvalEvent && approvalEvent.approval_request;
1238
+ if (!request || request.status !== 'approval_required') return null;
1239
+ return approvalRequestWithStableId(request);
1240
+ }
1241
+
1242
+ function approvalExecutionRequestFromReceipt(receipt) {
1243
+ if (!receipt || typeof receipt !== 'object') return null;
1244
+ const accepted = receipt.task_accept_receipt || {};
1245
+ if (accepted.accepted !== true) return null;
1246
+ if (accepted.execution_endpoint && accepted.execution_endpoint !== APPROVAL_EXECUTE_PATH) return null;
1247
+ if (accepted.execution_status && accepted.execution_status !== 'pending_approval_execution') return null;
1248
+ return approvalRequestFromReceipt(receipt);
1249
+ }
1250
+
1251
+ function approvalExecutionRequestFromState(state) {
1252
+ return approvalExecutionRequestFromReceipt(receiptFromState(state));
1253
+ }
1254
+
1255
+ function approvalActionLabel(actionType) {
1256
+ if (actionType === 'google_calendar_create_event') return 'calendar event';
1257
+ if (actionType === 'gmail_send') return 'email';
1258
+ if (actionType === 'slack_post_message') return 'Slack message';
1259
+ if (actionType === 'github_create_issue') return 'GitHub issue';
1260
+ if (actionType === 'google_drive_create_file') return 'Drive file';
1261
+ if (actionType === 'notion_create_page') return 'Notion page';
1262
+ return 'approved action';
1263
+ }
1264
+
1265
+ function capitalize(value) {
1266
+ const text = String(value || '');
1267
+ return text ? `${text[0].toUpperCase()}${text.slice(1)}` : text;
1268
+ }
1269
+
1270
+ function approvalSuccessMessage(actionType) {
1271
+ const actionLabel = approvalActionLabel(actionType);
1272
+ if (actionType === 'gmail_send') return 'Sent email.';
1273
+ if (actionType === 'slack_post_message') return 'Sent Slack message.';
1274
+ return `Created ${actionLabel}.`;
1275
+ }
1276
+
1277
+ function approvalNotDoneMessage(actionType, reason) {
1278
+ const actionLabel = approvalActionLabel(actionType);
1279
+ const verb = actionType === 'gmail_send' || actionType === 'slack_post_message' ? 'sent' : 'created';
1280
+ return `${capitalize(actionLabel)} was not ${verb} because ${reason}.`;
1281
+ }
1282
+
1283
+ function formatApprovalExecutionResult(response) {
1284
+ if (!response) return null;
1285
+ const data = response.data && typeof response.data === 'object' ? response.data : {};
1286
+ const detail = data.detail && typeof data.detail === 'object' ? data.detail : {};
1287
+ const execution = data.execution && typeof data.execution === 'object' ? data.execution : {};
1288
+ const actionType = data.action_type || execution.action_type || detail.action_type || '';
1289
+ const actionLabel = approvalActionLabel(actionType);
1290
+ const status = data.status || execution.status || detail.error || '';
1291
+
1292
+ if (!response.ok) {
1293
+ if (detail.error === 'paid_cloud_computer_required') return 'Approval execution needs a paid cloud computer.';
1294
+ if (detail.error === 'approval_signing_key_required') return 'Approval execution is not configured in this runtime.';
1295
+ if (detail.error === 'approval_already_executed') return `Already executed this ${actionLabel}.`;
1296
+ if (detail.user_message) return String(detail.user_message);
1297
+ if (detail.message) return String(detail.message);
1298
+ return response.error || `Approval execution failed (${response.status || 'unknown'}).`;
1299
+ }
1300
+
1301
+ if (execution.executed || status === 'executed') return approvalSuccessMessage(actionType);
1302
+ if (status === 'blocked_connector_not_connected') return approvalNotDoneMessage(actionType, 'the connector is not connected');
1303
+ if (status === 'execution_failed') return approvalNotDoneMessage(actionType, 'execution failed');
1304
+ if (execution.would_execute) return `${actionLabel[0].toUpperCase()}${actionLabel.slice(1)} was approved but not confirmed as created.`;
1305
+ return `Approval handled for ${actionLabel}.`;
1306
+ }
1307
+
1308
+ function approvalExecutionShouldRemainPending(response) {
1309
+ if (!response) return true;
1310
+ const data = response.data && typeof response.data === 'object' ? response.data : {};
1311
+ const detail = data.detail && typeof data.detail === 'object' ? data.detail : {};
1312
+ const execution = data.execution && typeof data.execution === 'object' ? data.execution : {};
1313
+ const status = data.status || execution.status || detail.error || '';
1314
+ if (execution.executed || status === 'executed') return false;
1315
+ if (detail.error === 'approval_already_executed') return false;
1316
+ return true;
1317
+ }
1318
+
1319
+ function creditsFromApprovalExecution(response) {
1320
+ const billing = response && response.data && response.data.billing;
1321
+ if (!billing || billing.billed === false) return null;
1322
+ const credits = Number(billing.credits_charged);
1323
+ return Number.isFinite(credits) && credits > 0 ? credits : null;
1324
+ }
1325
+
1326
+ async function postApprovalExecution(approvalRequest, { model = 'atris:fast', token = authToken() } = {}) {
1327
+ return postJson(APPROVAL_EXECUTE_PATH, {
1328
+ model,
1329
+ approval_request: approvalRequest,
1330
+ }, { token });
1331
+ }
1332
+
1333
+ async function executeApprovalRequest(approvalRequest, {
1334
+ model = 'atris:fast',
1335
+ output = process.stdout,
1336
+ postApproval = postApprovalExecution,
1337
+ state = null,
1338
+ } = {}) {
1339
+ if (!approvalRequest) return null;
1340
+ const localState = state || {
1341
+ events: [],
1342
+ errors: [],
1343
+ output: '',
1344
+ pendingText: '',
1345
+ wroteText: false,
1346
+ wroteActivity: false,
1347
+ lastChar: '\n',
1348
+ progress: null,
1349
+ inAuxBlock: false,
1350
+ needsBullet: true,
1351
+ };
1352
+ writeAuxLine(localState, output, formatAuxRow('wait', 'Sending approved action to Atris cloud...', output));
1353
+ const response = await postApproval(approvalRequest, { model });
1354
+ const message = formatApprovalExecutionResult(response);
1355
+ if (message) {
1356
+ const execution = response && response.data && response.data.execution;
1357
+ const label = response && response.ok && execution && execution.executed ? 'done' : 'wait';
1358
+ writeAuxLine(localState, output, formatAuxRow(label, message, output));
1359
+ }
1360
+ return { approval_request: approvalRequest, response, message };
1361
+ }
1362
+
1363
+ async function executeApprovalFromState(state, options = {}) {
1364
+ const approvalRequest = approvalExecutionRequestFromState(state);
1365
+ if (!approvalRequest) return null;
1366
+ return executeApprovalRequest(approvalRequest, { ...options, state });
1367
+ }
1368
+
1369
+ async function approveStoredApproval(ref, options = {}) {
1370
+ const record = findStoredApproval(ref, options);
1371
+ if (!record) throw new Error(`Pending approval not found: ${ref}`);
1372
+ const output = options.output || process.stdout;
1373
+ const startedAt = Date.now();
1374
+ output.write(`${formatAuxRow('id', record.id, output)}\n`);
1375
+ for (const row of Array.isArray(record.display_rows) ? record.display_rows : []) {
1376
+ output.write(`${row}\n`);
1377
+ }
1378
+ if (storedApprovalIsStale(record, options)) {
1379
+ removeStoredApproval(record.id, options);
1380
+ const message = 'Approval expired. Recreate the preview before approving.';
1381
+ output.write(`${formatAuxRow('wait', message, output)}\n`);
1382
+ output.write(`${formatDoneLine(Date.now() - startedAt)}\n`);
1383
+ return { approval_request: record.approval_request, response: null, message, stale: true };
1384
+ }
1385
+ output.write('Accepted.\n');
1386
+ const execution = await executeApprovalRequest(record.approval_request, {
1387
+ model: options.model || modelForMode(options.mode || record.mode || 'fast'),
1388
+ output,
1389
+ ...(options.postApproval ? { postApproval: options.postApproval } : {}),
1390
+ });
1391
+ const credits = creditsFromApprovalExecution(execution && execution.response);
1392
+ output.write(`${formatDoneLine(Date.now() - startedAt, credits)}\n`);
1393
+ if (approvalExecutionShouldRemainPending(execution && execution.response)) {
1394
+ const store = readApprovalStore(options);
1395
+ store.approvals = store.approvals.map(row => row.id === record.id ? { ...row, updated_at: new Date().toISOString() } : row);
1396
+ writeApprovalStore(store, options);
1397
+ } else {
1398
+ removeStoredApproval(record.id, options);
1399
+ }
1400
+ return execution;
1401
+ }
1402
+
1403
+ function denyStoredApproval(ref, options = {}) {
1404
+ const record = removeStoredApproval(ref, options);
1405
+ if (!record) throw new Error(`Pending approval not found: ${ref}`);
1406
+ return record;
1407
+ }
1408
+
1698
1409
  function clearRetriedText(state) {
1699
1410
  state.pendingText = '';
1700
1411
  state.output = '';
@@ -1712,7 +1423,7 @@ function stopProgress(state) {
1712
1423
  }
1713
1424
 
1714
1425
  function flushPendingText(state, output) {
1715
- const hasMarkdownRemainder = output && (
1426
+ const hasMarkdownRemainder = output && output.isTTY && (
1716
1427
  state.markdownCarry
1717
1428
  || state.markdownBuffer
1718
1429
  || (state.markdownMode && state.markdownMode !== 'normal')
@@ -1724,7 +1435,7 @@ function flushPendingText(state, output) {
1724
1435
  output.write('● ');
1725
1436
  state.needsBullet = false;
1726
1437
  }
1727
- if (state.pendingText) output.write(renderTerminalMarkdown(state.pendingText, output));
1438
+ if (state.pendingText) output.write(output && output.isTTY ? renderTerminalMarkdown(state.pendingText, output) : state.pendingText);
1728
1439
  const flushedMarkdown = hasMarkdownRemainder ? flushStreamingMarkdown(state, output) : '';
1729
1440
  state.wroteText = true;
1730
1441
  state.wroteActivity = true;
@@ -1763,42 +1474,92 @@ function writeAuxLine(state, output, line) {
1763
1474
  state.needsBullet = true;
1764
1475
  }
1765
1476
 
1766
- function compactHistory(history, limit = 8) {
1767
- return history
1477
+ function structuredHistory(history, limit = 8) {
1478
+ return (Array.isArray(history) ? history : [])
1768
1479
  .slice(-limit)
1769
- .map(turn => `${turn.role}: ${String(turn.content || '').slice(0, 1200)}`)
1770
- .join('\n');
1480
+ .map(turn => {
1481
+ const message = {
1482
+ role: turn && turn.role === 'assistant' ? 'assistant' : 'user',
1483
+ content: String((turn && turn.content) || '').slice(0, 1200)
1484
+ };
1485
+ if (message.role === 'assistant' && turn && turn.task_preview) {
1486
+ message.task_preview = turn.task_preview;
1487
+ }
1488
+ return message;
1489
+ })
1490
+ .filter(turn => turn.content.trim());
1491
+ }
1492
+
1493
+ function latestPendingTaskPreview(history = []) {
1494
+ for (const turn of [...(Array.isArray(history) ? history : [])].reverse()) {
1495
+ if (!turn || turn.role !== 'assistant' || !turn.task_preview) continue;
1496
+ const status = String(turn.task_preview.status || '');
1497
+ if (['needs_input', 'approval_required'].includes(status)) return turn.task_preview;
1498
+ return null;
1499
+ }
1500
+ return null;
1501
+ }
1502
+
1503
+ function latestPendingApprovalRequest(history = []) {
1504
+ for (const turn of [...(Array.isArray(history) ? history : [])].reverse()) {
1505
+ if (!turn || turn.role !== 'assistant') continue;
1506
+ if (turn.approval_request) return approvalRequestWithStableId(turn.approval_request);
1507
+ return null;
1508
+ }
1509
+ return null;
1510
+ }
1511
+
1512
+ function messageApprovesPendingApproval(message) {
1513
+ const normalized = String(message || '').trim().toLowerCase().replace(/[.!?]+$/g, '');
1514
+ return new Set([
1515
+ 'yes',
1516
+ 'y',
1517
+ 'yep',
1518
+ 'yeah',
1519
+ 'ok',
1520
+ 'okay',
1521
+ 'approve',
1522
+ 'approved',
1523
+ 'confirm',
1524
+ 'create it',
1525
+ 'send it',
1526
+ 'post it',
1527
+ 'do it',
1528
+ 'go ahead',
1529
+ 'looks good',
1530
+ 'yes create it',
1531
+ 'yes send it',
1532
+ 'yes post it',
1533
+ ]).has(normalized);
1534
+ }
1535
+
1536
+ function messageCancelsPendingApproval(message) {
1537
+ const normalized = String(message || '').trim().toLowerCase().replace(/[.!?]+$/g, '');
1538
+ return new Set(['no', 'nope', 'cancel', 'cancel it', 'stop', 'never mind', 'nevermind', 'do not', "don't"]).has(normalized);
1771
1539
  }
1772
1540
 
1773
1541
  function buildMessage(message, history = []) {
1774
- const trimmed = String(message || '').trim();
1775
- if (!history.length) return trimmed;
1776
- return [
1777
- 'Continue this terminal coding-agent conversation in the same workspace.',
1778
- 'Use local tools when the user asks about files, code, tests, or edits.',
1779
- '',
1780
- 'Recent conversation:',
1781
- compactHistory(history),
1782
- '',
1783
- '# Current user message',
1784
- trimmed,
1785
- ].join('\n');
1542
+ return String(message || '').trim();
1786
1543
  }
1787
1544
 
1788
1545
  function buildPayload(message, options = {}) {
1789
1546
  const mode = normalizeMode(options.mode);
1790
1547
  const route = options.business ? 'local' : resolveRoute(message, options);
1791
1548
  const local = route !== 'cloud';
1792
- const bypass = resolveBypassPermissions(options);
1793
1549
  const verifyCommand = String(options.verify || '').trim();
1550
+ const previousMessages = structuredHistory(options.history || []);
1794
1551
  const payload = {
1795
1552
  message: buildMessage(message, options.history || []),
1796
1553
  model: modelForMode(mode),
1797
- max_turns: options.goalEval ? 1 : (local ? (mode === 'fast' ? 8 : 14) : 1),
1798
- verify_command: verifyCommand || 'true',
1799
- member_slug: MEMBER_SLUG,
1800
- bypass_permissions: bypass,
1554
+ max_turns: local ? (mode === 'fast' ? 8 : 14) : 1,
1555
+ verify_command: verifyCommand || 'true'
1801
1556
  };
1557
+ if (previousMessages.length) {
1558
+ payload.previous_messages = previousMessages;
1559
+ }
1560
+ if (options.conversationId) {
1561
+ payload.conversation_id = String(options.conversationId);
1562
+ }
1802
1563
  if (options.business) {
1803
1564
  // Business cloud workspace: the model loop stays on the backend and every
1804
1565
  // file/bash tool call relays here, executing on the business EC2 via the
@@ -1814,20 +1575,22 @@ function buildPayload(message, options = {}) {
1814
1575
  if (!local && options.connectionUserId) {
1815
1576
  payload.connection_user_id = options.connectionUserId;
1816
1577
  }
1817
- if (!local && bypass && connectorWriteIntent(message)) {
1818
- payload.allow_external_actions = true;
1819
- payload.cleanup_external_actions = true;
1820
- payload.max_turns = mode === 'fast' ? 2 : 4;
1821
- }
1822
1578
  return payload;
1823
1579
  }
1824
1580
 
1825
1581
  function buildCodeFastPayload(message, options = {}) {
1582
+ const previousMessages = structuredHistory(options.history || []);
1826
1583
  const payload = {
1827
1584
  message: buildMessage(message, options.history || []),
1828
1585
  model: CODE_FAST.model,
1829
1586
  timeout_seconds: Number(options.timeoutSeconds || 180)
1830
1587
  };
1588
+ if (previousMessages.length) {
1589
+ payload.previous_messages = previousMessages;
1590
+ }
1591
+ if (options.conversationId) {
1592
+ payload.conversation_id = String(options.conversationId);
1593
+ }
1831
1594
  if (isCodeFastLocal(options)) {
1832
1595
  payload.workspace_path = options.cwd || process.cwd();
1833
1596
  }
@@ -1880,7 +1643,6 @@ function handleEvent(event, state, output) {
1880
1643
  writeAuxLine(state, output, formatToolResultLine(summarizeToolResult(result, output), output));
1881
1644
  state.lastAux = 'result';
1882
1645
  }
1883
- if (state.progress && state.progress.setVerb) state.progress.setVerb('Thinking');
1884
1646
  return;
1885
1647
  }
1886
1648
 
@@ -1911,13 +1673,23 @@ function handleEvent(event, state, output) {
1911
1673
  clearRetriedText(state);
1912
1674
  return;
1913
1675
  }
1914
- if (state.progress && state.progress.setVerb && /think|reason|plan/i.test(event.message)) {
1915
- state.progress.setVerb('Thinking');
1916
- }
1917
1676
  flushPendingText(state, output);
1918
- const statusText = formatStatusMessage(event.message);
1919
- if (statusText) {
1920
- writeAuxLine(state, output, paint(`✦ ${statusText}`, [ANSI.bold, ANSI.magenta], output));
1677
+ writeAuxLine(state, output, paint(`· ${formatStatusMessage(event.message)}`, [ANSI.muted], output));
1678
+ return;
1679
+ }
1680
+
1681
+ if (event.type === 'receipt' && event.receipt) {
1682
+ const approvalLine = formatApprovalReceipt(event.receipt);
1683
+ const previewRows = formatTaskPreviewRows(event.receipt, output);
1684
+ if (approvalLine) {
1685
+ flushPendingText(state, output);
1686
+ for (const row of previewRows) writeAuxLine(state, output, row);
1687
+ writeAuxLine(state, output, formatAuxRow('wait', approvalLine, output));
1688
+ state.lastAux = 'approval';
1689
+ } else if (previewRows.length) {
1690
+ flushPendingText(state, output);
1691
+ for (const row of previewRows) writeAuxLine(state, output, row);
1692
+ state.lastAux = 'preview';
1921
1693
  }
1922
1694
  return;
1923
1695
  }
@@ -1946,7 +1718,7 @@ async function postTurn(message, options = {}) {
1946
1718
  const connectionContext = options.connectionContext || (shouldSendConnectionContext
1947
1719
  ? await buildConnectionContext({ token, localWorkspace: local })
1948
1720
  : null);
1949
- const connectionUserId = !local && isLoopbackBackend({ route }) ? authUserId() : '';
1721
+ const connectionUserId = !local && isLoopbackBackend() ? authUserId() : '';
1950
1722
  const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId });
1951
1723
  const postData = JSON.stringify(payload);
1952
1724
  const output = options.output || process.stdout;
@@ -1954,7 +1726,7 @@ async function postTurn(message, options = {}) {
1954
1726
  // SSE traffic in between, so the socket-idle timeout needs more headroom.
1955
1727
  const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
1956
1728
  const timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
1957
- const turnUrl = new URL(backendUrl({ route }));
1729
+ const turnUrl = new URL(backendUrl());
1958
1730
  const transport = turnUrl.protocol === 'https:' ? https : http;
1959
1731
  const state = {
1960
1732
  events: [],
@@ -1972,19 +1744,15 @@ async function postTurn(message, options = {}) {
1972
1744
  markdownMode: 'normal',
1973
1745
  markdownBuffer: '',
1974
1746
  markdownCarry: '',
1975
- atLineStart: true,
1976
1747
  relay: options.business ? {
1977
1748
  chain: Promise.resolve(),
1978
1749
  execute: options.business.executor,
1979
- post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl({ route }))
1750
+ post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl())
1980
1751
  } : null
1981
1752
  };
1982
1753
 
1983
1754
  return new Promise((resolve, reject) => {
1984
1755
  let settled = false;
1985
- let interrupted = false;
1986
- let req = null;
1987
- let removeInterrupt = null;
1988
1756
  const startedAt = Date.now();
1989
1757
  state.progress = createProgressReporter(output, options);
1990
1758
  state.progress.start();
@@ -1992,8 +1760,6 @@ async function postTurn(message, options = {}) {
1992
1760
  const finish = (error, value) => {
1993
1761
  if (settled) return;
1994
1762
  settled = true;
1995
- if (removeInterrupt) removeInterrupt();
1996
- removeInterrupt = null;
1997
1763
  if (state.progress) state.progress.stop();
1998
1764
  state.progress = null;
1999
1765
  state.durationMs = Date.now() - startedAt;
@@ -2001,15 +1767,7 @@ async function postTurn(message, options = {}) {
2001
1767
  else resolve(value);
2002
1768
  };
2003
1769
 
2004
- removeInterrupt = manageTurnInterrupt(() => {
2005
- if (settled) return;
2006
- interrupted = true;
2007
- flushPendingText(state, output);
2008
- if (req) req.destroy();
2009
- finish(null, { ...state, interrupted: true });
2010
- }, options);
2011
-
2012
- req = transport.request({
1770
+ const req = transport.request({
2013
1771
  hostname: turnUrl.hostname,
2014
1772
  port: turnUrl.port || (turnUrl.protocol === 'https:' ? 443 : 80),
2015
1773
  path: `${turnUrl.pathname}${turnUrl.search}`,
@@ -2074,12 +1832,8 @@ async function postTurn(message, options = {}) {
2074
1832
  });
2075
1833
  });
2076
1834
 
2077
- req.on('error', (error) => {
2078
- if (interrupted) return;
2079
- finish(error);
2080
- });
1835
+ req.on('error', finish);
2081
1836
  req.setTimeout(timeoutMs, () => {
2082
- if (settled) return;
2083
1837
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
2084
1838
  req.destroy();
2085
1839
  });
@@ -2099,7 +1853,7 @@ async function postCodeFastTurn(message, options = {}) {
2099
1853
  const output = options.output || process.stdout;
2100
1854
  const token = authToken();
2101
1855
  const timeoutMs = Math.max(10000, Math.min(600000, Number(payload.timeout_seconds || 180) * 1000));
2102
- const turnUrl = new URL(codeFastUrl({ ...options, route }));
1856
+ const turnUrl = new URL(codeFastUrl());
2103
1857
  const transport = turnUrl.protocol === 'https:' ? https : http;
2104
1858
  const state = {
2105
1859
  events: [],
@@ -2116,15 +1870,11 @@ async function postCodeFastTurn(message, options = {}) {
2116
1870
  lastAux: '',
2117
1871
  markdownMode: 'normal',
2118
1872
  markdownBuffer: '',
2119
- markdownCarry: '',
2120
- atLineStart: true,
1873
+ markdownCarry: ''
2121
1874
  };
2122
1875
 
2123
1876
  return new Promise((resolve, reject) => {
2124
1877
  let settled = false;
2125
- let interrupted = false;
2126
- let req = null;
2127
- let removeInterrupt = null;
2128
1878
  const startedAt = Date.now();
2129
1879
  state.progress = createProgressReporter(output, options);
2130
1880
  state.progress.start();
@@ -2132,8 +1882,6 @@ async function postCodeFastTurn(message, options = {}) {
2132
1882
  const finish = (error, value) => {
2133
1883
  if (settled) return;
2134
1884
  settled = true;
2135
- if (removeInterrupt) removeInterrupt();
2136
- removeInterrupt = null;
2137
1885
  if (state.progress) state.progress.stop();
2138
1886
  state.progress = null;
2139
1887
  state.durationMs = Date.now() - startedAt;
@@ -2141,15 +1889,7 @@ async function postCodeFastTurn(message, options = {}) {
2141
1889
  else resolve(value);
2142
1890
  };
2143
1891
 
2144
- removeInterrupt = manageTurnInterrupt(() => {
2145
- if (settled) return;
2146
- interrupted = true;
2147
- flushPendingText(state, output);
2148
- if (req) req.destroy();
2149
- finish(null, { ...state, interrupted: true });
2150
- }, options);
2151
-
2152
- req = transport.request({
1892
+ const req = transport.request({
2153
1893
  hostname: turnUrl.hostname,
2154
1894
  port: turnUrl.port || (turnUrl.protocol === 'https:' ? 443 : 80),
2155
1895
  path: `${turnUrl.pathname}${turnUrl.search}`,
@@ -2201,12 +1941,8 @@ async function postCodeFastTurn(message, options = {}) {
2201
1941
  });
2202
1942
  });
2203
1943
 
2204
- req.on('error', (error) => {
2205
- if (interrupted) return;
2206
- finish(error);
2207
- });
1944
+ req.on('error', finish);
2208
1945
  req.setTimeout(timeoutMs, () => {
2209
- if (settled) return;
2210
1946
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
2211
1947
  req.destroy();
2212
1948
  });
@@ -2221,136 +1957,36 @@ function turnFunctionForMode(mode) {
2221
1957
 
2222
1958
  async function chat(options = {}) {
2223
1959
  let mode = normalizeMode(options.mode);
2224
- let bypassPermissions = resolveBypassPermissions(options);
2225
1960
  const cwd = options.cwd || process.cwd();
2226
1961
  const input = options.input || process.stdin;
2227
1962
  const baseOutput = options.output || process.stdout;
2228
1963
  const logger = createRunLogger({ cwd, mode, kind: 'play', output: baseOutput });
2229
1964
  const output = logger ? logger.output : baseOutput;
2230
1965
  const history = [];
2231
- let goalState = null;
2232
- let lastAchievedGoal = null;
2233
-
2234
- const goalPaintOptions = {
2235
- paint: (text, codes) => paint(text, codes, output),
2236
- bold: ANSI.bold,
2237
- magenta: ANSI.magenta,
2238
- muted: ANSI.muted,
2239
- accent: ANSI.accent,
2240
- ok: ANSI.ok,
2241
- };
1966
+ const conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
2242
1967
 
2243
- const writeHeader = () => {
2244
- output.write(`${formatHeader({ mode, cwd, chat: true, bypassPermissions, goal: goalState }, output)}\n\n`);
2245
- };
2246
-
2247
- writeHeader();
2248
-
2249
- const executeChatTurn = async (message, turnOptions = {}) => {
2250
- let result;
2251
- try {
2252
- const turnFn = typeof options.turnFunction === 'function'
2253
- ? options.turnFunction
2254
- : turnFunctionForMode(mode);
2255
- result = await turnFn(message, {
2256
- mode,
2257
- cwd,
2258
- history,
2259
- output,
2260
- route: options.route,
2261
- business: options.business,
2262
- verify: options.verify,
2263
- bypassPermissions,
2264
- registerAbort: options.registerAbort,
2265
- goalEval: turnOptions.goalEval === true,
2266
- });
2267
- } finally {
2268
- if (typeof options.registerAbort === 'function') options.registerAbort(null);
2269
- }
1968
+ output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
1969
+ if (logger) output.write(`${formatAuxRow('log', formatPathSubject(logger.path, output), output)}\n\n`);
2270
1970
 
2271
- if (result.interrupted) {
2272
- if (result.output && !result.output.endsWith('\n')) output.write('\n');
2273
- if (!turnOptions.goalEval) {
2274
- output.write(`${paint('· Interrupted', [ANSI.muted], output)}\n\n`);
2275
- }
2276
- if (result.output && result.output.trim()) {
2277
- history.push({ role: 'user', content: message });
2278
- history.push({ role: 'assistant', content: result.output });
2279
- }
2280
- return result;
2281
- }
2282
-
2283
- if (result.output && !result.output.endsWith('\n')) output.write('\n');
2284
- if (!turnOptions.goalEval) {
2285
- output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n\n`);
1971
+ const runtimePreflight = { ready: false };
1972
+ const ensureRuntimeReady = async () => {
1973
+ if (runtimePreflight.ready) return true;
1974
+ if (options.skipRuntimePreflight || options.turnFunction || options.business || normalizeMode(mode) === 'code-fast') {
1975
+ runtimePreflight.ready = true;
1976
+ return true;
2286
1977
  }
2287
- history.push({ role: 'user', content: message });
2288
- history.push({ role: 'assistant', content: result.output || '' });
2289
- return result;
2290
- };
2291
-
2292
- const runGoalLoop = async (activeGoal) => {
2293
- output.write(`${formatGoalStatus(activeGoal, goalPaintOptions)}\n\n`);
2294
- writeHeader();
2295
-
2296
- while (activeGoal.active) {
2297
- const directive = buildGoalDirective(activeGoal, { continue: activeGoal.turns > 0 });
2298
- if (activeGoal.turns > 0) {
2299
- output.write(`${formatGoalContinue(activeGoal, goalPaintOptions)}\n\n`);
2300
- }
2301
- output.write('\n');
2302
-
2303
- const result = await executeChatTurn(directive, { goalLoop: true });
2304
- activeGoal.turns += 1;
2305
- accumulateGoalUsage(activeGoal, result, creditsFromState);
2306
-
2307
- if (result.interrupted) {
2308
- output.write(`${paint('· Goal turn interrupted — /goal for status · /goal clear to stop', [ANSI.muted], output)}\n\n`);
2309
- return;
2310
- }
2311
-
2312
- const evalResult = await evaluateGoalTurn(activeGoal, {
2313
- history,
2314
- lastOutput: result.output,
2315
- turnOptions: {
2316
- mode,
2317
- cwd,
2318
- bypassPermissions,
2319
- route: options.route,
2320
- business: options.business,
2321
- verify: options.verify,
2322
- registerAbort: options.registerAbort,
2323
- },
2324
- }, { postTurn, evaluateGoal: options.evaluateGoal });
2325
-
2326
- activeGoal.evalTurns += 1;
2327
- activeGoal.lastReason = evalResult.reason || activeGoal.lastReason;
2328
-
2329
- if (evalResult.achieved) {
2330
- finishGoalAchieved(activeGoal, evalResult.reason);
2331
- output.write(`${formatGoalAchieved(activeGoal, goalPaintOptions)}\n\n`);
2332
- lastAchievedGoal = { ...activeGoal };
2333
- goalState = null;
2334
- writeHeader();
2335
- return;
2336
- }
2337
-
2338
- if (goalTurnLimitReached(activeGoal)) {
2339
- clearGoalState(activeGoal);
2340
- output.write(`${formatGoalStopped(activeGoal, 'Turn limit reached.', goalPaintOptions)}\n\n`);
2341
- goalState = null;
2342
- writeHeader();
2343
- return;
2344
- }
2345
-
2346
- if (goalBudgetExceeded(activeGoal)) {
2347
- clearGoalState(activeGoal);
2348
- output.write(`${formatGoalStopped(activeGoal, 'Token budget reached.', goalPaintOptions)}\n\n`);
2349
- goalState = null;
2350
- writeHeader();
2351
- return;
2352
- }
1978
+ const startedAt = Date.now();
1979
+ const health = options.runtimeHealth
1980
+ ? await Promise.resolve(options.runtimeHealth({ mode }))
1981
+ : await buildRuntimeHealth({ timeoutMs: options.preflightTimeoutMs || 1200 });
1982
+ if (runtimeReadyForChat(health, mode)) {
1983
+ runtimePreflight.ready = true;
1984
+ return true;
2353
1985
  }
1986
+ output.write(`${formatRuntimeHealth(health, output)}\n\n`);
1987
+ output.write(`${formatBackendHint()}\n`);
1988
+ output.write(`${formatDoneLine(Date.now() - startedAt)}\n\n`);
1989
+ return false;
2354
1990
  };
2355
1991
 
2356
1992
  const runLine = async (line) => {
@@ -2371,68 +2007,104 @@ async function chat(options = {}) {
2371
2007
  return false;
2372
2008
  }
2373
2009
 
2374
- const permission = chatPermissionCommand(trimmed);
2375
- if (permission !== null) {
2376
- bypassPermissions = permission;
2377
- const persistPrefs = options.persistPrefs !== false;
2378
- setBypassPermissions(permission, { persist: persistPrefs });
2379
- if (logger) logger.write(`${trimmed}\n`);
2380
- output.write(`${formatPermissionToggleMessage(permission, { ...output, persistPrefs })}\n\n`);
2381
- return false;
2382
- }
2383
-
2384
- const goalCmd = parseGoalCommand(trimmed);
2385
- if (goalCmd) {
2386
- if (logger) logger.write(`${trimmed}\n`);
2387
- if (goalCmd.action === 'clear') {
2388
- if (goalState) clearGoalState(goalState);
2389
- goalState = null;
2390
- output.write(`${paint('· Goal cleared', [ANSI.ok], output)}\n\n`);
2391
- writeHeader();
2392
- return false;
2393
- }
2394
- if (goalCmd.action === 'status') {
2395
- output.write(`${formatGoalStatus(goalState || lastAchievedGoal, goalPaintOptions)}\n\n`);
2396
- return false;
2397
- }
2398
- if (goalCmd.action === 'set') {
2399
- if (goalState && goalState.active) clearGoalState(goalState);
2400
- goalState = createGoalState(goalCmd.condition, goalCmd);
2401
- lastAchievedGoal = null;
2402
- await runGoalLoop(goalState);
2403
- return false;
2404
- }
2405
- }
2406
-
2407
2010
  if (trimmed.startsWith('/')) {
2408
2011
  output.write(`${chatMenu(output)}\n\n`);
2409
2012
  return false;
2410
2013
  }
2411
2014
 
2412
- if (extractYoutubeUrl(trimmed)) {
2413
- if (logger) logger.write(`${formatPrompt(mode, { ...output, goal: goalState })}${trimmed}\n`);
2414
- output.write('\n');
2015
+ if (logger) logger.write(`${formatPrompt(mode)}${trimmed}\n`);
2016
+ output.write('\n');
2017
+ const pendingApprovalRequest = latestPendingApprovalRequest(history);
2018
+ if (pendingApprovalRequest && normalizeMode(mode) !== 'code-fast' && messageCancelsPendingApproval(trimmed)) {
2415
2019
  const startedAt = Date.now();
2416
- const youtubeOutput = [];
2417
- await runAxYoutubeCommand([trimmed], {
2418
- youtubeCommand: options.youtubeCommand,
2419
- ensureValidCredentials: options.ensureValidCredentials,
2420
- apiRequestJson: options.apiRequestJson,
2421
- output: (line = '') => {
2422
- const text = String(line);
2423
- youtubeOutput.push(text);
2424
- output.write(`${text}\n`);
2425
- },
2426
- });
2020
+ if (!options.turnFunction) removeStoredApproval(pendingApprovalRequest.approval_request_id || pendingApprovalRequest.approval_id, { storePath: options.approvalStorePath });
2021
+ output.write('Cancelled.\n');
2427
2022
  output.write(`${formatDoneLine(Date.now() - startedAt)}\n\n`);
2428
2023
  history.push({ role: 'user', content: trimmed });
2429
- history.push({ role: 'assistant', content: youtubeOutput.join('\n') });
2024
+ history.push({ role: 'assistant', content: 'Cancelled.' });
2025
+ return false;
2026
+ }
2027
+ if (pendingApprovalRequest && normalizeMode(mode) !== 'code-fast' && messageApprovesPendingApproval(trimmed)) {
2028
+ if (!(await ensureRuntimeReady())) return false;
2029
+ const startedAt = Date.now();
2030
+ const approvalState = {
2031
+ events: [],
2032
+ errors: [],
2033
+ output: 'Accepted.',
2034
+ pendingText: '',
2035
+ wroteText: true,
2036
+ wroteActivity: true,
2037
+ lastChar: '\n',
2038
+ progress: null,
2039
+ inAuxBlock: false,
2040
+ needsBullet: true,
2041
+ };
2042
+ output.write('Accepted.\n');
2043
+ const approvalExecution = await executeApprovalRequest(pendingApprovalRequest, {
2044
+ model: modelForMode(mode),
2045
+ output,
2046
+ state: approvalState,
2047
+ ...(options.postApproval ? { postApproval: options.postApproval } : {}),
2048
+ });
2049
+ const credits = creditsFromApprovalExecution(approvalExecution && approvalExecution.response);
2050
+ output.write(`${formatDoneLine(Date.now() - startedAt, credits)}\n\n`);
2051
+ if (!options.turnFunction) {
2052
+ const approvalId = pendingApprovalRequest.approval_request_id || pendingApprovalRequest.approval_id;
2053
+ if (approvalExecutionShouldRemainPending(approvalExecution && approvalExecution.response)) {
2054
+ persistPendingApproval({}, pendingApprovalRequest, { cwd, mode, storePath: options.approvalStorePath });
2055
+ } else {
2056
+ removeStoredApproval(approvalId, { storePath: options.approvalStorePath });
2057
+ }
2058
+ }
2059
+ history.push({ role: 'user', content: trimmed });
2060
+ history.push({
2061
+ role: 'assistant',
2062
+ content: ['Accepted.', approvalExecution && approvalExecution.message ? approvalExecution.message : ''].filter(Boolean).join('\n'),
2063
+ ...(
2064
+ approvalExecutionShouldRemainPending(approvalExecution && approvalExecution.response)
2065
+ ? { approval_request: pendingApprovalRequest }
2066
+ : {}
2067
+ ),
2068
+ });
2430
2069
  return false;
2431
2070
  }
2432
2071
 
2433
- if (logger) logger.write(`${formatPrompt(mode, { ...output, goal: goalState })}${trimmed}\n`);
2434
- output.write('\n');
2435
- await executeChatTurn(trimmed);
2072
+ const pendingTaskPreview = latestPendingTaskPreview(history);
2073
+ const route = pendingTaskPreview ? 'cloud' : options.route;
2074
+ if (!(await ensureRuntimeReady())) return false;
2075
+ const turnFunction = options.turnFunction || turnFunctionForMode(mode);
2076
+ const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId });
2077
+ if (result.output && !result.output.endsWith('\n')) output.write('\n');
2078
+ const approvalExecution = normalizeMode(mode) === 'code-fast'
2079
+ ? null
2080
+ : await executeApprovalFromState(result, {
2081
+ model: modelForMode(mode),
2082
+ output,
2083
+ ...(options.postApproval ? { postApproval: options.postApproval } : {}),
2084
+ });
2085
+ output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n\n`);
2086
+ const receipt = receiptFromState(result);
2087
+ const approvalRequest = approvalExecution
2088
+ ? (
2089
+ approvalExecutionShouldRemainPending(approvalExecution.response)
2090
+ ? approvalExecution.approval_request
2091
+ : null
2092
+ )
2093
+ : approvalRequestFromReceipt(receipt);
2094
+ if (approvalRequest && !options.turnFunction) {
2095
+ persistPendingApproval(receipt, approvalRequest, { cwd, mode, storePath: options.approvalStorePath });
2096
+ }
2097
+ const assistantContent = [
2098
+ result.output || '',
2099
+ approvalExecution && approvalExecution.message ? approvalExecution.message : '',
2100
+ ].filter(Boolean).join('\n');
2101
+ history.push({ role: 'user', content: trimmed });
2102
+ history.push({
2103
+ role: 'assistant',
2104
+ content: assistantContent,
2105
+ ...(receipt && receipt.task_preview ? { task_preview: receipt.task_preview } : {}),
2106
+ ...(approvalRequest ? { approval_request: approvalRequest } : {}),
2107
+ });
2436
2108
  return false;
2437
2109
  };
2438
2110
 
@@ -2445,445 +2117,29 @@ async function chat(options = {}) {
2445
2117
  return;
2446
2118
  }
2447
2119
 
2448
- const rl = readline.createInterface({
2449
- input,
2450
- output: baseOutput,
2451
- completer: chatCompleter,
2452
- crlfDelay: Infinity,
2453
- });
2454
- readline.emitKeypressEvents(input, rl);
2455
- // Bracketed paste: terminals wrap pasted text in \x1b[200~ … \x1b[201~ so a
2456
- // multi-line paste lands as ONE message instead of submitting on every
2457
- // newline. readline + the multiline hook already understand the markers; we
2458
- // just have to turn the mode on (and off again on every exit path).
2459
- const setBracketedPaste = (on) => {
2460
- if (baseOutput && baseOutput.isTTY && typeof baseOutput.write === 'function') {
2461
- baseOutput.write(on ? '\x1b[?2004h' : '\x1b[?2004l');
2462
- }
2463
- };
2464
- const disableBracketedPasteOnExit = () => setBracketedPaste(false);
2465
- setBracketedPaste(true);
2466
- process.once('exit', disableBracketedPasteOnExit);
2467
- // readline can't emit a 'line' string that holds a literal newline, so the
2468
- // multiline hook hands us the true buffer here and ask() prefers it.
2469
- let pendingMultilineLine = null;
2470
- const detachMultilineInput = attachMultilineChatInput(rl, {
2471
- insertBreak: insertMultilineBreak,
2472
- onSubmit: (line) => { pendingMultilineLine = line; },
2473
- });
2474
- const boxLayoutOptions = () => inputBoxLayoutOptions(baseOutput, { bypassPermissions });
2475
- let exiting = false;
2476
- let awaitingInput = false;
2477
- // Repaint the box bottom rule after every readline render so the frame stays
2478
- // closed while typing (empty, wrapped, and multiline input alike).
2479
- const baseRefreshLine = typeof rl._refreshLine === 'function' ? rl._refreshLine.bind(rl) : null;
2480
- if (baseRefreshLine) {
2481
- rl._refreshLine = () => {
2482
- baseRefreshLine();
2483
- if (awaitingInput) repaintInputBoxBottom(rl, baseOutput, boxLayoutOptions());
2484
- };
2485
- }
2486
- let turnAbort = null;
2487
- options.registerAbort = (fn) => {
2488
- turnAbort = typeof fn === 'function' ? fn : null;
2489
- };
2490
- const onChatKeypress = (_str, key) => {
2491
- if (!awaitingInput) return;
2492
- if (!isPermissionToggleKey(key)) return;
2493
- bypassPermissions = !bypassPermissions;
2494
- const persistPrefs = options.persistPrefs !== false;
2495
- setBypassPermissions(bypassPermissions, { persist: persistPrefs });
2496
- if (logger) logger.write(`[shift+tab permissions ${bypassPermissions ? 'bypass' : 'safe'}]\n`);
2497
- // The mode label lives on the status line below the box now, so repaint
2498
- // that (it also restores the cursor) instead of redrawing the plain top.
2499
- repaintInputBoxBottom(rl, baseOutput, boxLayoutOptions());
2500
- };
2501
- input.prependListener('keypress', onChatKeypress);
2502
- rl.on('SIGINT', () => {
2503
- if (turnAbort) {
2504
- turnAbort();
2505
- return;
2506
- }
2507
- exiting = true;
2508
- output.write('\n');
2509
- rl.close();
2510
- });
2511
- const ask = () => new Promise((resolve, reject) => {
2512
- function onClose() {
2513
- reject(new Error('CHAT_CLOSED'));
2514
- }
2515
- rl.once('close', onClose);
2516
- awaitingInput = true;
2517
- const layout = boxLayoutOptions();
2518
- output.write(`\n${formatInputBoxTop(layout)}\n`);
2519
- pendingMultilineLine = null;
2520
- rl.question(formatChatInputPrompt(mode, { ...layout, goal: goalState }), (answer) => {
2521
- awaitingInput = false;
2522
- rl.removeListener('close', onClose);
2523
- closeInputBox(output, layout);
2524
- const captured = pendingMultilineLine;
2525
- pendingMultilineLine = null;
2526
- resolve(stripMultilineCsiText(captured != null ? captured : answer));
2527
- });
2528
- });
2529
- while (!exiting) {
2530
- let line = '';
2531
- try {
2532
- line = await ask();
2533
- } catch (error) {
2534
- if (error && error.message === 'CHAT_CLOSED') break;
2535
- throw error;
2536
- }
2120
+ const rl = readline.createInterface({ input, output: baseOutput, completer: chatCompleter });
2121
+ const ask = () => new Promise(resolve => rl.question(formatPrompt(mode, baseOutput), resolve));
2122
+ while (true) {
2123
+ const line = await ask();
2537
2124
  if (await runLine(line)) {
2538
2125
  rl.close();
2539
2126
  break;
2540
2127
  }
2541
2128
  }
2542
- input.removeListener('keypress', onChatKeypress);
2543
- detachMultilineInput();
2544
- if (baseRefreshLine) rl._refreshLine = baseRefreshLine;
2545
- setBracketedPaste(false);
2546
- process.removeListener('exit', disableBracketedPasteOnExit);
2547
2129
  if (logger) logger.close(0);
2548
2130
  }
2549
2131
 
2550
- function newestAxLog(cwd, sinceMs = 0) {
2551
- const runsDir = path.join(cwd, 'atris', 'runs');
2552
- if (!fs.existsSync(runsDir)) return null;
2553
- let newest = null;
2554
- for (const name of fs.readdirSync(runsDir)) {
2555
- if (!/^ax-.*\.log$/.test(name)) continue;
2556
- const full = path.join(runsDir, name);
2557
- let stat = null;
2558
- try {
2559
- stat = fs.statSync(full);
2560
- } catch {
2561
- continue;
2562
- }
2563
- if (!stat.isFile() || stat.mtimeMs < sinceMs) continue;
2564
- if (!newest || stat.mtimeMs > newest.mtimeMs) newest = { path: full, mtimeMs: stat.mtimeMs };
2565
- }
2566
- return newest ? newest.path : null;
2567
- }
2568
-
2569
- function makeChecklistItem(id, label, passed, evidence = '') {
2570
- return { id, label, passed: passed === true, evidence: compactText(evidence, 180) };
2571
- }
2572
-
2573
- function compactText(text, max = 180) {
2574
- const one = String(text || '').replace(/\s+/g, ' ').trim();
2575
- if (!one) return '';
2576
- return one.length <= max ? one : `${one.slice(0, max - 3)}...`;
2577
- }
2578
-
2579
- function buildDogfoodChecklist({ cases, outputText, turnCalls, youtubeCalls, logPath }) {
2580
- const turnCases = cases.filter(item => item.kind === 'turn');
2581
- const paired = turnCases.map((item, index) => ({ case: item, call: turnCalls[index] || null }));
2582
- const smallTalk = paired.filter(item => /small_talk|short_noise|greeting/.test(item.case.coverage));
2583
- const workspace = paired.filter(item => /workspace_|github_workspace_mutation|max_cloud/.test(item.case.coverage));
2584
- const cloud = paired.filter(item => item.case.expectRoute === 'cloud');
2585
- const bypassCall = paired.find(item => item.case.coverage === 'connector_write_bypass')?.call;
2586
- const safeCalls = paired.filter(item => /connector_write_(safe|resafe)/.test(item.case.coverage)).map(item => item.call);
2587
- const maxCall = paired.find(item => item.case.coverage === 'max_cloud')?.call;
2588
- const firstHeader = outputText.split(/\r?\n/).slice(0, 4).map(stripAnsi);
2589
- const longestHeader = firstHeader.reduce((max, line) => Math.max(max, line.length), 0);
2590
- const modelPattern = /atris:|composer-2-5|gpt-|kimi|fable|fireworks|openrouter/i;
2591
- const errorPattern = /Cannot access 'business'|Blocked: direct network access|ReferenceError|TypeError|UnhandledPromiseRejection|ECONNREFUSED|HTTP 5\d\d/i;
2592
-
2593
- return [
2594
- makeChecklistItem('loops_25', 'Ran the requested loop count', cases.length === 25, `${cases.length}/25 loops`),
2595
- makeChecklistItem('header_compact', 'Chat header fits a small terminal', longestHeader <= 96 && /shift\+enter/.test(outputText), `longest header line ${longestHeader}`),
2596
- makeChecklistItem('menu_discoverable', 'Slash menu exposes key controls', /\/goal/.test(outputText) && /\/fast/.test(outputText) && /\/safe/.test(outputText), 'menu printed by /help and unknown slash'),
2597
- makeChecklistItem('small_talk_cloud', 'Greetings/noise avoid workspace listings', smallTalk.length >= 4 && smallTalk.every(item => item.call?.route === 'cloud' && !item.call?.workspace_path), smallTalk.map(item => `${item.case.input}:${item.call?.route}`).join(', ')),
2598
- makeChecklistItem('workspace_cloud_default', 'Workspace/code asks stay hosted by default', workspace.length >= 5 && workspace.every(item => item.call?.route === 'cloud' && !item.call?.workspace_path), workspace.map(item => `${item.case.coverage}:${item.call?.route}`).join(', ')),
2599
- makeChecklistItem('connectors_cloud', 'Connector reads/writes stay cloud by default', cloud.length >= 8 && cloud.every(item => item.call?.route === 'cloud'), cloud.map(item => `${item.case.coverage}:${item.call?.route}`).join(', ')),
2600
- makeChecklistItem('tier_switches', 'Tier commands affect following turns', maxCall?.mode === 'max', `max turn mode ${maxCall?.mode || 'missing'}`),
2601
- makeChecklistItem('permission_gates', 'Safe/bypass gates are visible in payloads', bypassCall?.allow_external_actions === true && safeCalls.every(call => call && call.allow_external_actions !== true), `bypass=${bypassCall?.allow_external_actions === true}; safe=${safeCalls.length}`),
2602
- makeChecklistItem('youtube_shortcut', 'YouTube URLs route to local processor path', youtubeCalls.length === 1 && !turnCalls.some(call => extractYoutubeUrl(call.message)), `${youtubeCalls.length} youtube call(s)`),
2603
- makeChecklistItem('no_model_ids', 'UI hides backend model ids', !modelPattern.test(outputText), 'no raw model ids in transcript'),
2604
- makeChecklistItem('no_error_patterns', 'Transcript has no known crash/block patterns', !errorPattern.test(outputText), 'business TDZ/network/crash patterns absent'),
2605
- makeChecklistItem('log_written', 'Dogfood leaves an inspectable ax run log', Boolean(logPath && fs.existsSync(logPath)), logPath || 'missing'),
2606
- ];
2607
- }
2608
-
2609
- async function runChatDogfood(options = {}) {
2610
- const cwd = options.cwd || process.cwd();
2611
- const loops = Math.max(1, Number(options.loops) || 25);
2612
- const cases = buildChatDogfoodCases(loops);
2613
- const startedAt = Date.now();
2614
- const chunks = [];
2615
- const output = options.output || {
2616
- isTTY: true,
2617
- color: false,
2618
- write(chunk) {
2619
- chunks.push(String(chunk || ''));
2620
- return true;
2621
- },
2622
- };
2623
- const turnCalls = [];
2624
- const youtubeCalls = [];
2625
- const turnFunction = options.live === true ? undefined : async (message, turnOptions = {}) => {
2626
- const route = resolveRoute(message, turnOptions);
2627
- const payload = buildPayload(message, { ...turnOptions, route });
2628
- const record = {
2629
- message,
2630
- mode: normalizeMode(turnOptions.mode),
2631
- route,
2632
- workspace_path: payload.workspace_path || null,
2633
- max_turns: payload.max_turns,
2634
- bypass_permissions: payload.bypass_permissions === true,
2635
- allow_external_actions: payload.allow_external_actions === true,
2636
- };
2637
- turnCalls.push(record);
2638
- const text = `dogfood ok: ${record.mode}/${record.route}`;
2639
- if (turnOptions.output) turnOptions.output.write(text);
2640
- return {
2641
- output: text,
2642
- durationMs: 1,
2643
- events: [{ type: 'receipt', receipt: { billing: { billed: false }, dogfood: record } }],
2644
- };
2645
- };
2646
- const youtubeCommand = async (argv, deps = {}) => {
2647
- youtubeCalls.push(argv);
2648
- if (deps.output) deps.output(`dogfood youtube: ${argv[0] || 'missing-url'}`);
2649
- return 0;
2650
- };
2651
- const input = Readable.from(cases.map(item => `${item.input}\n`).concat('exit\n'));
2652
-
2653
- await chat({
2654
- mode: 'fast',
2655
- cwd,
2656
- input,
2657
- output,
2658
- turnFunction,
2659
- youtubeCommand: options.youtubeCommand || youtubeCommand,
2660
- persistPrefs: false,
2661
- bypassPermissions: false,
2662
- });
2663
-
2664
- const outputText = typeof output.text === 'function' ? output.text() : chunks.join('');
2665
- const logPath = newestAxLog(cwd, startedAt - 1000);
2666
- const checklist = buildDogfoodChecklist({ cases, outputText, turnCalls, youtubeCalls, logPath });
2667
- const failures = checklist.filter(item => !item.passed);
2668
- return {
2669
- schema: 'atris.ax_chat_dogfood.v1',
2670
- mode: 'fast',
2671
- loops_requested: loops,
2672
- loops_run: cases.length,
2673
- dry_run: options.live !== true,
2674
- cwd,
2675
- log_path: logPath,
2676
- turn_calls: turnCalls,
2677
- youtube_calls: youtubeCalls,
2678
- checklist,
2679
- failures,
2680
- output: outputText,
2681
- };
2682
- }
2683
-
2684
- function formatChatDogfoodReport(report) {
2685
- const passed = report.checklist.filter(item => item.passed).length;
2686
- const lines = [
2687
- 'Ax fast chat dogfood',
2688
- `loops: ${report.loops_run}/${report.loops_requested}`,
2689
- `checklist: ${passed}/${report.checklist.length} passed`,
2690
- `log: ${report.log_path || 'missing'}`,
2691
- '',
2692
- ];
2693
- for (const item of report.checklist) {
2694
- lines.push(`${item.passed ? 'ok' : 'fail'} ${item.id} — ${item.evidence || item.label}`);
2695
- }
2696
- if (report.failures.length) {
2697
- lines.push('');
2698
- lines.push(`failures: ${report.failures.map(item => item.id).join(', ')}`);
2699
- }
2700
- return lines.join('\n');
2701
- }
2702
-
2703
- function safeJsonFile(filePath) {
2704
- try {
2705
- if (!fs.existsSync(filePath)) return null;
2706
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2707
- } catch {
2708
- return null;
2709
- }
2710
- }
2711
-
2712
- function readJsonlRows(filePath) {
2713
- try {
2714
- if (!fs.existsSync(filePath)) return [];
2715
- return fs.readFileSync(filePath, 'utf8')
2716
- .split(/\r?\n/)
2717
- .filter(Boolean)
2718
- .map(line => {
2719
- try {
2720
- return JSON.parse(line);
2721
- } catch {
2722
- return null;
2723
- }
2724
- })
2725
- .filter(Boolean);
2726
- } catch {
2727
- return [];
2728
- }
2729
- }
2730
-
2731
- function parseDogfoodVerifierOutput(stdout = '') {
2732
- const text = String(stdout || '');
2733
- const loops = text.match(/loops:\s*(\d+)\/(\d+)/i);
2734
- const checklist = text.match(/checklist:\s*(\d+)\/(\d+)\s+passed/i);
2735
- const log = text.match(/^log:\s*(.+)$/im);
2736
- return {
2737
- loops_passed: loops ? Number(loops[1]) : 0,
2738
- loops_total: loops ? Number(loops[2]) : 0,
2739
- checklist_passed: checklist ? Number(checklist[1]) : 0,
2740
- checklist_total: checklist ? Number(checklist[2]) : 0,
2741
- log_path: log ? log[1].trim() : null,
2742
- };
2743
- }
2744
-
2745
- function latestDogfoodMission(cwd = process.cwd()) {
2746
- const rows = readJsonlRows(path.join(cwd, '.atris', 'state', 'missions.jsonl'));
2747
- const byId = new Map();
2748
- for (const row of rows) {
2749
- if (!row || row.schema !== 'atris.mission.v1') continue;
2750
- if (!/--dogfood-chat/.test(String(row.verifier || ''))) continue;
2751
- byId.set(row.id, row);
2752
- }
2753
- const missions = Array.from(byId.values());
2754
- missions.sort((a, b) => new Date(b.updated_at || b.created_at || 0) - new Date(a.updated_at || a.created_at || 0));
2755
- return missions[0] || null;
2756
- }
2757
-
2758
- function collectDogfoodTicks(cwd, mission) {
2759
- const runsDir = path.join(cwd, 'atris', 'runs');
2760
- const out = new Map();
2761
- if (!mission || !mission.id || !fs.existsSync(runsDir)) return [];
2762
- const prefix = `mission-${mission.id}-`;
2763
- for (const name of fs.readdirSync(runsDir)) {
2764
- if (!name.startsWith(prefix) || !name.endsWith('.json')) continue;
2765
- const full = path.join(runsDir, name);
2766
- const payload = safeJsonFile(full);
2767
- if (!payload) continue;
2768
- let mtimeMs = 0;
2769
- try {
2770
- mtimeMs = fs.statSync(full).mtimeMs;
2771
- } catch {}
2772
- const body = payload.result || payload;
2773
- const stdout = body.verifier_result?.stdout || payload.verifier_result?.stdout || payload.mission?.verifier_result?.stdout || '';
2774
- const ticks = [];
2775
- if (payload.tick) ticks.push(payload.tick);
2776
- if (body.tick) ticks.push(body.tick);
2777
- if (Array.isArray(payload.ticks)) ticks.push(...payload.ticks);
2778
- if (Array.isArray(body.ticks)) ticks.push(...body.ticks);
2779
- for (const tick of ticks) {
2780
- const index = Number(tick && tick.tick_index);
2781
- if (!Number.isFinite(index) || index < 1) continue;
2782
- const previous = out.get(index);
2783
- if (previous && previous.mtimeMs > mtimeMs) continue;
2784
- const summary = parseDogfoodVerifierOutput(stdout);
2785
- out.set(index, {
2786
- index,
2787
- verifier_passed: tick.verifier_passed === true || body.verifier_result?.passed === true || payload.verifier_result?.passed === true,
2788
- receipt_path: path.relative(cwd, full),
2789
- mtimeMs,
2790
- ...summary,
2791
- });
2792
- }
2793
- }
2794
- return Array.from(out.values()).sort((a, b) => a.index - b.index);
2795
- }
2796
-
2797
- function dogfoodCrontabInstalled(crontabText = null) {
2798
- const text = crontabText !== null ? String(crontabText || '') : (() => {
2799
- try {
2800
- const res = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 8000 });
2801
- return res.status === 0 ? String(res.stdout || '') : '';
2802
- } catch {
2803
- return '';
2804
- }
2805
- })();
2806
- return /ATRIS_AX_FAST_CHAT_DOGFOOD/.test(text);
2807
- }
2808
-
2809
- function countDogfoodCronRuns(cwd = process.cwd(), cronLogText = null) {
2810
- const text = cronLogText !== null
2811
- ? String(cronLogText || '')
2812
- : String(safeReadText(path.join(cwd, 'atris', 'runs', 'ax-fast-chat-overnight-cron.log')) || '');
2813
- return (text.match(/^=== ax fast chat dogfood tick /gm) || []).length;
2814
- }
2815
-
2816
- function safeReadText(filePath) {
2817
- try {
2818
- if (!fs.existsSync(filePath)) return '';
2819
- return fs.readFileSync(filePath, 'utf8');
2820
- } catch {
2821
- return '';
2822
- }
2132
+ function printBackendHint() {
2133
+ console.log('');
2134
+ console.log(formatBackendHint());
2823
2135
  }
2824
2136
 
2825
- function buildChatDogfoodStatus(options = {}) {
2826
- const cwd = options.cwd || process.cwd();
2827
- const targetLoops = Math.max(1, Number(options.targetLoops) || 25);
2828
- const mission = options.mission || latestDogfoodMission(cwd);
2829
- const ticks = collectDogfoodTicks(cwd, mission);
2830
- const cleanTicks = ticks.filter(tick => tick.verifier_passed && tick.loops_passed > 0);
2831
- const cleanLoops = cleanTicks.reduce((sum, tick) => sum + tick.loops_passed, 0);
2832
- const latestClean = cleanTicks[cleanTicks.length - 1] || null;
2833
- const latestTick = ticks[ticks.length - 1] || null;
2834
- const checklistPassed = latestClean && latestClean.checklist_total > 0 && latestClean.checklist_passed === latestClean.checklist_total;
2835
- const cronInstalled = dogfoodCrontabInstalled(options.crontabText ?? null);
2836
- const cronRuns = countDogfoodCronRuns(cwd, options.cronLogText ?? null);
2837
- return {
2838
- schema: 'atris.ax_chat_dogfood_status.v1',
2839
- cwd,
2840
- mission_id: mission ? mission.id : null,
2841
- mission_status: mission ? mission.status : null,
2842
- mission_cadence: mission ? mission.cadence : null,
2843
- last_tick_index: mission ? Number(mission.last_tick_index || 0) : 0,
2844
- last_tick_at: mission ? mission.last_tick_at || null : null,
2845
- target_loops: targetLoops,
2846
- clean_chat_loops: cleanLoops,
2847
- remaining_loops: Math.max(0, targetLoops - cleanLoops),
2848
- target_met: cleanLoops >= targetLoops,
2849
- clean_tick_count: cleanTicks.length,
2850
- latest_receipt_path: latestClean ? latestClean.receipt_path : latestTick ? latestTick.receipt_path : null,
2851
- latest_log_path: latestClean ? latestClean.log_path : null,
2852
- latest_checklist_passed: checklistPassed === true,
2853
- cron_installed: cronInstalled,
2854
- cron_runs: cronRuns,
2855
- overnight_proven: cronRuns > 0 && cleanLoops >= targetLoops,
2856
- };
2137
+ function backendStartCommand() {
2138
+ return `cd /Users/keshavrao/arena/atrisos-backend/backend && ATRIS2_ALLOW_LOCAL_WORKSPACE=1 ENVIRONMENT=development ENV=development ../venv/bin/uvicorn main:app --host ${BACKEND.host} --port ${BACKEND.port}`;
2857
2139
  }
2858
2140
 
2859
- function formatChatDogfoodStatusReport(status) {
2860
- const lines = [
2861
- 'Ax fast chat overnight',
2862
- `mission: ${status.mission_id || 'missing'}`,
2863
- `status: ${status.mission_status || 'missing'} · cadence ${status.mission_cadence || 'n/a'}`,
2864
- `loops: ${status.clean_chat_loops}/${status.target_loops} clean (${status.remaining_loops} remaining)`,
2865
- `ticks: ${status.clean_tick_count} clean · last ${status.last_tick_index || 0} at ${status.last_tick_at || 'never'}`,
2866
- `checklist: ${status.latest_checklist_passed ? 'passed' : 'missing/failing'}`,
2867
- `cron: ${status.cron_installed ? 'installed' : 'missing'} · runs ${status.cron_runs}`,
2868
- `receipt: ${status.latest_receipt_path || 'missing'}`,
2869
- `log: ${status.latest_log_path || 'missing'}`,
2870
- ];
2871
- lines.push(status.overnight_proven ? 'result: overnight 25-loop proof is complete' : 'result: still collecting overnight proof');
2872
- return lines.join('\n');
2873
- }
2874
-
2875
- function printBackendHint(options = {}) {
2876
- console.log('');
2877
- console.log('Hosted lane:');
2878
- console.log('ax --cloud "hello"');
2879
- console.log('');
2880
- console.log('Cloud API:');
2881
- console.log(`ATRIS_API_BASE=${BACKEND.publicBase}`);
2882
- if (options.route === 'local' || options.forceLocal) {
2883
- console.log('');
2884
- console.log('Local developer lane:');
2885
- console.log(`Set AX_BACKEND_URL=http://${BACKEND.host}:${BACKEND.port} after starting your local Atris2 service.`);
2886
- }
2141
+ function formatBackendHint() {
2142
+ return `Start backend:\n${backendStartCommand()}`;
2887
2143
  }
2888
2144
 
2889
2145
  function bufferedOutput() {
@@ -2942,6 +2198,535 @@ async function runBenchmarkCase(label, fn, results, output) {
2942
2198
  }
2943
2199
  }
2944
2200
 
2201
+ async function runSelfTestCase(label, fn, results, output) {
2202
+ output.write(`- ${label} ... `);
2203
+ try {
2204
+ await fn();
2205
+ output.write('ok\n');
2206
+ results.push({ label, ok: true });
2207
+ } catch (error) {
2208
+ output.write(`fail - ${error.message}\n`);
2209
+ results.push({ label, ok: false, error: error.message });
2210
+ }
2211
+ }
2212
+
2213
+ function assertSelfTest(condition, message) {
2214
+ if (!condition) throw new Error(message);
2215
+ }
2216
+
2217
+ async function runSelfTest(options = {}) {
2218
+ const output = options.output || process.stdout;
2219
+ const results = [];
2220
+ output.write('AX self-test\n\n');
2221
+
2222
+ await runSelfTestCase('doctor redaction', async () => {
2223
+ const health = await buildRuntimeHealth({
2224
+ healthRes: {
2225
+ ok: true,
2226
+ status: 200,
2227
+ data: {
2228
+ ready: true,
2229
+ models: [{
2230
+ id: 'atris:fast',
2231
+ route: 'fireworks',
2232
+ chat_model: 'fireworks:glm-5.2',
2233
+ tool_model: 'fireworks:glm-5.2',
2234
+ ready: true,
2235
+ }],
2236
+ },
2237
+ },
2238
+ });
2239
+ const text = formatRuntimeHealth(health, { color: false });
2240
+ assertSelfTest(/backend\s+ready/.test(text), 'backend readiness missing');
2241
+ assertSelfTest(/fast\s+ready/.test(text), 'fast readiness missing');
2242
+ assertSelfTest(!/atris:|fireworks|glm|secret|token/i.test(text), 'doctor output leaked provider/debug text');
2243
+ }, results, output);
2244
+
2245
+ await runSelfTestCase('chat backend offline preflight', async () => {
2246
+ const previousAuto = process.env.AX_AUTO_LOG;
2247
+ process.env.AX_AUTO_LOG = '0';
2248
+ const chunks = [];
2249
+ const chatOutput = {
2250
+ isTTY: false,
2251
+ write(chunk) {
2252
+ chunks.push(String(chunk || ''));
2253
+ return true;
2254
+ },
2255
+ };
2256
+ let healthCalls = 0;
2257
+ try {
2258
+ await chat({
2259
+ mode: 'fast',
2260
+ cwd: os.tmpdir(),
2261
+ input: Readable.from(['hi\n', 'exit\n']),
2262
+ output: chatOutput,
2263
+ runtimeHealth: async () => {
2264
+ healthCalls += 1;
2265
+ return {
2266
+ schema: 'ax.runtime_health.v1',
2267
+ backend: { ready: false, reachable: false, status: 0, error: 'ECONNREFUSED secret-token' },
2268
+ fast: { ready: false, route_ready: false },
2269
+ permissions: { ready: false },
2270
+ };
2271
+ },
2272
+ });
2273
+ } finally {
2274
+ if (previousAuto === undefined) delete process.env.AX_AUTO_LOG;
2275
+ else process.env.AX_AUTO_LOG = previousAuto;
2276
+ }
2277
+ const text = chunks.join('');
2278
+ assertSelfTest(healthCalls === 1, 'offline preflight repeated unexpectedly');
2279
+ assertSelfTest(/backend\s+offline/.test(text), 'offline backend status missing');
2280
+ assertSelfTest(/Start backend:/.test(text) && /uvicorn main:app/.test(text), 'start-backend command missing');
2281
+ assertSelfTest(!/secret-token/.test(text), 'offline preflight leaked error detail');
2282
+ }, results, output);
2283
+
2284
+ await runSelfTestCase('approval id', async () => {
2285
+ const receipt = {
2286
+ tool_events: [{
2287
+ approval_request: {
2288
+ connector: 'slack',
2289
+ executor_action_type: 'slack_post_message',
2290
+ status: 'approval_required',
2291
+ payload: { channel: '#general', text: 'ship note' },
2292
+ },
2293
+ }],
2294
+ };
2295
+ const first = approvalRequestFromReceipt(receipt);
2296
+ const second = approvalRequestFromReceipt(receipt);
2297
+ assertSelfTest(first && /^apreq_ax_[a-f0-9]{20}$/.test(first.approval_request_id), 'stable approval id missing');
2298
+ assertSelfTest(first.approval_request_id === second.approval_request_id, 'approval id was not stable');
2299
+ }, results, output);
2300
+
2301
+ await runSelfTestCase('calendar approval rail', async () => {
2302
+ const payload = buildPayload('add calendar event today at 2pm', {
2303
+ mode: 'fast',
2304
+ route: 'cloud',
2305
+ connectionContext: {
2306
+ schema: 'atris.connection_capabilities.v1',
2307
+ source: 'ax',
2308
+ connections: [{
2309
+ id: 'google-calendar',
2310
+ connected: true,
2311
+ actions: ['list_events', 'create_event'],
2312
+ authority: { list_events: 'read_only', create_event: 'approval_required' },
2313
+ }],
2314
+ },
2315
+ });
2316
+ assertSelfTest(payload.workspace_path === undefined, 'calendar write should stay on cloud approval route');
2317
+ assertSelfTest(payload.allow_external_actions === undefined, 'calendar write bypassed approval rail');
2318
+ assertSelfTest(payload.cleanup_external_actions === undefined, 'calendar write requested cleanup bypass');
2319
+ }, results, output);
2320
+
2321
+ await runSelfTestCase('calendar approval execution', async () => {
2322
+ const approvalRequest = {
2323
+ connector: 'google_calendar',
2324
+ action: 'create_event',
2325
+ executor_action_type: 'google_calendar_create_event',
2326
+ status: 'approval_required',
2327
+ payload: {
2328
+ summary: 'markoo',
2329
+ start: { dateTime: '2026-06-28T14:00:00-07:00' },
2330
+ end: { dateTime: '2026-06-28T15:00:00-07:00' },
2331
+ },
2332
+ };
2333
+ const previewOnly = {
2334
+ tool_events: [{ approval_request: approvalRequest }],
2335
+ };
2336
+ assertSelfTest(approvalExecutionRequestFromReceipt(previewOnly) === null, 'preview-only calendar receipt would execute');
2337
+
2338
+ const accepted = {
2339
+ task_accept_receipt: {
2340
+ accepted: true,
2341
+ task: 'calendar.create_event',
2342
+ execution_status: 'pending_approval_execution',
2343
+ execution_endpoint: APPROVAL_EXECUTE_PATH,
2344
+ },
2345
+ tool_events: [{ approval_request: approvalRequest }],
2346
+ };
2347
+ const executable = approvalExecutionRequestFromReceipt(accepted);
2348
+ assertSelfTest(executable && /^apreq_ax_[a-f0-9]{20}$/.test(executable.approval_request_id), 'accepted calendar approval lacks stable id');
2349
+ assertSelfTest(executable.executor_action_type === 'google_calendar_create_event', 'wrong calendar executor action');
2350
+ assertSelfTest(executable.payload && executable.payload.summary === 'markoo', 'calendar approval payload changed');
2351
+ }, results, output);
2352
+
2353
+ await runSelfTestCase('calendar chat approval loop', async () => {
2354
+ const chunks = [];
2355
+ const chatOutput = {
2356
+ isTTY: false,
2357
+ write(chunk) {
2358
+ chunks.push(String(chunk || ''));
2359
+ return true;
2360
+ },
2361
+ };
2362
+ const approvalRequest = {
2363
+ connector: 'google_calendar',
2364
+ action: 'create_event',
2365
+ executor_action_type: 'google_calendar_create_event',
2366
+ status: 'approval_required',
2367
+ payload: {
2368
+ summary: 'markoo',
2369
+ start: { dateTime: '2026-06-28T14:00:00-07:00' },
2370
+ end: { dateTime: '2026-06-28T15:00:00-07:00' },
2371
+ },
2372
+ };
2373
+ const firstFinal = "I'll add it today at 2:00 PM.\nWhat should I call it?";
2374
+ const firstTaskPreview = {
2375
+ task: 'calendar.create_event',
2376
+ owner_member: 'harness-engineer',
2377
+ status: 'needs_input',
2378
+ missing: ['title'],
2379
+ slots: {
2380
+ date: '2026-06-28',
2381
+ start: '2026-06-28T14:00:00-07:00',
2382
+ end: '2026-06-28T15:00:00-07:00',
2383
+ },
2384
+ };
2385
+ const secondFinal = 'Ready to create: markoo today at 2:00 PM.\nCreate it?';
2386
+ const secondTaskPreview = {
2387
+ task: 'calendar.create_event',
2388
+ owner_member: 'harness-engineer',
2389
+ status: 'approval_required',
2390
+ missing: [],
2391
+ slots: {
2392
+ title: 'markoo',
2393
+ date: '2026-06-28',
2394
+ start: '2026-06-28T14:00:00-07:00',
2395
+ end: '2026-06-28T15:00:00-07:00',
2396
+ },
2397
+ };
2398
+ const turns = [];
2399
+ const approvals = [];
2400
+ await chat({
2401
+ mode: 'fast',
2402
+ route: 'cloud',
2403
+ cwd: os.tmpdir(),
2404
+ conversationId: 'ax-self-test-calendar-chat',
2405
+ input: Readable.from(['add calendar event today at 2pm\n', 'markoo\n', 'yes\n', 'exit\n']),
2406
+ output: chatOutput,
2407
+ turnFunction: async (message, turnOptions) => {
2408
+ turns.push({ message, route: turnOptions.route, historyLength: turnOptions.history.length });
2409
+ const receipt = turns.length === 1
2410
+ ? {
2411
+ final: firstFinal,
2412
+ task_preview: firstTaskPreview,
2413
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'blocked_missing_details' }],
2414
+ }
2415
+ : {
2416
+ final: secondFinal,
2417
+ task_preview: secondTaskPreview,
2418
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'approval_required', approval_request: approvalRequest }],
2419
+ };
2420
+ turnOptions.output.write(receipt.final);
2421
+ return { output: receipt.final, durationMs: 1, events: [{ type: 'receipt', receipt }] };
2422
+ },
2423
+ postApproval: async (request) => {
2424
+ approvals.push(request);
2425
+ return {
2426
+ ok: true,
2427
+ status: 200,
2428
+ data: {
2429
+ status: 'executed',
2430
+ action_type: 'google_calendar_create_event',
2431
+ execution: { status: 'executed', action_type: 'google_calendar_create_event', executed: true },
2432
+ },
2433
+ };
2434
+ },
2435
+ });
2436
+ const text = chunks.join('');
2437
+ assertSelfTest(turns.length === 2, 'approval yes triggered another model/backend turn');
2438
+ assertSelfTest(turns[1].route === 'cloud', 'slot-fill follow-up did not stay on cloud approval route');
2439
+ assertSelfTest(approvals.length === 1, 'approval execution was not called exactly once');
2440
+ assertSelfTest(approvals[0].executor_action_type === 'google_calendar_create_event', 'chat loop executed wrong approval action');
2441
+ const previewRows = formatTaskPreviewRows({
2442
+ task_preview: secondTaskPreview,
2443
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'approval_required', approval_request: approvalRequest }],
2444
+ }, { color: false }).join('\n');
2445
+ assertSelfTest(/owner\s+harness-engineer/.test(previewRows), 'task preview owner row missing');
2446
+ assertSelfTest(/plan\s+create calendar event "markoo" Jun 28, 2026 at 2:00 PM/.test(previewRows), 'task preview plan row missing');
2447
+ assertSelfTest(/check\s+approval required/.test(previewRows), 'task preview check row missing');
2448
+ assertSelfTest(/Accepted\./.test(text) && /Created calendar event\./.test(text), 'chat loop did not render accepted execution result');
2449
+ }, results, output);
2450
+
2451
+ await runSelfTestCase('calendar blocked approval retry', async () => {
2452
+ const chunks = [];
2453
+ const chatOutput = {
2454
+ isTTY: false,
2455
+ write(chunk) {
2456
+ chunks.push(String(chunk || ''));
2457
+ return true;
2458
+ },
2459
+ };
2460
+ const approvalRequest = {
2461
+ connector: 'google_calendar',
2462
+ action: 'create_event',
2463
+ executor_action_type: 'google_calendar_create_event',
2464
+ status: 'approval_required',
2465
+ payload: {
2466
+ summary: 'markoo',
2467
+ start: { dateTime: '2026-06-28T14:00:00-07:00' },
2468
+ end: { dateTime: '2026-06-28T15:00:00-07:00' },
2469
+ },
2470
+ };
2471
+ const turns = [];
2472
+ const approvals = [];
2473
+ await chat({
2474
+ mode: 'fast',
2475
+ route: 'cloud',
2476
+ cwd: os.tmpdir(),
2477
+ conversationId: 'ax-self-test-calendar-blocked-retry',
2478
+ input: Readable.from(['add calendar event today at 2pm\n', 'markoo\n', 'yes\n', 'yes\n', 'exit\n']),
2479
+ output: chatOutput,
2480
+ turnFunction: async (message, turnOptions) => {
2481
+ turns.push({ message, route: turnOptions.route, historyLength: turnOptions.history.length });
2482
+ const receipt = turns.length === 1
2483
+ ? {
2484
+ final: "I'll add it today at 2:00 PM.\nWhat should I call it?",
2485
+ task_preview: {
2486
+ task: 'calendar.create_event',
2487
+ status: 'needs_input',
2488
+ missing: ['title'],
2489
+ slots: {
2490
+ date: '2026-06-28',
2491
+ start: '2026-06-28T14:00:00-07:00',
2492
+ end: '2026-06-28T15:00:00-07:00',
2493
+ },
2494
+ },
2495
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'blocked_missing_details' }],
2496
+ }
2497
+ : {
2498
+ final: 'Ready to create: markoo today at 2:00 PM.\nCreate it?',
2499
+ task_preview: {
2500
+ task: 'calendar.create_event',
2501
+ status: 'approval_required',
2502
+ missing: [],
2503
+ slots: {
2504
+ title: 'markoo',
2505
+ date: '2026-06-28',
2506
+ start: '2026-06-28T14:00:00-07:00',
2507
+ end: '2026-06-28T15:00:00-07:00',
2508
+ },
2509
+ },
2510
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'approval_required', approval_request: approvalRequest }],
2511
+ };
2512
+ turnOptions.output.write(receipt.final);
2513
+ return { output: receipt.final, durationMs: 1, events: [{ type: 'receipt', receipt }] };
2514
+ },
2515
+ postApproval: async (request) => {
2516
+ approvals.push(request);
2517
+ if (approvals.length === 1) {
2518
+ return {
2519
+ ok: false,
2520
+ status: 402,
2521
+ data: { detail: { error: 'paid_cloud_computer_required', action_type: 'google_calendar_create_event' } },
2522
+ };
2523
+ }
2524
+ return {
2525
+ ok: true,
2526
+ status: 200,
2527
+ data: {
2528
+ status: 'executed',
2529
+ action_type: 'google_calendar_create_event',
2530
+ execution: { status: 'executed', action_type: 'google_calendar_create_event', executed: true },
2531
+ },
2532
+ };
2533
+ },
2534
+ });
2535
+ const text = chunks.join('');
2536
+ assertSelfTest(turns.length === 2, 'blocked approval retry triggered another model/backend turn');
2537
+ assertSelfTest(approvals.length === 2, 'blocked approval was not retryable');
2538
+ assertSelfTest(approvals[0].approval_request_id === approvals[1].approval_request_id, 'retry changed approval id');
2539
+ assertSelfTest(/Approval execution needs a paid cloud computer\./.test(text), 'blocked approval message missing');
2540
+ assertSelfTest(/Created calendar event\./.test(text), 'retry success message missing');
2541
+ }, results, output);
2542
+
2543
+ await runSelfTestCase('calendar chat denial loop', async () => {
2544
+ const chunks = [];
2545
+ const chatOutput = {
2546
+ isTTY: false,
2547
+ write(chunk) {
2548
+ chunks.push(String(chunk || ''));
2549
+ return true;
2550
+ },
2551
+ };
2552
+ const approvalRequest = {
2553
+ connector: 'google_calendar',
2554
+ action: 'create_event',
2555
+ executor_action_type: 'google_calendar_create_event',
2556
+ status: 'approval_required',
2557
+ payload: {
2558
+ summary: 'markoo',
2559
+ start: { dateTime: '2026-06-28T14:00:00-07:00' },
2560
+ end: { dateTime: '2026-06-28T15:00:00-07:00' },
2561
+ },
2562
+ };
2563
+ const turns = [];
2564
+ const approvals = [];
2565
+ await chat({
2566
+ mode: 'fast',
2567
+ route: 'cloud',
2568
+ cwd: os.tmpdir(),
2569
+ conversationId: 'ax-self-test-calendar-denial',
2570
+ input: Readable.from(['add calendar event today at 2pm\n', 'markoo\n', 'no\n', 'exit\n']),
2571
+ output: chatOutput,
2572
+ turnFunction: async (message, turnOptions) => {
2573
+ turns.push({ message, route: turnOptions.route, historyLength: turnOptions.history.length });
2574
+ const receipt = turns.length === 1
2575
+ ? {
2576
+ final: "I'll add it today at 2:00 PM.\nWhat should I call it?",
2577
+ task_preview: {
2578
+ task: 'calendar.create_event',
2579
+ status: 'needs_input',
2580
+ missing: ['title'],
2581
+ slots: {
2582
+ date: '2026-06-28',
2583
+ start: '2026-06-28T14:00:00-07:00',
2584
+ end: '2026-06-28T15:00:00-07:00',
2585
+ },
2586
+ },
2587
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'blocked_missing_details' }],
2588
+ }
2589
+ : {
2590
+ final: 'Ready to create: markoo today at 2:00 PM.\nCreate it?',
2591
+ task_preview: {
2592
+ task: 'calendar.create_event',
2593
+ status: 'approval_required',
2594
+ missing: [],
2595
+ slots: {
2596
+ title: 'markoo',
2597
+ date: '2026-06-28',
2598
+ start: '2026-06-28T14:00:00-07:00',
2599
+ end: '2026-06-28T15:00:00-07:00',
2600
+ },
2601
+ },
2602
+ tool_events: [{ tool: 'google_calendar', action: 'create_event', status: 'approval_required', approval_request: approvalRequest }],
2603
+ };
2604
+ turnOptions.output.write(receipt.final);
2605
+ return { output: receipt.final, durationMs: 1, events: [{ type: 'receipt', receipt }] };
2606
+ },
2607
+ postApproval: async (request) => {
2608
+ approvals.push(request);
2609
+ return { ok: true, status: 200, data: { status: 'executed' } };
2610
+ },
2611
+ });
2612
+ const text = chunks.join('');
2613
+ assertSelfTest(turns.length === 2, 'denial triggered another model/backend turn');
2614
+ assertSelfTest(approvals.length === 0, 'denial executed an approval action');
2615
+ assertSelfTest(/Cancelled\./.test(text), 'denial did not render cancellation');
2616
+ }, results, output);
2617
+
2618
+ await runSelfTestCase('approval queue privacy', async () => {
2619
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ax-self-test-approvals-'));
2620
+ const storePath = path.join(dir, 'approvals.json');
2621
+ try {
2622
+ const receipt = {
2623
+ task_preview: {
2624
+ task: 'slack.post_message',
2625
+ owner_member: 'comms',
2626
+ status: 'approval_required',
2627
+ missing: [],
2628
+ },
2629
+ tool_events: [{
2630
+ tool: 'slack',
2631
+ approval_request: {
2632
+ connector: 'slack',
2633
+ action: 'post_message',
2634
+ executor_action_type: 'slack_post_message',
2635
+ status: 'approval_required',
2636
+ payload: { channel: '#general', text: 'secret payload text' },
2637
+ },
2638
+ }],
2639
+ };
2640
+ const request = approvalRequestFromReceipt(receipt);
2641
+ const record = persistPendingApproval(receipt, request, { storePath, cwd: dir, mode: 'fast' });
2642
+ const mode = fs.statSync(storePath).mode & 0o777;
2643
+ const listed = formatStoredApprovals(readApprovalStore({ storePath }), { color: false });
2644
+ assertSelfTest(record && record.id, 'approval queue record missing');
2645
+ assertSelfTest(mode === 0o600, `approval queue mode was ${mode.toString(8)}`);
2646
+ assertSelfTest(/owner\s+comms/.test(listed), 'approval queue owner missing');
2647
+ assertSelfTest(!/secret payload text/.test(listed), 'approval queue list leaked payload');
2648
+ denyStoredApproval(record.id, { storePath });
2649
+ assertSelfTest(readApprovalStore({ storePath }).approvals.length === 0, 'approval denial did not remove queue record');
2650
+
2651
+ const oldCreatedAt = new Date(Date.now() - (25 * 60 * 60 * 1000)).toISOString();
2652
+ const stale = persistPendingApproval(receipt, request, { storePath, cwd: dir, mode: 'fast', createdAt: oldCreatedAt });
2653
+ let executed = false;
2654
+ const staleOutput = {
2655
+ isTTY: false,
2656
+ chunks: [],
2657
+ write(chunk) {
2658
+ this.chunks.push(String(chunk || ''));
2659
+ return true;
2660
+ },
2661
+ };
2662
+ const result = await approveStoredApproval(stale.id, {
2663
+ storePath,
2664
+ output: staleOutput,
2665
+ postApproval: async () => {
2666
+ executed = true;
2667
+ return { ok: true, status: 200, data: {} };
2668
+ },
2669
+ });
2670
+ assertSelfTest(result && result.stale === true, 'stale approval was not marked stale');
2671
+ assertSelfTest(executed === false, 'stale approval executed');
2672
+ assertSelfTest(readApprovalStore({ storePath }).approvals.length === 0, 'stale approval was not removed');
2673
+ assertSelfTest(/Approval expired/.test(staleOutput.chunks.join('')), 'stale approval message missing');
2674
+ } finally {
2675
+ fs.rmSync(dir, { recursive: true, force: true });
2676
+ }
2677
+ }, results, output);
2678
+
2679
+ await runSelfTestCase('approval output privacy', async () => {
2680
+ const text = formatApprovalExecutionResult({
2681
+ ok: false,
2682
+ status: 402,
2683
+ data: { detail: { error: 'paid_cloud_computer_required', message: 'secret payload text' } },
2684
+ });
2685
+ assertSelfTest(text === 'Approval execution needs a paid cloud computer.', 'unexpected approval failure text');
2686
+ assertSelfTest(!/secret payload/i.test(text), 'approval failure leaked private detail');
2687
+ }, results, output);
2688
+
2689
+ await runSelfTestCase('run log privacy', async () => {
2690
+ const previousFull = process.env.AX_LOG_FULL;
2691
+ const previousAuto = process.env.AX_AUTO_LOG;
2692
+ delete process.env.AX_LOG_FULL;
2693
+ delete process.env.AX_AUTO_LOG;
2694
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ax-self-test-log-'));
2695
+ fs.mkdirSync(path.join(cwd, 'atris'), { recursive: true });
2696
+ try {
2697
+ const logger = createRunLogger({
2698
+ cwd,
2699
+ mode: 'fast',
2700
+ kind: 'self-test',
2701
+ output: { isTTY: true, write() { return true; } },
2702
+ });
2703
+ assertSelfTest(logger, 'logger was not created');
2704
+ logger.output.write('private input secret\n');
2705
+ logger.write('private output secret\n');
2706
+ logger.close(0);
2707
+ const logText = fs.readFileSync(logger.path, 'utf8');
2708
+ assertSelfTest(/transcript: redacted/.test(logText), 'redacted transcript marker missing');
2709
+ assertSelfTest(!/private input secret|private output secret/.test(logText), 'run log leaked private text');
2710
+ } finally {
2711
+ if (previousFull === undefined) delete process.env.AX_LOG_FULL;
2712
+ else process.env.AX_LOG_FULL = previousFull;
2713
+ if (previousAuto === undefined) delete process.env.AX_AUTO_LOG;
2714
+ else process.env.AX_AUTO_LOG = previousAuto;
2715
+ fs.rmSync(cwd, { recursive: true, force: true });
2716
+ }
2717
+ }, results, output);
2718
+
2719
+ const failed = results.filter(result => !result.ok);
2720
+ output.write('\n');
2721
+ output.write(failed.length ? `Self-test failed: ${failed.length}/${results.length} failed\n` : `Self-test passed: ${results.length}/${results.length}\n`);
2722
+ if (failed.length) {
2723
+ const error = new Error(failed.map(result => `${result.label}: ${result.error}`).join('; '));
2724
+ error.results = results;
2725
+ throw error;
2726
+ }
2727
+ return results;
2728
+ }
2729
+
2945
2730
  async function runBenchmark(options = {}) {
2946
2731
  const normalized = normalizeMode(options.mode);
2947
2732
  const mode = normalized === 'code-fast' ? 'fast' : normalized;
@@ -3063,35 +2848,12 @@ async function runBenchmark(options = {}) {
3063
2848
  }
3064
2849
 
3065
2850
  async function main() {
3066
- let args = process.argv.slice(2);
3067
- if (isAxSpawnCommand(args[0])) {
3068
- try {
3069
- runAxSpawnCommand(args, { root: process.cwd() });
3070
- } catch (error) {
3071
- console.error(`x ${error.message}`);
3072
- process.exit(1);
3073
- }
3074
- return;
3075
- }
3076
- if (args[0] === 'youtube') {
3077
- try {
3078
- await runAxYoutubeCommand(args, { output: (line = '') => console.log(line) });
3079
- } catch (error) {
3080
- console.error(`x ${error.message}`);
3081
- process.exit(1);
3082
- }
3083
- return;
3084
- }
2851
+ const args = process.argv.slice(2);
3085
2852
  if (args.includes('--help') || args.includes('-h')) {
3086
2853
  console.log(formatUsage());
3087
2854
  return;
3088
2855
  }
3089
2856
 
3090
- const bypassPermissions = parsePermissionFlags(args);
3091
- args = stripPermissionFlags(args);
3092
- const chatArgs = normalizeChatCommandArgs(args);
3093
- args = chatArgs.args;
3094
-
3095
2857
  // --business <slug>: chat against that business's cloud workspace. The flag
3096
2858
  // takes a value, so splice the pair out before building the prompt.
3097
2859
  let businessSlug = null;
@@ -3118,63 +2880,42 @@ async function main() {
3118
2880
  args.splice(verifyIdx, 2);
3119
2881
  }
3120
2882
 
3121
- let dogfoodLoops = 25;
3122
- const loopsIdx = args.indexOf('--loops');
3123
- if (loopsIdx !== -1) {
3124
- dogfoodLoops = Number(args[loopsIdx + 1]);
3125
- if (!Number.isInteger(dogfoodLoops) || dogfoodLoops < 1 || dogfoodLoops > 100) {
3126
- console.error('Usage: ax --dogfood-chat --loops <1-100>');
3127
- process.exit(1);
3128
- }
3129
- args.splice(loopsIdx, 2);
3130
- }
3131
-
3132
- const mode = args.includes('--code-fast') || args.includes('--code') ? 'code-fast'
3133
- : args.includes('--max') ? 'max'
3134
- : args.includes('--pro') ? 'pro'
3135
- : 'fast';
2883
+ const mode = args.includes('--code-fast') || args.includes('--code') ? 'code-fast' : args.includes('--max') ? 'max' : args.includes('--fast') ? 'fast' : 'pro';
3136
2884
  const doctor = args.includes('--doctor');
2885
+ const selfTest = args.includes('--self-test');
3137
2886
  const benchmark = args.includes('--benchmark');
3138
- const dogfoodChat = args.includes('--dogfood-chat');
3139
- const dogfoodStatus = args.includes('--dogfood-status');
3140
- const dogfoodJson = dogfoodChat && args.includes('--json');
3141
- const dogfoodLive = dogfoodChat && args.includes('--live');
2887
+ const approvals = args.includes('--approvals');
3142
2888
  const forceCloud = args.includes('--cloud');
3143
2889
  const forceLocal = args.includes('--local');
3144
- const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
3145
- const prompt = args
3146
- .filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--benchmark', '--dogfood-chat', '--dogfood-status', '--json', '--live', '--local', '--cloud', '--help', '-h'].includes(arg))
3147
- .join(' ')
3148
- .trim();
3149
-
3150
- if (dogfoodStatus) {
3151
- const status = buildChatDogfoodStatus({ cwd: process.cwd(), targetLoops: dogfoodLoops });
3152
- console.log(args.includes('--json') ? JSON.stringify(status, null, 2) : formatChatDogfoodStatusReport(status));
3153
- return;
3154
- }
3155
2890
 
3156
- if (dogfoodChat) {
3157
- try {
3158
- const report = await runChatDogfood({ mode, cwd: process.cwd(), loops: dogfoodLoops, live: dogfoodLive });
3159
- console.log(dogfoodJson ? JSON.stringify(report, null, 2) : formatChatDogfoodReport(report));
3160
- if (report.failures.length) process.exit(1);
3161
- } catch (error) {
3162
- console.error(`x ${error.message}`);
2891
+ let approveId = '';
2892
+ const approveIdx = args.indexOf('--approve');
2893
+ if (approveIdx !== -1) {
2894
+ approveId = String(args[approveIdx + 1] || '').trim();
2895
+ if (!approveId) {
2896
+ console.error('Usage: ax --approve <approval-id>');
3163
2897
  process.exit(1);
3164
2898
  }
3165
- return;
2899
+ args.splice(approveIdx, 2);
3166
2900
  }
3167
2901
 
3168
- if (prompt && extractYoutubeUrl(prompt)) {
3169
- try {
3170
- await runAxYoutubeCommand([prompt], { output: (line = '') => console.log(line) });
3171
- } catch (error) {
3172
- console.error(`x ${error.message}`);
2902
+ let denyId = '';
2903
+ const denyIdx = args.indexOf('--deny');
2904
+ if (denyIdx !== -1) {
2905
+ denyId = String(args[denyIdx + 1] || '').trim();
2906
+ if (!denyId) {
2907
+ console.error('Usage: ax --deny <approval-id>');
3173
2908
  process.exit(1);
3174
2909
  }
3175
- return;
2910
+ args.splice(denyIdx, 2);
3176
2911
  }
3177
2912
 
2913
+ const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
2914
+ const prompt = args
2915
+ .filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--local', '--cloud', '--help', '-h'].includes(arg))
2916
+ .join(' ')
2917
+ .trim();
2918
+
3178
2919
  let business = null;
3179
2920
  if (businessSlug) {
3180
2921
  if (normalizeMode(mode) === 'code-fast') {
@@ -3206,46 +2947,61 @@ async function main() {
3206
2947
  console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
3207
2948
  }
3208
2949
 
3209
- const runOptions = {
3210
- mode,
3211
- cwd: process.cwd(),
3212
- route: route === 'auto' ? undefined : route,
3213
- business,
3214
- verify,
3215
- bypassPermissions,
3216
- };
3217
-
3218
2950
  try {
2951
+ if (selfTest) {
2952
+ await runSelfTest({ output: process.stdout });
2953
+ return;
2954
+ }
2955
+
3219
2956
  if (benchmark) {
3220
2957
  await runBenchmark({ mode, cwd: process.cwd(), output: process.stdout });
3221
2958
  return;
3222
2959
  }
3223
2960
 
3224
2961
  if (doctor) {
3225
- console.log(formatHeader({ mode, cwd: process.cwd(), chat: false, bypassPermissions: resolveBypassPermissions(runOptions) }, process.stdout));
2962
+ console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
3226
2963
  console.log('');
3227
- console.log(formatRunProfile(buildRunProfile(runOptions), process.stdout));
2964
+ console.log(formatRunProfile(buildRunProfile({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route }), process.stdout));
2965
+ console.log('');
2966
+ console.log(formatRuntimeHealth(await buildRuntimeHealth(), process.stdout));
3228
2967
  return;
3229
2968
  }
3230
2969
 
3231
- if (!prompt || chatArgs.chatRequested) {
3232
- await chat(runOptions);
2970
+ if (approvals) {
2971
+ console.log(formatStoredApprovals(readApprovalStore(), process.stdout));
3233
2972
  return;
3234
2973
  }
3235
2974
 
3236
- console.log(formatHeader({ mode, cwd: process.cwd(), chat: false, bypassPermissions: resolveBypassPermissions(runOptions) }, process.stdout));
3237
- console.log('');
3238
- const result = await turnFunctionForMode(mode)(prompt, runOptions);
3239
- if (result.interrupted) {
3240
- console.log('');
3241
- console.log( Interrupted');
3242
- process.exit(130);
2975
+ if (approveId) {
2976
+ await approveStoredApproval(approveId, { mode, output: process.stdout });
2977
+ return;
2978
+ }
2979
+
2980
+ if (denyId) {
2981
+ const record = denyStoredApproval(denyId);
2982
+ console.log(`Cancelled pending approval ${record.id}.`);
2983
+ return;
3243
2984
  }
2985
+
2986
+ if (!prompt || args.includes('--chat')) {
2987
+ await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
2988
+ return;
2989
+ }
2990
+
2991
+ console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
2992
+ console.log('');
2993
+ const result = await turnFunctionForMode(mode)(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
2994
+ const receipt = receiptFromState(result);
2995
+ const approvalRequest = normalizeMode(mode) === 'code-fast' ? null : approvalRequestFromReceipt(receipt);
2996
+ const approvalRecord = approvalRequest
2997
+ ? persistPendingApproval(receipt, approvalRequest, { cwd: process.cwd(), mode })
2998
+ : null;
2999
+ if (approvalRecord) console.log(formatAuxRow('approve', formatApprovalCommand(approvalRecord.id), process.stdout));
3244
3000
  console.log('');
3245
3001
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
3246
3002
  } catch (error) {
3247
3003
  console.error(`x ${error.message}`);
3248
- printBackendHint(runOptions);
3004
+ printBackendHint();
3249
3005
  process.exit(1);
3250
3006
  }
3251
3007
  }
@@ -3257,87 +3013,67 @@ if (require.main === module) {
3257
3013
  module.exports = {
3258
3014
  authToken,
3259
3015
  authUserId,
3016
+ approvalExecutionRequestFromReceipt,
3017
+ approvalRequestFromReceipt,
3018
+ approvalStorePath,
3019
+ approveStoredApproval,
3260
3020
  backendBaseUrl,
3021
+ backendStartCommand,
3261
3022
  backendUrl,
3262
- buildAxYoutubeArgs,
3263
3023
  buildCodeFastPayload,
3264
- buildChatDogfoodCases,
3265
- buildChatDogfoodStatus,
3266
- buildMessage,
3267
3024
  buildPayload,
3025
+ buildRuntimeHealth,
3268
3026
  cachedIntegrationStatus,
3269
3027
  buildConnectionContext,
3270
3028
  buildRunProfile,
3271
3029
  chat,
3272
3030
  chatCompleter,
3273
- chatGoalCommand: parseGoalCommand,
3274
3031
  chatMenu,
3275
- chatPermissionCommand,
3276
3032
  chatTierCommand,
3277
- loadAxPrefs,
3278
- MEMBER_SLUG,
3279
- parsePermissionFlags,
3280
- permissionsLabel,
3281
- printBackendHint,
3282
- resolveBypassPermissions,
3283
- runAxSpawnCommand,
3284
- setBypassPermissions,
3285
3033
  codeFastWorkspaceNotice,
3286
3034
  creditsFromState,
3287
3035
  codeFastBaseUrl,
3288
3036
  codeFastUrl,
3289
3037
  createRunLogger,
3290
3038
  createProgressReporter,
3039
+ creditsFromApprovalExecution,
3040
+ denyStoredApproval,
3041
+ executeApprovalRequest,
3042
+ executeApprovalFromState,
3291
3043
  formatDoneLine,
3292
- formatChatDogfoodReport,
3293
- formatChatDogfoodStatusReport,
3294
3044
  formatDuration,
3045
+ formatApprovalExecutionResult,
3046
+ formatApprovalReceipt,
3047
+ formatBackendHint,
3295
3048
  formatHeader,
3296
3049
  formatPathSubject,
3297
- buildInputBoxBottomPlain,
3298
- buildInputBoxStatusPlain,
3299
- buildInputBoxTopPlain,
3300
- closeInputBox,
3301
- repaintInputBoxBottom,
3302
- formatChatInputInnerPrefix,
3303
- formatChatInputPrompt,
3304
- formatInputBoxBottom,
3305
- formatInputBoxInputRow,
3306
- formatInputBoxStatus,
3307
- formatInputBoxTop,
3308
- formatPermissionModeBrief,
3309
- formatPermissionToggleMessage,
3310
3050
  formatPrompt,
3311
- insertMultilineBreak,
3312
- inputBoxLayoutOptions,
3313
- inputBoxPlainWidth,
3314
- isMultilineInsertKey,
3315
- isPermissionToggleKey,
3316
- permissionAccentCodes,
3317
3051
  formatRunProfile,
3052
+ formatRuntimeHealth,
3318
3053
  formatStatusMessage,
3054
+ formatStoredApprovals,
3319
3055
  formatSystemInit,
3056
+ formatTaskPreviewRows,
3320
3057
  formatUsage,
3321
3058
  formatWorkingLine,
3322
3059
  handleEvent,
3323
- extractYoutubeUrl,
3324
- isAxSpawnCommand,
3325
- manageTurnInterrupt,
3060
+ latestPendingApprovalRequest,
3061
+ messageApprovesPendingApproval,
3062
+ messageCancelsPendingApproval,
3326
3063
  modelForMode,
3327
- normalizeChatCommandArgs,
3328
3064
  parseSseBlock,
3065
+ persistPendingApproval,
3066
+ postApprovalExecution,
3329
3067
  postCodeFastTurn,
3330
3068
  postTurn,
3331
- renderTerminalInline,
3332
- renderTerminalBlockLine,
3333
- formatMarkdownLink,
3069
+ readApprovalStore,
3070
+ removeStoredApproval,
3334
3071
  renderStreamingMarkdown,
3335
- renderShimmerText,
3336
3072
  renderTerminalMarkdown,
3337
3073
  resolveRoute,
3338
3074
  runBenchmark,
3339
- runChatDogfood,
3340
- runAxYoutubeCommand,
3075
+ runSelfTest,
3076
+ runtimeReadyForChat,
3341
3077
  summarizeToolInput,
3342
3078
  summarizeToolResult,
3343
3079
  tierLabel