atris 3.31.0 → 3.33.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -4
- package/atris/CLAUDE.md +8 -0
- package/atris.md +8 -0
- package/ax +458 -15
- package/bin/atris.js +200 -76
- package/commands/autoland.js +78 -12
- package/commands/autopilot-front.js +273 -0
- package/commands/engine.js +299 -0
- package/commands/init.js +21 -0
- package/commands/integrations.js +147 -0
- package/commands/member.js +85 -0
- package/commands/mission.js +500 -43
- package/commands/run-front.js +144 -0
- package/commands/run.js +9 -6
- package/commands/sign.js +90 -0
- package/commands/task.js +380 -20
- package/commands/truth.js +72 -11
- package/lib/autoland.js +133 -25
- package/lib/fleet.js +354 -0
- package/lib/inspect-fields.js +174 -0
- package/lib/operator-next.js +7 -0
- package/lib/pulse.js +4 -3
- package/lib/runner-command.js +54 -11
- package/lib/task-db.js +57 -2
- package/package.json +3 -2
- package/scripts/agent_worktree.py +72 -0
package/ax
CHANGED
|
@@ -306,6 +306,20 @@ function formatRunProfile(profile, options = {}) {
|
|
|
306
306
|
return rows.map(([label, value]) => formatAuxRow(label, value, options)).join('\n');
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// Auto-routed local turns must never die on a missing local backend — a
|
|
310
|
+
// consumer machine without the launchd service gets a degraded cloud chat
|
|
311
|
+
// turn, not ECONNREFUSED. Probed once per process (REPL lines reuse it).
|
|
312
|
+
// Explicit --local skips the downgrade so devs see the real error. Interim
|
|
313
|
+
// guard until the cloud relay executes workspace tools in-process and
|
|
314
|
+
// consumers stop needing a local backend at all.
|
|
315
|
+
let _localBackendUp = null;
|
|
316
|
+
async function localBackendReachable() {
|
|
317
|
+
if (_localBackendUp !== null) return _localBackendUp;
|
|
318
|
+
const res = await requestJson(ATRIS2_HEALTH_PATH, { timeoutMs: 1200, route: 'local' });
|
|
319
|
+
_localBackendUp = Boolean(res.ok || Number(res.status || 0) > 0);
|
|
320
|
+
return _localBackendUp;
|
|
321
|
+
}
|
|
322
|
+
|
|
309
323
|
async function buildRuntimeHealth(options = {}) {
|
|
310
324
|
const route = options.route === 'local' || options.forceLocal ? 'local' : 'cloud';
|
|
311
325
|
const healthRes = options.healthRes
|
|
@@ -621,9 +635,49 @@ function githubWorkspaceIntent(message) {
|
|
|
621
635
|
return /\b(push|commit|commits?|branch|branches|checkout|merge|rebase|tag|release|pr|pull request|pull-request|repo change|code change|small change)\b/i.test(text);
|
|
622
636
|
}
|
|
623
637
|
|
|
638
|
+
// Walk up from cwd looking for a workspace marker (.git or atris/ or atris.md).
|
|
639
|
+
// The process.cwd() answer is cached — resolveRoute runs per chat line — but an
|
|
640
|
+
// explicit startDir (callers pass their own cwd) is always checked fresh.
|
|
641
|
+
let _workspaceCwd = null;
|
|
642
|
+
function insideWorkspaceCwd(startDir) {
|
|
643
|
+
const explicit = typeof startDir === 'string' && startDir.length > 0;
|
|
644
|
+
if (!explicit && _workspaceCwd !== null) return _workspaceCwd;
|
|
645
|
+
let dir = explicit ? startDir : process.cwd();
|
|
646
|
+
let found = false;
|
|
647
|
+
for (let i = 0; i < 24; i += 1) {
|
|
648
|
+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'atris.md')) || fs.existsSync(path.join(dir, 'atris'))) {
|
|
649
|
+
found = true;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
const parent = path.dirname(dir);
|
|
653
|
+
if (parent === dir) break;
|
|
654
|
+
dir = parent;
|
|
655
|
+
}
|
|
656
|
+
if (!explicit) _workspaceCwd = found;
|
|
657
|
+
return found;
|
|
658
|
+
}
|
|
659
|
+
|
|
624
660
|
function resolveRoute(message, options = {}) {
|
|
625
661
|
if (options.route === 'local' || options.forceLocal) return 'local';
|
|
626
662
|
if (options.route === 'cloud' || options.forceCloud) return 'cloud';
|
|
663
|
+
// Workspace standard v2 (2026-07-02, supersedes intent routing): inside a
|
|
664
|
+
// workspace, tools are ALWAYS attached and the model decides whether to use
|
|
665
|
+
// them — the Claude Code / Codex shape. v1 guessed intent with regexes and
|
|
666
|
+
// lost three times in one day ("in scripts/fast-coach/tick.mjs, what does
|
|
667
|
+
// SLOW_MS control?", "what did Atris ship last night?", "find it lol" — all
|
|
668
|
+
// tool-less misses that told the user to paste files they were sitting on).
|
|
669
|
+
// Measured cost of always-tools: pure math 3s vs 2s; and "plan my morning"
|
|
670
|
+
// through the tool lane reads the real task board instead of guessing.
|
|
671
|
+
// Non-workspace cwds stay pure cloud (the privacy line); explicit flags
|
|
672
|
+
// always win. One carve-out: GitHub-connector phrasing prefers the cloud
|
|
673
|
+
// GitHub API lane unless it names local-checkout work.
|
|
674
|
+
const text = String(message || '');
|
|
675
|
+
// Local-checkout work includes every git mutation verb: cloud chat can only
|
|
676
|
+
// read the GitHub connector, so "do a quick github push" through the cloud
|
|
677
|
+
// lane dead-ends in a write-blocked upsell (live miss 2026-07-02). Inside a
|
|
678
|
+
// workspace the local tool loop just runs git.
|
|
679
|
+
if (/\bgithub\b/i.test(text) && !/\b(commits?|branch(?:es)?|checkout|merge|rebase|changes?|diff|push(?:es|ed|ing)?|pull(?:s|ed|ing)?|fetch|clone|stash|tags?)\b/i.test(text)) return 'cloud';
|
|
680
|
+
if (insideWorkspaceCwd(options.cwd)) return 'local';
|
|
627
681
|
return 'cloud';
|
|
628
682
|
}
|
|
629
683
|
|
|
@@ -808,6 +862,19 @@ function formatAtrisGoal(goal) {
|
|
|
808
862
|
].filter(Boolean).join('\n');
|
|
809
863
|
}
|
|
810
864
|
|
|
865
|
+
function formatGoalStatusLine(goal, options = {}) {
|
|
866
|
+
if (!goal || !goal.objective) return '';
|
|
867
|
+
const status = String(goal.mission_status || '').toLowerCase();
|
|
868
|
+
if (!['active', 'running', 'planning', 'in_progress', 'ready'].includes(status)) return '';
|
|
869
|
+
const elapsed = goalElapsedSeconds(goal);
|
|
870
|
+
const objective = String(goal.objective).length > 64
|
|
871
|
+
? `${String(goal.objective).slice(0, 61)}...`
|
|
872
|
+
: String(goal.objective);
|
|
873
|
+
const parts = [`goal: ${objective}`, status];
|
|
874
|
+
if (elapsed !== null) parts.push(`${formatSeconds(elapsed)} in`);
|
|
875
|
+
return paint(`· ${parts.join(' · ')}`, [ANSI.muted], options);
|
|
876
|
+
}
|
|
877
|
+
|
|
811
878
|
function writeAtrisGoalBanner(output, cwd = process.cwd()) {
|
|
812
879
|
const text = formatAtrisGoal(currentAtrisGoal(cwd));
|
|
813
880
|
if (!text) return false;
|
|
@@ -883,28 +950,176 @@ function resetMarkdownState(state) {
|
|
|
883
950
|
state.markdownMode = 'normal';
|
|
884
951
|
state.markdownBuffer = '';
|
|
885
952
|
state.markdownCarry = '';
|
|
953
|
+
state.markdownFence = false;
|
|
954
|
+
state.markdownTable = [];
|
|
955
|
+
state.markdownLine = '';
|
|
956
|
+
state.markdownLineMode = 'start';
|
|
886
957
|
}
|
|
887
958
|
|
|
888
959
|
function ensureMarkdownState(state) {
|
|
889
960
|
if (!state.markdownMode) state.markdownMode = 'normal';
|
|
890
961
|
if (typeof state.markdownBuffer !== 'string') state.markdownBuffer = '';
|
|
891
962
|
if (typeof state.markdownCarry !== 'string') state.markdownCarry = '';
|
|
963
|
+
if (typeof state.markdownFence !== 'boolean') state.markdownFence = false;
|
|
964
|
+
if (!Array.isArray(state.markdownTable)) state.markdownTable = [];
|
|
965
|
+
if (typeof state.markdownLine !== 'string') state.markdownLine = '';
|
|
966
|
+
if (!state.markdownLineMode) state.markdownLineMode = 'start';
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function renderInlineMarkdown(text, options = {}) {
|
|
970
|
+
let rendered = String(text || '');
|
|
971
|
+
const codeSpans = [];
|
|
972
|
+
rendered = rendered.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
973
|
+
const token = `\u0000CODE${codeSpans.length}\u0000`;
|
|
974
|
+
codeSpans.push(code);
|
|
975
|
+
return token;
|
|
976
|
+
});
|
|
977
|
+
rendered = rendered.replace(/\*\*([^*\n]+)\*\*/g, (_, value) => paint(value, [ANSI.bold], options));
|
|
978
|
+
rendered = rendered.replace(/__([^_\n]+)__/g, (_, value) => paint(value, [ANSI.bold], options));
|
|
979
|
+
rendered = rendered.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => paint(codeSpans[Number(index)] || '', [ANSI.accent], options));
|
|
980
|
+
return rendered;
|
|
892
981
|
}
|
|
893
982
|
|
|
983
|
+
function renderMarkdownTable(lines, options = {}) {
|
|
984
|
+
const rows = [];
|
|
985
|
+
for (const line of lines) {
|
|
986
|
+
const trimmed = String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
987
|
+
const cells = trimmed.split('|').map(cell => cell.trim());
|
|
988
|
+
if (cells.length && cells.every(cell => /^:?-{2,}:?$/.test(cell))) continue;
|
|
989
|
+
rows.push(cells);
|
|
990
|
+
}
|
|
991
|
+
if (!rows.length) return '';
|
|
992
|
+
const width = Math.max(...rows.map(row => row.length));
|
|
993
|
+
const plain = value => String(value || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
994
|
+
const rendered = rows.map(row => row.map(cell => renderInlineMarkdown(cell, options)));
|
|
995
|
+
const colWidths = Array.from({ length: width }, (_, col) => (
|
|
996
|
+
Math.max(...rendered.map(row => plain(row[col] || '').length))
|
|
997
|
+
));
|
|
998
|
+
const sep = paint('│', [ANSI.muted], options);
|
|
999
|
+
return rendered.map((row, rowIndex) => {
|
|
1000
|
+
const cells = Array.from({ length: width }, (_, col) => {
|
|
1001
|
+
const cell = row[col] || '';
|
|
1002
|
+
const pad = ' '.repeat(Math.max(0, colWidths[col] - plain(cell).length));
|
|
1003
|
+
const value = `${cell}${pad}`;
|
|
1004
|
+
return rowIndex === 0 ? paint(plain(value), [ANSI.bold], options) : value;
|
|
1005
|
+
});
|
|
1006
|
+
return ` ${cells.join(` ${sep} `)}`;
|
|
1007
|
+
}).join('\n') + '\n';
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function flushMarkdownTable(state, options = {}) {
|
|
1011
|
+
if (!state.markdownTable.length) return '';
|
|
1012
|
+
const table = renderMarkdownTable(state.markdownTable, options);
|
|
1013
|
+
state.markdownTable = [];
|
|
1014
|
+
return table;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function renderMarkdownLine(state, line, options = {}) {
|
|
1018
|
+
const fenceMatch = /^\s*```/.test(line);
|
|
1019
|
+
if (fenceMatch) {
|
|
1020
|
+
state.markdownFence = !state.markdownFence;
|
|
1021
|
+
const label = line.trim().slice(3).trim();
|
|
1022
|
+
if (state.markdownFence && label) return `${paint(label, [ANSI.muted], options)}\n`;
|
|
1023
|
+
return '';
|
|
1024
|
+
}
|
|
1025
|
+
if (state.markdownFence) {
|
|
1026
|
+
return ` ${paint(line, [ANSI.accent], options)}\n`;
|
|
1027
|
+
}
|
|
1028
|
+
if (/^\s*\|/.test(line)) {
|
|
1029
|
+
state.markdownTable.push(line);
|
|
1030
|
+
return '';
|
|
1031
|
+
}
|
|
1032
|
+
let out = flushMarkdownTable(state, options);
|
|
1033
|
+
const header = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1034
|
+
if (header) {
|
|
1035
|
+
const codes = header[1].length <= 2 ? [ANSI.bold, ANSI.accent] : [ANSI.bold];
|
|
1036
|
+
return `${out}${paint(renderInlineMarkdown(header[2], options).replace(/\x1b\[[0-9;]*m/g, ''), codes, options)}\n`;
|
|
1037
|
+
}
|
|
1038
|
+
if (/^\s*(?:-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
|
|
1039
|
+
return `${out}${paint('─'.repeat(36), [ANSI.muted], options)}\n`;
|
|
1040
|
+
}
|
|
1041
|
+
const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
|
|
1042
|
+
if (bullet) {
|
|
1043
|
+
return `${out}${bullet[1]}${paint('•', [ANSI.bold], options)} ${renderInlineMarkdown(bullet[2], options)}\n`;
|
|
1044
|
+
}
|
|
1045
|
+
const quote = line.match(/^\s*>\s?(.*)$/);
|
|
1046
|
+
if (quote) {
|
|
1047
|
+
return `${out}${paint('│', [ANSI.muted], options)} ${paint(renderInlineMarkdown(quote[1], options).replace(/\x1b\[[0-9;]*m/g, ''), [ANSI.muted], options)}\n`;
|
|
1048
|
+
}
|
|
1049
|
+
return `${out}${renderInlineMarkdown(line, options)}\n`;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Hybrid streaming renderer. Prose streams character-live through the inline
|
|
1053
|
+
// state machine (bold/code style as delimiters close), so chat feels
|
|
1054
|
+
// immediate. Structure-shaped lines (tables, fences, headers, bullets,
|
|
1055
|
+
// quotes) buffer to the line boundary and render as blocks, because they
|
|
1056
|
+
// cannot be styled until the whole line exists. The first character of each
|
|
1057
|
+
// line decides which lane the line takes.
|
|
1058
|
+
const MARKDOWN_BLOCK_LINE_START = new Set(['|', '#', '`', '>', '-', '*']);
|
|
1059
|
+
|
|
894
1060
|
function renderStreamingMarkdown(state, text, options = {}) {
|
|
895
1061
|
ensureMarkdownState(state);
|
|
896
|
-
const input =
|
|
897
|
-
state.markdownCarry = '';
|
|
1062
|
+
const input = String(text || '');
|
|
898
1063
|
let out = '';
|
|
899
1064
|
|
|
900
1065
|
for (let i = 0; i < input.length;) {
|
|
901
1066
|
const char = input[i];
|
|
902
|
-
const next = input[i + 1];
|
|
903
1067
|
|
|
1068
|
+
// Structure lane: buffer the whole line, render at the newline.
|
|
1069
|
+
if (state.markdownFence || state.markdownLineMode === 'block') {
|
|
1070
|
+
if (char === '\n') {
|
|
1071
|
+
out += renderMarkdownLine(state, state.markdownLine, options);
|
|
1072
|
+
state.markdownLine = '';
|
|
1073
|
+
state.markdownLineMode = 'start';
|
|
1074
|
+
} else {
|
|
1075
|
+
state.markdownLine += char;
|
|
1076
|
+
}
|
|
1077
|
+
i += 1;
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// Line start: decide which lane this line takes.
|
|
1082
|
+
if (state.markdownLineMode === 'start') {
|
|
1083
|
+
if (char === '\n') {
|
|
1084
|
+
out += flushMarkdownTable(state, options);
|
|
1085
|
+
out += '\n';
|
|
1086
|
+
i += 1;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (MARKDOWN_BLOCK_LINE_START.has(char)) {
|
|
1090
|
+
state.markdownLineMode = 'block';
|
|
1091
|
+
state.markdownLine = char;
|
|
1092
|
+
i += 1;
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
out += flushMarkdownTable(state, options);
|
|
1096
|
+
state.markdownLineMode = 'prose';
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Prose lane: the inline state machine, character-live.
|
|
1101
|
+
const next = input[i + 1];
|
|
904
1102
|
if (state.markdownMode === 'normal') {
|
|
1103
|
+
if (state.markdownCarry === '*') {
|
|
1104
|
+
state.markdownCarry = '';
|
|
1105
|
+
if (char === '*') {
|
|
1106
|
+
state.markdownMode = 'bold';
|
|
1107
|
+
state.markdownBuffer = '';
|
|
1108
|
+
i += 1;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
out += '*';
|
|
1112
|
+
}
|
|
1113
|
+
if (char === '\n') {
|
|
1114
|
+
out += '\n';
|
|
1115
|
+
state.markdownLineMode = 'start';
|
|
1116
|
+
i += 1;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
905
1119
|
if (char === '*' && next === undefined) {
|
|
906
1120
|
state.markdownCarry = '*';
|
|
907
|
-
|
|
1121
|
+
i += 1;
|
|
1122
|
+
continue;
|
|
908
1123
|
}
|
|
909
1124
|
if (char === '*' && next === '*') {
|
|
910
1125
|
state.markdownMode = 'bold';
|
|
@@ -924,23 +1139,52 @@ function renderStreamingMarkdown(state, text, options = {}) {
|
|
|
924
1139
|
}
|
|
925
1140
|
|
|
926
1141
|
if (state.markdownMode === 'bold') {
|
|
1142
|
+
if (char === '\n') {
|
|
1143
|
+
out += `**${state.markdownBuffer}\n`;
|
|
1144
|
+
state.markdownMode = 'normal';
|
|
1145
|
+
state.markdownBuffer = '';
|
|
1146
|
+
state.markdownLineMode = 'start';
|
|
1147
|
+
i += 1;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
927
1150
|
if (char === '*' && next === undefined) {
|
|
928
1151
|
state.markdownCarry = '*';
|
|
929
|
-
|
|
1152
|
+
i += 1;
|
|
1153
|
+
continue;
|
|
930
1154
|
}
|
|
931
|
-
if (char === '*' && next === '*') {
|
|
1155
|
+
if (char === '*' && (next === '*' || state.markdownCarry === '*')) {
|
|
932
1156
|
out += paint(state.markdownBuffer, [ANSI.bold], options);
|
|
933
1157
|
state.markdownMode = 'normal';
|
|
934
1158
|
state.markdownBuffer = '';
|
|
935
|
-
|
|
1159
|
+
state.markdownCarry = '';
|
|
1160
|
+
i += next === '*' ? 2 : 1;
|
|
936
1161
|
continue;
|
|
937
1162
|
}
|
|
1163
|
+
if (state.markdownCarry === '*') {
|
|
1164
|
+
state.markdownCarry = '';
|
|
1165
|
+
if (char === '*') {
|
|
1166
|
+
out += paint(state.markdownBuffer, [ANSI.bold], options);
|
|
1167
|
+
state.markdownMode = 'normal';
|
|
1168
|
+
state.markdownBuffer = '';
|
|
1169
|
+
i += 1;
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
state.markdownBuffer += '*';
|
|
1173
|
+
}
|
|
938
1174
|
state.markdownBuffer += char;
|
|
939
1175
|
i += 1;
|
|
940
1176
|
continue;
|
|
941
1177
|
}
|
|
942
1178
|
|
|
943
1179
|
if (state.markdownMode === 'code') {
|
|
1180
|
+
if (char === '\n') {
|
|
1181
|
+
out += `\`${state.markdownBuffer}\n`;
|
|
1182
|
+
state.markdownMode = 'normal';
|
|
1183
|
+
state.markdownBuffer = '';
|
|
1184
|
+
state.markdownLineMode = 'start';
|
|
1185
|
+
i += 1;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
944
1188
|
if (char === '`') {
|
|
945
1189
|
out += paint(state.markdownBuffer, [ANSI.accent], options);
|
|
946
1190
|
state.markdownMode = 'normal';
|
|
@@ -959,12 +1203,27 @@ function renderStreamingMarkdown(state, text, options = {}) {
|
|
|
959
1203
|
|
|
960
1204
|
function flushStreamingMarkdown(state, output) {
|
|
961
1205
|
ensureMarkdownState(state);
|
|
962
|
-
let out =
|
|
1206
|
+
let out = '';
|
|
1207
|
+
if (state.markdownLine) {
|
|
1208
|
+
if (/^\s*```/.test(state.markdownLine)) {
|
|
1209
|
+
// A fence marker with no trailing newline is a block boundary, not code.
|
|
1210
|
+
state.markdownFence = !state.markdownFence;
|
|
1211
|
+
} else if (state.markdownFence) {
|
|
1212
|
+
out += ` ${paint(state.markdownLine, [ANSI.accent], output)}`;
|
|
1213
|
+
} else {
|
|
1214
|
+
out += renderMarkdownLine(state, state.markdownLine, output).replace(/\n$/, '');
|
|
1215
|
+
}
|
|
1216
|
+
state.markdownLine = '';
|
|
1217
|
+
}
|
|
1218
|
+
if (state.markdownCarry) {
|
|
1219
|
+
out += state.markdownCarry;
|
|
1220
|
+
}
|
|
963
1221
|
if (state.markdownMode === 'bold' && state.markdownBuffer) {
|
|
964
1222
|
out += paint(state.markdownBuffer, [ANSI.bold], output);
|
|
965
1223
|
} else if (state.markdownMode === 'code' && state.markdownBuffer) {
|
|
966
1224
|
out += paint(state.markdownBuffer, [ANSI.accent], output);
|
|
967
1225
|
}
|
|
1226
|
+
out += flushMarkdownTable(state, output);
|
|
968
1227
|
resetMarkdownState(state);
|
|
969
1228
|
if (out) output.write(out);
|
|
970
1229
|
return out;
|
|
@@ -1422,6 +1681,99 @@ function removeStoredApproval(ref, options = {}) {
|
|
|
1422
1681
|
return record;
|
|
1423
1682
|
}
|
|
1424
1683
|
|
|
1684
|
+
// Local turns stage side effects as signed artifacts in the workspace's
|
|
1685
|
+
// atris/approvals/ (atris.local_approval.v1), a different store from
|
|
1686
|
+
// ~/.atris/ax-approvals.json. ax --approve redeems them through the local
|
|
1687
|
+
// backend's approvals/execute endpoint, which re-signs with explicit user
|
|
1688
|
+
// approval and runs the gated executor.
|
|
1689
|
+
function workspaceApprovalsDir(cwd) {
|
|
1690
|
+
return path.join(cwd || process.cwd(), 'atris', 'approvals');
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function listWorkspaceApprovals(cwd) {
|
|
1694
|
+
const dir = workspaceApprovalsDir(cwd);
|
|
1695
|
+
let names = [];
|
|
1696
|
+
try {
|
|
1697
|
+
names = fs.readdirSync(dir).filter(name => name.endsWith('.json'));
|
|
1698
|
+
} catch {
|
|
1699
|
+
return [];
|
|
1700
|
+
}
|
|
1701
|
+
const records = [];
|
|
1702
|
+
for (const name of names) {
|
|
1703
|
+
const filePath = path.join(dir, name);
|
|
1704
|
+
try {
|
|
1705
|
+
const record = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
1706
|
+
if (record && record.schema === 'atris.local_approval.v1') {
|
|
1707
|
+
records.push({ path: filePath, record, mtimeMs: fs.statSync(filePath).mtimeMs });
|
|
1708
|
+
}
|
|
1709
|
+
} catch {
|
|
1710
|
+
// Unreadable artifact: skip, never block the turn.
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return records;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function findWorkspaceApproval(ref, cwd) {
|
|
1717
|
+
const target = String(ref || '').trim();
|
|
1718
|
+
if (!target) return null;
|
|
1719
|
+
const matches = listWorkspaceApprovals(cwd)
|
|
1720
|
+
.filter(item => String(item.record.approval_id || '').startsWith(target));
|
|
1721
|
+
if (matches.length !== 1) return null;
|
|
1722
|
+
return matches[0];
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function pendingWorkspaceApprovalsSince(cwd, sinceMs) {
|
|
1726
|
+
return listWorkspaceApprovals(cwd).filter(item => (
|
|
1727
|
+
item.mtimeMs >= sinceMs
|
|
1728
|
+
&& String(item.record.status || '') === 'pending'
|
|
1729
|
+
&& !(item.record.decision && item.record.decision.approved)
|
|
1730
|
+
));
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function approveWorkspaceApproval(ref, options = {}) {
|
|
1734
|
+
const cwd = options.cwd || process.cwd();
|
|
1735
|
+
const found = findWorkspaceApproval(ref, cwd);
|
|
1736
|
+
if (!found) throw new Error(`Pending approval not found: ${ref}`);
|
|
1737
|
+
const output = options.output || process.stdout;
|
|
1738
|
+
const { record } = found;
|
|
1739
|
+
const startedAt = Date.now();
|
|
1740
|
+
const payload = record.payload && typeof record.payload === 'object' ? record.payload : {};
|
|
1741
|
+
output.write(`${formatAuxRow('id', record.approval_id, output)}\n`);
|
|
1742
|
+
if (record.summary) output.write(`${formatAuxRow('action', record.summary, output)}\n`);
|
|
1743
|
+
if (payload.command) output.write(`${formatAuxRow('command', payload.command, output)}\n`);
|
|
1744
|
+
output.write('Accepted.\n');
|
|
1745
|
+
const response = await postJson(APPROVAL_EXECUTE_PATH, {
|
|
1746
|
+
model: modelForMode(options.mode || 'fast'),
|
|
1747
|
+
approval_request: {
|
|
1748
|
+
action_type: record.action_type,
|
|
1749
|
+
approval_id: record.approval_id,
|
|
1750
|
+
summary: record.summary,
|
|
1751
|
+
payload: { ...payload, workspace: payload.workspace || cwd },
|
|
1752
|
+
},
|
|
1753
|
+
}, { route: 'local' });
|
|
1754
|
+
const execution = response && response.data && typeof response.data === 'object' ? response.data : {};
|
|
1755
|
+
const executed = Boolean(execution.execution && execution.execution.executed) || execution.status === 'executed';
|
|
1756
|
+
const statusText = executed
|
|
1757
|
+
? 'Done.'
|
|
1758
|
+
: `Not executed: ${String(execution.status || response.error || `HTTP ${response.status || 0}`)}`;
|
|
1759
|
+
output.write(`${formatAuxRow(executed ? 'done' : 'wait', statusText, output)}\n`);
|
|
1760
|
+
try {
|
|
1761
|
+
const spent = { ...record, status: executed ? 'executed' : record.status, executed_at: executed ? new Date().toISOString() : undefined };
|
|
1762
|
+
fs.writeFileSync(found.path, `${JSON.stringify(spent, null, 2)}\n`);
|
|
1763
|
+
} catch {
|
|
1764
|
+
// Bookkeeping only; the backend holds replay protection.
|
|
1765
|
+
}
|
|
1766
|
+
output.write(`${formatDoneLine(Date.now() - startedAt)}\n`);
|
|
1767
|
+
if (!response.ok && !executed) process.exitCode = 1;
|
|
1768
|
+
return { response, executed };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function writeWorkspaceApprovalHints(cwd, sinceMs, output) {
|
|
1772
|
+
for (const item of pendingWorkspaceApprovalsSince(cwd, sinceMs)) {
|
|
1773
|
+
output.write(`${formatAuxRow('approve', formatApprovalCommand(item.record.approval_id), output)}\n`);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1425
1777
|
function formatStoredApprovals(store, options = {}) {
|
|
1426
1778
|
const approvals = Array.isArray(store && store.approvals) ? store.approvals : [];
|
|
1427
1779
|
if (!approvals.length) return 'No pending approvals.';
|
|
@@ -1708,8 +2060,30 @@ function writeAuxLine(state, output, line) {
|
|
|
1708
2060
|
state.needsBullet = true;
|
|
1709
2061
|
}
|
|
1710
2062
|
|
|
2063
|
+
// Turns beyond the recent window used to fall off a cliff: turn 9 of a chat
|
|
2064
|
+
// silently forgot turn 1 and never said so. Evicted turns now compress into
|
|
2065
|
+
// a digest that rides as the first previous message, so long chats keep the
|
|
2066
|
+
// thread of the whole conversation. AX_NO_COMPACT=1 restores the bare window.
|
|
2067
|
+
const HISTORY_DIGEST_MAX_CHARS = 2400;
|
|
2068
|
+
|
|
2069
|
+
function compactedHistoryDigest(evicted) {
|
|
2070
|
+
const lines = [];
|
|
2071
|
+
for (const turn of evicted) {
|
|
2072
|
+
const who = turn && turn.role === 'assistant' ? 'atris' : 'user';
|
|
2073
|
+
const text = String((turn && turn.content) || '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
2074
|
+
if (text) lines.push(`${who}: ${text}`);
|
|
2075
|
+
}
|
|
2076
|
+
let digest = lines.join('\n');
|
|
2077
|
+
if (digest.length > HISTORY_DIGEST_MAX_CHARS) {
|
|
2078
|
+
digest = digest.slice(digest.length - HISTORY_DIGEST_MAX_CHARS);
|
|
2079
|
+
digest = digest.slice(digest.indexOf('\n') + 1);
|
|
2080
|
+
}
|
|
2081
|
+
return digest;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1711
2084
|
function structuredHistory(history, limit = 8) {
|
|
1712
|
-
|
|
2085
|
+
const rows = Array.isArray(history) ? history : [];
|
|
2086
|
+
const recent = rows
|
|
1713
2087
|
.slice(-limit)
|
|
1714
2088
|
.map(turn => {
|
|
1715
2089
|
const message = {
|
|
@@ -1722,6 +2096,16 @@ function structuredHistory(history, limit = 8) {
|
|
|
1722
2096
|
return message;
|
|
1723
2097
|
})
|
|
1724
2098
|
.filter(turn => turn.content.trim());
|
|
2099
|
+
if (rows.length <= limit || process.env.AX_NO_COMPACT === '1') return recent;
|
|
2100
|
+
const digest = compactedHistoryDigest(rows.slice(0, rows.length - limit));
|
|
2101
|
+
if (!digest) return recent;
|
|
2102
|
+
return [
|
|
2103
|
+
{
|
|
2104
|
+
role: 'user',
|
|
2105
|
+
content: `[conversation so far, compacted from ${rows.length - limit} earlier turns]\n${digest}`,
|
|
2106
|
+
},
|
|
2107
|
+
...recent,
|
|
2108
|
+
];
|
|
1725
2109
|
}
|
|
1726
2110
|
|
|
1727
2111
|
function latestPendingTaskPreview(history = []) {
|
|
@@ -1871,6 +2255,19 @@ function codeFastWorkspaceNotice(result) {
|
|
|
1871
2255
|
function handleEvent(event, state, output) {
|
|
1872
2256
|
if (!event || typeof event !== 'object') return;
|
|
1873
2257
|
state.events.push(event);
|
|
2258
|
+
// AX_TIMING=1: phase scoreboard to stderr: one line per SSE event with ms
|
|
2259
|
+
// since turn start and since the previous event, so latency work can see
|
|
2260
|
+
// WHERE a slow turn spends its time (model TTFT vs tool exec vs loop count).
|
|
2261
|
+
if (process.env.AX_TIMING === '1') {
|
|
2262
|
+
const now = Date.now();
|
|
2263
|
+
if (!state._t0) state._t0 = now;
|
|
2264
|
+
const kind = event.type || event.status || 'event';
|
|
2265
|
+
const detail = event.type === 'assistant_blocks' && Array.isArray(event.blocks)
|
|
2266
|
+
? event.blocks.map((b) => (b && b.type === 'tool_use' ? `tool:${b.tool}` : b && b.type)).join(',')
|
|
2267
|
+
: event.type === 'tool_results' ? `results:${(event.results || []).length}` : '';
|
|
2268
|
+
process.stderr.write(`[ax-timing] +${String(now - state._t0).padStart(6)}ms (gap ${String(now - (state._tPrev || now)).padStart(5)}ms) ${kind}${detail ? ' ' + detail : ''}\n`);
|
|
2269
|
+
state._tPrev = now;
|
|
2270
|
+
}
|
|
1874
2271
|
|
|
1875
2272
|
if (event.type === 'system_init') {
|
|
1876
2273
|
state.runtime = event;
|
|
@@ -1968,7 +2365,11 @@ function handleEvent(event, state, output) {
|
|
|
1968
2365
|
}
|
|
1969
2366
|
|
|
1970
2367
|
async function postTurn(message, options = {}) {
|
|
1971
|
-
|
|
2368
|
+
let route = options.business ? 'local' : resolveRoute(message, options);
|
|
2369
|
+
if (route === 'local' && !options.business && options.route !== 'local' && !options.forceLocal
|
|
2370
|
+
&& !(await localBackendReachable())) {
|
|
2371
|
+
route = 'cloud';
|
|
2372
|
+
}
|
|
1972
2373
|
const local = route !== 'cloud';
|
|
1973
2374
|
const endpointRoute = options.business ? 'cloud' : route;
|
|
1974
2375
|
const token = authToken();
|
|
@@ -1987,7 +2388,11 @@ async function postTurn(message, options = {}) {
|
|
|
1987
2388
|
// Relayed business turns wait on EC2 terminal calls (up to 60s each) with no
|
|
1988
2389
|
// SSE traffic in between, so the socket-idle timeout needs more headroom.
|
|
1989
2390
|
const baseTimeoutMs = payload.model === 'atris:max' ? 300000 : payload.model === 'atris:pro' ? 180000 : 60000;
|
|
1990
|
-
|
|
2391
|
+
let timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
|
|
2392
|
+
// Local workspace tool loops (max_turns 8/14) legitimately run past the fast
|
|
2393
|
+
// lane's 60s chat wall — SwapBench 2026-07-02: three tool-loop tasks died at
|
|
2394
|
+
// ~60s as "Atris cloud did not respond". Same headroom as business relays.
|
|
2395
|
+
if (local) timeoutMs = Math.max(timeoutMs, 180000);
|
|
1991
2396
|
const turnUrl = new URL(backendUrl({ route: endpointRoute }));
|
|
1992
2397
|
const transport = turnUrl.protocol === 'https:' ? https : http;
|
|
1993
2398
|
const state = {
|
|
@@ -2225,6 +2630,7 @@ async function chat(options = {}) {
|
|
|
2225
2630
|
const logger = createRunLogger({ cwd, mode, kind: 'play', output: baseOutput });
|
|
2226
2631
|
const output = logger ? logger.output : baseOutput;
|
|
2227
2632
|
const history = [];
|
|
2633
|
+
let lastCompactionNotice = 0;
|
|
2228
2634
|
const conversationId = options.conversationId || `ax-${process.pid}-${Date.now().toString(36)}`;
|
|
2229
2635
|
|
|
2230
2636
|
output.write(`${formatHeader({ mode, cwd, chat: true }, output)}\n\n`);
|
|
@@ -2357,10 +2763,24 @@ async function chat(options = {}) {
|
|
|
2357
2763
|
const pendingTaskPreview = latestPendingTaskPreview(history);
|
|
2358
2764
|
const route = pendingTaskPreview ? 'cloud' : options.route;
|
|
2359
2765
|
maybeWriteAtrisGoalBanner(output, cwd);
|
|
2360
|
-
|
|
2766
|
+
let turnRoute = options.business ? 'cloud' : resolveRoute(trimmed, { route, cwd });
|
|
2767
|
+
// Same downgrade as postTurn: an auto-routed local turn with no local
|
|
2768
|
+
// backend listening becomes a cloud turn instead of a blocked preflight
|
|
2769
|
+
// that dumps localhost start instructions at a consumer. Explicit --local
|
|
2770
|
+
// keeps the real error.
|
|
2771
|
+
if (turnRoute === 'local' && route !== 'local' && !options.forceLocal) {
|
|
2772
|
+
const probeLocal = options.localBackendProbe || localBackendReachable;
|
|
2773
|
+
if (!(await probeLocal())) turnRoute = 'cloud';
|
|
2774
|
+
}
|
|
2361
2775
|
if (!(await ensureRuntimeReady(turnRoute))) return false;
|
|
2362
2776
|
const turnFunction = options.turnFunction || turnFunctionForMode(mode);
|
|
2363
2777
|
const turnId = options.turnId || createTurnId();
|
|
2778
|
+
const turnStartedAt = Date.now();
|
|
2779
|
+
const compactedTurns = process.env.AX_NO_COMPACT === '1' ? 0 : Math.max(0, history.length - 8);
|
|
2780
|
+
if (compactedTurns > 0 && compactedTurns !== lastCompactionNotice) {
|
|
2781
|
+
lastCompactionNotice = compactedTurns;
|
|
2782
|
+
output.write(`${paint(`· context: ${compactedTurns} earlier turn${compactedTurns === 1 ? '' : 's'} compacted into a digest`, [ANSI.muted], output)}\n`);
|
|
2783
|
+
}
|
|
2364
2784
|
const result = await turnFunction(trimmed, { mode, cwd, history, output, route, business: options.business, verify: options.verify, conversationId, turnId });
|
|
2365
2785
|
if (result.output && !result.output.endsWith('\n')) output.write('\n');
|
|
2366
2786
|
const approvalExecution = normalizeMode(mode) === 'code-fast'
|
|
@@ -2370,7 +2790,11 @@ async function chat(options = {}) {
|
|
|
2370
2790
|
output,
|
|
2371
2791
|
...(options.postApproval ? { postApproval: options.postApproval } : {}),
|
|
2372
2792
|
});
|
|
2373
|
-
|
|
2793
|
+
if (!options.turnFunction) writeWorkspaceApprovalHints(cwd, turnStartedAt, output);
|
|
2794
|
+
output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n`);
|
|
2795
|
+
const goalLine = options.turnFunction ? '' : formatGoalStatusLine(currentAtrisGoal(cwd), output);
|
|
2796
|
+
if (goalLine) output.write(`${goalLine}\n`);
|
|
2797
|
+
output.write('\n');
|
|
2374
2798
|
const receipt = receiptFromState(result);
|
|
2375
2799
|
const approvalRequest = approvalExecution
|
|
2376
2800
|
? (
|
|
@@ -3282,7 +3706,11 @@ async function main() {
|
|
|
3282
3706
|
}
|
|
3283
3707
|
|
|
3284
3708
|
if (approveId) {
|
|
3285
|
-
|
|
3709
|
+
if (findStoredApproval(approveId)) {
|
|
3710
|
+
await approveStoredApproval(approveId, { mode, output: process.stdout });
|
|
3711
|
+
} else {
|
|
3712
|
+
await approveWorkspaceApproval(approveId, { cwd: process.cwd(), mode, output: process.stdout });
|
|
3713
|
+
}
|
|
3286
3714
|
return;
|
|
3287
3715
|
}
|
|
3288
3716
|
|
|
@@ -3300,6 +3728,7 @@ async function main() {
|
|
|
3300
3728
|
console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }, process.stdout));
|
|
3301
3729
|
console.log('');
|
|
3302
3730
|
maybeWriteAtrisGoalBanner(process.stdout, process.cwd());
|
|
3731
|
+
const turnStartedAt = Date.now();
|
|
3303
3732
|
const result = await turnFunctionForMode(mode)(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route, business, verify });
|
|
3304
3733
|
const receipt = receiptFromState(result);
|
|
3305
3734
|
const approvalRequest = normalizeMode(mode) === 'code-fast' ? null : approvalRequestFromReceipt(receipt);
|
|
@@ -3307,11 +3736,21 @@ async function main() {
|
|
|
3307
3736
|
? persistPendingApproval(receipt, approvalRequest, { cwd: process.cwd(), mode })
|
|
3308
3737
|
: null;
|
|
3309
3738
|
if (approvalRecord) console.log(formatAuxRow('approve', formatApprovalCommand(approvalRecord.id), process.stdout));
|
|
3739
|
+
// A one-shot has no next turn, so "reply yes to approve" is a dead end;
|
|
3740
|
+
// surface the exact redeem command for anything the turn staged on disk.
|
|
3741
|
+
writeWorkspaceApprovalHints(process.cwd(), turnStartedAt, process.stdout);
|
|
3310
3742
|
console.log('');
|
|
3311
3743
|
console.log(formatDoneLine(result.durationMs, creditsFromState(result)));
|
|
3312
3744
|
} catch (error) {
|
|
3313
3745
|
console.error(`x ${error.message}`);
|
|
3314
|
-
|
|
3746
|
+
// Only name a lane when the user picked one. An auto-routed turn that
|
|
3747
|
+
// failed locally must not claim "Atris cloud did not respond" (seen live
|
|
3748
|
+
// 2026-07-02 after a local workspace-root rejection).
|
|
3749
|
+
if (route === 'auto') {
|
|
3750
|
+
console.error('\nRetry, or run ax --doctor to check the runtime.');
|
|
3751
|
+
} else {
|
|
3752
|
+
printBackendHint({ route });
|
|
3753
|
+
}
|
|
3315
3754
|
process.exit(1);
|
|
3316
3755
|
}
|
|
3317
3756
|
}
|
|
@@ -3327,6 +3766,10 @@ module.exports = {
|
|
|
3327
3766
|
approvalRequestFromReceipt,
|
|
3328
3767
|
approvalStorePath,
|
|
3329
3768
|
approveStoredApproval,
|
|
3769
|
+
approveWorkspaceApproval,
|
|
3770
|
+
findWorkspaceApproval,
|
|
3771
|
+
listWorkspaceApprovals,
|
|
3772
|
+
pendingWorkspaceApprovalsSince,
|
|
3330
3773
|
backendBaseUrl,
|
|
3331
3774
|
backendStartCommand,
|
|
3332
3775
|
backendUrl,
|