@moda-ai/cli 1.15.1 → 1.17.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.
@@ -4,6 +4,16 @@ export declare class ApiError extends Error {
4
4
  readonly responseBody: string;
5
5
  constructor(message: string, statusCode: number, responseBody: string);
6
6
  }
7
+ /**
8
+ * Transport-level failure: the socket died before any HTTP response existed
9
+ * (`fetch failed`, ECONNRESET, …). Distinct from `ApiError` (an HTTP status)
10
+ * and from the per-attempt timeout so callers can retry ONLY genuine network
11
+ * faults — e.g. undici handing out a pooled connection poisoned by a
12
+ * just-dropped SSE stream on the same origin.
13
+ */
14
+ export declare class NetworkError extends Error {
15
+ constructor(message: string);
16
+ }
7
17
  export declare function callDataAPI(endpoint: string): Promise<unknown>;
8
18
  /**
9
19
  * Per-call transport overrides. Used by expensive, long-running, non-idempotent
@@ -15,6 +25,20 @@ export interface ModaRequestOptions {
15
25
  timeoutMs?: number;
16
26
  /** Max retries (default MAX_RETRIES). Set 0 to disable retries entirely. */
17
27
  retries?: number;
28
+ /**
29
+ * Retry exactly once more on a NETWORK-level failure, even with `retries: 0`.
30
+ * After a streamed SSE connection dies, undici's per-origin pool can hand
31
+ * the very next fetch the same dead socket — the buffered fallback then
32
+ * fails instantly ("fetch failed") against a healthy backend. The retry
33
+ * waits ~1s and sends `Connection: close` so it cannot reuse a pooled
34
+ * connection. HTTP errors (`ApiError`) and the per-attempt timeout NEVER
35
+ * consume this retry.
36
+ */
37
+ retryOnceOnNetworkError?: boolean;
38
+ /** Delay before the network retry (default 1s; test seam). */
39
+ networkRetryDelayMs?: number;
40
+ /** Invoked when the network retry is consumed (callers may log a note). */
41
+ onNetworkRetry?: () => void;
18
42
  }
19
43
  export declare function callControlAPI(endpoint: string, options?: RequestInit, profileOptions?: ProfileIOOptions, requestOptions?: ModaRequestOptions): Promise<unknown>;
20
44
  export declare function getApiRequestCount(): number;
@@ -3,6 +3,7 @@ import { resolveApiKey, resolveModaBaseUrl } from './config.js';
3
3
  const REQUEST_TIMEOUT_MS = 30_000;
4
4
  const MAX_RETRIES = 3;
5
5
  const INITIAL_BACKOFF_MS = 500;
6
+ const NETWORK_RETRY_DELAY_MS = 1_000;
6
7
  let apiRequestCount = 0;
7
8
  export class ApiError extends Error {
8
9
  statusCode;
@@ -14,6 +15,19 @@ export class ApiError extends Error {
14
15
  this.name = 'ApiError';
15
16
  }
16
17
  }
18
+ /**
19
+ * Transport-level failure: the socket died before any HTTP response existed
20
+ * (`fetch failed`, ECONNRESET, …). Distinct from `ApiError` (an HTTP status)
21
+ * and from the per-attempt timeout so callers can retry ONLY genuine network
22
+ * faults — e.g. undici handing out a pooled connection poisoned by a
23
+ * just-dropped SSE stream on the same origin.
24
+ */
25
+ export class NetworkError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = 'NetworkError';
29
+ }
30
+ }
17
31
  function isRetryable(status) {
18
32
  return status === 429 || status >= 500;
19
33
  }
@@ -29,10 +43,23 @@ export async function callControlAPI(endpoint, options = {}, profileOptions = {}
29
43
  async function callModaAPI(url, options, profileOptions = {}, requestOptions = {}) {
30
44
  const timeoutMs = requestOptions.timeoutMs ?? REQUEST_TIMEOUT_MS;
31
45
  const maxRetries = requestOptions.retries ?? MAX_RETRIES;
32
- let lastError;
33
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
46
+ const initialNetworkRetryBudget = requestOptions.retryOnceOnNetworkError
47
+ ? 1
48
+ : 0;
49
+ let networkRetryBudget = initialNetworkRetryBudget;
50
+ // Set once the network-retry budget is consumed: the retry attempt (and any
51
+ // later ones) must not reuse a possibly-poisoned pooled connection.
52
+ let connectionClose = false;
53
+ // Structural termination bound: (maxRetries + 1) normal attempts plus at most
54
+ // one extra network-retry attempt. Every path inside returns, throws, or
55
+ // `continue`s, so the trailing throw is unreachable — but the bound makes the
56
+ // contract hold by construction rather than by manual path analysis.
57
+ const maxAttempts = maxRetries + initialNetworkRetryBudget + 1;
58
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
34
59
  if (attempt > 0) {
35
- const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
60
+ const backoff = connectionClose
61
+ ? requestOptions.networkRetryDelayMs ?? NETWORK_RETRY_DELAY_MS
62
+ : INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
36
63
  await new Promise((resolve) => setTimeout(resolve, backoff));
37
64
  }
38
65
  const controller = new AbortController();
@@ -44,6 +71,7 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
44
71
  'x-api-key': resolveApiKey(profileOptions) ?? '',
45
72
  'Content-Type': 'application/json',
46
73
  ...(options.headers ?? {}),
74
+ ...(connectionClose ? { Connection: 'close' } : {}),
47
75
  },
48
76
  signal: controller.signal,
49
77
  });
@@ -51,7 +79,6 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
51
79
  const errorText = await response.text();
52
80
  const error = new ApiError(`API error (${response.status}): ${errorText}`, response.status, errorText);
53
81
  if (isRetryable(response.status) && attempt < maxRetries) {
54
- lastError = error;
55
82
  continue;
56
83
  }
57
84
  throw error;
@@ -62,11 +89,16 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
62
89
  if (error instanceof ApiError)
63
90
  throw error;
64
91
  const isAbort = error instanceof DOMException && error.name === 'AbortError';
65
- const wrapped = new Error(isAbort
66
- ? `Request timed out after ${timeoutMs}ms: ${url}`
67
- : `Network error: ${error instanceof Error ? error.message : String(error)}`);
92
+ const wrapped = isAbort
93
+ ? new Error(`Request timed out after ${timeoutMs}ms: ${url}`)
94
+ : new NetworkError(`Network error: ${error instanceof Error ? error.message : String(error)}`);
68
95
  if (attempt < maxRetries) {
69
- lastError = wrapped;
96
+ continue;
97
+ }
98
+ if (!isAbort && networkRetryBudget > 0) {
99
+ networkRetryBudget -= 1;
100
+ connectionClose = true;
101
+ requestOptions.onNetworkRetry?.();
70
102
  continue;
71
103
  }
72
104
  throw wrapped;
@@ -75,7 +107,9 @@ async function callModaAPI(url, options, profileOptions = {}, requestOptions = {
75
107
  clearTimeout(timeout);
76
108
  }
77
109
  }
78
- throw lastError ?? new Error('Request failed after retries');
110
+ // Unreachable: the loop above always returns or throws within maxAttempts.
111
+ // Present so the bound is enforced by construction, not just documented.
112
+ throw new NetworkError(`Request retries exhausted: ${url}`);
79
113
  }
80
114
  export function getApiRequestCount() {
81
115
  return apiRequestCount;
@@ -25,8 +25,29 @@ export interface AskStreamOutcome {
25
25
  }
26
26
  /** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
27
27
  export declare class AskStreamError extends Error {
28
+ /** Wall-clock ms the stream ran before failing (annotated by `streamAsk`). */
29
+ elapsedMs?: number;
30
+ /** tool_start/tool_end events seen before failing (annotated by `streamAsk`). */
31
+ toolEvents?: number;
28
32
  constructor(message: string);
29
33
  }
34
+ /**
35
+ * Thresholds for "substantial progress": a stream that ran at least this long,
36
+ * or produced more tool events than this, did real work before dropping —
37
+ * the buffered fallback then re-runs the WHOLE question, roughly doubling
38
+ * total latency, so the user deserves a notice instead of silence. The elapsed
39
+ * check is inclusive (`>=`) so a drop landing exactly on the boundary — where
40
+ * the doubled wait is felt most acutely — still gets the notice.
41
+ */
42
+ export declare const SUBSTANTIAL_STREAM_PROGRESS_MS = 60000;
43
+ export declare const SUBSTANTIAL_STREAM_TOOL_EVENTS = 5;
44
+ /**
45
+ * One-line stderr notice for a stream that died after substantial progress.
46
+ * Returns null for early failures (they keep today's silent fallback) and for
47
+ * non-stream errors. The caller still falls back either way — the user must
48
+ * still get an answer, just an informed wait instead of an apparent hang.
49
+ */
50
+ export declare function streamFallbackNotice(error: unknown): string | null;
30
51
  /**
31
52
  * Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
32
53
  * even mid `data:` line or mid multi-byte frame); complete `data:` payloads
@@ -40,6 +61,33 @@ export declare class SseParser {
40
61
  /** Drain a final frame that was not terminated by a blank line. */
41
62
  flush(): string[];
42
63
  }
64
+ /** Quiet period before the first liveness line is printed. */
65
+ export declare const IDLE_TICKER_THRESHOLD_MS = 10000;
66
+ /** Minimum spacing between liveness lines. */
67
+ export declare const IDLE_TICKER_MIN_UPDATE_MS = 15000;
68
+ /**
69
+ * Client-side liveness for quiet stream periods: long tool-less LLM turns
70
+ * (coverage gate, synthesis) emit no events for 30–90s, which reads as a
71
+ * hang. Once no event has arrived for IDLE_TICKER_THRESHOLD_MS this prints an
72
+ * unobtrusive "… still working (Ns)" line (at most every
73
+ * IDLE_TICKER_MIN_UPDATE_MS) through the same progress channel as tool cards —
74
+ * stderr only, never interleaved into the final answer.
75
+ */
76
+ export declare class IdleTicker {
77
+ private readonly write;
78
+ private readonly now;
79
+ private startedAt;
80
+ private lastEventAt;
81
+ private lastPrintAt;
82
+ private timer;
83
+ constructor(write: (line: string) => void, now?: () => number);
84
+ start(): void;
85
+ /** Reset the idle clock — call on every real stream event. */
86
+ touch(): void;
87
+ stop(): void;
88
+ /** One liveness check; driven by the interval (public for tests). */
89
+ poll(): void;
90
+ }
43
91
  /**
44
92
  * Consumes parsed stream events: renders live progress lines through
45
93
  * `onProgress` and accumulates the final outcome. `finish()` throws
@@ -58,9 +106,12 @@ export declare class AskStreamCollector {
58
106
  private terminal;
59
107
  private lastPhase;
60
108
  private toolNames;
109
+ private toolEvents;
61
110
  constructor(onProgress?: (line: string) => void);
62
111
  handle(raw: unknown): void;
63
112
  isTerminal(): boolean;
113
+ /** tool_start/tool_end events seen so far (fallback-heuristic input). */
114
+ get toolEventCount(): number;
64
115
  finish(): AskStreamOutcome;
65
116
  }
66
117
  /**
@@ -68,7 +119,12 @@ export declare class AskStreamCollector {
68
119
  * parser into the collector. Exported separately from `streamAsk` so tests can
69
120
  * exercise chunk-splitting behavior without a network layer.
70
121
  */
71
- export declare function consumeAskSse(chunks: AsyncIterable<Uint8Array | string>, collector: AskStreamCollector): Promise<AskStreamOutcome>;
122
+ export declare function consumeAskSse(chunks: AsyncIterable<Uint8Array | string>, collector: AskStreamCollector,
123
+ /**
124
+ * Fires once per parsed `data:` event (keepalive comment frames never reach
125
+ * it). Used to reset the idle liveness ticker on real progress.
126
+ */
127
+ onEvent?: () => void): Promise<AskStreamOutcome>;
72
128
  export interface StreamAskOptions {
73
129
  question: string;
74
130
  daysBack: number;
@@ -13,13 +13,46 @@
13
13
  // failure: it is returned as an outcome (`errorMessage`) and rendered the same
14
14
  // way buffered agent errors render.
15
15
  import { resolveApiKey, resolveModaBaseUrl } from './config.js';
16
+ import { stripAnsi } from './markdown-terminal.js';
16
17
  /** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
17
18
  export class AskStreamError extends Error {
19
+ /** Wall-clock ms the stream ran before failing (annotated by `streamAsk`). */
20
+ elapsedMs;
21
+ /** tool_start/tool_end events seen before failing (annotated by `streamAsk`). */
22
+ toolEvents;
18
23
  constructor(message) {
19
24
  super(message);
20
25
  this.name = 'AskStreamError';
21
26
  }
22
27
  }
28
+ /**
29
+ * Thresholds for "substantial progress": a stream that ran at least this long,
30
+ * or produced more tool events than this, did real work before dropping —
31
+ * the buffered fallback then re-runs the WHOLE question, roughly doubling
32
+ * total latency, so the user deserves a notice instead of silence. The elapsed
33
+ * check is inclusive (`>=`) so a drop landing exactly on the boundary — where
34
+ * the doubled wait is felt most acutely — still gets the notice.
35
+ */
36
+ export const SUBSTANTIAL_STREAM_PROGRESS_MS = 60_000;
37
+ export const SUBSTANTIAL_STREAM_TOOL_EVENTS = 5;
38
+ /**
39
+ * One-line stderr notice for a stream that died after substantial progress.
40
+ * Returns null for early failures (they keep today's silent fallback) and for
41
+ * non-stream errors. The caller still falls back either way — the user must
42
+ * still get an answer, just an informed wait instead of an apparent hang.
43
+ */
44
+ export function streamFallbackNotice(error) {
45
+ if (!(error instanceof AskStreamError))
46
+ return null;
47
+ const elapsedMs = error.elapsedMs ?? 0;
48
+ const toolEvents = error.toolEvents ?? 0;
49
+ if (elapsedMs < SUBSTANTIAL_STREAM_PROGRESS_MS &&
50
+ toolEvents <= SUBSTANTIAL_STREAM_TOOL_EVENTS) {
51
+ return null;
52
+ }
53
+ return (`stream interrupted after ${Math.round(elapsedMs / 1000)}s; ` +
54
+ 'retrying via buffered request — this may take another minute or two.');
55
+ }
23
56
  /**
24
57
  * Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
25
58
  * even mid `data:` line or mid multi-byte frame); complete `data:` payloads
@@ -73,6 +106,68 @@ function dataPayloadOf(frame) {
73
106
  }
74
107
  /** Phases worth a human status line (terminal/idle phases are omitted). */
75
108
  const RENDERED_PHASES = new Set(['planning', 'searching', 'analyzing', 'writing']);
109
+ // ─── Idle liveness ticker ────────────────────────────────────
110
+ /** Quiet period before the first liveness line is printed. */
111
+ export const IDLE_TICKER_THRESHOLD_MS = 10_000;
112
+ /** Minimum spacing between liveness lines. */
113
+ export const IDLE_TICKER_MIN_UPDATE_MS = 15_000;
114
+ const IDLE_TICKER_POLL_MS = 1_000;
115
+ /**
116
+ * Client-side liveness for quiet stream periods: long tool-less LLM turns
117
+ * (coverage gate, synthesis) emit no events for 30–90s, which reads as a
118
+ * hang. Once no event has arrived for IDLE_TICKER_THRESHOLD_MS this prints an
119
+ * unobtrusive "… still working (Ns)" line (at most every
120
+ * IDLE_TICKER_MIN_UPDATE_MS) through the same progress channel as tool cards —
121
+ * stderr only, never interleaved into the final answer.
122
+ */
123
+ export class IdleTicker {
124
+ write;
125
+ now;
126
+ startedAt;
127
+ lastEventAt;
128
+ lastPrintAt = 0;
129
+ timer;
130
+ constructor(write, now = Date.now) {
131
+ this.write = write;
132
+ this.now = now;
133
+ this.startedAt = this.now();
134
+ this.lastEventAt = this.startedAt;
135
+ }
136
+ start() {
137
+ if (this.timer)
138
+ return;
139
+ this.startedAt = this.now();
140
+ this.lastEventAt = this.startedAt;
141
+ this.timer = setInterval(() => this.poll(), IDLE_TICKER_POLL_MS);
142
+ // Never hold the process open for a liveness timer.
143
+ this.timer.unref?.();
144
+ }
145
+ /** Reset the idle clock — call on every real stream event. */
146
+ touch() {
147
+ this.lastEventAt = this.now();
148
+ }
149
+ stop() {
150
+ if (this.timer !== undefined)
151
+ clearInterval(this.timer);
152
+ this.timer = undefined;
153
+ }
154
+ /** One liveness check; driven by the interval (public for tests). */
155
+ poll() {
156
+ const at = this.now();
157
+ if (at - this.lastEventAt < IDLE_TICKER_THRESHOLD_MS)
158
+ return;
159
+ if (this.lastPrintAt !== 0 && at - this.lastPrintAt < IDLE_TICKER_MIN_UPDATE_MS)
160
+ return;
161
+ this.lastPrintAt = at;
162
+ const secs = Math.round((at - this.startedAt) / 1000);
163
+ try {
164
+ this.write(`… still working (${secs}s)`);
165
+ }
166
+ catch {
167
+ /* a liveness line must never break the stream */
168
+ }
169
+ }
170
+ }
76
171
  /**
77
172
  * Consumes parsed stream events: renders live progress lines through
78
173
  * `onProgress` and accumulates the final outcome. `finish()` throws
@@ -91,6 +186,7 @@ export class AskStreamCollector {
91
186
  terminal = false;
92
187
  lastPhase;
93
188
  toolNames = new Map();
189
+ toolEvents = 0;
94
190
  constructor(onProgress = () => { }) {
95
191
  this.onProgress = onProgress;
96
192
  }
@@ -110,20 +206,35 @@ export class AskStreamCollector {
110
206
  }
111
207
  break;
112
208
  }
113
- case 'thinking':
114
- // Model narration is too chatty for one-line terminal progress; skip.
209
+ case 'thinking': {
210
+ // Model narration (`t<N>` ids) is too chatty for one-line terminal
211
+ // progress and stays hidden. Deliberation status lines the backend
212
+ // emits on purpose ('plan', 'gate-*': multi-part plan, coverage
213
+ // checks, nudge cycles) are concise and cover otherwise-silent LLM
214
+ // calls — render them like tool cards so the gate never reads as a hang.
215
+ const id = typeof event.id === 'string' ? event.id : '';
216
+ if (id === 'plan' || id.startsWith('gate-')) {
217
+ const delta = typeof event.delta === 'string' ? event.delta : '';
218
+ if (delta.trim().length > 0)
219
+ this.onProgress(`◦ ${oneLine(delta)}`);
220
+ }
115
221
  break;
222
+ }
116
223
  case 'tool_start': {
224
+ this.toolEvents += 1;
117
225
  const tool = event.tool;
118
226
  const id = typeof tool?.id === 'string' ? tool.id : '';
119
- const name = typeof tool?.tool === 'string' ? tool.tool : 'tool';
120
- const title = typeof tool?.title === 'string' ? tool.title : name;
227
+ // Backend-controlled strings — strip ANSI so a rogue/compromised backend
228
+ // (or prompt-injected LLM output) can't inject terminal control sequences.
229
+ const name = stripAnsi(typeof tool?.tool === 'string' ? tool.tool : 'tool');
230
+ const title = stripAnsi(typeof tool?.title === 'string' ? tool.title : name);
121
231
  if (id)
122
232
  this.toolNames.set(id, name);
123
233
  this.onProgress(`• ${name} — ${title}…`);
124
234
  break;
125
235
  }
126
236
  case 'tool_end': {
237
+ this.toolEvents += 1;
127
238
  const id = typeof event.id === 'string' ? event.id : '';
128
239
  const name = this.toolNames.get(id) ?? 'tool';
129
240
  this.onProgress(`• ${name} — ${toolEndDetail(event)}`);
@@ -165,6 +276,10 @@ export class AskStreamCollector {
165
276
  isTerminal() {
166
277
  return this.terminal;
167
278
  }
279
+ /** tool_start/tool_end events seen so far (fallback-heuristic input). */
280
+ get toolEventCount() {
281
+ return this.toolEvents;
282
+ }
168
283
  finish() {
169
284
  if (!this.terminal) {
170
285
  throw new AskStreamError('stream ended before a terminal done/error event');
@@ -191,7 +306,9 @@ export class AskStreamCollector {
191
306
  */
192
307
  const TOOL_CARD_DETAIL_MAX = 100;
193
308
  function oneLine(text) {
194
- const collapsed = text.replace(/\s+/g, ' ').trim();
309
+ // Strip ANSI first: the source is backend-controlled tool summaries/errors,
310
+ // and ESC bytes survive the whitespace collapse below.
311
+ const collapsed = stripAnsi(text).replace(/\s+/g, ' ').trim();
195
312
  return collapsed.length > TOOL_CARD_DETAIL_MAX
196
313
  ? `${collapsed.slice(0, TOOL_CARD_DETAIL_MAX - 1)}…`
197
314
  : collapsed;
@@ -241,19 +358,26 @@ function normalizeStreamConfidence(value) {
241
358
  * parser into the collector. Exported separately from `streamAsk` so tests can
242
359
  * exercise chunk-splitting behavior without a network layer.
243
360
  */
244
- export async function consumeAskSse(chunks, collector) {
361
+ export async function consumeAskSse(chunks, collector,
362
+ /**
363
+ * Fires once per parsed `data:` event (keepalive comment frames never reach
364
+ * it). Used to reset the idle liveness ticker on real progress.
365
+ */
366
+ onEvent) {
245
367
  const parser = new SseParser();
246
368
  const decoder = new TextDecoder();
247
369
  for await (const chunk of chunks) {
248
370
  const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
249
371
  for (const payload of parser.push(text)) {
250
372
  collector.handle(parsePayload(payload));
373
+ onEvent?.();
251
374
  if (collector.isTerminal())
252
375
  return collector.finish();
253
376
  }
254
377
  }
255
378
  for (const payload of parser.flush()) {
256
379
  collector.handle(parsePayload(payload));
380
+ onEvent?.();
257
381
  if (collector.isTerminal())
258
382
  return collector.finish();
259
383
  }
@@ -279,6 +403,13 @@ export async function streamAsk(options) {
279
403
  const url = `${resolveModaBaseUrl(profileOptions)}/api/cli/ask/stream`;
280
404
  const controller = new AbortController();
281
405
  const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS);
406
+ const startedAt = Date.now();
407
+ const onProgress = options.onProgress ?? (() => { });
408
+ const collector = new AskStreamCollector(onProgress);
409
+ // Liveness lines share the progress channel (stderr in the CLI), so they
410
+ // can never interleave into the final stdout answer.
411
+ const ticker = new IdleTicker(onProgress);
412
+ ticker.start();
282
413
  try {
283
414
  const fetchImpl = options.fetchImpl ?? fetch;
284
415
  let response;
@@ -313,9 +444,8 @@ export async function streamAsk(options) {
313
444
  if (!response.body) {
314
445
  throw new AskStreamError('stream response had no body');
315
446
  }
316
- const collector = new AskStreamCollector(options.onProgress);
317
447
  try {
318
- return await consumeAskSse(response.body, collector);
448
+ return await consumeAskSse(response.body, collector, () => ticker.touch());
319
449
  }
320
450
  catch (error) {
321
451
  if (error instanceof AskStreamError)
@@ -323,7 +453,20 @@ export async function streamAsk(options) {
323
453
  throw new AskStreamError(`stream read failed: ${error instanceof Error ? error.message : String(error)}`);
324
454
  }
325
455
  }
456
+ catch (error) {
457
+ // Annotate stream failures with how far the run got, so the caller can
458
+ // decide between today's silent fallback (early drop) and an informed
459
+ // one (drop after substantial progress). See `streamFallbackNotice`.
460
+ if (error instanceof AskStreamError) {
461
+ if (error.elapsedMs === undefined)
462
+ error.elapsedMs = Date.now() - startedAt;
463
+ if (error.toolEvents === undefined)
464
+ error.toolEvents = collector.toolEventCount;
465
+ }
466
+ throw error;
467
+ }
326
468
  finally {
469
+ ticker.stop();
327
470
  clearTimeout(timeout);
328
471
  }
329
472
  }
@@ -0,0 +1,49 @@
1
+ export interface MarkdownTerminalOptions {
2
+ /** When false, all structural fixes still apply but zero ANSI codes are emitted. */
3
+ color: boolean;
4
+ }
5
+ /** Minimal ANSI styling helpers shared by the renderer and ask chrome. */
6
+ export interface TerminalStyles {
7
+ bold(text: string): string;
8
+ italic(text: string): string;
9
+ dim(text: string): string;
10
+ code(text: string): string;
11
+ /** Section heading: bold + underline. */
12
+ heading(text: string): string;
13
+ }
14
+ /**
15
+ * The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
16
+ * color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
17
+ * including the empty string, disables — per the no-color.org spec) and
18
+ * `TERM=dumb`.
19
+ */
20
+ export declare function shouldColorTerminalOutput(opts?: {
21
+ isTTY?: boolean;
22
+ env?: NodeJS.ProcessEnv;
23
+ }): boolean;
24
+ /**
25
+ * ANSI style constructors. Closers restore the specific attribute (22/23/39)
26
+ * rather than a full reset so nested styles (code inside bold, bold inside a
27
+ * heading) survive. When `color` is false every helper is the identity.
28
+ */
29
+ export declare function terminalStyles(color: boolean): TerminalStyles;
30
+ /** Remove ANSI/OSC control sequences from untrusted content. */
31
+ export declare function stripAnsi(text: string): string;
32
+ /**
33
+ * Render an agent markdown answer for direct terminal display.
34
+ *
35
+ * - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
36
+ * with a blank line before.
37
+ * - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
38
+ * sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
39
+ * - List items are re-indented to consistent 2-space-per-level steps based on
40
+ * their original RELATIVE nesting (odd indents round to the nearest level; a
41
+ * top-level-looking item after deeper items stays top-level). Bullets render
42
+ * `•` at level 0 and `-` deeper; ordered items keep their numbers.
43
+ * - `|` table rows and fenced code blocks pass through untouched (only ANSI
44
+ * control sequences are stripped, since that content is backend-controlled).
45
+ * - Runs of 3+ blank lines collapse to a single blank line.
46
+ * - Prose is never reflowed or wrapped.
47
+ */
48
+ export declare function renderMarkdownForTerminal(markdown: string, opts: MarkdownTerminalOptions): string;
49
+ //# sourceMappingURL=markdown-terminal.d.ts.map
@@ -0,0 +1,212 @@
1
+ // src/markdown-terminal.ts
2
+ //
3
+ // Pure markdown -> terminal renderer for human-mode output (`moda ask`).
4
+ // The cloud agent answers in markdown; printing it raw leaves literal `##`
5
+ // header sigils, `**bold**` asterisks, and the model's erratic list
6
+ // indentation on screen. This module strips the sigils, applies ANSI styling
7
+ // when the terminal supports it, and normalizes list nesting to consistent
8
+ // 2-space steps — WITHOUT reflowing prose (terminals wrap themselves) and
9
+ // WITHOUT touching `|` tables or fenced code blocks.
10
+ //
11
+ // No dependencies: raw ANSI escape constants only (the CLI has no color lib).
12
+ /**
13
+ * The CLI's color convention (mirrors `src/tui.ts` / `src/analyst-tui.ts`):
14
+ * color only for a human at a TTY, honoring `NO_COLOR` (present with ANY value,
15
+ * including the empty string, disables — per the no-color.org spec) and
16
+ * `TERM=dumb`.
17
+ */
18
+ export function shouldColorTerminalOutput(opts = {}) {
19
+ const env = opts.env ?? process.env;
20
+ const isTTY = opts.isTTY ?? process.stdout.isTTY === true;
21
+ if (!isTTY)
22
+ return false;
23
+ if (env.NO_COLOR !== undefined)
24
+ return false;
25
+ if (env.TERM === 'dumb')
26
+ return false;
27
+ return true;
28
+ }
29
+ /**
30
+ * ANSI style constructors. Closers restore the specific attribute (22/23/39)
31
+ * rather than a full reset so nested styles (code inside bold, bold inside a
32
+ * heading) survive. When `color` is false every helper is the identity.
33
+ */
34
+ export function terminalStyles(color) {
35
+ const paint = (open, close) => (text) => color ? `\u001b[${open}m${text}\u001b[${close}m` : text;
36
+ return {
37
+ bold: paint('1', '22'),
38
+ italic: paint('3', '23'),
39
+ dim: paint('2', '22'),
40
+ code: paint('36', '39'),
41
+ heading: paint('1;4', '22;24'),
42
+ };
43
+ }
44
+ const FENCE_RE = /^\s*(`{3,}|~{3,})/;
45
+ const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
46
+ const TABLE_ROW_RE = /^\s*\|.*\S/;
47
+ // Matches ANSI/OSC control sequences: CSI (ESC [ … final byte), OSC (ESC ] …
48
+ // BEL or ST), and other two-byte escapes. Backend-controlled content (table
49
+ // cells, code-fence bodies, streamed tool names/titles) is passed through
50
+ // verbatim, so any escape sequences an LLM or a compromised backend emits must
51
+ // be stripped before they reach the terminal — otherwise they could clear the
52
+ // screen, spoof UI, or trigger OSC side-effects in some emulators.
53
+ const ANSI_RE = /\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/g;
54
+ /** Remove ANSI/OSC control sequences from untrusted content. */
55
+ export function stripAnsi(text) {
56
+ return text.replace(ANSI_RE, '');
57
+ }
58
+ /**
59
+ * A fenced code block's opening marker: the fence character (`` ` `` or `~`)
60
+ * and its length. CommonMark closes a fence only with the same character and at
61
+ * least as many markers, so tracking these prevents a `~~~` line from closing a
62
+ * ` ``` ` block (and a 3-backtick line from closing a 4-backtick opener).
63
+ */
64
+ function parseFenceOpen(line) {
65
+ const match = FENCE_RE.exec(line);
66
+ if (!match)
67
+ return null;
68
+ return { char: match[1][0], len: match[1].length };
69
+ }
70
+ /** Whether `line` closes an open fence per the CommonMark same-char/length rule. */
71
+ function isFenceClose(line, fence) {
72
+ const match = FENCE_CLOSE_RE.exec(line);
73
+ return match !== null && match[1][0] === fence.char && match[1].length >= fence.len;
74
+ }
75
+ const HEADER_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
76
+ const BULLET_RE = /^(\s*)([-*+])\s+(.*)$/;
77
+ const ORDERED_RE = /^(\s*)(\d+)[.)]\s+(.*)$/;
78
+ /**
79
+ * Render an agent markdown answer for direct terminal display.
80
+ *
81
+ * - Headers lose their `#` sigils; h1/h2 render bold+underlined, h3+ bold,
82
+ * with a blank line before.
83
+ * - `**bold**`, `*italic*`/`_italic_`, and backtick `code` spans lose their
84
+ * sigils and gain ANSI styling (bold / italic / cyan) when `color` is on.
85
+ * - List items are re-indented to consistent 2-space-per-level steps based on
86
+ * their original RELATIVE nesting (odd indents round to the nearest level; a
87
+ * top-level-looking item after deeper items stays top-level). Bullets render
88
+ * `•` at level 0 and `-` deeper; ordered items keep their numbers.
89
+ * - `|` table rows and fenced code blocks pass through untouched (only ANSI
90
+ * control sequences are stripped, since that content is backend-controlled).
91
+ * - Runs of 3+ blank lines collapse to a single blank line.
92
+ * - Prose is never reflowed or wrapped.
93
+ */
94
+ export function renderMarkdownForTerminal(markdown, opts) {
95
+ const style = terminalStyles(opts.color);
96
+ const out = [];
97
+ // Original indent width backing each active list level; index == level.
98
+ const listStack = [];
99
+ // The open fence's char + length, or null outside a fenced code block.
100
+ let fence = null;
101
+ for (const raw of markdown.replace(/\r\n/g, '\n').split('\n')) {
102
+ if (fence) {
103
+ out.push(stripAnsi(raw));
104
+ if (isFenceClose(raw, fence))
105
+ fence = null;
106
+ continue;
107
+ }
108
+ const opened = parseFenceOpen(raw);
109
+ if (opened) {
110
+ fence = opened;
111
+ listStack.length = 0;
112
+ out.push(stripAnsi(raw));
113
+ continue;
114
+ }
115
+ if (TABLE_ROW_RE.test(raw)) {
116
+ // Tables depend on exact column spacing — pass through monospaced.
117
+ out.push(stripAnsi(raw));
118
+ continue;
119
+ }
120
+ const line = raw.replace(/\s+$/, '');
121
+ if (line === '') {
122
+ out.push('');
123
+ continue;
124
+ }
125
+ const header = HEADER_RE.exec(line);
126
+ if (header) {
127
+ listStack.length = 0;
128
+ // Strip inline sigils inside the header text (plain), then style the
129
+ // whole line so nested `**bold**` can't fight the heading attributes.
130
+ const text = renderInline(header[2], terminalStyles(false));
131
+ if (out.length > 0 && out[out.length - 1] !== '')
132
+ out.push('');
133
+ out.push(header[1].length <= 2 ? style.heading(text) : style.bold(text));
134
+ continue;
135
+ }
136
+ const bullet = BULLET_RE.exec(line);
137
+ const ordered = bullet ? null : ORDERED_RE.exec(line);
138
+ if (bullet || ordered) {
139
+ const match = (bullet ?? ordered);
140
+ const level = resolveListLevel(match[1].length, listStack);
141
+ const indent = ' '.repeat(level);
142
+ const text = renderInline(match[3], style);
143
+ if (bullet) {
144
+ out.push(`${indent}${level === 0 ? '•' : '-'} ${text}`);
145
+ }
146
+ else {
147
+ out.push(`${indent}${match[2]}. ${text}`);
148
+ }
149
+ continue;
150
+ }
151
+ // Unindented prose ends any open list; indented non-list lines are treated
152
+ // as continuations and keep the current nesting context.
153
+ if (!/^\s{2,}/.test(line))
154
+ listStack.length = 0;
155
+ out.push(renderInline(line, style));
156
+ }
157
+ return collapseBlankRuns(out).join('\n');
158
+ }
159
+ /**
160
+ * Map an item's original indent width onto a consistent nesting level.
161
+ * `stack[level]` remembers the original indent that opened that level:
162
+ * meaningfully shallower indents pop back out, meaningfully deeper indents
163
+ * open a new level, and anything within +/-1 space sticks to the current
164
+ * level (rounding the model's odd 3/5/7-space indents to the nearest level).
165
+ */
166
+ function resolveListLevel(indent, stack) {
167
+ while (stack.length > 0 && indent < stack[stack.length - 1] - 1)
168
+ stack.pop();
169
+ if (stack.length === 0 || indent > stack[stack.length - 1] + 1)
170
+ stack.push(indent);
171
+ return stack.length - 1;
172
+ }
173
+ /**
174
+ * Strip/style inline markdown sigils: `code` spans first (protected via
175
+ * placeholders so asterisks/underscores inside them are never re-parsed),
176
+ * then `**bold**`/`__bold__`, then word-boundary `*italic*`/`_italic_`
177
+ * (mid-word underscores as in `silent_empty_result` never match).
178
+ */
179
+ function renderInline(text, style) {
180
+ const codeSpans = [];
181
+ // Strip any ANSI escapes already in the (backend-controlled) text BEFORE we
182
+ // add our own styling, so this only removes injected sequences — never the
183
+ // codes the style helpers emit below.
184
+ let result = stripAnsi(text).replace(/`([^`]+)`/g, (_, code) => {
185
+ codeSpans.push(code);
186
+ return `\u0000${codeSpans.length - 1}\u0000`;
187
+ });
188
+ result = result.replace(/\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g, (_, inner) => style.bold(inner));
189
+ result = result.replace(/__([^\s_](?:[^_]*[^\s_])?)__/g, (_, inner) => style.bold(inner));
190
+ result = result.replace(/(?<![\w*])\*([^\s*](?:[^*\n]*[^\s*])?)\*(?![\w*])/g, (_, inner) => style.italic(inner));
191
+ result = result.replace(/(?<![\w_])_([^\s_](?:[^_\n]*[^\s_])?)_(?![\w_])/g, (_, inner) => style.italic(inner));
192
+ return result.replace(/\u0000(\d+)\u0000/g, (_, index) => style.code(codeSpans[Number(index)]));
193
+ }
194
+ /** Collapse runs of 3+ blank lines down to a single blank line. */
195
+ function collapseBlankRuns(lines) {
196
+ const collapsed = [];
197
+ let blanks = 0;
198
+ for (const line of lines) {
199
+ if (line === '') {
200
+ blanks += 1;
201
+ continue;
202
+ }
203
+ if (blanks > 0)
204
+ collapsed.push('', ...(blanks === 2 ? [''] : []));
205
+ blanks = 0;
206
+ collapsed.push(line);
207
+ }
208
+ if (blanks > 0)
209
+ collapsed.push('', ...(blanks === 2 ? [''] : []));
210
+ return collapsed;
211
+ }
212
+ //# sourceMappingURL=markdown-terminal.js.map
@@ -151,6 +151,8 @@ export declare function askModa(options: {
151
151
  daysBack: number;
152
152
  cwd: string;
153
153
  profile?: string;
154
+ /** Invoked if the POST hits a network fault and the one retry is used. */
155
+ onNetworkRetry?: () => void;
154
156
  }): Promise<ProductionAskResult>;
155
157
  /**
156
158
  * Whether `moda ask` should use the live SSE endpoint: only for a human
@@ -166,6 +168,8 @@ export declare function shouldStreamAsk(context: Pick<CommandContext, 'outputMod
166
168
  * confidence — the buffered endpoint also returns HTTP 200 in that case).
167
169
  */
168
170
  export declare function askResultFromStreamOutcome(outcome: AskStreamOutcome, question: string): ProductionAskResult;
169
- export declare function renderAskHuman(result: ProductionAskResult): string;
171
+ export declare function renderAskHuman(result: ProductionAskResult, options?: {
172
+ color?: boolean;
173
+ }): string;
170
174
  export {};
171
175
  //# sourceMappingURL=production-intelligence.d.ts.map
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { callControlAPI, callDataAPI } from './api-client.js';
4
- import { streamAsk } from './ask-stream.js';
4
+ import { streamAsk, streamFallbackNotice } from './ask-stream.js';
5
5
  import { resolveModaBaseUrl } from './config.js';
6
6
  import { CliAuthError, CliInputError } from './errors.js';
7
7
  import { EXIT_DEGRADED, EXIT_OK, safeWriteStderr } from './output.js';
8
+ import { renderMarkdownForTerminal, shouldColorTerminalOutput, stripAnsi, terminalStyles, } from './markdown-terminal.js';
8
9
  import { asNumber, asRecord, asString } from './transforms.js';
9
10
  import { INTELLIGENCE_SCHEMA_VERSION, } from './intelligence-schema.js';
10
11
  const INVESTIGATION_SCHEMA_VERSION = 'production_investigation.v0.1';
@@ -182,7 +183,16 @@ export async function askModa(options) {
182
183
  profile: options.profile,
183
184
  harness: localHarnessSummary(options.cwd),
184
185
  }),
185
- }, options.profile ? { profile: options.profile } : {}, { timeoutMs: askTimeoutMs(), retries: 0 });
186
+ }, options.profile ? { profile: options.profile } : {}, {
187
+ timeoutMs: askTimeoutMs(),
188
+ retries: 0,
189
+ // A stream that just dropped can poison undici's pooled connection to
190
+ // this origin; without this the buffered fallback fails instantly at
191
+ // the transport level against a healthy backend. One retry, network
192
+ // faults only — HTTP errors and the run timeout still never retry.
193
+ retryOnceOnNetworkError: true,
194
+ onNetworkRetry: options.onNetworkRetry,
195
+ });
186
196
  const cloud = normalizeCloudAsk(response, options.question);
187
197
  if (cloud)
188
198
  return cloud;
@@ -262,6 +272,11 @@ export function shouldStreamAsk(context, isTTY = process.stdout.isTTY === true)
262
272
  */
263
273
  async function askModaForContext(context, question, daysBack) {
264
274
  if (shouldStreamAsk(context)) {
275
+ // Progress lines go to stderr, so key color off the stderr TTY. Tool cards
276
+ // (`• …`), deliberation lines (`◦ …`), and liveness ticks (`… …`) are
277
+ // dimmed subtly; phase lines (`▸ …`) stay at full strength.
278
+ const progressStyle = terminalStyles(shouldColorTerminalOutput({ isTTY: process.stderr.isTTY === true }));
279
+ const dimmed = (line) => line.startsWith('•') || line.startsWith('◦') || line.startsWith('…');
265
280
  try {
266
281
  const outcome = await streamAsk({
267
282
  question,
@@ -269,7 +284,7 @@ async function askModaForContext(context, question, daysBack) {
269
284
  profile: context.profile,
270
285
  harness: localHarnessSummary(context.cwd),
271
286
  timeoutMs: askTimeoutMs(),
272
- onProgress: (line) => safeWriteStderr(`${line}\n`),
287
+ onProgress: (line) => safeWriteStderr(`${dimmed(line) ? progressStyle.dim(line) : line}\n`),
273
288
  });
274
289
  return askResultFromStreamOutcome(outcome, question);
275
290
  }
@@ -277,12 +292,26 @@ async function askModaForContext(context, question, daysBack) {
277
292
  // Auth problems must keep their structured exit-4 handling.
278
293
  if (error instanceof CliAuthError)
279
294
  throw error;
280
- if (context.verbose) {
295
+ const notice = streamFallbackNotice(error);
296
+ if (notice) {
297
+ // The stream died AFTER substantial visible progress: the buffered
298
+ // re-run roughly doubles the wait, so say so instead of going silent.
299
+ safeWriteStderr(`[moda] ${notice}\n`);
300
+ }
301
+ else if (context.verbose) {
281
302
  safeWriteStderr(`[moda] live progress unavailable (${error instanceof Error ? error.message : String(error)}); using buffered ask.\n`);
282
303
  }
283
304
  }
284
305
  }
285
- return askModa({ question, daysBack, cwd: context.cwd, profile: context.profile });
306
+ return askModa({
307
+ question,
308
+ daysBack,
309
+ cwd: context.cwd,
310
+ profile: context.profile,
311
+ onNetworkRetry: context.verbose
312
+ ? () => safeWriteStderr('[moda] network hiccup reaching Moda Cloud; retrying the buffered ask once…\n')
313
+ : undefined,
314
+ });
286
315
  }
287
316
  /**
288
317
  * Project a completed stream into the same `ProductionAskResult` the buffered
@@ -1023,22 +1052,26 @@ function renderInvestigationHuman(investigation) {
1023
1052
  }
1024
1053
  return lines.join('\n');
1025
1054
  }
1026
- export function renderAskHuman(result) {
1055
+ export function renderAskHuman(result, options = {}) {
1056
+ // Same convention as the rest of the CLI: color only for a human at a TTY,
1057
+ // honoring NO_COLOR and TERM=dumb. All information is identical either way.
1058
+ const color = options.color ?? shouldColorTerminalOutput();
1059
+ const style = terminalStyles(color);
1027
1060
  const lines = [];
1028
- lines.push('Moda ask');
1029
- lines.push(`Question: ${result.question}`);
1061
+ lines.push(style.heading('Moda ask'));
1062
+ lines.push(`${style.dim('Question:')} ${result.question}`);
1030
1063
  if (result.degraded) {
1031
1064
  lines.push('');
1032
1065
  lines.push('[degraded] Moda Cloud was unavailable — this answer is synthesized from local Data API evidence (lower trust).');
1033
1066
  }
1034
1067
  lines.push('');
1035
- lines.push(result.answer);
1068
+ lines.push(renderMarkdownForTerminal(result.answer, { color }));
1036
1069
  lines.push('');
1037
- lines.push(`Confidence: ${result.confidence}`);
1070
+ lines.push(`Confidence: ${style.bold(result.confidence)}`);
1038
1071
  lines.push(`Source: ${result.source}${result.degraded ? ' (degraded)' : ''}`);
1039
1072
  if (result.evidenceRefs.length > 0) {
1040
1073
  lines.push('');
1041
- lines.push('Evidence');
1074
+ lines.push(style.bold('Evidence'));
1042
1075
  // Subtle grounding marker: flag model-inferred evidence so a human reader
1043
1076
  // can weight it. Ids/anchors stay hidden in the human projection.
1044
1077
  const grounding = new Map(result.intelligence.evidence_refs.map((ref) => [ref.id, ref.grounding]));
@@ -1047,8 +1080,11 @@ export function renderAskHuman(result) {
1047
1080
  const marker = grounding.get(ref.id) === 'inferred' ? ' (inferred)' : '';
1048
1081
  // Backend hrefs are root-relative dashboard paths; print the absolute
1049
1082
  // URL so terminals auto-linkify it and the reader can click through.
1050
- const link = ref.href ? `\n ${dashboardBase}${ref.href}` : '';
1051
- lines.push(` - ${ref.label}${ref.path ? ` (${ref.path})` : ''}${marker}${link}`);
1083
+ // Strip ANSI from all backend-controlled fields before printing.
1084
+ const label = stripAnsi(ref.label);
1085
+ const path = ref.path ? ` (${stripAnsi(ref.path)})` : '';
1086
+ const link = ref.href ? `\n ${style.dim(`${dashboardBase}${stripAnsi(ref.href)}`)}` : '';
1087
+ lines.push(` - ${label}${path}${marker}${link}`);
1052
1088
  }
1053
1089
  }
1054
1090
  const unverifiedClaims = result.intelligence.answer_claims.filter((claim) => claim.status !== 'grounded').length;
@@ -1058,15 +1094,15 @@ export function renderAskHuman(result) {
1058
1094
  }
1059
1095
  if (result.nextCommands.length > 0) {
1060
1096
  lines.push('');
1061
- lines.push('Next commands');
1097
+ lines.push(style.bold('Next commands'));
1062
1098
  for (const command of result.nextCommands.slice(0, 5))
1063
- lines.push(` ${command}`);
1099
+ lines.push(` ${stripAnsi(command)}`);
1064
1100
  }
1065
1101
  if (result.warnings.length > 0) {
1066
1102
  lines.push('');
1067
- lines.push('Warnings');
1103
+ lines.push(style.bold('Warnings'));
1068
1104
  for (const warning of result.warnings.slice(0, 3))
1069
- lines.push(` - ${warning}`);
1105
+ lines.push(` - ${stripAnsi(warning)}`);
1070
1106
  }
1071
1107
  return lines.join('\n');
1072
1108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.15.1",
3
+ "version": "1.17.0",
4
4
  "description": "CLI for Moda - AI agent analytics and observability",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "moda.skill_index.v1",
3
- "bundled_at": "2026-07-08T14:31:50.320Z",
4
- "cli_version": "1.15.1",
3
+ "bundled_at": "2026-07-08T22:14:49.889Z",
4
+ "cli_version": "1.17.0",
5
5
  "skills": [
6
6
  {
7
7
  "id": "integration-node-anthropic",