@nexrall/code-core 1.4.12 → 1.4.14

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.
@@ -6,14 +6,69 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.API_BASE = void 0;
7
7
  exports.chooseFinalContent = chooseFinalContent;
8
8
  exports.streamChat = streamChat;
9
+ exports.cancelTurn = cancelTurn;
9
10
  exports.getBalance = getBalance;
10
11
  exports.exchangeVscodeCode = exchangeVscodeCode;
11
12
  exports.login = login;
12
13
  const eventsource_parser_1 = require("eventsource-parser");
13
14
  const node_fetch_1 = __importDefault(require("node-fetch"));
15
+ const crypto_1 = require("crypto");
14
16
  const index_1 = require("../auth/index");
15
17
  // ─── Constants ────────────────────────────────────────────────────────────────
16
- exports.API_BASE = 'https://api.nexrall.com';
18
+ const DEFAULT_API_BASE = 'https://api.nexrall.com';
19
+ /**
20
+ * Resolve the backend origin, honouring NEXRALL_API_BASE.
21
+ *
22
+ * Everything sensitive in this module goes to API_BASE: the bearer token on
23
+ * `/api/code/chat`, the user's plaintext password on `/api/auth/login/email`, and the
24
+ * full source-code context of every turn.
25
+ *
26
+ * Loopback is allowed freely (it cannot exfiltrate, and the transport-failure tests need
27
+ * it). A REMOTE host requires NEXRALL_ALLOW_API_OVERRIDE=1 in addition, so redirecting a
28
+ * user's credentials is a deliberate act rather than something a stray env var in a CI
29
+ * config or an npm lifecycle script can do silently. Every override is announced on stderr,
30
+ * so it can never be in effect unnoticed. This is still not a hard security boundary —
31
+ * something that can set two env vars can set one — but it removes the ambient-risk case,
32
+ * which is the one that actually happens.
33
+ *
34
+ * An invalid value falls back to production rather than throwing: this runs at module
35
+ * load, and `@nexrall/code-core` is imported statically by the VS Code extension, so a
36
+ * throw here would abort activation and bury the reason in the extension-host log.
37
+ */
38
+ function resolveApiBase() {
39
+ const raw = process.env.NEXRALL_API_BASE?.trim();
40
+ if (!raw)
41
+ return DEFAULT_API_BASE;
42
+ const isLoopback = /^http:\/\/(127\.0\.0\.1|\[::1\]|localhost)(:\d+)?(\/|$)/i.test(raw);
43
+ if (isLoopback) {
44
+ console.error(`⚠️ Nexrall API override in effect (loopback): ${raw.replace(/\/+$/, '')}`);
45
+ return raw.replace(/\/+$/, '');
46
+ }
47
+ // A REMOTE override additionally requires an explicit opt-in flag.
48
+ //
49
+ // Loopback can't exfiltrate anything, so it stays freely available (it is what makes the
50
+ // transport-failure tests possible). A remote host is different: this variable receives the
51
+ // bearer token, the plaintext login password, and the full source context of every turn. In
52
+ // a published package, honouring it from a bare env var means anything that can set one
53
+ // variable — a CI config, a shell profile, an npm lifecycle script that spawns `nex` — can
54
+ // silently redirect all of that, with nothing visible in the UI. Requiring a second,
55
+ // purpose-named variable makes redirection a deliberate act rather than an ambient one.
56
+ if (process.env.NEXRALL_ALLOW_API_OVERRIDE !== '1') {
57
+ console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": redirecting to a remote host also requires ` +
58
+ `NEXRALL_ALLOW_API_OVERRIDE=1, because this variable receives your auth token and ` +
59
+ `source code. Falling back to ${DEFAULT_API_BASE}.`);
60
+ return DEFAULT_API_BASE;
61
+ }
62
+ if (!/^https:\/\//i.test(raw)) {
63
+ console.error(`⚠️ Ignoring NEXRALL_API_BASE="${raw}": a remote override must be https:// so your ` +
64
+ `token is never sent in plaintext. Falling back to ${DEFAULT_API_BASE}.`);
65
+ return DEFAULT_API_BASE;
66
+ }
67
+ const base = raw.replace(/\/+$/, '');
68
+ console.error(`⚠️ Nexrall API override in effect: ${base}`);
69
+ return base;
70
+ }
71
+ exports.API_BASE = resolveApiBase();
17
72
  // ─── Helpers ──────────────────────────────────────────────────────────────────
18
73
  /**
19
74
  * Decide the final assistant `content` array to store in history for a completed
@@ -37,7 +92,18 @@ function chooseFinalContent(rebuilt, rawContent) {
37
92
  if (!Array.isArray(rawContent))
38
93
  return rebuilt;
39
94
  const hasServerSideBlocks = rawContent.some((b) => b && b.type !== 'text' && b.type !== 'tool_use');
40
- return hasServerSideBlocks ? rawContent : rebuilt;
95
+ if (hasServerSideBlocks)
96
+ return rawContent;
97
+ // The rebuild came out EMPTY even though the authoritative message has content. That
98
+ // means the incremental events this reconstruction depends on never landed — a `text`
99
+ // frame split across a chunk boundary and dropped as malformed, or a mid-stream
100
+ // reconnect that resumed past them. Preferring `rebuilt` here would silently return an
101
+ // empty assistant turn and the agent loop would treat the whole turn as a no-op, losing
102
+ // an answer the server had already delivered in full. The raw array is authoritative,
103
+ // so fall back to it.
104
+ if (rebuilt.length === 0 && rawContent.length > 0)
105
+ return rawContent;
106
+ return rebuilt;
41
107
  }
42
108
  function authHeaders() {
43
109
  const token = (0, index_1.getToken)();
@@ -53,6 +119,27 @@ function authHeaders() {
53
119
  const MAX_RETRIES = 5;
54
120
  const RETRY_BASE_MS = 1000;
55
121
  const RETRY_MAX_MS = 30000;
122
+ /**
123
+ * Wall-clock ceiling on ALL reconnect effort for a single streamChat() call.
124
+ *
125
+ * The retry loops are nested — a connect-time loop inside an outer stream loop —
126
+ * so their attempt counts MULTIPLY: 6 × 6 = 36 network attempts, each able to
127
+ * sleep up to 30 s of backoff. In a genuine outage that meant the UI could sit
128
+ * "reconnecting…" for the better part of an hour, silently resending the full
129
+ * conversation (and paying cache-write on it) every single time, before finally
130
+ * admitting failure.
131
+ *
132
+ * A time budget is the honest limit — the user cares about "how long until you
133
+ * tell me it's broken", not how many sockets were opened. Once it's spent, the
134
+ * next failure surfaces immediately. Attempt caps stay as a secondary bound.
135
+ */
136
+ const MAX_TOTAL_RETRY_MS = (() => {
137
+ // Overridable so tests can exercise budget EXHAUSTION, which is otherwise unreachable
138
+ // in under five minutes and is exactly where the tricky control flow lives (the
139
+ // connect-time catch must break rather than fall back into its loop unthrottled).
140
+ const raw = Number(process.env.NEXRALL_MAX_RETRY_MS);
141
+ return Number.isFinite(raw) && raw > 0 ? raw : 5 * 60000;
142
+ })();
56
143
  /** Exponential backoff with full jitter, capped at RETRY_MAX_MS. The jitter spreads
57
144
  * retries out so a fleet of clients hitting the SAME overloaded API (529) don't all
58
145
  * reconnect in lockstep and re-trigger the overload (thundering herd). Returns a delay
@@ -64,30 +151,105 @@ function backoffMs(attempt) {
64
151
  function sleep(ms) {
65
152
  return new Promise((r) => setTimeout(r, ms));
66
153
  }
67
- /** Total number of network-level attempts a single streamChat() call may make
68
- * (connect-time retries × outer stream-retries), used only to report a
69
- * meaningful "attempt N of M" to the UI not a hard limit by itself. */
154
+ /** Upper bound on network-level attempts (connect-time retries × outer stream-retries),
155
+ * reported to the UI as the "of M" in "attempt N of M". It is NOT the real stopping
156
+ * condition MAX_TOTAL_RETRY_MS almost always runs out first, and deliberately so:
157
+ * the nested product (36) exists to bound pathological cases, not to be reached. */
70
158
  const MAX_TOTAL_ATTEMPTS = (MAX_RETRIES + 1) * (MAX_RETRIES + 1);
71
159
  async function streamChat(messages, options, onEvent) {
72
- const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents } = options;
160
+ const { model, env, editorContext, nexrallMd, mode, effort, clientType, abortSignal, extraTools, agents, skills, allowRestartAfterRender } = options;
161
+ // IDEMPOTENCY KEY for this logical turn, generated ONCE and reused by every retry
162
+ // below (that reuse is the entire point — a fresh id per attempt would be useless).
163
+ //
164
+ // The failure it closes is expensive and invisible: the backend finishes the model
165
+ // call, bills it, and writes `done` — but the socket dies before the client reads it.
166
+ // The client sees a dropped connection and retries, so the backend runs the SAME turn
167
+ // through the model a SECOND time and bills it again. The first, perfectly good answer
168
+ // is discarded. The user paid twice for one question and nothing on either side could
169
+ // tell. With a stable key the backend recognises the replay and returns the stored
170
+ // result instead of re-invoking the model.
171
+ let turnId = (0, crypto_1.randomUUID)();
73
172
  // Bridge custom abort signal → native AbortController so fetch is cancelled immediately
74
173
  const controller = new AbortController();
75
174
  if (abortSignal?.aborted)
76
175
  throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
77
176
  let abortPoll;
177
+ // Guards against the 50ms poll firing a fresh cancel request on every tick between the
178
+ // signal flipping and streamChat returning — each one a real POST doing Redis work, i.e.
179
+ // a self-inflicted burst on the exact path a user hits when things are already wrong.
180
+ let cancelSent = false;
78
181
  if (abortSignal) {
79
- abortPoll = setInterval(() => { if (abortSignal.aborted)
80
- controller.abort(); }, 50);
182
+ abortPoll = setInterval(() => {
183
+ if (!abortSignal.aborted)
184
+ return;
185
+ if (cancelSent)
186
+ return; // the poll keeps ticking until the outer finally clears it
187
+ cancelSent = true;
188
+ // Tell the SERVER to stop, not just this socket.
189
+ //
190
+ // Closing the socket used to be sufficient — the backend aborted the model when the
191
+ // request closed. That is exactly what changed to make streams resumable: a dropped
192
+ // connection now DETACHES and keeps generating, because a blip must not throw the
193
+ // turn away. But a deliberate Stop arrives as the very same dead socket. Without an
194
+ // explicit cancel the model would run to completion and bill in full while the user
195
+ // believed they had stopped it.
196
+ //
197
+ // Fire-and-forget, before aborting: we don't want Stop to feel slow, and if the
198
+ // request fails the server's detach grace still bounds the damage.
199
+ void cancelTurn(turnId);
200
+ controller.abort();
201
+ }, 50);
81
202
  }
82
- const fetchArgs = [
83
- `${exports.API_BASE}/api/code/chat`,
84
- {
85
- method: 'POST',
86
- headers: authHeaders(),
87
- body: JSON.stringify({ messages, model, env, editorContext, nexrallMd, mode, effort, clientType, extraTools, agents }),
88
- signal: controller.signal,
89
- },
90
- ];
203
+ // ── Resume state ───────────────────────────────────────────────────────────
204
+ // Set when the backend announces (via a `resumable` frame) that this turn's SSE frames
205
+ // are numbered and buffered server-side. Once true, a dropped connection can be RESUMED
206
+ // — reattached mid-response — instead of restarting the turn.
207
+ //
208
+ // Why that matters: restarting re-sends the whole conversation, pays for prompt
209
+ // processing and time-to-first-token again, and re-bills every token already generated.
210
+ // Resuming costs one buffer read and wastes nothing. Older backends never send the frame,
211
+ // so this stays false and the existing restart path is used unchanged.
212
+ let serverResumable = false;
213
+ // Highest frame id received. The resume cursor handed back as Last-Event-ID.
214
+ let lastEventId = 0;
215
+ // Whether the CURRENT attempt is a resume (GET …/resume) rather than a fresh POST.
216
+ let resuming = false;
217
+ // Output received BEFORE the drop, carried across a resume. A resume delivers only the
218
+ // frames past `lastEventId`, so without this the rebuilt message would consist of just
219
+ // the tail and the start of the answer would be lost.
220
+ let carryText = [];
221
+ let carryToolUse = [];
222
+ // Whether ANY output has reached the caller across ALL attempts of this turn, and how
223
+ // much. `emittedToCaller` inside runOnce is per-attempt and resets, but a rollback
224
+ // decision has to consider everything the user can currently see — including output
225
+ // delivered by an earlier attempt before a resume.
226
+ let emittedAnythingAcrossAttempts = false;
227
+ let emittedCharsAcrossAttempts = 0;
228
+ // Built per attempt, because an attempt is EITHER a fresh turn (POST /chat) or a resume
229
+ // of the one already generating server-side (GET /chat/resume + Last-Event-ID). The
230
+ // resume carries no body at all — the conversation is already being processed; re-sending
231
+ // it is exactly the waste this avoids.
232
+ const buildFetchArgs = () => {
233
+ if (resuming) {
234
+ return [
235
+ `${exports.API_BASE}/api/code/chat/resume?turnId=${encodeURIComponent(turnId)}`,
236
+ {
237
+ method: 'GET',
238
+ headers: { ...authHeaders(), 'Last-Event-ID': String(lastEventId) },
239
+ signal: controller.signal,
240
+ },
241
+ ];
242
+ }
243
+ return [
244
+ `${exports.API_BASE}/api/code/chat`,
245
+ {
246
+ method: 'POST',
247
+ headers: authHeaders(),
248
+ body: JSON.stringify({ messages, model, env, editorContext, nexrallMd, mode, effort, clientType, extraTools, agents, skills, turnId }),
249
+ signal: controller.signal,
250
+ },
251
+ ];
252
+ };
91
253
  // A transient mid-stream failure (Anthropic "overloaded_error") arrives as an SSE error
92
254
  // event over an ALREADY-200 response, so the connect-time HTTP-status retry below can't
93
255
  // see it. We detect it in the parser and retry the WHOLE attempt with exponential backoff
@@ -102,7 +264,33 @@ async function streamChat(messages, options, onEvent) {
102
264
  // happened (this is the fix for that gap).
103
265
  let didRetry = false;
104
266
  let totalAttemptsMade = 0;
267
+ // Shared wall-clock budget for every reconnect across BOTH nested retry loops.
268
+ // `retryBudgetLeft()` gates each retry decision and also clamps the backoff sleep,
269
+ // so we never wait longer than the budget we have left.
270
+ //
271
+ // The clock starts at the FIRST retry, not at streamChat() entry. This matters: a
272
+ // healthy first attempt on a huge context can legitimately spend minutes in
273
+ // prompt-processing before the first token (hence FIRST_EVENT_TIMEOUT_MS = 300 s).
274
+ // Anchoring the deadline at entry would let that normal, successful work consume the
275
+ // entire reconnect budget, so the first real network blip would get zero retries —
276
+ // precisely backwards. Measuring only time spent RECONNECTING keeps the guarantee
277
+ // meaningful: "at most 5 minutes of retrying before we tell you it's broken".
278
+ let retryDeadline = 0;
279
+ const retryBudgetLeft = () => retryDeadline === 0 ? MAX_TOTAL_RETRY_MS : retryDeadline - Date.now();
280
+ // Require a MEANINGFUL remainder, not just "> 0". With a bare positive check, 1 ms of
281
+ // budget was enough to launch a whole fresh attempt — which can then sit in
282
+ // FIRST_EVENT_TIMEOUT_MS (300 s) of prompt-processing wait, blowing past the very
283
+ // deadline it was checked against. It also turned a fast-failing connect error
284
+ // (ENOTFOUND) into a tight loop of near-zero sleeps. One base backoff of headroom
285
+ // makes the budget an approximate ceiling instead of a suggestion.
286
+ const canRetry = () => retryBudgetLeft() > RETRY_BASE_MS;
287
+ /** Sleep for `ms`, but never past the shared retry deadline. */
288
+ const sleepWithinBudget = (ms) => sleep(Math.max(0, Math.min(ms, retryBudgetLeft())));
105
289
  const reportRetry = (reason) => {
290
+ // Start the reconnect budget on the first retry (see retryDeadline above). Every
291
+ // retry path funnels through here, so this is the one place it needs to happen.
292
+ if (retryDeadline === 0)
293
+ retryDeadline = Date.now() + MAX_TOTAL_RETRY_MS;
106
294
  totalAttemptsMade++;
107
295
  didRetry = true;
108
296
  onEvent({ type: 'retry', attempt: totalAttemptsMade, maxAttempts: MAX_TOTAL_ATTEMPTS, reason });
@@ -120,24 +308,119 @@ async function streamChat(messages, options, onEvent) {
120
308
  // Retry loop — handles connect-time 429 (rate limit) and 5xx (server errors, incl. 529)
121
309
  let response;
122
310
  let lastErr;
311
+ // How many times we've waited on a `turnInFlight` 409. Drives that path's own backoff,
312
+ // separately from `attempt` (a wait is not a failed attempt — see the 409 branch).
313
+ let inFlightWaits = 0;
314
+ // A 409's body, already consumed to classify it. node-fetch bodies are single-use, so
315
+ // the !response.ok handler below reads this instead of calling response.text() again.
316
+ let errorBodyOverride = null;
123
317
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
124
318
  try {
125
- response = await (0, node_fetch_1.default)(...fetchArgs);
126
- if (response.status === 429 && attempt < MAX_RETRIES) {
319
+ response = await (0, node_fetch_1.default)(...buildFetchArgs());
320
+ if (response.status === 429 && attempt < MAX_RETRIES && canRetry()) {
127
321
  reportRetry('Rate limited by the API — retrying');
128
322
  const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
129
- await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
323
+ await sleepWithinBudget(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
130
324
  continue;
131
325
  }
132
- if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES) {
326
+ if (response.status >= 500 && response.status < 600 && attempt < MAX_RETRIES && canRetry()) {
133
327
  reportRetry(`Server error (${response.status}) — retrying`);
134
- await sleep(backoffMs(attempt));
328
+ await sleepWithinBudget(backoffMs(attempt));
135
329
  continue;
136
330
  }
137
- // 4xx (incl. 413 Payload Too Large, 400 Bad Request, 402 Insufficient balance)
138
- // are DETERMINISTIC given the same body retrying resends the identical oversized
139
- // conversation and can never succeed. Fall through to the non-retryable !response.ok
140
- // handler below instead of burning retries (this was the 16-request "retry storm").
331
+ // 410 Gone a resume was refused because the buffered window no longer contains
332
+ // our position (we were away too long, or the turn produced a huge amount in the
333
+ // meantime). Continuity can't be proven, and silently skipping frames would corrupt
334
+ // the message being rebuilt. So abandon resuming and fall back to restarting the
335
+ // turn from scratch: correct, just not free.
336
+ if (response.status === 410 && resuming) {
337
+ await response.text().catch(() => '');
338
+ // A 410 means the BUFFER WINDOW moved past our cursor — it does NOT mean the turn
339
+ // ended. It is almost certainly still generating and still holding its idempotency
340
+ // claim, so re-POSTing with the same turnId would be rejected as `turnInFlight`,
341
+ // and we would wait out the entire retry budget only to fail — while the orphaned
342
+ // turn kept generating and billing.
343
+ //
344
+ // So: stop the old turn (awaited — the claim must be released BEFORE we re-POST),
345
+ // and mint a fresh id, because a restart is semantically a new turn.
346
+ await cancelTurn(turnId);
347
+ turnId = (0, crypto_1.randomUUID)();
348
+ resuming = false;
349
+ serverResumable = false;
350
+ lastEventId = 0;
351
+ carryText = [];
352
+ carryToolUse = [];
353
+ // Snapshot BEFORE resetting: the rollback decision below needs to know what the
354
+ // user can currently see, but the counters must not carry into the restarted turn
355
+ // or a second restart would report characters this one already discarded.
356
+ const hadRendered = emittedAnythingAcrossAttempts;
357
+ const renderedChars = emittedCharsAcrossAttempts;
358
+ emittedAnythingAcrossAttempts = false;
359
+ emittedCharsAcrossAttempts = 0;
360
+ if (hadRendered && !allowRestartAfterRender) {
361
+ // Output is already on screen and the caller can't take it back, so a restart
362
+ // would duplicate it. Surface the failure instead of corrupting the transcript.
363
+ throw new Error('Connection lost and this turn could no longer be resumed. Send your message again.');
364
+ }
365
+ if (hadRendered) {
366
+ onEvent({
367
+ type: 'stream_restart',
368
+ reason: 'Connection lost too long to resume',
369
+ discardedChars: renderedChars,
370
+ });
371
+ }
372
+ if (attempt < MAX_RETRIES && canRetry()) {
373
+ reportRetry('Could not resume — restarting this turn');
374
+ await sleepWithinBudget(backoffMs(attempt));
375
+ continue;
376
+ }
377
+ errorBodyOverride = JSON.stringify({ error: 'Could not resume this turn — send your message again.' });
378
+ break;
379
+ }
380
+ // 409 from the backend's turn-idempotency gate. Two distinct meanings, and the
381
+ // body distinguishes them:
382
+ //
383
+ // turnInFlight — another attempt carrying the same turnId is STILL RUNNING
384
+ // (two sockets racing; typically a reconnect landing before the
385
+ // original finished tearing down). Running it anyway would
386
+ // double-charge one turn, which is what that gate prevents.
387
+ // WAITING is correct: the original either completes (and this
388
+ // attempt is then served the stored answer for free) or fails
389
+ // (and this attempt runs for real).
390
+ // turnCompleted — the turn finished and was billed, but its response is too
391
+ // large to resend. DETERMINISTIC; retrying can never help.
392
+ if (response.status === 409) {
393
+ const body = await response.text();
394
+ let inFlight = false;
395
+ try {
396
+ inFlight = JSON.parse(body)?.turnInFlight === true;
397
+ }
398
+ catch { /* treat as terminal */ }
399
+ // A wait is NOT a failed attempt, so it must not consume the network-error
400
+ // budget: a real coding turn runs for minutes, and spending `MAX_RETRIES` on
401
+ // fixed short sleeps burned the whole allowance in ~10 s and hard-failed while
402
+ // the original turn was perfectly healthy — leaving the user billed for an
403
+ // answer they never received. Only the wall-clock budget bounds this loop.
404
+ if (inFlight && canRetry()) {
405
+ inFlightWaits++;
406
+ reportRetry('This turn is already being processed — waiting for it');
407
+ const retryAfter = parseInt(response.headers.get('retry-after') ?? '0', 10);
408
+ // Honour the server's hint but keep growing our own backoff, so a wildly
409
+ // optimistic Retry-After can't turn this into a hot loop.
410
+ await sleepWithinBudget(Math.max(retryAfter * 1000, backoffMs(inFlightWaits)));
411
+ attempt--; // refund the attempt — this was a wait, not a failure
412
+ continue;
413
+ }
414
+ // Terminal 409 (or the wait budget is spent): rebuild a response-like shape so
415
+ // the shared !response.ok handler below can surface the server's own message.
416
+ errorBodyOverride = body;
417
+ break;
418
+ }
419
+ // Every other 4xx (413 Payload Too Large, 400 Bad Request, 402 Insufficient
420
+ // balance) is DETERMINISTIC given the same body — retrying resends the identical
421
+ // oversized conversation and can never succeed. Fall through to the non-retryable
422
+ // !response.ok handler below instead of burning retries (this was the 16-request
423
+ // "retry storm").
141
424
  break; // success or non-retryable error
142
425
  }
143
426
  catch (err) {
@@ -145,19 +428,31 @@ async function streamChat(messages, options, onEvent) {
145
428
  if (err.name === 'AbortError')
146
429
  throw err;
147
430
  lastErr = err;
148
- if (attempt < MAX_RETRIES) {
431
+ if (attempt < MAX_RETRIES && canRetry()) {
149
432
  // Connection couldn't even be established — network drop / DNS blip / the
150
433
  // machine just woke from sleep and Wi-Fi hasn't reconnected yet.
151
434
  reportRetry('Connection lost — attempting to reconnect');
152
- await sleep(backoffMs(attempt));
435
+ await sleepWithinBudget(backoffMs(attempt));
153
436
  continue;
154
437
  }
438
+ // Out of attempts, or out of reconnect budget. MUST break: falling off the end
439
+ // of the catch re-enters the for-loop with no backoff and no further budget
440
+ // check, firing the remaining attempts back-to-back in milliseconds — a burst
441
+ // of full-conversation POSTs, invisible to the UI. (The 429/5xx branches above
442
+ // already break for the same reason.) Tag the error so the outer loop can
443
+ // report budget exhaustion rather than a bare transport failure.
444
+ // Guard the shape: a patched fetch/undici that throws a string or null would turn
445
+ // a network failure into a TypeError raised by the error handler itself.
446
+ if (!canRetry() && lastErr && typeof lastErr === 'object') {
447
+ Object.assign(lastErr, { retryable: true });
448
+ }
449
+ break;
155
450
  }
156
451
  }
157
452
  if (!response)
158
453
  throw lastErr ?? new Error('Request failed after max retries');
159
454
  if (!response.ok) {
160
- const errText = await response.text();
455
+ const errText = errorBodyOverride ?? await response.text();
161
456
  let errMsg = `API error ${response.status}`;
162
457
  let balance;
163
458
  try {
@@ -178,13 +473,60 @@ async function streamChat(messages, options, onEvent) {
178
473
  if (!response.body) {
179
474
  throw new Error('Response body is null');
180
475
  }
181
- // Accumulated state for building the assistant message
182
- const textParts = [];
183
- const toolUseBlocks = [];
476
+ // Accumulated state for building the assistant message.
477
+ //
478
+ // Seeded from the previous attempt when RESUMING. This is essential and easy to miss:
479
+ // a resume delivers only the frames after `lastEventId`, so the text and tool calls
480
+ // received before the drop live in the outer carry-over. Starting empty here would
481
+ // rebuild the message from the tail alone and silently truncate the answer — the
482
+ // beginning of the response would simply vanish.
483
+ const textParts = resuming ? [...carryText] : [];
484
+ const toolUseBlocks = resuming ? [...carryToolUse] : [];
184
485
  let completedMessage = null;
185
- // Once anything has been shown to the caller (text / tool_use / thinking), a retry would
186
- // duplicate it so from that point on a stream error is surfaced, never retried.
486
+ // Publish the accumulators outward continuously, so if THIS attempt dies the next one
487
+ // can resume on top of what it already received. Assigning the same array references is
488
+ // enough — the resume path copies them.
489
+ carryText = textParts;
490
+ carryToolUse = toolUseBlocks;
491
+ // Once anything has been shown to the caller (text / tool_use / thinking), a naive retry
492
+ // would duplicate it on screen. Two outcomes are possible from here:
493
+ // • caller CAN'T undo its rendering → the failure stays fatal (legacy behaviour);
494
+ // • caller CAN (allowRestartAfterRender) → we tag the error `needsRestart`, the outer
495
+ // loop emits `stream_restart` so the caller drops the partial render, and the turn
496
+ // is retried from scratch. Nothing else needs undoing: tool calls only execute after
497
+ // `message_complete`, which by definition never arrived on a failed attempt.
187
498
  let emittedToCaller = false;
499
+ // Visible characters emitted this attempt — reported in `stream_restart` purely so the
500
+ // UI can say how much output is being discarded.
501
+ let emittedChars = 0;
502
+ /**
503
+ * Classify a transport/stream failure. `retryable` means "safe to retry with nothing
504
+ * shown yet"; `needsRestart` means "safe to retry ONLY if the caller rolls back what
505
+ * it already rendered". Anything else is terminal.
506
+ */
507
+ const tagTransient = (err) => {
508
+ if (!emittedToCaller)
509
+ return Object.assign(err, { retryable: true });
510
+ if (allowRestartAfterRender) {
511
+ return Object.assign(err, { retryable: true, needsRestart: true, discardedChars: emittedChars });
512
+ }
513
+ return err;
514
+ };
515
+ /**
516
+ * True once the turn's complete assistant message has arrived.
517
+ *
518
+ * There is a real window between `message_complete` and `done`: the backend awaits
519
+ * its billing promise (a DB round-trip) before sending `done`, so a socket that
520
+ * dies in between produces a transport error on a turn that has ALREADY fully
521
+ * succeeded. Retrying there would be the worst possible outcome — it discards a
522
+ * complete, correct answer, re-runs the model, and bills the user twice.
523
+ *
524
+ * Every failure path therefore checks this and resolves instead of rejecting: we
525
+ * have the message, and `done` carries no information we need. This also restores
526
+ * the premise the restart design rests on — a restart now genuinely implies
527
+ * `message_complete` never arrived.
528
+ */
529
+ const haveCompleteMessage = () => completedMessage !== null;
188
530
  // Partial input accumulator keyed by tool_use id
189
531
  const partialInputs = {};
190
532
  await new Promise((resolve, reject) => {
@@ -192,11 +534,19 @@ async function streamChat(messages, options, onEvent) {
192
534
  // Declared here so the heartbeat watchdog closure can reference it.
193
535
  const stream = response.body;
194
536
  // ── Heartbeat watchdog ────────────────────────────────────────────────────
195
- // The backend sends ": ping" comments every 20 s. If we receive no data at
196
- // all for 45 s the connection has silently dropped (proxy timeout, network
537
+ // The backend sends ": ping" comments every 10 s. If we receive no data at
538
+ // all for 90 s the connection has silently dropped (proxy timeout, network
197
539
  // blip). Reject so the caller sees an actionable error instead of hanging.
198
- const HEARTBEAT_TIMEOUT_MS = 45000;
199
- // Progress watchdog. The backend's ": ping" keepalive (every 20 s) resets lastDataAt,
540
+ //
541
+ // Sizing: this must tolerate a burst of consecutive LOST pings, not just one.
542
+ // The old pairing (20 s ping / 45 s timeout) survived exactly two misses —
543
+ // routinely exceeded on flaky Wi-Fi, tethered 4G, or the seconds right after a
544
+ // laptop wakes, so a perfectly healthy connection got torn down and the turn
545
+ // restarted for nothing. At 10 s / 90 s we ride out eight consecutive misses
546
+ // while still detecting a truly dead socket faster in absolute terms than the
547
+ // proxy's own idle timeout.
548
+ const HEARTBEAT_TIMEOUT_MS = 90000;
549
+ // Progress watchdog. The backend's ": ping" keepalive (every 10 s) resets lastDataAt,
200
550
  // so the heartbeat above only catches a fully dead connection — NOT an upstream model
201
551
  // stall, where Anthropic stops emitting tokens but the backend keeps pinging. That case
202
552
  // would otherwise hang on "Thinking…" forever. lastProgressAt is bumped ONLY on real
@@ -210,13 +560,34 @@ async function streamChat(messages, options, onEvent) {
210
560
  // attempt warmed the prompt cache, so the retry's TTFT was short. Allow 300 s.
211
561
  // • AFTER the first model event, tokens are flowing; a 150 s silent gap mid-stream
212
562
  // is a genuine stall.
213
- const FIRST_EVENT_TIMEOUT_MS = 300000;
214
- const PROGRESS_TIMEOUT_MS = 150000;
563
+ // Overridable so tests can reach the stall path in seconds instead of minutes.
564
+ // That path is where the subtlest bug in this file lives: `lastProgressAt` stops
565
+ // advancing at `message_complete` while heartbeat pings keep `lastDataAt` fresh, so
566
+ // this watchdog — and only this one — can fire on a turn that already succeeded.
567
+ const FIRST_EVENT_TIMEOUT_MS = Number(process.env.NEXRALL_FIRST_EVENT_TIMEOUT_MS) || 300000;
568
+ const PROGRESS_TIMEOUT_MS = Number(process.env.NEXRALL_PROGRESS_TIMEOUT_MS) || 150000;
215
569
  let sawModelEvent = false;
216
570
  let lastDataAt = Date.now();
217
571
  let lastProgressAt = Date.now();
218
572
  const heartbeatWatchdog = setInterval(() => {
219
573
  const now = Date.now();
574
+ // The turn already succeeded and we're only waiting on the trailing `done`.
575
+ // Finish with what we have rather than discarding a complete message and
576
+ // re-running the model.
577
+ //
578
+ // The silence condition is essential, not decorative: the backend AWAITS its
579
+ // billing write before emitting `balance_status` and `done`, so the gap after
580
+ // `message_complete` is normal, expected, and can run for seconds on a slow DB.
581
+ // Firing on the next tick regardless would tear down a perfectly healthy socket
582
+ // and swallow the low-balance nudge — trading the bug this guard was written to
583
+ // fix for a different one. Wait out the full heartbeat timeout first; only then
584
+ // is the trailing frame genuinely never coming.
585
+ if (haveCompleteMessage() && now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
586
+ clearInterval(heartbeatWatchdog);
587
+ stream.destroy?.();
588
+ resolve();
589
+ return;
590
+ }
220
591
  if (now - lastDataAt > HEARTBEAT_TIMEOUT_MS) {
221
592
  clearInterval(heartbeatWatchdog);
222
593
  stream.destroy?.();
@@ -227,24 +598,36 @@ async function streamChat(messages, options, onEvent) {
227
598
  // `now - lastDataAt` immediately reads as a large gap even though nothing is
228
599
  // actually wrong with the connection; without this it used to hard-fail
229
600
  // instead of silently reconnecting.
230
- const deadErr = new Error(emittedToCaller
231
- ? 'Connection lost — no data received for 45 s. Retry your message.'
232
- : 'Connection lost — no data received for 45 s. Reconnecting…');
233
- reject(emittedToCaller ? deadErr : Object.assign(deadErr, { retryable: true }));
601
+ const recoverable = !emittedToCaller || !!allowRestartAfterRender;
602
+ const secs = Math.round(HEARTBEAT_TIMEOUT_MS / 1000);
603
+ reject(tagTransient(new Error(recoverable
604
+ ? `Connection lost no data received for ${secs} s. Reconnecting…`
605
+ : `Connection lost — no data received for ${secs} s. Retry your message.`)));
234
606
  return;
235
607
  }
236
608
  const stallLimitMs = sawModelEvent ? PROGRESS_TIMEOUT_MS : FIRST_EVENT_TIMEOUT_MS;
237
609
  if (now - lastProgressAt > stallLimitMs) {
238
610
  clearInterval(heartbeatWatchdog);
239
611
  stream.destroy?.();
612
+ // Same rule as the heartbeat branch above, and MORE easily reached here: this
613
+ // watchdog reads `lastProgressAt`, which only real model events bump. Once
614
+ // `message_complete` lands, nothing bumps it again — so while the backend awaits
615
+ // its billing write, the heartbeat's `: ping` keeps `lastDataAt` fresh (both
616
+ // branches above correctly stay silent) but THIS clock runs out and reports a
617
+ // "stall" on a turn that already succeeded. Restarting there would discard a
618
+ // correct answer and re-bill the model call, repeatedly. The turn is complete;
619
+ // finish with it.
620
+ if (haveCompleteMessage()) {
621
+ resolve();
622
+ return;
623
+ }
240
624
  // If nothing has been shown to the caller yet, a fresh attempt can't duplicate
241
625
  // output — tag it retryable so the outer loop transparently retries the turn
242
626
  // instead of killing it. Once output has been emitted, a retry would duplicate
243
627
  // rendered text/tool calls, so surface the stall as a terminal error.
244
- const stallErr = new Error(sawModelEvent
245
- ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s). Retry your message.`
246
- : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process). Retry your message.`);
247
- reject(emittedToCaller ? stallErr : Object.assign(stallErr, { retryable: true }));
628
+ reject(tagTransient(new Error(sawModelEvent
629
+ ? `The model stopped responding mid-stream (no output for ${Math.round(stallLimitMs / 1000)} s).`
630
+ : `The model did not start responding within ${Math.round(stallLimitMs / 1000)} s (large context can take a while to process).`)));
248
631
  return;
249
632
  }
250
633
  }, 5000);
@@ -255,6 +638,15 @@ async function streamChat(messages, options, onEvent) {
255
638
  return;
256
639
  }
257
640
  {
641
+ // Track the server-assigned frame id. This is the resume cursor: on a drop we
642
+ // hand it back via Last-Event-ID and receive exactly the frames we missed,
643
+ // instead of re-running the whole turn. Only present on resumable turns.
644
+ const frameId = event.id;
645
+ if (frameId) {
646
+ const n = Number(frameId);
647
+ if (Number.isFinite(n) && n > lastEventId)
648
+ lastEventId = n;
649
+ }
258
650
  const raw = event.data;
259
651
  if (!raw || raw === '[DONE]') {
260
652
  resolve();
@@ -283,6 +675,9 @@ async function streamChat(messages, options, onEvent) {
283
675
  const text = typeof evt.text === 'string' ? evt.text : '';
284
676
  textParts.push(text);
285
677
  emittedToCaller = true;
678
+ emittedChars += text.length;
679
+ emittedAnythingAcrossAttempts = true;
680
+ emittedCharsAcrossAttempts += text.length;
286
681
  onEvent({ type: 'text', text });
287
682
  break;
288
683
  }
@@ -305,7 +700,14 @@ async function streamChat(messages, options, onEvent) {
305
700
  }
306
701
  }
307
702
  toolUseBlocks.push(block);
703
+ // Deliberately does NOT bump `emittedChars`: a streamed tool_use is a
704
+ // preview that no consumer renders. The agent loop's `tool_use` case is a
705
+ // no-op and the UI's tool row is only drawn later, from `options.onToolUse`
706
+ // during execution — which happens after streamChat() returns and so never
707
+ // runs on a failed attempt. `emittedToCaller` is still set, conservatively,
708
+ // so a restart continues to require an explicit rollback handler.
308
709
  emittedToCaller = true;
710
+ emittedAnythingAcrossAttempts = true;
309
711
  onEvent({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
310
712
  break;
311
713
  }
@@ -360,6 +762,17 @@ async function streamChat(messages, options, onEvent) {
360
762
  if (typeof evt.input_tokens === 'number' && typeof evt.output_tokens === 'number') {
361
763
  onEvent({
362
764
  type: 'usage',
765
+ // Forwarded, not dropped: the backend tags the usage of an attempt it
766
+ // billed but never completed (its stream `abort` path). Without this
767
+ // the flag dies here and every consumer that sums usage silently folds
768
+ // a discarded attempt's tokens into the successful turn's total.
769
+ ...(evt.partial === true ? { partial: true } : {}),
770
+ // `replayed` means these tokens are being reported a SECOND time: the
771
+ // turn completed and was billed on an earlier attempt whose `done` never
772
+ // reached us, and the backend served this one from its idempotency cache
773
+ // instead of re-running the model. Nothing new was charged, so a cost
774
+ // display must not add them again.
775
+ ...(evt.replayed === true ? { replayed: true } : {}),
363
776
  usage: {
364
777
  input_tokens: evt.input_tokens,
365
778
  output_tokens: evt.output_tokens,
@@ -380,6 +793,9 @@ async function streamChat(messages, options, onEvent) {
380
793
  const text = typeof evt.text === 'string' ? evt.text : '';
381
794
  if (text) {
382
795
  emittedToCaller = true;
796
+ emittedChars += text.length;
797
+ emittedAnythingAcrossAttempts = true;
798
+ emittedCharsAcrossAttempts += text.length;
383
799
  onEvent({ type: 'thinking', text });
384
800
  }
385
801
  break;
@@ -397,10 +813,20 @@ async function streamChat(messages, options, onEvent) {
397
813
  const text = typeof evt.text === 'string' ? evt.text : '';
398
814
  if (text) {
399
815
  emittedToCaller = true;
816
+ emittedChars += text.length;
817
+ emittedAnythingAcrossAttempts = true;
818
+ emittedCharsAcrossAttempts += text.length;
400
819
  onEvent({ type: 'thinking_delta', text });
401
820
  }
402
821
  break;
403
822
  }
823
+ case 'resumable': {
824
+ // The backend numbers this turn's frames and buffers them, so a dropped
825
+ // connection can reattach mid-response rather than re-running the turn.
826
+ // Purely a capability announcement — nothing to show the user.
827
+ serverResumable = true;
828
+ break;
829
+ }
404
830
  case 'balance_status': {
405
831
  const balance = typeof evt.balance === 'number' ? evt.balance : 0;
406
832
  const zero = !!evt.zero;
@@ -417,10 +843,19 @@ async function streamChat(messages, options, onEvent) {
417
843
  typeof evt.error === 'string' ? evt.error : 'Unknown SSE error';
418
844
  // Transient upstream failure before any output → let the outer loop retry it
419
845
  // transparently instead of killing the turn (this is the "Overloaded" case).
420
- if (isRetryableStreamMsg(message) && !emittedToCaller) {
846
+ // But never retry once the turn's message has already been delivered in
847
+ // full: a late error frame (e.g. the backend's billing write failing after
848
+ // `message_complete`) must not discard a correct answer and re-bill the
849
+ // model call. Surface it as a notice and finish with what we have.
850
+ if (haveCompleteMessage()) {
851
+ clearInterval(heartbeatWatchdog);
852
+ onEvent({ type: 'error', message });
853
+ resolve();
854
+ }
855
+ else if (isRetryableStreamMsg(message) && (!emittedToCaller || allowRestartAfterRender)) {
421
856
  clearInterval(heartbeatWatchdog);
422
857
  stream.destroy?.();
423
- reject(Object.assign(new Error(message), { retryable: true }));
858
+ reject(tagTransient(new Error(message)));
424
859
  }
425
860
  else {
426
861
  onEvent({ type: 'error', message });
@@ -459,16 +894,21 @@ async function streamChat(messages, options, onEvent) {
459
894
  resolve();
460
895
  return;
461
896
  }
897
+ // Socket died AFTER the turn was fully delivered — the only thing still
898
+ // outstanding was the trailing `done`. Treat as success: rejecting here would
899
+ // throw away a complete answer and (with restart enabled) re-run and re-bill
900
+ // the entire turn. This is the widest such window in practice, because the
901
+ // backend does a DB write between `message_complete` and `done`.
902
+ if (haveCompleteMessage()) {
903
+ resolve();
904
+ return;
905
+ }
462
906
  // Transport-level failures ("Premature close" / ECONNRESET / socket hang up)
463
907
  // happen whenever the backend restarts mid-deploy or a proxy drops the socket.
464
908
  // They are exactly as transient as an overloaded_error — if nothing has been
465
909
  // emitted to the caller yet, retry the attempt transparently instead of
466
910
  // surfacing "Stream failed: Premature close" and killing the whole turn.
467
- if (!emittedToCaller) {
468
- reject(Object.assign(err, { retryable: true }));
469
- return;
470
- }
471
- reject(err);
911
+ reject(tagTransient(err));
472
912
  });
473
913
  });
474
914
  if (completedMessage) {
@@ -497,11 +937,48 @@ async function streamChat(messages, options, onEvent) {
497
937
  catch (err) {
498
938
  if (abortSignal?.aborted || controller.signal.aborted || err.name === 'AbortError')
499
939
  throw err;
500
- if (err.retryable && sAttempt < MAX_RETRIES) {
940
+ const e = err;
941
+ if (e.retryable && sAttempt < MAX_RETRIES && canRetry()) {
942
+ // PREFER RESUMING over restarting.
943
+ //
944
+ // The turn is still generating on the server (a dropped socket no longer aborts
945
+ // it), and every frame it emits is numbered and buffered. So instead of throwing
946
+ // the work away we reattach at `lastEventId` and receive only what we missed:
947
+ // nothing re-processed, nothing re-billed, nothing re-rendered — which also means
948
+ // NO `stream_restart` and no rollback for the caller, because the output already
949
+ // on screen remains valid.
950
+ //
951
+ // Requires having seen at least one numbered frame; without a cursor there is
952
+ // nothing to resume from and a fresh attempt is correct anyway.
953
+ if (serverResumable && lastEventId > 0) {
954
+ resuming = true;
955
+ reportRetry(err.message || 'Connection interrupted — resuming');
956
+ await sleepWithinBudget(backoffMs(sAttempt));
957
+ continue;
958
+ }
959
+ // Not resumable — fall back to restarting the whole turn. The dead attempt had
960
+ // already rendered output, so the caller must throw that partial render away
961
+ // BEFORE the replacement starts streaming or the re-sent text appears twice.
962
+ // Emitted first so the UI is clean by the time "reconnecting…" goes up.
963
+ if (e.needsRestart) {
964
+ onEvent({
965
+ type: 'stream_restart',
966
+ reason: err.message || 'Connection interrupted',
967
+ discardedChars: e.discardedChars ?? 0,
968
+ });
969
+ }
501
970
  reportRetry(err.message || 'Connection interrupted — reconnecting');
502
- await sleep(backoffMs(sAttempt));
971
+ await sleepWithinBudget(backoffMs(sAttempt));
503
972
  continue;
504
973
  }
974
+ // Out of retries (or out of time budget) — make the distinction explicit so
975
+ // "it just gave up" is never a mystery. A budget exhaustion means the network
976
+ // was down for the entire window, which is actionable in a way that a bare
977
+ // "Stream failed" is not.
978
+ if (e.retryable && !canRetry()) {
979
+ throw Object.assign(new Error(`${err.message} — gave up after ${Math.round(MAX_TOTAL_RETRY_MS / 60000)} minutes of reconnect attempts. ` +
980
+ `Check your connection and resend; completed work in this turn is preserved.`), { retryBudgetExhausted: true });
981
+ }
505
982
  throw err;
506
983
  }
507
984
  }
@@ -512,6 +989,31 @@ async function streamChat(messages, options, onEvent) {
512
989
  clearInterval(abortPoll);
513
990
  }
514
991
  }
992
+ // ─── Cancel a turn ────────────────────────────────────────────────────────────
993
+ /**
994
+ * Ask the server to stop generating a turn.
995
+ *
996
+ * This is REQUIRED for Stop to work, not an optimisation. A dropped socket used to abort
997
+ * the model; now it detaches and keeps generating so a network blip doesn't discard the
998
+ * turn (see the resume machinery above). Since a deliberate Stop looks identical to a
999
+ * blip from the server's side, it has to be signalled explicitly — otherwise the model
1000
+ * would run to completion and bill in full after the user pressed Stop.
1001
+ *
1002
+ * Best-effort and never throws: Stop must feel instant, and the server's detach grace
1003
+ * bounds the cost if this request never lands.
1004
+ */
1005
+ async function cancelTurn(turnId) {
1006
+ try {
1007
+ await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/chat/cancel`, {
1008
+ method: 'POST',
1009
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
1010
+ body: JSON.stringify({ turnId }),
1011
+ });
1012
+ }
1013
+ catch {
1014
+ // Ignored deliberately — see above.
1015
+ }
1016
+ }
515
1017
  // ─── Get Balance ──────────────────────────────────────────────────────────────
516
1018
  async function getBalance() {
517
1019
  const response = await (0, node_fetch_1.default)(`${exports.API_BASE}/api/code/balance`, {