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/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 = `${state.markdownCarry || ''}${String(text || '')}`;
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
- break;
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
- break;
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
- i += 2;
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 = state.markdownCarry || '';
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
- return (Array.isArray(history) ? history : [])
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
- const route = options.business ? 'local' : resolveRoute(message, options);
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
- const timeoutMs = options.business ? Math.max(baseTimeoutMs, 180000) : baseTimeoutMs;
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
- const turnRoute = options.business ? 'cloud' : resolveRoute(trimmed, { route });
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
- output.write(`${formatDoneLine(result.durationMs, creditsFromState(result))}\n\n`);
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
- await approveStoredApproval(approveId, { mode, output: process.stdout });
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
- printBackendHint({ route: route === 'auto' ? undefined : route });
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,