atris 3.51.0 → 3.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ax CHANGED
@@ -78,7 +78,7 @@ const ANSI = {
78
78
  magenta: '\x1b[35m'
79
79
  };
80
80
 
81
- const TIER_COLORS = { fast: '\x1b[32m', pro: '\x1b[36m', max: '\x1b[35m' };
81
+ const TIER_COLORS = { rapid: '\x1b[33m', fast: '\x1b[32m', pro: '\x1b[36m', max: '\x1b[35m' };
82
82
 
83
83
  // Extend backend_api by adding one row here. Keep mutating endpoints narrow;
84
84
  // any non-get method is approval-gated before the HTTP request is made.
@@ -99,6 +99,7 @@ function tierColor(mode) {
99
99
  function modelForMode(mode) {
100
100
  if (mode === 'code-fast') return CODE_FAST.model;
101
101
  if (mode === 'max') return 'atris:max';
102
+ if (mode === 'rapid') return 'atris:rapid';
102
103
  return mode === 'fast' ? 'atris:fast' : 'atris:pro';
103
104
  }
104
105
 
@@ -120,7 +121,7 @@ function postTurnTimeoutMs(payload = {}, options = {}) {
120
121
  // AX postTurn is the tool-capable Atris2 path. Fast tool turns need the same
121
122
  // headroom as pro because the backend can spend long stretches inside tools
122
123
  // without SSE traffic. Plain tool-free one-shot fast chat stays at 60s.
123
- if (model === 'atris:fast' && payloadCanRunTools(payload, options)) {
124
+ if ((model === 'atris:fast' || model === 'atris:rapid') && payloadCanRunTools(payload, options)) {
124
125
  timeoutMs = Math.max(timeoutMs, PRO_TURN_TIMEOUT_MS);
125
126
  }
126
127
  if (options.business || options.local) {
@@ -146,6 +147,7 @@ function formatSeconds(totalSeconds) {
146
147
  function tierLabel(mode) {
147
148
  if (mode === 'code-fast') return 'Atris Code Fast';
148
149
  if (mode === 'max') return 'Atris 2 Max';
150
+ if (mode === 'rapid') return 'Atris Rapid';
149
151
  if (mode === 'fast') return 'Atris 2 Fast';
150
152
  return 'Atris 2 Pro';
151
153
  }
@@ -154,7 +156,7 @@ function formatHeader({ mode = 'pro', cwd = process.cwd(), chat = false } = {},
154
156
  return [
155
157
  paint(`${tierLabel(mode)}${chat ? ' chat' : ''}`, [ANSI.bold, tierColor(normalizeMode(mode))], options),
156
158
  cwd,
157
- chat ? paint('type / for the menu · /fast /pro /max swap tiers · exit to leave', [ANSI.muted], options) : '',
159
+ chat ? paint('shift-tab bypass · /auto /log /today /now /who · /fast /pro /max /rapid · exit to leave', [ANSI.muted], options) : '',
158
160
  ].filter(Boolean).join('\n');
159
161
  }
160
162
 
@@ -163,15 +165,16 @@ function formatUsage() {
163
165
  'ax - Atris local/code agent',
164
166
  '',
165
167
  'Usage:',
166
- ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] <message>',
167
- ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] --print <message>',
168
- ' ax [--auto|--max|--pro|--fast|--code-fast] [--local|--cloud] --chat',
169
- ' ax [--max|--pro|--fast] --business <slug> [<message>|--chat]',
170
- ' ax [--max|--pro|--fast|--code-fast] --doctor',
168
+ ' ax [--auto|--max|--pro|--fast|--rapid|--code-fast] [--local|--cloud] <message>',
169
+ ' ax [--auto|--max|--pro|--fast|--rapid|--code-fast] [--local|--cloud] --print <message>',
170
+ ' ax [--auto|--max|--pro|--fast|--rapid|--code-fast] [--local|--cloud] --chat',
171
+ ' ax [--max|--pro|--fast|--rapid] --business <slug> [<message>|--chat]',
172
+ ' ax [--max|--pro|--fast|--rapid|--code-fast] --doctor',
171
173
  ' ax --approvals',
172
174
  ' ax --approve <approval-id>',
173
175
  ' ax --deny <approval-id>',
174
176
  ' ax --grant <approval-id> approve and remember this exact command (this workspace)',
177
+ ' ax --auto-approve chat session auto-approves safe git push',
175
178
  ' ax --grants list active permission grants',
176
179
  ' ax --sync-grants push/pull grants with atrisos-web',
177
180
  ' ax --revoke-grant <grant-id>',
@@ -183,6 +186,7 @@ function formatUsage() {
183
186
  ' --max hosted Atris 2, highest reasoning, slowest turns',
184
187
  ' --pro hosted Atris 2, deeper tool loop',
185
188
  ' --fast hosted Atris 2, faster low-latency turns',
189
+ ' --rapid hosted Atris Rapid, cheapest chat and first-hop tools',
186
190
  ' --code-fast Atris Code Fast public lane',
187
191
  ' --local opt into local backend/workspace tools',
188
192
  ' --cloud force authenticated cloud connectors/chat',
@@ -193,6 +197,7 @@ function formatUsage() {
193
197
  'Examples:',
194
198
  ' ax --pro find the config file and explain it',
195
199
  ' ax --fast what files are here',
200
+ ' ax --rapid --chat',
196
201
  ' ax --max refactor this module and verify the tests',
197
202
  ' ax --max --verify "npm test" fix the failing suite',
198
203
  ' ax --code-fast explain this error',
@@ -352,7 +357,9 @@ function buildRunProfile(options = {}) {
352
357
  ? 'Max workspace tool loop uses high reasoning effort'
353
358
  : mode === 'pro'
354
359
  ? 'Pro workspace tool loop uses API default medium'
355
- : 'Fast workspace tool loop uses provider default';
360
+ : mode === 'rapid'
361
+ ? 'Rapid workspace tool loop uses thinking off'
362
+ : 'Fast workspace tool loop uses provider default';
356
363
  return {
357
364
  endpoint: backendUrl({ route }),
358
365
  mode,
@@ -404,6 +411,7 @@ async function buildRuntimeHealth(options = {}) {
404
411
  const data = healthRes.ok && healthRes.data && typeof healthRes.data === 'object' ? healthRes.data : {};
405
412
  const models = Array.isArray(data.models) ? data.models : [];
406
413
  const fast = models.find(row => row && row.id === 'atris:fast') || null;
414
+ const rapid = models.find(row => row && row.id === 'atris:rapid') || null;
407
415
  const status = Number(healthRes.status || 0);
408
416
  const authRequired = status === 401 || status === 403;
409
417
  return {
@@ -420,6 +428,10 @@ async function buildRuntimeHealth(options = {}) {
420
428
  ready: Boolean(fast && fast.ready),
421
429
  route_ready: Boolean(fast && fast.ready),
422
430
  },
431
+ rapid: {
432
+ ready: Boolean(rapid && rapid.ready),
433
+ route_ready: Boolean(rapid && rapid.ready),
434
+ },
423
435
  permissions: {
424
436
  approval_execution_path: APPROVAL_EXECUTE_PATH,
425
437
  ready: Boolean(healthRes.ok),
@@ -432,10 +444,12 @@ function formatRuntimeHealth(health, options = {}) {
432
444
  const backendReachable = health && health.backend && health.backend.reachable;
433
445
  const authRequired = health && health.backend && health.backend.auth_required;
434
446
  const fastReady = health && health.fast && health.fast.ready;
447
+ const rapidReady = health && health.rapid && health.rapid.ready;
435
448
  const permissionReady = health && health.permissions && health.permissions.ready;
436
449
  const rows = [
437
450
  ['backend', authRequired ? 'auth required' : backendReady ? 'ready' : backendReachable ? 'not ready' : 'offline'],
438
451
  ['fast', fastReady ? 'ready' : 'not ready'],
452
+ ['rapid', rapidReady ? 'ready' : 'not ready (backend has no WAFER_API_KEY)'],
439
453
  ['approvals', authRequired ? 'auth required' : permissionReady ? 'ready' : 'offline'],
440
454
  ];
441
455
  if (authRequired) {
@@ -450,6 +464,7 @@ function runtimeReadyForChat(health, mode = 'fast') {
450
464
  if (!health || !health.backend || !health.permissions) return false;
451
465
  if (!health.backend.ready || !health.permissions.ready) return false;
452
466
  if (normalizeMode(mode) === 'fast' && (!health.fast || health.fast.ready === false)) return false;
467
+ if (normalizeMode(mode) === 'rapid' && health.rapid && health.rapid.ready === false) return false;
453
468
  return true;
454
469
  }
455
470
 
@@ -955,6 +970,7 @@ function resolveRoute(message, options = {}) {
955
970
  function normalizeMode(mode) {
956
971
  if (mode === 'code-fast' || mode === 'code') return 'code-fast';
957
972
  if (mode === 'max') return 'max';
973
+ if (mode === 'rapid') return 'rapid';
958
974
  return mode === 'fast' ? 'fast' : 'pro';
959
975
  }
960
976
 
@@ -1060,52 +1076,347 @@ function autoLaneForMessage(message, options = {}) {
1060
1076
  return picked;
1061
1077
  }
1062
1078
 
1079
+ function cycleApproveMode(mode) {
1080
+ return String(mode) === 'auto' ? 'stage' : 'auto';
1081
+ }
1082
+
1083
+ function approveModeTag(mode) {
1084
+ return String(mode) === 'auto' ? 'bypass permissions' : '';
1085
+ }
1086
+
1087
+ function nextApproveMode(arg, current) {
1088
+ const want = String(arg || '').toLowerCase();
1089
+ if (want === 'on' || want === 'bypass') return 'auto';
1090
+ if (want === 'off' || want === 'ask') return 'stage';
1091
+ return cycleApproveMode(current);
1092
+ }
1093
+
1063
1094
  function formatPrompt(mode, options = {}) {
1064
1095
  if (!mode) return '› ';
1065
1096
  const tier = normalizeMode(mode);
1066
1097
  const label = paint(tier, [ANSI.bold, tierColor(tier)], options);
1067
- if (options.approveMode === 'auto') {
1068
- return `${label} ${paint('[auto-approve]', [ANSI.muted], options)} › `;
1098
+ const tag = approveModeTag(options.approveMode);
1099
+ if (tag) {
1100
+ return `${label} ${paint(`[${tag}]`, [ANSI.muted], options)} › `;
1069
1101
  }
1070
1102
  return `${label} › `;
1071
1103
  }
1072
1104
 
1073
- const TIER_COMMANDS = new Map([
1074
- ['/fast', 'fast'],
1075
- ['/pro', 'pro'],
1076
- ['/max', 'max'],
1077
- ]);
1105
+ function refreshReadlinePrompt(rl, prompt) {
1106
+ if (!rl || typeof rl.setPrompt !== 'function') return;
1107
+ rl.setPrompt(prompt);
1108
+ if (typeof rl.prompt === 'function') rl.prompt(true);
1109
+ }
1110
+
1111
+ function attachApproveModeHotkey(rl, session, getPrompt) {
1112
+ if (!rl || typeof rl._ttyWrite !== 'function') return () => {};
1113
+ const original = rl._ttyWrite.bind(rl);
1114
+ rl._ttyWrite = (s, key) => {
1115
+ if (key && key.name === 'tab' && key.shift && !key.ctrl && !key.meta) {
1116
+ session.approveMode = cycleApproveMode(session.approveMode);
1117
+ refreshReadlinePrompt(rl, getPrompt());
1118
+ return;
1119
+ }
1120
+ return original(s, key);
1121
+ };
1122
+ return () => { rl._ttyWrite = original; };
1123
+ }
1124
+
1125
+ const TIER_BLURBS = {
1126
+ rapid: 'cheap chat and first-hop tools',
1127
+ fast: 'quick answers, lowest latency',
1128
+ pro: 'deeper tool loop for real work',
1129
+ max: 'highest reasoning for the hardest jobs',
1130
+ };
1131
+
1132
+ const CHAT_COMMANDS = Object.freeze([
1133
+ { name: '/help', description: 'list all commands', type: 'help' },
1134
+ { name: '/model', description: 'show the current lane and how to switch', type: 'model' },
1135
+ { name: '/status', description: 'show runtime health', type: 'status' },
1136
+ { name: '/fast', description: 'switch to quick answers with low latency', type: 'tier', mode: 'fast' },
1137
+ { name: '/pro', description: 'switch to a deeper tool loop', type: 'tier', mode: 'pro' },
1138
+ { name: '/max', description: 'switch to the highest reasoning lane', type: 'tier', mode: 'max' },
1139
+ { name: '/rapid', description: 'switch to the cheapest chat lane', type: 'tier', mode: 'rapid' },
1140
+ { name: '/new', description: 'start a new conversation', type: 'clear' },
1141
+ { name: '/clear', description: 'reset the current conversation', type: 'clear' },
1142
+ { name: '/context', description: 'show the conversation size', type: 'context' },
1143
+ { name: '/log', description: 'append a note to today\'s journal', type: 'log' },
1144
+ { name: '/today', description: 'show today\'s journal', type: 'today' },
1145
+ { name: '/now', description: 'show the current priorities', type: 'now' },
1146
+ { name: '/who', description: 'show the current lane', type: 'who' },
1147
+ { name: '/auto', description: 'toggle safe git push approval', type: 'auto' },
1148
+ { name: '/bypass', description: 'toggle the same safe approval mode', type: 'auto' },
1149
+ { name: '/exit', description: 'leave the chat', type: 'exit' },
1150
+ { name: '/quit', description: 'leave the chat', type: 'exit' },
1151
+ ].map(command => Object.freeze(command)));
1152
+
1153
+ const CHAT_COMMAND_BY_NAME = new Map(CHAT_COMMANDS.map(command => [command.name, command]));
1154
+ const TIER_COMMANDS = new Map(
1155
+ CHAT_COMMANDS
1156
+ .filter(command => command.type === 'tier')
1157
+ .map(command => [command.name, command.mode])
1158
+ );
1078
1159
 
1079
1160
  function chatTierCommand(line) {
1080
1161
  return TIER_COMMANDS.get(String(line || '').trim().toLowerCase()) || null;
1081
1162
  }
1082
1163
 
1083
- const CHAT_COMMANDS = [
1084
- ['/fast', 'quick answers, lowest latency'],
1085
- ['/pro', 'deeper tool loop for real work'],
1086
- ['/max', 'highest reasoning for the hardest jobs'],
1087
- ['/clear', 'wipe chat history and reset conversation id'],
1088
- ['/context', 'show turn count and rough token estimate'],
1089
- ['/help', 'show this menu'],
1090
- ['exit', 'leave chat'],
1091
- ];
1092
-
1093
- function chatMenu(options = {}) {
1094
- return CHAT_COMMANDS
1095
- .map(([name, desc]) => {
1096
- const tier = TIER_COMMANDS.get(name);
1164
+ function chatMenu(options = {}, commands = CHAT_COMMANDS) {
1165
+ const width = commands.reduce((max, command) => Math.max(max, command.name.length), 0);
1166
+ return commands
1167
+ .map(command => {
1168
+ const tier = TIER_COMMANDS.get(command.name);
1097
1169
  const color = tier ? tierColor(tier) : ANSI.accent;
1098
- return ` ${paint(name.padEnd(6), [color], options)} ${paint(desc, [ANSI.muted], options)}`;
1170
+ return ` ${paint(command.name.padEnd(width), [color], options)} ${paint(command.description, [ANSI.muted], options)}`;
1099
1171
  })
1100
1172
  .join('\n');
1101
1173
  }
1102
1174
 
1175
+ function filterChatCommands(line) {
1176
+ const text = String(line || '');
1177
+ if (!text.startsWith('/') || /\s/.test(text)) return [];
1178
+ const query = text.toLowerCase();
1179
+ return CHAT_COMMANDS.filter(command => command.name.startsWith(query));
1180
+ }
1181
+
1182
+ function parseChatSlash(line) {
1183
+ const trimmed = String(line || '').trim();
1184
+ if (!trimmed.startsWith('/')) return null;
1185
+ const [raw, ...rest] = trimmed.split(/\s+/);
1186
+ return {
1187
+ name: raw.toLowerCase(),
1188
+ arg: rest.join(' ').trim(),
1189
+ raw: trimmed,
1190
+ };
1191
+ }
1192
+
1193
+ function todayJournalPath(cwd = process.cwd(), date = new Date()) {
1194
+ const year = date.getFullYear();
1195
+ const month = String(date.getMonth() + 1).padStart(2, '0');
1196
+ const day = String(date.getDate()).padStart(2, '0');
1197
+ const stamp = `${year}-${month}-${day}`;
1198
+ return {
1199
+ file: path.join(cwd, 'atris', 'logs', String(year), `${stamp}.md`),
1200
+ stamp,
1201
+ time: `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`,
1202
+ };
1203
+ }
1204
+
1205
+ function readTextTail(file, maxLines = 24) {
1206
+ if (!fs.existsSync(file)) return null;
1207
+ const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
1208
+ return lines.slice(Math.max(0, lines.length - maxLines)).join('\n').trimEnd();
1209
+ }
1210
+
1211
+ function readTextHead(file, maxLines = 24) {
1212
+ if (!fs.existsSync(file)) return null;
1213
+ return fs.readFileSync(file, 'utf8').split(/\r?\n/).slice(0, maxLines).join('\n').trimEnd();
1214
+ }
1215
+
1216
+ function appendChatJournal(cwd, note, date = new Date()) {
1217
+ const { file, stamp, time } = todayJournalPath(cwd, date);
1218
+ const text = String(note || '').replace(/\s+/g, ' ').trim();
1219
+ if (!text) return { ok: false, error: 'Usage: /log <what happened>' };
1220
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1221
+ const block = `\n## ${time} · Chat\n\n- ${text}\n`;
1222
+ if (!fs.existsSync(file)) {
1223
+ fs.writeFileSync(file, `# ${stamp}\n${block}`);
1224
+ } else {
1225
+ let existing = fs.readFileSync(file, 'utf8');
1226
+ if (existing && !existing.endsWith('\n')) existing += '\n';
1227
+ fs.writeFileSync(file, existing + block);
1228
+ }
1229
+ return { ok: true, file: path.relative(cwd, file) };
1230
+ }
1231
+
1232
+ function runChatSlash(line, options = {}) {
1233
+ const parsed = parseChatSlash(line);
1234
+ if (!parsed) return null;
1235
+ const cwd = options.cwd || process.cwd();
1236
+ const mode = normalizeMode(options.mode || 'fast');
1237
+ if (parsed.name === '/') {
1238
+ return { type: 'help', output: chatMenu(options) };
1239
+ }
1240
+ const command = CHAT_COMMAND_BY_NAME.get(parsed.name);
1241
+ if (!command) {
1242
+ return { type: 'unknown', output: `unknown command ${parsed.name}\n${chatMenu(options)}` };
1243
+ }
1244
+ if (command.type === 'help') {
1245
+ return { type: 'help', output: chatMenu(options) };
1246
+ }
1247
+ if (command.type === 'tier') {
1248
+ return { type: 'tier', mode: command.mode, output: `· ${tierLabel(command.mode)}` };
1249
+ }
1250
+ if (command.type === 'exit') {
1251
+ return { type: 'exit', output: '' };
1252
+ }
1253
+ if (command.type === 'clear') {
1254
+ return { type: 'clear', output: 'context cleared' };
1255
+ }
1256
+ if (command.type === 'context') {
1257
+ const ctx = estimateHistoryContext(options.history || []);
1258
+ return {
1259
+ type: 'context',
1260
+ output: `· ${ctx.turns} turn${ctx.turns === 1 ? '' : 's'} · ~${ctx.chars} chars · ~${ctx.tokens} tokens`,
1261
+ };
1262
+ }
1263
+ if (command.type === 'model') {
1264
+ return {
1265
+ type: 'model',
1266
+ output: `· current lane: ${tierLabel(mode)} · switch with /fast /pro /max /rapid`,
1267
+ };
1268
+ }
1269
+ if (command.type === 'status') {
1270
+ return { type: 'status', output: '' };
1271
+ }
1272
+ if (command.type === 'who') {
1273
+ return { type: 'who', output: `· ${tierLabel(mode)} · ${TIER_BLURBS[mode] || 'chat'}` };
1274
+ }
1275
+ if (command.type === 'auto') {
1276
+ const next = nextApproveMode(parsed.arg, options.approveMode);
1277
+ return {
1278
+ type: 'auto',
1279
+ approveMode: next,
1280
+ output: next === 'auto'
1281
+ ? '· bypass permissions on · safe git push this session, not force and not mail'
1282
+ : '· bypass permissions off',
1283
+ };
1284
+ }
1285
+ if (command.type === 'log') {
1286
+ const result = appendChatJournal(cwd, parsed.arg, options.now);
1287
+ if (!result.ok) return { type: 'log', output: result.error };
1288
+ return { type: 'log', output: `· logged to ${result.file}` };
1289
+ }
1290
+ if (command.type === 'today') {
1291
+ const { file } = todayJournalPath(cwd, options.now);
1292
+ const text = readTextTail(file);
1293
+ return {
1294
+ type: 'today',
1295
+ output: text || `· no journal yet at ${path.relative(cwd, file)}`,
1296
+ };
1297
+ }
1298
+ if (command.type === 'now') {
1299
+ const file = path.join(cwd, 'atris', 'now.md');
1300
+ const text = readTextHead(file);
1301
+ return { type: 'now', output: text || '· atris/now.md is missing' };
1302
+ }
1303
+ return { type: 'unknown', output: `unknown command ${parsed.name}\n${chatMenu(options)}` };
1304
+ }
1305
+
1103
1306
  function chatCompleter(line) {
1104
- const trimmed = String(line || '').trimStart().toLowerCase();
1105
- if (!trimmed.startsWith('/')) return [[], line];
1106
- const names = CHAT_COMMANDS.map(([name]) => name).filter(name => name.startsWith('/'));
1107
- const hits = names.filter(name => name.startsWith(trimmed));
1108
- return [hits.length ? hits : names, trimmed];
1307
+ const text = String(line || '');
1308
+ const hits = filterChatCommands(text).map(command => command.name);
1309
+ return [hits, text];
1310
+ }
1311
+
1312
+ function attachSlashMenu(rl, output, options = {}) {
1313
+ if (!rl || !output || !output.isTTY || typeof output.write !== 'function' || typeof rl._ttyWrite !== 'function') {
1314
+ return { clear() {}, render() {}, detach() {} };
1315
+ }
1316
+ const original = rl._ttyWrite.bind(rl);
1317
+ let visibleRows = 0;
1318
+
1319
+ const clear = () => {
1320
+ if (!visibleRows) return;
1321
+ output.write('\x1b7');
1322
+ for (let index = 0; index < visibleRows; index += 1) {
1323
+ output.write('\x1b[1B\r\x1b[2K');
1324
+ }
1325
+ output.write('\x1b8');
1326
+ visibleRows = 0;
1327
+ };
1328
+
1329
+ const render = () => {
1330
+ clear();
1331
+ if (typeof options.isActive === 'function' && !options.isActive()) return;
1332
+ const matches = filterChatCommands(rl.line);
1333
+ if (!matches.length) return;
1334
+ const rows = chatMenu(output, matches).split('\n');
1335
+ output.write('\x1b7');
1336
+ for (const row of rows) output.write(`\r\n${row}`);
1337
+ output.write('\x1b8');
1338
+ visibleRows = rows.length;
1339
+ };
1340
+
1341
+ const wrapped = (s, key) => {
1342
+ const submitsLine = key && (key.name === 'return' || key.name === 'enter');
1343
+ if (submitsLine) clear();
1344
+ const result = original(s, key);
1345
+ if (!submitsLine) render();
1346
+ return result;
1347
+ };
1348
+ rl._ttyWrite = wrapped;
1349
+
1350
+ return {
1351
+ clear,
1352
+ render,
1353
+ detach() {
1354
+ clear();
1355
+ if (rl._ttyWrite === wrapped) rl._ttyWrite = original;
1356
+ },
1357
+ };
1358
+ }
1359
+
1360
+ function attachChatCtrlC(rl, options = {}) {
1361
+ if (!rl || typeof rl.on !== 'function') return () => {};
1362
+ const output = options.output || process.stdout;
1363
+ const exitWindowMs = Number(options.exitWindowMs) || 2000;
1364
+ let lastEmptyPressAt = 0;
1365
+ let expiryTimer = null;
1366
+
1367
+ const resetExitWindow = () => {
1368
+ lastEmptyPressAt = 0;
1369
+ if (expiryTimer) clearTimeout(expiryTimer);
1370
+ expiryTimer = null;
1371
+ };
1372
+
1373
+ const clearDraft = () => {
1374
+ if (typeof options.clearMenu === 'function') options.clearMenu();
1375
+ if (typeof rl.write === 'function') {
1376
+ rl.write(null, { ctrl: true, name: 'u' });
1377
+ rl.write(null, { ctrl: true, name: 'k' });
1378
+ return;
1379
+ }
1380
+ rl.line = '';
1381
+ rl.cursor = 0;
1382
+ if (typeof rl._refreshLine === 'function') rl._refreshLine();
1383
+ };
1384
+
1385
+ const onSigint = () => {
1386
+ if (typeof options.isTurnInFlight === 'function' && options.isTurnInFlight()) {
1387
+ resetExitWindow();
1388
+ if (typeof options.interruptTurn === 'function') options.interruptTurn();
1389
+ return;
1390
+ }
1391
+ if (String(rl.line || '').length > 0) {
1392
+ resetExitWindow();
1393
+ clearDraft();
1394
+ return;
1395
+ }
1396
+
1397
+ const now = Date.now();
1398
+ if (lastEmptyPressAt && now - lastEmptyPressAt <= exitWindowMs) {
1399
+ resetExitWindow();
1400
+ if (typeof options.exit === 'function') options.exit();
1401
+ return;
1402
+ }
1403
+
1404
+ lastEmptyPressAt = now;
1405
+ if (typeof options.clearMenu === 'function') options.clearMenu();
1406
+ output.write('\npress ctrl-c again to exit\n');
1407
+ if (typeof rl._refreshLine === 'function') rl._refreshLine();
1408
+ expiryTimer = setTimeout(() => {
1409
+ lastEmptyPressAt = 0;
1410
+ expiryTimer = null;
1411
+ }, exitWindowMs);
1412
+ if (typeof expiryTimer.unref === 'function') expiryTimer.unref();
1413
+ };
1414
+
1415
+ rl.on('SIGINT', onSigint);
1416
+ return () => {
1417
+ resetExitWindow();
1418
+ if (typeof rl.removeListener === 'function') rl.removeListener('SIGINT', onSigint);
1419
+ };
1109
1420
  }
1110
1421
 
1111
1422
  function formatWorkingLine(ms, verb, frame) {
@@ -2152,19 +2463,49 @@ async function approveWorkspaceApproval(ref, options = {}) {
2152
2463
  // A persistent grant (ax --grant) lets an exact approved command pattern
2153
2464
  // redeem itself on later turns instead of re-asking; anything unmatched
2154
2465
  // falls through to the normal hint. Store: ~/.atris/permission-grants.json.
2466
+ function isSafeGitPushApproval(record) {
2467
+ const action = String((record && record.action_type) || '');
2468
+ const payload = (record && record.payload && typeof record.payload === 'object') ? record.payload : {};
2469
+ if (action === 'git_push') {
2470
+ if (payload.force || payload.force_with_lease || payload['force-with-lease']) return false;
2471
+ const remote = String(payload.remote || 'origin');
2472
+ const branch = String(payload.branch || 'HEAD');
2473
+ if (!/^[A-Za-z0-9._\/-]+$/.test(remote) || !/^[A-Za-z0-9._\/-]+$/.test(branch)) return false;
2474
+ return true;
2475
+ }
2476
+ if (action === 'local_command') {
2477
+ const command = String(payload.command || '').trim();
2478
+ if (!/^git\s+push\b/i.test(command)) return false;
2479
+ if (/\s(-f|--force|--force-with-lease)\b/i.test(command)) return false;
2480
+ // Flags are not the only force path: a "+refspec" force-pushes, ":branch"
2481
+ // and --delete delete remote refs, --mirror/--prune force-prune them.
2482
+ if (/\s(--delete|-d|--mirror|--prune)\b/i.test(command)) return false;
2483
+ if (/\s\+\S/.test(command)) return false;
2484
+ if (/\s:\S/.test(command)) return false;
2485
+ return permissionGrants.commandIsGrantable(command).ok;
2486
+ }
2487
+ return false;
2488
+ }
2489
+
2155
2490
  async function autoRedeemGrantedApprovals(cwd, sinceMs, options = {}) {
2156
2491
  const redeemed = [];
2157
2492
  for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
2158
2493
  const record = item.record;
2159
- if (String(record.action_type || '') !== 'local_command') continue;
2160
2494
  const command = record.payload && record.payload.command;
2161
- const grant = permissionGrants.matchGrant({ command, workspaceRoot: cwd });
2162
- if (!grant) continue;
2495
+ const grant = String(record.action_type || '') === 'local_command'
2496
+ ? permissionGrants.matchGrant({ command, workspaceRoot: cwd })
2497
+ : null;
2498
+ const sessionAuto = options.approveMode === 'auto' && isSafeGitPushApproval(record);
2499
+ if (!grant && !sessionAuto) continue;
2163
2500
  const output = options.output || process.stdout;
2164
- output.write(`${formatAuxRow('grant', `auto-approved via ${grant.grant_id}: ${grant.pattern.display}`, output)}\n`);
2501
+ const why = grant
2502
+ ? `auto-approved via ${grant.grant_id}: ${grant.pattern.display}`
2503
+ : `auto-approved ${record.action_type} this session`;
2504
+ output.write(`${formatAuxRow('grant', why, output)}\n`);
2165
2505
  try {
2166
- await approveWorkspaceApproval(record.approval_id, { ...options, cwd, output });
2167
- permissionGrants.recordUse(grant.grant_id);
2506
+ const approve = options.approveFn || approveWorkspaceApproval;
2507
+ await approve(record.approval_id, { ...options, cwd, output });
2508
+ if (grant) permissionGrants.recordUse(grant.grant_id);
2168
2509
  redeemed.push(record.approval_id);
2169
2510
  } catch {
2170
2511
  // Redemption failed: leave the artifact pending so the hint still shows.
@@ -2614,7 +2955,7 @@ function buildPayload(message, options = {}) {
2614
2955
  const payload = {
2615
2956
  message: buildMessage(message, options.history || []),
2616
2957
  model: modelForMode(mode),
2617
- max_turns: local ? (mode === 'fast' ? 16 : 24) : 1,
2958
+ max_turns: local ? (mode === 'rapid' ? 6 : mode === 'fast' ? 16 : 24) : 1,
2618
2959
  verify_command: verifyCommand || 'true',
2619
2960
  local_tools: [backendApiToolDescriptor()]
2620
2961
  };
@@ -2811,6 +3152,16 @@ function handleEvent(event, state, output) {
2811
3152
  }
2812
3153
  }
2813
3154
 
3155
+ function turnInterruptedError() {
3156
+ const error = new Error('turn interrupted');
3157
+ error.name = 'AbortError';
3158
+ return error;
3159
+ }
3160
+
3161
+ function isTurnInterruptedError(error) {
3162
+ return Boolean(error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'));
3163
+ }
3164
+
2814
3165
  async function postTurn(message, options = {}) {
2815
3166
  let route = options.business ? 'local' : resolveRoute(message, options);
2816
3167
  if (route === 'local' && !options.business && options.route !== 'local' && !options.forceLocal
@@ -2883,6 +3234,7 @@ async function postTurn(message, options = {}) {
2883
3234
 
2884
3235
  return new Promise((resolve, reject) => {
2885
3236
  let settled = false;
3237
+ let abortListener = null;
2886
3238
  const startedAt = Date.now();
2887
3239
  state.progress = createProgressReporter(output, options);
2888
3240
  state.progress.start();
@@ -2890,6 +3242,7 @@ async function postTurn(message, options = {}) {
2890
3242
  const finish = (error, value) => {
2891
3243
  if (settled) return;
2892
3244
  settled = true;
3245
+ if (abortListener && options.signal) options.signal.removeEventListener('abort', abortListener);
2893
3246
  if (state.progress) state.progress.stop();
2894
3247
  state.progress = null;
2895
3248
  state.durationMs = Date.now() - startedAt;
@@ -2925,7 +3278,13 @@ async function postTurn(message, options = {}) {
2925
3278
  const block = buffer.slice(0, boundary);
2926
3279
  buffer = buffer.slice(boundary + 2);
2927
3280
  try {
2928
- handleEvent(parseSseBlock(block), state, output);
3281
+ const event = parseSseBlock(block);
3282
+ handleEvent(event, state, output);
3283
+ if (event && event.type === 'result') {
3284
+ flushPendingText(state, output);
3285
+ if (state.resultOutput) state.output = state.resultOutput;
3286
+ finish(null, state);
3287
+ }
2929
3288
  } catch (error) {
2930
3289
  state.errors.push(`bad_sse_event: ${error.message}`);
2931
3290
  }
@@ -2972,6 +3331,17 @@ async function postTurn(message, options = {}) {
2972
3331
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
2973
3332
  req.destroy();
2974
3333
  });
3334
+ if (options.signal) {
3335
+ abortListener = () => {
3336
+ finish(turnInterruptedError());
3337
+ req.destroy();
3338
+ };
3339
+ if (options.signal.aborted) {
3340
+ abortListener();
3341
+ return;
3342
+ }
3343
+ options.signal.addEventListener('abort', abortListener, { once: true });
3344
+ }
2975
3345
  req.write(postData);
2976
3346
  req.end();
2977
3347
  });
@@ -3010,6 +3380,7 @@ async function postCodeFastTurn(message, options = {}) {
3010
3380
 
3011
3381
  return new Promise((resolve, reject) => {
3012
3382
  let settled = false;
3383
+ let abortListener = null;
3013
3384
  const startedAt = Date.now();
3014
3385
  state.progress = createProgressReporter(output, options);
3015
3386
  state.progress.start();
@@ -3017,6 +3388,7 @@ async function postCodeFastTurn(message, options = {}) {
3017
3388
  const finish = (error, value) => {
3018
3389
  if (settled) return;
3019
3390
  settled = true;
3391
+ if (abortListener && options.signal) options.signal.removeEventListener('abort', abortListener);
3020
3392
  if (state.progress) state.progress.stop();
3021
3393
  state.progress = null;
3022
3394
  state.durationMs = Date.now() - startedAt;
@@ -3081,6 +3453,17 @@ async function postCodeFastTurn(message, options = {}) {
3081
3453
  finish(new Error(`Request timeout after ${timeoutMs / 1000}s`));
3082
3454
  req.destroy();
3083
3455
  });
3456
+ if (options.signal) {
3457
+ abortListener = () => {
3458
+ finish(turnInterruptedError());
3459
+ req.destroy();
3460
+ };
3461
+ if (options.signal.aborted) {
3462
+ abortListener();
3463
+ return;
3464
+ }
3465
+ options.signal.addEventListener('abort', abortListener, { once: true });
3466
+ }
3084
3467
  req.write(postData);
3085
3468
  req.end();
3086
3469
  });
@@ -3145,8 +3528,11 @@ async function chat(options = {}) {
3145
3528
  const history = [];
3146
3529
  let lastCompactionNotice = 0;
3147
3530
  let conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
3148
- const session = { approveMode: 'stage' };
3531
+ const session = { approveMode: options.autoApprove || options.approveMode === 'auto' ? 'auto' : 'stage' };
3149
3532
  let turnInFlight = false;
3533
+ let activeTurnController = null;
3534
+ let slashMenu = null;
3535
+ let pendingApprovalAbort = null;
3150
3536
 
3151
3537
  output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
3152
3538
  if (logger) output.write(`${formatAuxRow('log', formatPathSubject(logger.path, output), output)}\n\n`);
@@ -3170,7 +3556,18 @@ async function chat(options = {}) {
3170
3556
  return true;
3171
3557
  }
3172
3558
  output.write(`${formatRuntimeHealth(health, output)}\n\n`);
3173
- output.write(`${formatBackendHint({ route: endpointRoute })}\n`);
3559
+ // A reachable backend with one not-ready lane is a missing provider key,
3560
+ // not a dead cloud; saying "cloud did not respond" here sent live debugging
3561
+ // at the network when the fix was an env var (seen live 2026-08-16).
3562
+ const laneKey = normalizeMode(mode) === 'rapid' ? 'rapid' : normalizeMode(mode) === 'fast' ? 'fast' : null;
3563
+ const laneNotReady = laneKey && health && health[laneKey] && health[laneKey].ready === false;
3564
+ if (health && health.backend && health.backend.ready && laneNotReady) {
3565
+ const keyName = laneKey === 'rapid' ? 'WAFER_API_KEY' : 'FIREWORKS_API_KEY';
3566
+ const where = endpointRoute === 'local' ? `${keyName} to backend/.env and restart it` : `${keyName} to the cloud backend env and redeploy`;
3567
+ output.write(`The ${laneKey} model has no key on the ${endpointRoute} backend. Add ${where}, or switch lanes with /fast.\n`);
3568
+ } else {
3569
+ output.write(`${formatBackendHint({ route: endpointRoute })}\n`);
3570
+ }
3174
3571
  output.write(`${formatDoneLine(Date.now() - startedAt)}\n\n`);
3175
3572
  return false;
3176
3573
  };
@@ -3180,34 +3577,30 @@ async function chat(options = {}) {
3180
3577
  if (!trimmed) return false;
3181
3578
  if (EXIT_WORDS.has(trimmed.toLowerCase())) return true;
3182
3579
 
3183
- if (trimmed === '/' || trimmed.toLowerCase() === '/help') {
3184
- output.write(`${chatMenu(output)}\n\n`);
3185
- return false;
3186
- }
3187
-
3188
- const tier = chatTierCommand(trimmed);
3189
- if (tier) {
3190
- mode = tier;
3580
+ const slash = runChatSlash(trimmed, { cwd, mode, history, approveMode: session.approveMode, isTTY: output.isTTY });
3581
+ if (slash) {
3582
+ if (slash.type === 'exit') return true;
3583
+ if (slash.mode) mode = slash.mode;
3584
+ if (slash.approveMode) session.approveMode = slash.approveMode;
3585
+ if (slash.type === 'clear') {
3586
+ history.length = 0;
3587
+ conversationId = `ax-${process.pid}-${Date.now().toString(36)}`;
3588
+ }
3589
+ if (slash.type === 'status') {
3590
+ const healthRoute = options.business
3591
+ ? 'cloud'
3592
+ : options.route === 'local' || options.forceLocal
3593
+ ? 'local'
3594
+ : 'cloud';
3595
+ const health = options.runtimeHealth
3596
+ ? await Promise.resolve(options.runtimeHealth({ mode, route: healthRoute }))
3597
+ : await buildRuntimeHealth({ timeoutMs: options.preflightTimeoutMs || 1200, route: healthRoute });
3598
+ slash.output = formatRuntimeHealth(health, output);
3599
+ }
3191
3600
  if (logger) logger.write(`${trimmed}\n`);
3192
- output.write(`${paint(`· ${tierLabel(mode)}`, [tierColor(mode)], output)}\n\n`);
3193
- return false;
3194
- }
3195
-
3196
- if (trimmed === '/clear') {
3197
- history.length = 0;
3198
- conversationId = `ax-${process.pid}-${Date.now().toString(36)}`;
3199
- output.write(`${paint('context cleared', [ANSI.muted], output)}\n\n`);
3200
- return false;
3201
- }
3202
-
3203
- if (trimmed === '/context') {
3204
- const ctx = estimateHistoryContext(history);
3205
- output.write(`${paint(`· ${ctx.turns} turn${ctx.turns === 1 ? '' : 's'} · ~${ctx.chars} chars · ~${ctx.tokens} tokens`, [ANSI.muted], output)}\n\n`);
3206
- return false;
3207
- }
3208
-
3209
- if (trimmed.startsWith('/')) {
3210
- output.write(`${chatMenu(output)}\n\n`);
3601
+ const color = slash.mode ? [tierColor(slash.mode)] : [ANSI.muted];
3602
+ const preservesOwnFormatting = slash.type === 'help' || slash.type === 'status';
3603
+ output.write(`${preservesOwnFormatting ? slash.output : paint(slash.output, color, output)}\n\n`);
3211
3604
  return false;
3212
3605
  }
3213
3606
 
@@ -3322,6 +3715,9 @@ async function chat(options = {}) {
3322
3715
  output.write(`${paint(`· context: ${compactedTurns} earlier turn${compactedTurns === 1 ? '' : 's'} compacted into a digest`, [ANSI.muted], output)}\n`);
3323
3716
  }
3324
3717
  let result;
3718
+ activeTurnController = new AbortController();
3719
+ turnInFlight = true;
3720
+ if (slashMenu) slashMenu.clear();
3325
3721
  try {
3326
3722
  result = await turnFunction(trimmed, {
3327
3723
  mode,
@@ -3338,6 +3734,7 @@ async function chat(options = {}) {
3338
3734
  apiRequestJson: options.apiRequestJson,
3339
3735
  loadCredentials: options.loadCredentials,
3340
3736
  getApiBaseUrl: options.getApiBaseUrl,
3737
+ signal: activeTurnController.signal,
3341
3738
  });
3342
3739
  appendAutoOutcome(autoPick, {
3343
3740
  ok: true,
@@ -3345,6 +3742,10 @@ async function chat(options = {}) {
3345
3742
  durationMs: Number(result && result.durationMs) || (Date.now() - turnStartedAt),
3346
3743
  });
3347
3744
  } catch (error) {
3745
+ if (isTurnInterruptedError(error)) {
3746
+ output.write(`${paint('Interrupted.', [ANSI.muted], output)}\n\n`);
3747
+ return false;
3748
+ }
3348
3749
  appendAutoOutcome(autoPick, {
3349
3750
  ok: false,
3350
3751
  model: modelForMode(mode),
@@ -3352,6 +3753,16 @@ async function chat(options = {}) {
3352
3753
  error: String((error && error.message) || error),
3353
3754
  });
3354
3755
  throw error;
3756
+ } finally {
3757
+ turnInFlight = false;
3758
+ activeTurnController = null;
3759
+ // A turn that ends (abort, timeout, stream error) with an approval
3760
+ // question still open must cancel it: an orphaned rl.question makes
3761
+ // readline silently drop every later question and the chat goes dead.
3762
+ if (pendingApprovalAbort) {
3763
+ try { pendingApprovalAbort.abort(); } catch {}
3764
+ pendingApprovalAbort = null;
3765
+ }
3355
3766
  }
3356
3767
  if (result.output && !result.output.endsWith('\n')) output.write('\n');
3357
3768
  const approvalExecution = normalizeMode(mode) === 'code-fast'
@@ -3361,7 +3772,14 @@ async function chat(options = {}) {
3361
3772
  output,
3362
3773
  ...(options.postApproval ? { postApproval: options.postApproval } : {}),
3363
3774
  });
3364
- if (!options.turnFunction) writeWorkspaceApprovalHints(cwd, turnStartedAt, output);
3775
+ if (!options.turnFunction) {
3776
+ await autoRedeemGrantedApprovals(cwd, turnStartedAt, {
3777
+ mode,
3778
+ output,
3779
+ approveMode: session.approveMode,
3780
+ });
3781
+ writeWorkspaceApprovalHints(cwd, turnStartedAt, output);
3782
+ }
3365
3783
  output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n`);
3366
3784
  const goalLine = options.turnFunction ? '' : formatGoalStatusLine(currentAtrisGoal(cwd), output);
3367
3785
  if (goalLine) output.write(`${goalLine}\n`);
@@ -3401,15 +3819,75 @@ async function chat(options = {}) {
3401
3819
  }
3402
3820
 
3403
3821
  const rl = readline.createInterface({ input, output: baseOutput, completer: chatCompleter });
3404
- const ask = () => new Promise(resolve => rl.question(formatPrompt(mode, baseOutput), resolve));
3405
- while (true) {
3406
- const line = await ask();
3407
- if (await runLine(line)) {
3408
- rl.close();
3409
- break;
3822
+ if (!options.approveBackendApi) {
3823
+ // Mid-turn approvals must reuse the chat's readline: a second interface on
3824
+ // the same stdin double-consumes keypresses and corrupts both prompts.
3825
+ // The question is abortable so an interrupted or failed turn cancels it
3826
+ // (deny by default) instead of leaving readline holding a dead callback.
3827
+ // The approval question text is already written by the caller.
3828
+ options.approveBackendApi = () => new Promise(resolve => {
3829
+ const controller = new AbortController();
3830
+ pendingApprovalAbort = controller;
3831
+ const settle = answer => {
3832
+ if (pendingApprovalAbort === controller) pendingApprovalAbort = null;
3833
+ resolve(answer);
3834
+ };
3835
+ controller.signal.addEventListener('abort', () => settle(false), { once: true });
3836
+ try {
3837
+ rl.question('', { signal: controller.signal }, settle);
3838
+ } catch {
3839
+ settle(false);
3840
+ }
3841
+ });
3842
+ }
3843
+ const promptOptions = () => ({ isTTY: baseOutput.isTTY, approveMode: session.approveMode });
3844
+ const detachHotkey = attachApproveModeHotkey(rl, session, () => formatPrompt(mode, promptOptions()));
3845
+ slashMenu = attachSlashMenu(rl, baseOutput, { isActive: () => !turnInFlight });
3846
+ const exitSignal = Symbol('chat exit');
3847
+ let pendingAsk = null;
3848
+ const requestExit = () => {
3849
+ if (pendingAsk) pendingAsk(exitSignal);
3850
+ };
3851
+ const detachCtrlC = attachChatCtrlC(rl, {
3852
+ output: baseOutput,
3853
+ clearMenu: () => slashMenu.clear(),
3854
+ isTurnInFlight: () => turnInFlight,
3855
+ interruptTurn: () => {
3856
+ if (activeTurnController) activeTurnController.abort();
3857
+ },
3858
+ exit: requestExit,
3859
+ });
3860
+ const onClose = () => requestExit();
3861
+ rl.on('close', onClose);
3862
+ const ask = () => new Promise(resolve => {
3863
+ let settled = false;
3864
+ const settle = value => {
3865
+ if (settled) return;
3866
+ settled = true;
3867
+ if (pendingAsk === settle) pendingAsk = null;
3868
+ resolve(value);
3869
+ };
3870
+ pendingAsk = settle;
3871
+ try {
3872
+ rl.question(formatPrompt(mode, promptOptions()), settle);
3873
+ } catch (_) {
3874
+ settle(exitSignal);
3410
3875
  }
3876
+ });
3877
+
3878
+ try {
3879
+ while (true) {
3880
+ const line = await ask();
3881
+ if (line === exitSignal || await runLine(line)) break;
3882
+ }
3883
+ } finally {
3884
+ detachCtrlC();
3885
+ slashMenu.detach();
3886
+ detachHotkey();
3887
+ rl.removeListener('close', onClose);
3888
+ if (!rl.closed) rl.close();
3889
+ if (logger) logger.close(0);
3411
3890
  }
3412
- if (logger) logger.close(0);
3413
3891
  }
3414
3892
 
3415
3893
  function printBackendHint(options = {}) {
@@ -4170,11 +4648,13 @@ async function main() {
4170
4648
  ? 'code-fast'
4171
4649
  : args.includes('--max')
4172
4650
  ? 'max'
4173
- : args.includes('--fast')
4174
- ? 'fast'
4175
- : args.includes('--pro')
4176
- ? 'pro'
4177
- : null;
4651
+ : args.includes('--rapid')
4652
+ ? 'rapid'
4653
+ : args.includes('--fast')
4654
+ ? 'fast'
4655
+ : args.includes('--pro')
4656
+ ? 'pro'
4657
+ : null;
4178
4658
  const autoEnabled = args.includes('--auto') && !explicitLane;
4179
4659
  let mode = explicitLane || 'pro';
4180
4660
  const doctor = args.includes('--doctor');
@@ -4210,6 +4690,9 @@ async function main() {
4210
4690
  const listGrants = args.includes('--grants');
4211
4691
  if (listGrants) args.splice(args.indexOf('--grants'), 1);
4212
4692
 
4693
+ const autoApprove = args.includes('--auto-approve');
4694
+ if (autoApprove) args.splice(args.indexOf('--auto-approve'), 1);
4695
+
4213
4696
  const syncGrantsFlag = args.includes('--sync-grants');
4214
4697
  if (syncGrantsFlag) args.splice(args.indexOf('--sync-grants'), 1);
4215
4698
 
@@ -4237,7 +4720,7 @@ async function main() {
4237
4720
 
4238
4721
  const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
4239
4722
  const prompt = args
4240
- .filter(arg => !['--auto', '--max', '--fast', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h'].includes(arg))
4723
+ .filter(arg => !['--auto', '--auto-approve', '--max', '--fast', '--rapid', '--pro', '--code-fast', '--code', '--chat', '--doctor', '--approvals', '--self-test', '--benchmark', '--print', '--headless', '--local', '--cloud', '--help', '-h'].includes(arg))
4241
4724
  .join(' ')
4242
4725
  .trim();
4243
4726
 
@@ -4398,12 +4881,17 @@ async function main() {
4398
4881
  autoPick,
4399
4882
  })
4400
4883
  : { ok: false, model: modelForMode(mode), output: '', durationMs: 0, error: 'missing prompt' };
4401
- console.log(JSON.stringify(payload));
4402
- process.exit(payload.ok ? 0 : 1);
4884
+ // Drain stdout before exiting: process.exit right after a large write
4885
+ // truncates piped output at the 64KB pipe buffer (same class as BCK-1306).
4886
+ const code = payload.ok ? 0 : 1;
4887
+ process.exitCode = code;
4888
+ process.stdout.write(`${JSON.stringify(payload)}\n`, () => process.exit(code));
4889
+ setTimeout(() => process.exit(code), 3000).unref();
4890
+ return;
4403
4891
  }
4404
4892
 
4405
4893
  if (!prompt || args.includes('--chat')) {
4406
- await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify, auto: autoEnabled });
4894
+ await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify, auto: autoEnabled, autoApprove });
4407
4895
  return;
4408
4896
  }
4409
4897
 
@@ -4437,7 +4925,11 @@ async function main() {
4437
4925
  // A one-shot has no next turn, so "reply yes to approve" is a dead end;
4438
4926
  // granted patterns redeem themselves, everything else gets the exact
4439
4927
  // redeem command for what the turn staged on disk.
4440
- await autoRedeemGrantedApprovals(process.cwd(), turnStartedAt, { mode, output: process.stdout });
4928
+ await autoRedeemGrantedApprovals(process.cwd(), turnStartedAt, {
4929
+ mode,
4930
+ output: process.stdout,
4931
+ approveMode: autoApprove ? 'auto' : undefined,
4932
+ });
4441
4933
  writeWorkspaceApprovalHints(process.cwd(), turnStartedAt, process.stdout);
4442
4934
  console.log('');
4443
4935
  console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
@@ -4472,6 +4964,7 @@ module.exports = {
4472
4964
  approveStoredApproval,
4473
4965
  approveWorkspaceApproval,
4474
4966
  autoRedeemGrantedApprovals,
4967
+ isSafeGitPushApproval,
4475
4968
  findWorkspaceApproval,
4476
4969
  listWorkspaceApprovals,
4477
4970
  pendingWorkspaceApprovalsSince,
@@ -4487,9 +4980,17 @@ module.exports = {
4487
4980
  buildConnectionContext,
4488
4981
  buildRunProfile,
4489
4982
  chat,
4983
+ appendChatJournal,
4984
+ attachApproveModeHotkey,
4985
+ attachChatCtrlC,
4986
+ attachSlashMenu,
4490
4987
  chatCompleter,
4491
4988
  chatMenu,
4492
4989
  chatTierCommand,
4990
+ cycleApproveMode,
4991
+ filterChatCommands,
4992
+ parseChatSlash,
4993
+ runChatSlash,
4493
4994
  codeFastWorkspaceNotice,
4494
4995
  creditsFromState,
4495
4996
  codeFastBaseUrl,