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/atris/skills/engines/SKILL.md +8 -7
- package/atris/skills/youtube/SKILL.md +8 -8
- package/ax +592 -91
- package/bin/atris.js +17 -5
- package/commands/computer.js +2 -2
- package/commands/mission.js +52 -24
- package/commands/now.js +10 -3
- package/commands/radar.js +65 -33
- package/commands/youtube.js +206 -23
- package/lib/auto-accept-certified.js +2 -1
- package/lib/engine-ask.js +11 -3
- package/lib/engine-registry.js +32 -7
- package/lib/mission-ledger-compact.js +127 -0
- package/lib/mission-runtime-loop.js +30 -2
- package/lib/next-moves.js +34 -34
- package/lib/permission-grants.js +10 -1
- package/lib/runner-command.js +5 -4
- package/lib/task-db.js +18 -2
- package/lib/task-proof.js +6 -1
- package/lib/task-receipt.js +5 -1
- package/package.json +2 -1
- package/scripts/outbound-artifact-gate.js +227 -0
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('
|
|
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
|
-
:
|
|
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
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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(
|
|
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
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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 =
|
|
2162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3184
|
-
|
|
3185
|
-
return
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
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
|
-
|
|
3193
|
-
|
|
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)
|
|
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
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
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('--
|
|
4174
|
-
? '
|
|
4175
|
-
: args.includes('--
|
|
4176
|
-
? '
|
|
4177
|
-
:
|
|
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
|
-
|
|
4402
|
-
|
|
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, {
|
|
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,
|