@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.
- package/dist/api-client.d.ts +24 -0
- package/dist/api-client.js +43 -9
- package/dist/ask-stream.d.ts +57 -1
- package/dist/ask-stream.js +143 -5
- package/dist/production-intelligence.d.ts +2 -0
- package/dist/production-intelligence.js +31 -6
- package/package.json +1 -1
- package/skills/integration/index.json +2 -2
package/dist/api-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/ask-stream.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/ask-stream.js
CHANGED
|
@@ -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
|
|
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 } : {}, {
|
|
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
|
-
// (`• …`)
|
|
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
|
|
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
|
-
|
|
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({
|
|
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