atris 3.24.0 → 3.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ax CHANGED
@@ -6,13 +6,46 @@ 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');
10
+ const { Readable } = require('stream');
9
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');
10
42
 
11
43
  const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
12
44
  const BACKEND = {
13
45
  host: '127.0.0.1',
14
46
  port: 8000,
15
- path: '/api/atris2/turn'
47
+ path: '/api/atris2/turn',
48
+ publicBase: 'https://api.atris.ai'
16
49
  };
17
50
  const DEFAULT_BACKEND_BASE = `http://${BACKEND.host}:${BACKEND.port}`;
18
51
  const CODE_FAST = {
@@ -55,14 +88,22 @@ const CONNECTOR_SCOPES = {
55
88
  const ANSI = {
56
89
  reset: '\x1b[0m',
57
90
  bold: '\x1b[1m',
91
+ italic: '\x1b[3m',
58
92
  dim: '\x1b[2m',
59
93
  muted: '\x1b[90m',
60
94
  accent: '\x1b[36m',
61
95
  ok: '\x1b[32m',
62
- magenta: '\x1b[35m'
96
+ safe: '\x1b[34m',
97
+ warn: '\x1b[33m',
98
+ magenta: '\x1b[35m',
99
+ underline: '\x1b[4m',
100
+ strike: '\x1b[9m',
63
101
  };
64
102
 
65
103
  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;
66
107
 
67
108
  function tierColor(mode) {
68
109
  return TIER_COLORS[mode] || ANSI.accent;
@@ -95,11 +136,23 @@ function tierLabel(mode) {
95
136
  return 'Atris 2 Pro';
96
137
  }
97
138
 
98
- function formatHeader({ mode = 'pro', cwd = process.cwd(), chat = false } = {}, options = {}) {
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;
99
152
  return [
100
- paint(`${tierLabel(mode)}${chat ? ' chat' : ''}`, [ANSI.bold, tierColor(normalizeMode(mode))], options),
101
- cwd,
102
- chat ? paint('type / for the menu · /fast /pro /max swap tiers · exit to leave', [ANSI.muted], options) : '',
153
+ titleLine,
154
+ goalLine,
155
+ chat ? paint('shift+tab permissions · shift+enter newline · ctrl-c · / · exit', [ANSI.muted], options) : '',
103
156
  ].filter(Boolean).join('\n');
104
157
  }
105
158
 
@@ -108,23 +161,51 @@ function formatUsage() {
108
161
  'ax - Atris local/code agent',
109
162
  '',
110
163
  'Usage:',
111
- ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
112
- ' ax [--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
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]',
113
172
  ' ax [--max|--pro|--fast] --business <slug> [<message>|--chat]',
114
173
  ' ax [--max|--pro|--fast|--code-fast] --doctor',
115
174
  ' ax [--max|--fast] --benchmark',
116
175
  '',
117
176
  'Modes:',
118
- ' --max local workspace agent, highest reasoning, slowest turns',
119
- ' --pro local workspace agent, deeper tool loop',
120
- ' --fast local workspace agent, faster low-latency turns',
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',
121
180
  ' --code-fast Atris Code Fast public lane',
122
181
  ' --local force local workspace tools',
123
182
  ' --cloud force authenticated cloud connectors/chat',
124
183
  ' --business <slug> run tools on that business cloud workspace (EC2)',
125
184
  ' --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',
126
203
  '',
127
204
  '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',
128
209
  ' ax --pro find the config file and explain it',
129
210
  ' ax --fast what files are here',
130
211
  ' ax --max refactor this module and verify the tests',
@@ -145,7 +226,7 @@ function stripAnsi(value) {
145
226
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
146
227
  }
147
228
 
148
- function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', output = process.stdout } = {}) {
229
+ function createRunLogger({ cwd = process.cwd(), mode = 'fast', kind = 'play', output = process.stdout } = {}) {
149
230
  if (process.env.AX_AUTO_LOG === '0') return null;
150
231
  if (output !== process.stdout && output !== process.stderr && !output.isTTY) return null;
151
232
 
@@ -185,30 +266,51 @@ function createRunLogger({ cwd = process.cwd(), mode = 'pro', kind = 'play', out
185
266
  };
186
267
  }
187
268
 
188
- function backendBaseUrl() {
189
- return (process.env.AX_BACKEND_URL
190
- || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL
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
191
285
  || process.env.OBELISK_ATRIS2_BACKEND_URL
192
286
  || DEFAULT_BACKEND_BASE).replace(/\/$/, '');
193
287
  }
194
288
 
195
- function backendUrl() {
196
- return new URL(BACKEND.path, backendBaseUrl()).toString();
289
+ function backendUrl(options = {}) {
290
+ return new URL(BACKEND.path, backendBaseUrl(options)).toString();
197
291
  }
198
292
 
199
- function backendPathUrl(pathname) {
200
- return new URL(pathname, backendBaseUrl()).toString();
293
+ function backendPathUrl(pathname, options = {}) {
294
+ return new URL(pathname, backendBaseUrl(options)).toString();
201
295
  }
202
296
 
203
- function codeFastBaseUrl() {
204
- return (process.env.AX_CODE_FAST_BACKEND_URL
205
- || process.env.AX_CODE_FAST_API_BASE
206
- || process.env.ATRIS_API_BASE
207
- || CODE_FAST.publicBase).replace(/\/$/, '');
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(/\/$/, '');
208
310
  }
209
311
 
210
- function codeFastUrl() {
211
- return new URL(CODE_FAST.path, codeFastBaseUrl()).toString();
312
+ function codeFastUrl(options = {}) {
313
+ return new URL(CODE_FAST.path, codeFastBaseUrl(options)).toString();
212
314
  }
213
315
 
214
316
  function isLoopbackUrl(value) {
@@ -220,6 +322,15 @@ function isLoopbackUrl(value) {
220
322
  }
221
323
  }
222
324
 
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
+
223
334
  function isCodeFastLocal(options = {}) {
224
335
  const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
225
336
  return route === 'local' && isLoopbackUrl(options.codeFastBaseUrl || codeFastBaseUrl());
@@ -231,39 +342,44 @@ function buildRunProfile(options = {}) {
231
342
  if (mode === 'code-fast') {
232
343
  const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
233
344
  return {
234
- endpoint: codeFastUrl(),
345
+ endpoint: codeFastUrl({ route }),
235
346
  mode,
236
347
  route,
237
348
  model: CODE_FAST.model,
238
349
  workspace_path: isCodeFastLocal(options) ? cwd : 'cloud scratch',
239
350
  max_turns: 1,
240
351
  streaming: false,
241
- runtime: isCodeFastLocal(options) ? 'local Cursor SDK through backend' : 'authenticated Code Fast cloud scratch',
352
+ runtime: isCodeFastLocal(options) ? 'local Cursor SDK bridge' : 'authenticated Code Fast cloud scratch',
242
353
  reasoning: 'Composer 2.5 fast lane; charges 10 credits per public turn'
243
354
  };
244
355
  }
245
- const route = resolveRoute(options.message || 'doctor', options);
246
- const payload = buildPayload(options.message || 'doctor', { mode, cwd, route });
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 });
247
359
  return {
248
- endpoint: backendUrl(),
360
+ endpoint: backendUrl({ route }),
249
361
  mode,
250
362
  route,
251
363
  model: payload.model,
252
364
  workspace_path: payload.workspace_path || 'cloud',
253
365
  max_turns: payload.max_turns,
366
+ member_slug: payload.member_slug,
367
+ bypass_permissions: payload.bypass_permissions,
254
368
  streaming: true,
255
369
  runtime: route === 'cloud' ? 'authenticated cloud connectors/chat' : 'local workspace',
256
370
  reasoning: mode === 'max'
257
- ? 'backend reports run row; Max workspace tool loop uses high reasoning effort'
371
+ ? 'Atris cloud service; Max workspace tool loop uses high reasoning effort'
258
372
  : mode === 'pro'
259
- ? 'backend reports run row; Pro workspace tool loop uses API default medium'
260
- : 'backend reports run row; Fast workspace tool loop uses provider default'
373
+ ? 'Atris cloud service; Pro workspace tool loop uses API default medium'
374
+ : 'Atris cloud service; Fast workspace tool loop uses provider default'
261
375
  };
262
376
  }
263
377
 
264
378
  function formatRunProfile(profile, options = {}) {
265
379
  const rows = [
266
380
  ['mode', profile.mode],
381
+ ['agent', profile.member_slug || MEMBER_SLUG],
382
+ ['permissions', profile.bypass_permissions ? 'bypass' : 'safe'],
267
383
  ['endpoint', profile.endpoint],
268
384
  ['route', profile.route || 'auto'],
269
385
  ['workspace', formatPathSubject(profile.workspace_path, options)],
@@ -307,9 +423,9 @@ function authUserId() {
307
423
  }
308
424
  }
309
425
 
310
- function isLoopbackBackend() {
426
+ function isLoopbackBackend(options = {}) {
311
427
  try {
312
- const parsed = new URL(backendBaseUrl());
428
+ const parsed = new URL(backendBaseUrl(options));
313
429
  return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
314
430
  } catch (_) {
315
431
  return false;
@@ -491,24 +607,220 @@ function githubWorkspaceIntent(message) {
491
607
  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);
492
608
  }
493
609
 
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
+
494
619
  function resolveRoute(message, options = {}) {
495
620
  if (options.route === 'local' || options.forceLocal) return 'local';
496
621
  if (options.route === 'cloud' || options.forceCloud) return 'cloud';
497
- if (githubWorkspaceIntent(message)) return 'local';
622
+ if (casualChatIntent(message)) return 'cloud';
623
+ const localWorkspace = hasLocalWorkspaceBackend(options);
624
+ if (githubWorkspaceIntent(message) && localWorkspace) return 'local';
498
625
  if (mentionsConnector(message) && !workspaceIntent(message)) return 'cloud';
626
+ if (!localWorkspace) return 'cloud';
499
627
  return 'local';
500
628
  }
501
629
 
502
630
  function normalizeMode(mode) {
503
631
  if (mode === 'code-fast' || mode === 'code') return 'code-fast';
504
632
  if (mode === 'max') return 'max';
505
- return mode === 'fast' ? 'fast' : 'pro';
633
+ if (mode === 'pro') return 'pro';
634
+ return 'fast';
506
635
  }
507
636
 
508
637
  function formatPrompt(mode, options = {}) {
509
- if (!mode) return '› ';
510
638
  const tier = normalizeMode(mode);
511
- return `${paint(tier, [ANSI.bold, tierColor(tier)], options)} › `;
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;
512
824
  }
513
825
 
514
826
  const TIER_COMMANDS = new Map([
@@ -522,21 +834,70 @@ function chatTierCommand(line) {
522
834
  }
523
835
 
524
836
  const CHAT_COMMANDS = [
837
+ ['/goal', 'set/show autonomous completion condition'],
525
838
  ['/fast', 'quick answers, lowest latency'],
526
839
  ['/pro', 'deeper tool loop for real work'],
527
840
  ['/max', 'highest reasoning for the hardest jobs'],
841
+ ['/bypass', 'allow external side effects (persisted)'],
842
+ ['/safe', 'approval-only external actions (default, persisted)'],
528
843
  ['/help', 'show this menu'],
529
844
  ['exit', 'leave chat'],
530
845
  ];
531
846
 
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
+
532
889
  function chatMenu(options = {}) {
533
- return CHAT_COMMANDS
890
+ const commands = CHAT_COMMANDS
534
891
  .map(([name, desc]) => {
535
892
  const tier = TIER_COMMANDS.get(name);
536
893
  const color = tier ? tierColor(tier) : ANSI.accent;
537
894
  return ` ${paint(name.padEnd(6), [color], options)} ${paint(desc, [ANSI.muted], options)}`;
538
895
  })
539
896
  .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}`;
540
901
  }
541
902
 
542
903
  function chatCompleter(line) {
@@ -547,10 +908,132 @@ function chatCompleter(line) {
547
908
  return [hits.length ? hits : names, trimmed];
548
909
  }
549
910
 
550
- function formatWorkingLine(ms, verb, frame) {
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);
551
1023
  const totalSeconds = Math.max(1, Math.round((Number(ms) || 0) / 1000));
552
- if (verb) return `${frame || '•'} ${verb}… (${formatSeconds(totalSeconds)} · ctrl-c to interrupt)`;
553
- return `• Working (${formatSeconds(totalSeconds)} ctrl-c to interrupt)`;
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}`;
554
1037
  }
555
1038
 
556
1039
  function verbForTool(tool) {
@@ -559,7 +1042,8 @@ function verbForTool(tool) {
559
1042
  if (name.includes('grep') || name.includes('glob') || name.includes('search')) return 'Searching';
560
1043
  if (name.includes('bash') || name.includes('command') || name.includes('run')) return 'Running';
561
1044
  if (name.includes('write') || name.includes('edit')) return 'Editing';
562
- return 'Working';
1045
+ if (name.includes('think') || name.includes('reason') || name === 'task') return 'Thinking';
1046
+ return 'Thinking';
563
1047
  }
564
1048
 
565
1049
  function formatDoneLine(ms, credits) {
@@ -581,7 +1065,18 @@ function paint(text, codes, options = {}) {
581
1065
  return `${codes.join('')}${text}${ANSI.reset}`;
582
1066
  }
583
1067
 
584
- function renderTerminalMarkdown(text, options = {}) {
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 = {}) {
585
1080
  let rendered = String(text || '');
586
1081
  const codeSpans = [];
587
1082
  rendered = rendered.replace(/`([^`\n]+)`/g, (_, code) => {
@@ -589,23 +1084,131 @@ function renderTerminalMarkdown(text, options = {}) {
589
1084
  codeSpans.push(code);
590
1085
  return token;
591
1086
  });
592
- rendered = rendered.replace(/^#{1,6}\s+(.+)$/gm, (_, title) => paint(title, [ANSI.bold], options));
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));
593
1089
  rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, (_, value) => paint(value, [ANSI.bold], options));
594
1090
  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));
595
1094
  rendered = rendered.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => paint(codeSpans[Number(index)] || '', [ANSI.accent], options));
596
1095
  return rendered;
597
1096
  }
598
1097
 
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
+
599
1160
  function resetMarkdownState(state) {
600
1161
  state.markdownMode = 'normal';
601
1162
  state.markdownBuffer = '';
602
1163
  state.markdownCarry = '';
1164
+ state.markdownDelim = '';
1165
+ state.atLineStart = true;
603
1166
  }
604
1167
 
605
1168
  function ensureMarkdownState(state) {
606
1169
  if (!state.markdownMode) state.markdownMode = 'normal';
607
1170
  if (typeof state.markdownBuffer !== 'string') state.markdownBuffer = '';
608
1171
  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
+ };
609
1212
  }
610
1213
 
611
1214
  function renderStreamingMarkdown(state, text, options = {}) {
@@ -615,43 +1218,239 @@ function renderStreamingMarkdown(state, text, options = {}) {
615
1218
  let out = '';
616
1219
 
617
1220
  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
+
618
1305
  const char = input[i];
619
1306
  const next = input[i + 1];
1307
+ const third = input[i + 2];
620
1308
 
621
1309
  if (state.markdownMode === 'normal') {
622
- if (char === '*' && next === undefined) {
623
- state.markdownCarry = '*';
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 = '[';
624
1319
  break;
625
1320
  }
1321
+ if (char === '*' && next === '*' && third === '*') {
1322
+ state.markdownMode = 'bold_italic';
1323
+ state.markdownBuffer = '';
1324
+ state.markdownDelim = '***';
1325
+ i += 3;
1326
+ continue;
1327
+ }
626
1328
  if (char === '*' && next === '*') {
627
1329
  state.markdownMode = 'bold';
628
1330
  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 = '~~';
629
1368
  i += 2;
630
1369
  continue;
631
1370
  }
632
1371
  if (char === '`') {
633
1372
  state.markdownMode = 'code';
634
1373
  state.markdownBuffer = '';
1374
+ state.markdownDelim = '`';
635
1375
  i += 1;
636
1376
  continue;
637
1377
  }
638
1378
  out += char;
1379
+ if (char === '\n') state.atLineStart = true;
1380
+ else state.atLineStart = false;
639
1381
  i += 1;
640
1382
  continue;
641
1383
  }
642
1384
 
643
- if (state.markdownMode === 'bold') {
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
+ }
644
1394
  if (char === '*' && next === undefined) {
645
1395
  state.markdownCarry = '*';
646
1396
  break;
647
1397
  }
648
- if (char === '*' && next === '*') {
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) {
649
1406
  out += paint(state.markdownBuffer, [ANSI.bold], options);
650
1407
  state.markdownMode = 'normal';
651
1408
  state.markdownBuffer = '';
1409
+ state.markdownDelim = '';
652
1410
  i += 2;
653
1411
  continue;
654
1412
  }
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
+ }
655
1454
  state.markdownBuffer += char;
656
1455
  i += 1;
657
1456
  continue;
@@ -677,10 +1476,19 @@ function renderStreamingMarkdown(state, text, options = {}) {
677
1476
  function flushStreamingMarkdown(state, output) {
678
1477
  ensureMarkdownState(state);
679
1478
  let out = state.markdownCarry || '';
680
- if (state.markdownMode === 'bold' && state.markdownBuffer) {
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) {
681
1483
  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);
682
1488
  } else if (state.markdownMode === 'code' && state.markdownBuffer) {
683
1489
  out += paint(state.markdownBuffer, [ANSI.accent], output);
1490
+ } else if (state.markdownMode === 'fence' && state.markdownBuffer) {
1491
+ out += renderFenceBuffer(state.markdownBuffer, output);
684
1492
  }
685
1493
  resetMarkdownState(state);
686
1494
  if (out) output.write(out);
@@ -730,21 +1538,26 @@ function createProgressReporter(output, options = {}) {
730
1538
  const enabled = options.showProgress !== false;
731
1539
  const isTty = Boolean(output && output.isTTY);
732
1540
  const startedAt = Date.now();
733
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
734
1541
  let frameIndex = 0;
735
- let verb = 'Working';
1542
+ let verb = 'Thinking';
736
1543
  let interval = null;
737
1544
  let timeout = null;
738
1545
  let shown = false;
739
1546
  let closed = false;
1547
+ const renderOptions = () => ({
1548
+ ...output,
1549
+ color: options.color,
1550
+ tick: frameIndex,
1551
+ frameIndex,
1552
+ });
740
1553
 
741
1554
  const render = () => {
742
1555
  if (!enabled || closed) return;
743
1556
  if (isTty) {
744
- const frame = paint(frames[frameIndex++ % frames.length], [ANSI.accent], output);
745
- output.write(`\r${formatWorkingLine(Date.now() - startedAt, verb, frame)}\x1b[K`);
1557
+ output.write(`\r${formatWorkingLine(Date.now() - startedAt, verb, renderOptions())}\x1b[K`);
1558
+ frameIndex += 1;
746
1559
  } else if (!shown) {
747
- output.write(`${formatWorkingLine(Date.now() - startedAt)}\n`);
1560
+ output.write(`${formatWorkingLine(Date.now() - startedAt, verb, renderOptions())}\n`);
748
1561
  }
749
1562
  shown = true;
750
1563
  };
@@ -754,11 +1567,11 @@ function createProgressReporter(output, options = {}) {
754
1567
  if (!enabled) return;
755
1568
  timeout = setTimeout(() => {
756
1569
  render();
757
- if (isTty) interval = setInterval(render, 120);
758
- }, 700);
1570
+ if (isTty) interval = setInterval(render, PROGRESS_FRAME_MS);
1571
+ }, PROGRESS_START_DELAY_MS);
759
1572
  },
760
1573
  setVerb(next) {
761
- verb = next || 'Working';
1574
+ verb = next || 'Thinking';
762
1575
  },
763
1576
  clear() {
764
1577
  if (!isTty || !shown || closed) return;
@@ -775,6 +1588,17 @@ function createProgressReporter(output, options = {}) {
775
1588
  };
776
1589
  }
777
1590
 
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
+
778
1602
  function parseSseBlock(block) {
779
1603
  const data = String(block || '')
780
1604
  .split(/\r?\n/)
@@ -888,7 +1712,7 @@ function stopProgress(state) {
888
1712
  }
889
1713
 
890
1714
  function flushPendingText(state, output) {
891
- const hasMarkdownRemainder = output && output.isTTY && (
1715
+ const hasMarkdownRemainder = output && (
892
1716
  state.markdownCarry
893
1717
  || state.markdownBuffer
894
1718
  || (state.markdownMode && state.markdownMode !== 'normal')
@@ -900,7 +1724,7 @@ function flushPendingText(state, output) {
900
1724
  output.write('● ');
901
1725
  state.needsBullet = false;
902
1726
  }
903
- if (state.pendingText) output.write(output && output.isTTY ? renderTerminalMarkdown(state.pendingText, output) : state.pendingText);
1727
+ if (state.pendingText) output.write(renderTerminalMarkdown(state.pendingText, output));
904
1728
  const flushedMarkdown = hasMarkdownRemainder ? flushStreamingMarkdown(state, output) : '';
905
1729
  state.wroteText = true;
906
1730
  state.wroteActivity = true;
@@ -956,7 +1780,8 @@ function buildMessage(message, history = []) {
956
1780
  'Recent conversation:',
957
1781
  compactHistory(history),
958
1782
  '',
959
- `Current user message: ${trimmed}`,
1783
+ '# Current user message',
1784
+ trimmed,
960
1785
  ].join('\n');
961
1786
  }
962
1787
 
@@ -964,12 +1789,15 @@ function buildPayload(message, options = {}) {
964
1789
  const mode = normalizeMode(options.mode);
965
1790
  const route = options.business ? 'local' : resolveRoute(message, options);
966
1791
  const local = route !== 'cloud';
1792
+ const bypass = resolveBypassPermissions(options);
967
1793
  const verifyCommand = String(options.verify || '').trim();
968
1794
  const payload = {
969
1795
  message: buildMessage(message, options.history || []),
970
1796
  model: modelForMode(mode),
971
- max_turns: local ? (mode === 'fast' ? 8 : 14) : 1,
972
- verify_command: verifyCommand || 'true'
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,
973
1801
  };
974
1802
  if (options.business) {
975
1803
  // Business cloud workspace: the model loop stays on the backend and every
@@ -986,7 +1814,7 @@ function buildPayload(message, options = {}) {
986
1814
  if (!local && options.connectionUserId) {
987
1815
  payload.connection_user_id = options.connectionUserId;
988
1816
  }
989
- if (!local && connectorWriteIntent(message)) {
1817
+ if (!local && bypass && connectorWriteIntent(message)) {
990
1818
  payload.allow_external_actions = true;
991
1819
  payload.cleanup_external_actions = true;
992
1820
  payload.max_turns = mode === 'fast' ? 2 : 4;
@@ -1052,6 +1880,7 @@ function handleEvent(event, state, output) {
1052
1880
  writeAuxLine(state, output, formatToolResultLine(summarizeToolResult(result, output), output));
1053
1881
  state.lastAux = 'result';
1054
1882
  }
1883
+ if (state.progress && state.progress.setVerb) state.progress.setVerb('Thinking');
1055
1884
  return;
1056
1885
  }
1057
1886
 
@@ -1082,8 +1911,14 @@ function handleEvent(event, state, output) {
1082
1911
  clearRetriedText(state);
1083
1912
  return;
1084
1913
  }
1914
+ if (state.progress && state.progress.setVerb && /think|reason|plan/i.test(event.message)) {
1915
+ state.progress.setVerb('Thinking');
1916
+ }
1085
1917
  flushPendingText(state, output);
1086
- writeAuxLine(state, output, paint(`· ${formatStatusMessage(event.message)}`, [ANSI.muted], output));
1918
+ const statusText = formatStatusMessage(event.message);
1919
+ if (statusText) {
1920
+ writeAuxLine(state, output, paint(`✦ ${statusText}`, [ANSI.bold, ANSI.magenta], output));
1921
+ }
1087
1922
  return;
1088
1923
  }
1089
1924
 
@@ -1111,7 +1946,7 @@ async function postTurn(message, options = {}) {
1111
1946
  const connectionContext = options.connectionContext || (shouldSendConnectionContext
1112
1947
  ? await buildConnectionContext({ token, localWorkspace: local })
1113
1948
  : null);
1114
- const connectionUserId = !local && isLoopbackBackend() ? authUserId() : '';
1949
+ const connectionUserId = !local && isLoopbackBackend({ route }) ? authUserId() : '';
1115
1950
  const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId });
1116
1951
  const postData = JSON.stringify(payload);
1117
1952
  const output = options.output || process.stdout;
@@ -1119,7 +1954,7 @@ async function postTurn(message, options = {}) {
1119
1954
  // SSE traffic in between, so the socket-idle timeout needs more headroom.
1120
1955
  const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
1121
1956
  const timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
1122
- const turnUrl = new URL(backendUrl());
1957
+ const turnUrl = new URL(backendUrl({ route }));
1123
1958
  const transport = turnUrl.protocol === 'https:' ? https : http;
1124
1959
  const state = {
1125
1960
  events: [],
@@ -1137,15 +1972,19 @@ async function postTurn(message, options = {}) {
1137
1972
  markdownMode: 'normal',
1138
1973
  markdownBuffer: '',
1139
1974
  markdownCarry: '',
1975
+ atLineStart: true,
1140
1976
  relay: options.business ? {
1141
1977
  chain: Promise.resolve(),
1142
1978
  execute: options.business.executor,
1143
- post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl())
1979
+ post: (callId, result) => options.business.postToolResult(callId, result, backendBaseUrl({ route }))
1144
1980
  } : null
1145
1981
  };
1146
1982
 
1147
1983
  return new Promise((resolve, reject) => {
1148
1984
  let settled = false;
1985
+ let interrupted = false;
1986
+ let req = null;
1987
+ let removeInterrupt = null;
1149
1988
  const startedAt = Date.now();
1150
1989
  state.progress = createProgressReporter(output, options);
1151
1990
  state.progress.start();
@@ -1153,6 +1992,8 @@ async function postTurn(message, options = {}) {
1153
1992
  const finish = (error, value) => {
1154
1993
  if (settled) return;
1155
1994
  settled = true;
1995
+ if (removeInterrupt) removeInterrupt();
1996
+ removeInterrupt = null;
1156
1997
  if (state.progress) state.progress.stop();
1157
1998
  state.progress = null;
1158
1999
  state.durationMs = Date.now() - startedAt;
@@ -1160,7 +2001,15 @@ async function postTurn(message, options = {}) {
1160
2001
  else resolve(value);
1161
2002
  };
1162
2003
 
1163
- const req = transport.request({
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({
1164
2013
  hostname: turnUrl.hostname,
1165
2014
  port: turnUrl.port || (turnUrl.protocol === 'https:' ? 443 : 80),
1166
2015
  path: `${turnUrl.pathname}${turnUrl.search}`,
@@ -1225,8 +2074,12 @@ async function postTurn(message, options = {}) {
1225
2074
  });
1226
2075
  });
1227
2076
 
1228
- req.on('error', finish);
2077
+ req.on('error', (error) => {
2078
+ if (interrupted) return;
2079
+ finish(error);
2080
+ });
1229
2081
  req.setTimeout(timeoutMs, () => {
2082
+ if (settled) return;
1230
2083
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
1231
2084
  req.destroy();
1232
2085
  });
@@ -1246,7 +2099,7 @@ async function postCodeFastTurn(message, options = {}) {
1246
2099
  const output = options.output || process.stdout;
1247
2100
  const token = authToken();
1248
2101
  const timeoutMs = Math.max(10000, Math.min(600000, Number(payload.timeout_seconds || 180) * 1000));
1249
- const turnUrl = new URL(codeFastUrl());
2102
+ const turnUrl = new URL(codeFastUrl({ ...options, route }));
1250
2103
  const transport = turnUrl.protocol === 'https:' ? https : http;
1251
2104
  const state = {
1252
2105
  events: [],
@@ -1263,11 +2116,15 @@ async function postCodeFastTurn(message, options = {}) {
1263
2116
  lastAux: '',
1264
2117
  markdownMode: 'normal',
1265
2118
  markdownBuffer: '',
1266
- markdownCarry: ''
2119
+ markdownCarry: '',
2120
+ atLineStart: true,
1267
2121
  };
1268
2122
 
1269
2123
  return new Promise((resolve, reject) => {
1270
2124
  let settled = false;
2125
+ let interrupted = false;
2126
+ let req = null;
2127
+ let removeInterrupt = null;
1271
2128
  const startedAt = Date.now();
1272
2129
  state.progress = createProgressReporter(output, options);
1273
2130
  state.progress.start();
@@ -1275,6 +2132,8 @@ async function postCodeFastTurn(message, options = {}) {
1275
2132
  const finish = (error, value) => {
1276
2133
  if (settled) return;
1277
2134
  settled = true;
2135
+ if (removeInterrupt) removeInterrupt();
2136
+ removeInterrupt = null;
1278
2137
  if (state.progress) state.progress.stop();
1279
2138
  state.progress = null;
1280
2139
  state.durationMs = Date.now() - startedAt;
@@ -1282,7 +2141,15 @@ async function postCodeFastTurn(message, options = {}) {
1282
2141
  else resolve(value);
1283
2142
  };
1284
2143
 
1285
- const req = transport.request({
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({
1286
2153
  hostname: turnUrl.hostname,
1287
2154
  port: turnUrl.port || (turnUrl.protocol === 'https:' ? 443 : 80),
1288
2155
  path: `${turnUrl.pathname}${turnUrl.search}`,
@@ -1334,8 +2201,12 @@ async function postCodeFastTurn(message, options = {}) {
1334
2201
  });
1335
2202
  });
1336
2203
 
1337
- req.on('error', finish);
2204
+ req.on('error', (error) => {
2205
+ if (interrupted) return;
2206
+ finish(error);
2207
+ });
1338
2208
  req.setTimeout(timeoutMs, () => {
2209
+ if (settled) return;
1339
2210
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
1340
2211
  req.destroy();
1341
2212
  });
@@ -1350,15 +2221,137 @@ function turnFunctionForMode(mode) {
1350
2221
 
1351
2222
  async function chat(options = {}) {
1352
2223
  let mode = normalizeMode(options.mode);
2224
+ let bypassPermissions = resolveBypassPermissions(options);
1353
2225
  const cwd = options.cwd || process.cwd();
1354
2226
  const input = options.input || process.stdin;
1355
2227
  const baseOutput = options.output || process.stdout;
1356
2228
  const logger = createRunLogger({ cwd, mode, kind: 'play', output: baseOutput });
1357
2229
  const output = logger ? logger.output : baseOutput;
1358
2230
  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
+ };
2242
+
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
+ }
2270
+
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`);
2286
+ }
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
+ }
1359
2337
 
1360
- output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
1361
- if (logger) output.write(`${formatAuxRow('log', formatPathSubject(logger.path, output), output)}\n\n`);
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
+ }
2353
+ }
2354
+ };
1362
2355
 
1363
2356
  const runLine = async (line) => {
1364
2357
  const trimmed = String(line || '').trim();
@@ -1378,18 +2371,68 @@ async function chat(options = {}) {
1378
2371
  return false;
1379
2372
  }
1380
2373
 
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
+
1381
2407
  if (trimmed.startsWith('/')) {
1382
2408
  output.write(`${chatMenu(output)}\n\n`);
1383
2409
  return false;
1384
2410
  }
1385
2411
 
1386
- if (logger) logger.write(`${formatPrompt(mode)}${trimmed}\n`);
2412
+ if (extractYoutubeUrl(trimmed)) {
2413
+ if (logger) logger.write(`${formatPrompt(mode, { ...output, goal: goalState })}${trimmed}\n`);
2414
+ output.write('\n');
2415
+ 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
+ });
2427
+ output.write(`${formatDoneLine(Date.now() - startedAt)}\n\n`);
2428
+ history.push({ role: 'user', content: trimmed });
2429
+ history.push({ role: 'assistant', content: youtubeOutput.join('\n') });
2430
+ return false;
2431
+ }
2432
+
2433
+ if (logger) logger.write(`${formatPrompt(mode, { ...output, goal: goalState })}${trimmed}\n`);
1387
2434
  output.write('\n');
1388
- const result = await turnFunctionForMode(mode)(trimmed, { mode, cwd, history, output, route: options.route, business: options.business, verify: options.verify });
1389
- if (result.output && !result.output.endsWith('\n')) output.write('\n');
1390
- output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n\n`);
1391
- history.push({ role: 'user', content: trimmed });
1392
- history.push({ role: 'assistant', content: result.output || '' });
2435
+ await executeChatTurn(trimmed);
1393
2436
  return false;
1394
2437
  };
1395
2438
 
@@ -1402,22 +2445,445 @@ async function chat(options = {}) {
1402
2445
  return;
1403
2446
  }
1404
2447
 
1405
- const rl = readline.createInterface({ input, output: baseOutput, completer: chatCompleter });
1406
- const ask = () => new Promise(resolve => rl.question(formatPrompt(mode, baseOutput), resolve));
1407
- while (true) {
1408
- const line = await ask();
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
+ }
1409
2537
  if (await runLine(line)) {
1410
2538
  rl.close();
1411
2539
  break;
1412
2540
  }
1413
2541
  }
2542
+ input.removeListener('keypress', onChatKeypress);
2543
+ detachMultilineInput();
2544
+ if (baseRefreshLine) rl._refreshLine = baseRefreshLine;
2545
+ setBracketedPaste(false);
2546
+ process.removeListener('exit', disableBracketedPasteOnExit);
1414
2547
  if (logger) logger.close(0);
1415
2548
  }
1416
2549
 
1417
- function printBackendHint() {
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
+ }
2823
+ }
2824
+
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
+ };
2857
+ }
2858
+
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 = {}) {
1418
2876
  console.log('');
1419
- console.log('Start backend:');
1420
- console.log(`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}`);
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
+ }
1421
2887
  }
1422
2888
 
1423
2889
  function bufferedOutput() {
@@ -1597,12 +3063,35 @@ async function runBenchmark(options = {}) {
1597
3063
  }
1598
3064
 
1599
3065
  async function main() {
1600
- const args = process.argv.slice(2);
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
+ }
1601
3085
  if (args.includes('--help') || args.includes('-h')) {
1602
3086
  console.log(formatUsage());
1603
3087
  return;
1604
3088
  }
1605
3089
 
3090
+ const bypassPermissions = parsePermissionFlags(args);
3091
+ args = stripPermissionFlags(args);
3092
+ const chatArgs = normalizeChatCommandArgs(args);
3093
+ args = chatArgs.args;
3094
+
1606
3095
  // --business <slug>: chat against that business's cloud workspace. The flag
1607
3096
  // takes a value, so splice the pair out before building the prompt.
1608
3097
  let businessSlug = null;
@@ -1629,17 +3118,63 @@ async function main() {
1629
3118
  args.splice(verifyIdx, 2);
1630
3119
  }
1631
3120
 
1632
- const mode = args.includes('--code-fast') || args.includes('--code') ? 'code-fast' : args.includes('--max') ? 'max' : args.includes('--fast') ? 'fast' : 'pro';
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';
1633
3136
  const doctor = args.includes('--doctor');
1634
3137
  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');
1635
3142
  const forceCloud = args.includes('--cloud');
1636
3143
  const forceLocal = args.includes('--local');
1637
3144
  const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
1638
3145
  const prompt = args
1639
- .filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--benchmark', '--local', '--cloud', '--help', '-h'].includes(arg))
3146
+ .filter(arg => !['--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--benchmark', '--dogfood-chat', '--dogfood-status', '--json', '--live', '--local', '--cloud', '--help', '-h'].includes(arg))
1640
3147
  .join(' ')
1641
3148
  .trim();
1642
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
+
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}`);
3163
+ process.exit(1);
3164
+ }
3165
+ return;
3166
+ }
3167
+
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}`);
3173
+ process.exit(1);
3174
+ }
3175
+ return;
3176
+ }
3177
+
1643
3178
  let business = null;
1644
3179
  if (businessSlug) {
1645
3180
  if (normalizeMode(mode) === 'code-fast') {
@@ -1671,6 +3206,15 @@ async function main() {
1671
3206
  console.log(`cloud workspace: ${biz.businessName || businessSlug}`);
1672
3207
  }
1673
3208
 
3209
+ const runOptions = {
3210
+ mode,
3211
+ cwd: process.cwd(),
3212
+ route: route === 'auto' ? undefined : route,
3213
+ business,
3214
+ verify,
3215
+ bypassPermissions,
3216
+ };
3217
+
1674
3218
  try {
1675
3219
  if (benchmark) {
1676
3220
  await runBenchmark({ mode, cwd: process.cwd(), output: process.stdout });
@@ -1678,25 +3222,30 @@ async function main() {
1678
3222
  }
1679
3223
 
1680
3224
  if (doctor) {
1681
- console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
3225
+ console.log(formatHeader({ mode, cwd: process.cwd(), chat: false, bypassPermissions: resolveBypassPermissions(runOptions) }, process.stdout));
1682
3226
  console.log('');
1683
- console.log(formatRunProfile(buildRunProfile({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route }), process.stdout));
3227
+ console.log(formatRunProfile(buildRunProfile(runOptions), process.stdout));
1684
3228
  return;
1685
3229
  }
1686
3230
 
1687
- if (!prompt || args.includes('--chat')) {
1688
- await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
3231
+ if (!prompt || chatArgs.chatRequested) {
3232
+ await chat(runOptions);
1689
3233
  return;
1690
3234
  }
1691
3235
 
1692
- console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
3236
+ console.log(formatHeader({ mode, cwd: process.cwd(), chat: false, bypassPermissions: resolveBypassPermissions(runOptions) }, process.stdout));
1693
3237
  console.log('');
1694
- const result = await turnFunctionForMode(mode)(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
3238
+ const result = await turnFunctionForMode(mode)(prompt, runOptions);
3239
+ if (result.interrupted) {
3240
+ console.log('');
3241
+ console.log('· Interrupted');
3242
+ process.exit(130);
3243
+ }
1695
3244
  console.log('');
1696
3245
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
1697
3246
  } catch (error) {
1698
3247
  console.error(`x ${error.message}`);
1699
- printBackendHint();
3248
+ printBackendHint(runOptions);
1700
3249
  process.exit(1);
1701
3250
  }
1702
3251
  }
@@ -1710,15 +3259,29 @@ module.exports = {
1710
3259
  authUserId,
1711
3260
  backendBaseUrl,
1712
3261
  backendUrl,
3262
+ buildAxYoutubeArgs,
1713
3263
  buildCodeFastPayload,
3264
+ buildChatDogfoodCases,
3265
+ buildChatDogfoodStatus,
3266
+ buildMessage,
1714
3267
  buildPayload,
1715
3268
  cachedIntegrationStatus,
1716
3269
  buildConnectionContext,
1717
3270
  buildRunProfile,
1718
3271
  chat,
1719
3272
  chatCompleter,
3273
+ chatGoalCommand: parseGoalCommand,
1720
3274
  chatMenu,
3275
+ chatPermissionCommand,
1721
3276
  chatTierCommand,
3277
+ loadAxPrefs,
3278
+ MEMBER_SLUG,
3279
+ parsePermissionFlags,
3280
+ permissionsLabel,
3281
+ printBackendHint,
3282
+ resolveBypassPermissions,
3283
+ runAxSpawnCommand,
3284
+ setBypassPermissions,
1722
3285
  codeFastWorkspaceNotice,
1723
3286
  creditsFromState,
1724
3287
  codeFastBaseUrl,
@@ -1726,24 +3289,55 @@ module.exports = {
1726
3289
  createRunLogger,
1727
3290
  createProgressReporter,
1728
3291
  formatDoneLine,
3292
+ formatChatDogfoodReport,
3293
+ formatChatDogfoodStatusReport,
1729
3294
  formatDuration,
1730
3295
  formatHeader,
1731
3296
  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,
1732
3310
  formatPrompt,
3311
+ insertMultilineBreak,
3312
+ inputBoxLayoutOptions,
3313
+ inputBoxPlainWidth,
3314
+ isMultilineInsertKey,
3315
+ isPermissionToggleKey,
3316
+ permissionAccentCodes,
1733
3317
  formatRunProfile,
1734
3318
  formatStatusMessage,
1735
3319
  formatSystemInit,
1736
3320
  formatUsage,
1737
3321
  formatWorkingLine,
1738
3322
  handleEvent,
3323
+ extractYoutubeUrl,
3324
+ isAxSpawnCommand,
3325
+ manageTurnInterrupt,
1739
3326
  modelForMode,
3327
+ normalizeChatCommandArgs,
1740
3328
  parseSseBlock,
1741
3329
  postCodeFastTurn,
1742
3330
  postTurn,
3331
+ renderTerminalInline,
3332
+ renderTerminalBlockLine,
3333
+ formatMarkdownLink,
1743
3334
  renderStreamingMarkdown,
3335
+ renderShimmerText,
1744
3336
  renderTerminalMarkdown,
1745
3337
  resolveRoute,
1746
3338
  runBenchmark,
3339
+ runChatDogfood,
3340
+ runAxYoutubeCommand,
1747
3341
  summarizeToolInput,
1748
3342
  summarizeToolResult,
1749
3343
  tierLabel