@moda-ai/cli 1.16.0 → 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;
@@ -16,11 +16,43 @@ import { resolveApiKey, resolveModaBaseUrl } from './config.js';
16
16
  import { stripAnsi } from './markdown-terminal.js';
17
17
  /** Signals "fall back to the buffered ask endpoint" — never surfaced raw. */
18
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;
19
23
  constructor(message) {
20
24
  super(message);
21
25
  this.name = 'AskStreamError';
22
26
  }
23
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
+ }
24
56
  /**
25
57
  * Incremental SSE frame parser. Feed decoded text chunks (arbitrarily split —
26
58
  * even mid `data:` line or mid multi-byte frame); complete `data:` payloads
@@ -74,6 +106,68 @@ function dataPayloadOf(frame) {
74
106
  }
75
107
  /** Phases worth a human status line (terminal/idle phases are omitted). */
76
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
+ }
77
171
  /**
78
172
  * Consumes parsed stream events: renders live progress lines through
79
173
  * `onProgress` and accumulates the final outcome. `finish()` throws
@@ -92,6 +186,7 @@ export class AskStreamCollector {
92
186
  terminal = false;
93
187
  lastPhase;
94
188
  toolNames = new Map();
189
+ toolEvents = 0;
95
190
  constructor(onProgress = () => { }) {
96
191
  this.onProgress = onProgress;
97
192
  }
@@ -111,10 +206,22 @@ export class AskStreamCollector {
111
206
  }
112
207
  break;
113
208
  }
114
- case 'thinking':
115
- // 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
+ }
116
221
  break;
222
+ }
117
223
  case 'tool_start': {
224
+ this.toolEvents += 1;
118
225
  const tool = event.tool;
119
226
  const id = typeof tool?.id === 'string' ? tool.id : '';
120
227
  // Backend-controlled strings — strip ANSI so a rogue/compromised backend
@@ -127,6 +234,7 @@ export class AskStreamCollector {
127
234
  break;
128
235
  }
129
236
  case 'tool_end': {
237
+ this.toolEvents += 1;
130
238
  const id = typeof event.id === 'string' ? event.id : '';
131
239
  const name = this.toolNames.get(id) ?? 'tool';
132
240
  this.onProgress(`• ${name} — ${toolEndDetail(event)}`);
@@ -168,6 +276,10 @@ export class AskStreamCollector {
168
276
  isTerminal() {
169
277
  return this.terminal;
170
278
  }
279
+ /** tool_start/tool_end events seen so far (fallback-heuristic input). */
280
+ get toolEventCount() {
281
+ return this.toolEvents;
282
+ }
171
283
  finish() {
172
284
  if (!this.terminal) {
173
285
  throw new AskStreamError('stream ended before a terminal done/error event');
@@ -246,19 +358,26 @@ function normalizeStreamConfidence(value) {
246
358
  * parser into the collector. Exported separately from `streamAsk` so tests can
247
359
  * exercise chunk-splitting behavior without a network layer.
248
360
  */
249
- 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) {
250
367
  const parser = new SseParser();
251
368
  const decoder = new TextDecoder();
252
369
  for await (const chunk of chunks) {
253
370
  const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
254
371
  for (const payload of parser.push(text)) {
255
372
  collector.handle(parsePayload(payload));
373
+ onEvent?.();
256
374
  if (collector.isTerminal())
257
375
  return collector.finish();
258
376
  }
259
377
  }
260
378
  for (const payload of parser.flush()) {
261
379
  collector.handle(parsePayload(payload));
380
+ onEvent?.();
262
381
  if (collector.isTerminal())
263
382
  return collector.finish();
264
383
  }
@@ -284,6 +403,13 @@ export async function streamAsk(options) {
284
403
  const url = `${resolveModaBaseUrl(profileOptions)}/api/cli/ask/stream`;
285
404
  const controller = new AbortController();
286
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();
287
413
  try {
288
414
  const fetchImpl = options.fetchImpl ?? fetch;
289
415
  let response;
@@ -318,9 +444,8 @@ export async function streamAsk(options) {
318
444
  if (!response.body) {
319
445
  throw new AskStreamError('stream response had no body');
320
446
  }
321
- const collector = new AskStreamCollector(options.onProgress);
322
447
  try {
323
- return await consumeAskSse(response.body, collector);
448
+ return await consumeAskSse(response.body, collector, () => ticker.touch());
324
449
  }
325
450
  catch (error) {
326
451
  if (error instanceof AskStreamError)
@@ -328,7 +453,20 @@ export async function streamAsk(options) {
328
453
  throw new AskStreamError(`stream read failed: ${error instanceof Error ? error.message : String(error)}`);
329
454
  }
330
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
+ }
331
468
  finally {
469
+ ticker.stop();
332
470
  clearTimeout(timeout);
333
471
  }
334
472
  }
@@ -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
@@ -1,7 +1,7 @@
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';
@@ -183,7 +183,16 @@ export async function askModa(options) {
183
183
  profile: options.profile,
184
184
  harness: localHarnessSummary(options.cwd),
185
185
  }),
186
- }, 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
+ });
187
196
  const cloud = normalizeCloudAsk(response, options.question);
188
197
  if (cloud)
189
198
  return cloud;
@@ -264,8 +273,10 @@ export function shouldStreamAsk(context, isTTY = process.stdout.isTTY === true)
264
273
  async function askModaForContext(context, question, daysBack) {
265
274
  if (shouldStreamAsk(context)) {
266
275
  // Progress lines go to stderr, so key color off the stderr TTY. Tool cards
267
- // (`• …`) are dimmed subtly; phase lines (`▸ …`) stay at full strength.
276
+ // (`• …`), deliberation lines (`◦ …`), and liveness ticks (`… …`) are
277
+ // dimmed subtly; phase lines (`▸ …`) stay at full strength.
268
278
  const progressStyle = terminalStyles(shouldColorTerminalOutput({ isTTY: process.stderr.isTTY === true }));
279
+ const dimmed = (line) => line.startsWith('•') || line.startsWith('◦') || line.startsWith('…');
269
280
  try {
270
281
  const outcome = await streamAsk({
271
282
  question,
@@ -273,7 +284,7 @@ async function askModaForContext(context, question, daysBack) {
273
284
  profile: context.profile,
274
285
  harness: localHarnessSummary(context.cwd),
275
286
  timeoutMs: askTimeoutMs(),
276
- onProgress: (line) => safeWriteStderr(`${line.startsWith('•') ? progressStyle.dim(line) : line}\n`),
287
+ onProgress: (line) => safeWriteStderr(`${dimmed(line) ? progressStyle.dim(line) : line}\n`),
277
288
  });
278
289
  return askResultFromStreamOutcome(outcome, question);
279
290
  }
@@ -281,12 +292,26 @@ async function askModaForContext(context, question, daysBack) {
281
292
  // Auth problems must keep their structured exit-4 handling.
282
293
  if (error instanceof CliAuthError)
283
294
  throw error;
284
- 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) {
285
302
  safeWriteStderr(`[moda] live progress unavailable (${error instanceof Error ? error.message : String(error)}); using buffered ask.\n`);
286
303
  }
287
304
  }
288
305
  }
289
- 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
+ });
290
315
  }
291
316
  /**
292
317
  * Project a completed stream into the same `ProductionAskResult` the buffered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moda-ai/cli",
3
- "version": "1.16.0",
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-08T20:47:23.985Z",
4
- "cli_version": "1.16.0",
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",