maxpool 1.5.44 → 1.5.46
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/package.json +1 -1
- package/src/account-manager.js +30 -2
- package/src/server.js +147 -2
- package/src/tui.js +46 -5
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -159,6 +159,10 @@ const DEFAULT_SCHEDULER = {
|
|
|
159
159
|
providerCrossFallback: true,
|
|
160
160
|
};
|
|
161
161
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
162
|
+
// Consecutive failed recovery probes before we stop trusting the SHARED breaker and
|
|
163
|
+
// fall back to per-account handling. Bounds any future "poisoned probe" from becoming
|
|
164
|
+
// an indefinite fleet-wide outage (2026-07-25 incident).
|
|
165
|
+
const MAX_FAILED_PROBES = 4;
|
|
162
166
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
163
167
|
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
164
168
|
|
|
@@ -734,6 +738,7 @@ export class AccountManager {
|
|
|
734
738
|
this.upstreamThrottle.until = Math.max(this.upstreamThrottle.until || 0, until);
|
|
735
739
|
this.upstreamThrottle.reason = reason;
|
|
736
740
|
this.upstreamThrottle.probeInFlight = false;
|
|
741
|
+
this.upstreamThrottle.failedProbes = 0; // fresh breaker → fresh probe budget
|
|
737
742
|
this.upstreamThrottle.count++;
|
|
738
743
|
this.upstreamThrottle.lastAt = Date.now();
|
|
739
744
|
console.log(`[Maxpool] Anthropic upstream temporarily limiting requests for ${retryAfter}s; pausing Claude routes`);
|
|
@@ -744,6 +749,7 @@ export class AccountManager {
|
|
|
744
749
|
this.upstreamThrottle.until = null;
|
|
745
750
|
this.upstreamThrottle.reason = null;
|
|
746
751
|
this.upstreamThrottle.probeInFlight = false;
|
|
752
|
+
this.upstreamThrottle.failedProbes = 0;
|
|
747
753
|
this.queueState.rampUntil = Date.now() + 5000;
|
|
748
754
|
this.queueState.lastAdmissionAt = Date.now();
|
|
749
755
|
console.log(`[Maxpool] Anthropic upstream throttle cleared (${reason})`);
|
|
@@ -755,14 +761,36 @@ export class AccountManager {
|
|
|
755
761
|
lease.upstreamThrottleProbe = false;
|
|
756
762
|
}
|
|
757
763
|
|
|
764
|
+
/** Hand the recovery probe back UNUSED — the request never got an upstream answer
|
|
765
|
+
* (client disconnected, token refresh failed), so it is no evidence either way.
|
|
766
|
+
* Leaves the window open so the very next request can claim the probe, instead of
|
|
767
|
+
* scoring a failure that would re-arm the fleet-wide throttle. */
|
|
768
|
+
relinquishUpstreamProbe(lease) {
|
|
769
|
+
if (!lease?.upstreamThrottleProbe) return;
|
|
770
|
+
lease.upstreamThrottleProbe = false;
|
|
771
|
+
this.upstreamThrottle.probeInFlight = false;
|
|
772
|
+
}
|
|
773
|
+
|
|
758
774
|
deferUpstreamThrottleProbe(retryAfterSeconds = 5, reason = 'probe_failed') {
|
|
759
775
|
if (!this.upstreamThrottle.until && !this.upstreamThrottle.probeInFlight) return;
|
|
760
|
-
|
|
776
|
+
// PROBE BUDGET. A flat 5s retry with unbounded repetition is what let a single
|
|
777
|
+
// poisoned request hold the whole fleet down forever. Escalate the backoff, and
|
|
778
|
+
// after MAX_FAILED_PROBES consecutive failures give up on the shared breaker and
|
|
779
|
+
// fall back to per-account handling — the fleet then retries for real, and if
|
|
780
|
+
// Anthropic genuinely is throttling, shouldPromoteUpstreamFailure re-arms it.
|
|
781
|
+
// (This is a circuit breaker's half-open state, paced by the backoff.)
|
|
782
|
+
this.upstreamThrottle.failedProbes = (this.upstreamThrottle.failedProbes || 0) + 1;
|
|
783
|
+
if (this.upstreamThrottle.failedProbes >= MAX_FAILED_PROBES) {
|
|
784
|
+
this.clearUpstreamThrottle(`probe budget exhausted after ${this.upstreamThrottle.failedProbes} failures (${reason}) — deferring to per-account handling`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const backoff = Math.min(60, retryAfterSeconds * 2 ** (this.upstreamThrottle.failedProbes - 1));
|
|
788
|
+
const retryAfter = clampRetryAfterSeconds(backoff);
|
|
761
789
|
this.upstreamThrottle.until = Date.now() + retryAfter * 1000;
|
|
762
790
|
this.upstreamThrottle.reason = reason;
|
|
763
791
|
this.upstreamThrottle.probeInFlight = false;
|
|
764
792
|
this.upstreamThrottle.lastAt = Date.now();
|
|
765
|
-
console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
|
|
793
|
+
console.log(`[Maxpool] Anthropic recovery probe failed (${this.upstreamThrottle.failedProbes}/${MAX_FAILED_PROBES}); retrying in ${retryAfter}s (${reason})`);
|
|
766
794
|
}
|
|
767
795
|
|
|
768
796
|
noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
|
package/src/server.js
CHANGED
|
@@ -402,6 +402,11 @@ async function forwardRequest(
|
|
|
402
402
|
res.once('close', onClientClose);
|
|
403
403
|
const releaseOnClientGone = () => {
|
|
404
404
|
res.off('close', onClientClose);
|
|
405
|
+
// The client vanished — we never got an upstream answer, so this carries ZERO
|
|
406
|
+
// evidence about Anthropic's health. Hand the recovery probe back instead of
|
|
407
|
+
// letting releaseAccount score it as a FAILED probe (which would re-arm the
|
|
408
|
+
// fleet-wide throttle for 5s on every disconnect — a second deadlock amplifier).
|
|
409
|
+
accountManager.relinquishUpstreamProbe?.(lease);
|
|
405
410
|
accountManager.releaseAccount(lease);
|
|
406
411
|
clearQueueHeartbeat(requestInfo);
|
|
407
412
|
accountManager.removeQueuedRequest?.(requestInfo);
|
|
@@ -421,6 +426,9 @@ async function forwardRequest(
|
|
|
421
426
|
// (MaxListenersExceededWarning + leak); the recursive/resumed frame registers
|
|
422
427
|
// its own.
|
|
423
428
|
res.off('close', onClientClose);
|
|
429
|
+
// Token refresh failed — the request never reached Anthropic, so this is no
|
|
430
|
+
// evidence about upstream health. Relinquish rather than fail the probe.
|
|
431
|
+
accountManager.relinquishUpstreamProbe?.(lease);
|
|
424
432
|
accountManager.releaseAccount(lease);
|
|
425
433
|
excludedIndexes.add(account.index);
|
|
426
434
|
if (
|
|
@@ -563,6 +571,18 @@ async function forwardRequest(
|
|
|
563
571
|
}
|
|
564
572
|
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
565
573
|
|
|
574
|
+
// SETTLE THE SHARED BREAKER AT THE HEADER BOUNDARY. Anthropic ANSWERED, so unless
|
|
575
|
+
// the status is itself a capacity signal, the upstream is provably reachable and
|
|
576
|
+
// serving — a per-request verdict (400 bad transcript, 401, 404, 413, 422) says
|
|
577
|
+
// NOTHING about capacity and must never keep the fleet-wide throttle armed.
|
|
578
|
+
// Without this, a poisoned request (e.g. a provider-authored thinking block that
|
|
579
|
+
// Anthropic 400s) claimed the recovery probe, "failed" it, re-armed the shared
|
|
580
|
+
// throttle every 5s, and benched EVERY healthy account indefinitely — a hard
|
|
581
|
+
// production-down deadlock (2026-07-25: 6 of 13 re-arms were client-side 400s
|
|
582
|
+
// while accounts sat at 2%/11%/25% weekly). confirmUpstreamProbe also clears
|
|
583
|
+
// lease.upstreamThrottleProbe, so the releaseAccount probe branch below no-ops.
|
|
584
|
+
if (!isCapacitySignalStatus(upstreamRes.status)) accountManager.confirmUpstreamProbe?.(lease);
|
|
585
|
+
|
|
566
586
|
// Retry/failover can only happen before response bytes are sent. Once a
|
|
567
587
|
// streaming response starts, rerouting would corrupt Claude Code's stream.
|
|
568
588
|
if (upstreamRes.status === 429) {
|
|
@@ -872,6 +892,12 @@ async function forwardRequest(
|
|
|
872
892
|
// 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
|
|
873
893
|
// terminal — nothing bigger to fall to) so we can pin the session to Claude.
|
|
874
894
|
const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
|
|
895
|
+
// DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
|
|
896
|
+
// for the strip-and-recover retry below. Deliberately NOT the fuzzy
|
|
897
|
+
// isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
|
|
898
|
+
// us to rewrite a user's transcript.
|
|
899
|
+
const isSignatureRejection = account.type !== 'provider'
|
|
900
|
+
&& /invalid `signature` in `thinking`/i.test(errorBody);
|
|
875
901
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
876
902
|
? 'invalid_thinking_signature'
|
|
877
903
|
: anthropicIncompat ? 'anthropic_incompatible_transcript'
|
|
@@ -906,6 +932,26 @@ async function forwardRequest(
|
|
|
906
932
|
}
|
|
907
933
|
}
|
|
908
934
|
|
|
935
|
+
// RECOVER-ON-CLAUDE (preferred over the provider pin below): the transcript
|
|
936
|
+
// carries provider-authored thinking blocks whose signature Anthropic rejects.
|
|
937
|
+
// Strip exactly those blocks and retry on Claude, so a session that took even one
|
|
938
|
+
// GLM/Kimi fallback turn is NOT bricked (or exiled to a provider) for the rest of
|
|
939
|
+
// its life. Verified against the real API — the stripped history returns 200, with
|
|
940
|
+
// text + tool_use preserved. Tried once per request (thinkingStripped guard); if it
|
|
941
|
+
// still fails, the provider pin below is the fallback.
|
|
942
|
+
if (isSignatureRejection && !requestInfo.thinkingStripped
|
|
943
|
+
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
944
|
+
const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
|
|
945
|
+
if (cleanBody) {
|
|
946
|
+
console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
|
|
947
|
+
return forwardRequest(
|
|
948
|
+
req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
949
|
+
retryConfig, queueConfig, { ...requestInfo, thinkingStripped: true },
|
|
950
|
+
canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
909
955
|
// React-and-heal: this transcript can't run on Claude (foreign server_tool_use
|
|
910
956
|
// id / thinking Anthropic can't validate). The 400 is pre-stream and the body
|
|
911
957
|
// is buffered, so latch the session Anthropic-incompatible (sticky → once per
|
|
@@ -951,13 +997,44 @@ async function forwardRequest(
|
|
|
951
997
|
);
|
|
952
998
|
}
|
|
953
999
|
|
|
1000
|
+
// Nothing could heal it — replace the cryptic upstream 400 with the real cause and
|
|
1001
|
+
// the actual way out. This is what the user saw for hours as a bare
|
|
1002
|
+
// "400 messages.51.content.8: Invalid `signature` in `thinking` block".
|
|
1003
|
+
// Trigger on the DETERMINISTIC signature rejection only — never the fuzzy
|
|
1004
|
+
// isAnthropicIncompatBody heuristic, so an unrelated malformed request keeps its
|
|
1005
|
+
// own upstream error instead of being mislabelled a provider-contamination.
|
|
1006
|
+
if (isSignatureRejection && !res.headersSent) {
|
|
1007
|
+
const provs = (accountManager.accounts || []).filter(a => a.type === 'provider');
|
|
1008
|
+
// Say only what ACTUALLY happened. `thinkingStripped` is set only when the strip
|
|
1009
|
+
// ran; without it we found nothing provider-shaped, so claiming we stripped —
|
|
1010
|
+
// or blaming GLM/Kimi — would misdirect the user.
|
|
1011
|
+
const what = requestInfo.thinkingStripped
|
|
1012
|
+
? 'This session ran on GLM/Kimi earlier and Anthropic rejects the thinking blocks they wrote. Maxpool removed them and retried on Claude, but Anthropic still rejected the history.'
|
|
1013
|
+
: "Anthropic rejected a thinking block in this session's history, and maxpool could not identify it as provider-authored, so it could not be repaired automatically.";
|
|
1014
|
+
const hint = provs.length === 0
|
|
1015
|
+
? ''
|
|
1016
|
+
: provs.every(a => a.enabled === false)
|
|
1017
|
+
? ' If this session previously ran on GLM/Kimi, re-enabling that provider in the maxpool TUI (a → t) lets it continue there.'
|
|
1018
|
+
: '';
|
|
1019
|
+
ctx.status = 400;
|
|
1020
|
+
sendErrorResponse(res, requestInfo, 400, {
|
|
1021
|
+
type: 'error',
|
|
1022
|
+
error: {
|
|
1023
|
+
type: 'invalid_request_error',
|
|
1024
|
+
message: `Start a new session to keep working — this one cannot continue. ${what}${hint}`,
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
954
1030
|
ctx.status = upstreamRes.status;
|
|
955
1031
|
sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
|
|
956
1032
|
return;
|
|
957
1033
|
}
|
|
958
1034
|
|
|
959
1035
|
if (upstreamRes.status < 400) {
|
|
960
|
-
|
|
1036
|
+
// (the probe was already settled at the header boundary above — this only
|
|
1037
|
+
// records the account-level "upstream accepted us" signal)
|
|
961
1038
|
accountManager.markUpstreamAccepted?.(account.index);
|
|
962
1039
|
}
|
|
963
1040
|
|
|
@@ -1257,7 +1334,7 @@ function isContextLengthError(errorBody) {
|
|
|
1257
1334
|
return /exceeded model token limit|maximum context length|context length exceeded|context window (?:size )?(?:exceeded|too)|prompt is too long|input is too long|reduce the length of|too many (?:input )?tokens|request too large/i.test(errorBody);
|
|
1258
1335
|
}
|
|
1259
1336
|
|
|
1260
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1337
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1261
1338
|
|
|
1262
1339
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1263
1340
|
if (!upstreamRes.body) return '';
|
|
@@ -1433,6 +1510,74 @@ function secondsUntilParsedTime(value) {
|
|
|
1433
1510
|
return null;
|
|
1434
1511
|
}
|
|
1435
1512
|
|
|
1513
|
+
// Provider thinking signatures CANNOT be told apart from Anthropic's by shape — measured
|
|
1514
|
+
// against the live APIs 2026-07-25: Anthropic ~200-400 char base64, GLM a 24-char hex
|
|
1515
|
+
// digest, Kimi(K3) a 12,946-char base64 blob. A shape heuristic tuned to GLM silently
|
|
1516
|
+
// missed Kimi entirely. So the repair below does NOT guess: it strips EVERY `thinking`
|
|
1517
|
+
// block. That is safe precisely because it only ever runs in response to Anthropic's own
|
|
1518
|
+
// signature-rejection 400 — a healthy Claude-only session never reaches it — and a history
|
|
1519
|
+
// with thinking removed is accepted (200 OK, verified, incl. tool_use → tool_result).
|
|
1520
|
+
// `redacted_thinking` is never touched: it legitimately carries `data` and no signature.
|
|
1521
|
+
function isStrippableThinkingBlock(block) {
|
|
1522
|
+
return block?.type === 'thinking';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Recovery for a provider-contaminated transcript: drop the assistant `thinking` /
|
|
1527
|
+
* `redacted_thinking` blocks whose signature Anthropic can't validate, so the session
|
|
1528
|
+
* can CONTINUE ON CLAUDE instead of being pinned to a provider forever.
|
|
1529
|
+
*
|
|
1530
|
+
* Empirically verified 2026-07-25 against the real API: replaying a GLM-authored
|
|
1531
|
+
* thinking block to Anthropic returns 400; the SAME history with those blocks removed
|
|
1532
|
+
* returns 200 — including an assistant turn carrying `tool_use` followed by a
|
|
1533
|
+
* `tool_result` (thinking blocks are not required on replay for a new turn). Text and
|
|
1534
|
+
* tool_use blocks are preserved, so no conversation content or tool wiring is lost.
|
|
1535
|
+
*
|
|
1536
|
+
* Returns { body, removed } — `body` is null when nothing needed stripping.
|
|
1537
|
+
*/
|
|
1538
|
+
function stripForeignThinkingBlocks(body) {
|
|
1539
|
+
try {
|
|
1540
|
+
const json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf8') : String(body));
|
|
1541
|
+
if (!Array.isArray(json?.messages)) return { body: null, removed: 0 };
|
|
1542
|
+
let removed = 0;
|
|
1543
|
+
const messages = [];
|
|
1544
|
+
for (const msg of json.messages) {
|
|
1545
|
+
if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
|
|
1546
|
+
let localRemoved = 0;
|
|
1547
|
+
const kept = msg.content.filter(block => {
|
|
1548
|
+
if (!isStrippableThinkingBlock(block)) return true;
|
|
1549
|
+
localRemoved++;
|
|
1550
|
+
return false;
|
|
1551
|
+
});
|
|
1552
|
+
removed += localRemoved;
|
|
1553
|
+
if (!localRemoved) { messages.push(msg); continue; }
|
|
1554
|
+
// A turn that stripping empties is DROPPED, not left with its poisoned block:
|
|
1555
|
+
// leaving it would resend the exact body that just 400'd while reporting success
|
|
1556
|
+
// and burning the single recovery attempt. Verified against the live API — the
|
|
1557
|
+
// resulting consecutive user messages are accepted (200 OK).
|
|
1558
|
+
if (kept.length === 0) continue;
|
|
1559
|
+
messages.push({ ...msg, content: kept });
|
|
1560
|
+
}
|
|
1561
|
+
if (!removed) return { body: null, removed: 0 };
|
|
1562
|
+
json.messages = messages;
|
|
1563
|
+
return { body: Buffer.from(JSON.stringify(json)), removed };
|
|
1564
|
+
} catch {
|
|
1565
|
+
return { body: null, removed: 0 }; // non-JSON / unparseable → no rewrite
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Is this status a CAPACITY signal (i.e. evidence the upstream can't serve us right
|
|
1571
|
+
* now), as opposed to a per-request verdict? Only these may keep the shared Anthropic
|
|
1572
|
+
* throttle armed. 403 is included deliberately: on these plans a 403 is almost always
|
|
1573
|
+
* quota/plan exhaustion, NOT bad credentials (see the provider-auth handler). 408 is a
|
|
1574
|
+
* latency/capacity signal. Everything else in 4xx (400/401/404/413/422…) means Anthropic
|
|
1575
|
+
* answered a specific request — the upstream is alive.
|
|
1576
|
+
*/
|
|
1577
|
+
function isCapacitySignalStatus(status) {
|
|
1578
|
+
return status === 429 || status === 403 || status === 408 || status >= 500;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1436
1581
|
function isRetriableUpstreamStatus(status) {
|
|
1437
1582
|
// 500 included: Anthropic 500s are transient server errors (same class as
|
|
1438
1583
|
// 502/503/504). Without this they were passed straight through to the client
|
package/src/tui.js
CHANGED
|
@@ -253,6 +253,7 @@ export class TUI {
|
|
|
253
253
|
// Set by index.js after construction (a deferred closure, not a constructor literal —
|
|
254
254
|
// avoids the const TDZ on applyUpdateIfReady, which is defined after `new TUI`).
|
|
255
255
|
this.checkNow = null;
|
|
256
|
+
this.updateBusy = null; // live progress text while an update check/apply runs
|
|
256
257
|
|
|
257
258
|
this.log = []; // completed activity entries
|
|
258
259
|
this.active = new Map(); // in-flight requests
|
|
@@ -456,10 +457,20 @@ export class TUI {
|
|
|
456
457
|
_keyUpdates(k) {
|
|
457
458
|
if (k === 'c') {
|
|
458
459
|
// Check & apply now — the dance-killer: pull the latest + seamless-reload in place,
|
|
459
|
-
// no quit/relaunch.
|
|
460
|
-
|
|
461
|
-
if (this.checkNow) this.
|
|
462
|
-
|
|
460
|
+
// no quit/relaunch. STAY on this screen and show live progress: bouncing straight
|
|
461
|
+
// back to the dashboard was indistinguishable from "nothing happened".
|
|
462
|
+
if (!this.checkNow) { this._addLog('Update check unavailable on this worker'); return; }
|
|
463
|
+
if (this.updateBusy) return; // already running
|
|
464
|
+
this.updateBusy = 'Checking npm…';
|
|
465
|
+
this.render();
|
|
466
|
+
Promise.resolve(this.checkNow())
|
|
467
|
+
.catch(() => {})
|
|
468
|
+
.finally(() => {
|
|
469
|
+
// If an update was found+applied, the seamless reload replaces this worker and
|
|
470
|
+
// this never renders. Otherwise report the outcome in place.
|
|
471
|
+
this.updateBusy = null;
|
|
472
|
+
if (this.mode === 'updates') this.render();
|
|
473
|
+
});
|
|
463
474
|
} else if (k === 't') {
|
|
464
475
|
this._toggleAutoUpdate();
|
|
465
476
|
} else if (k === 'esc' || k === 'q') {
|
|
@@ -467,6 +478,29 @@ export class TUI {
|
|
|
467
478
|
}
|
|
468
479
|
}
|
|
469
480
|
|
|
481
|
+
/** The visible Updates panel. Answers, at a glance: what am I running, is anything
|
|
482
|
+
* newer, is it automatic, and what is happening right now. */
|
|
483
|
+
_renderUpdatesDetail() {
|
|
484
|
+
const v = this.am.versionInfo;
|
|
485
|
+
const running = v?.current ? `v${v.current}` : 'unknown';
|
|
486
|
+
const auto = this._autoUpdateOn();
|
|
487
|
+
const out = [];
|
|
488
|
+
out.push(` ${bold('Updates')}`);
|
|
489
|
+
if (!v) {
|
|
490
|
+
out.push(` ${dim('Running')} ${running} ${dim('· checking npm for a newer version…')}`);
|
|
491
|
+
} else if (v.hasUpdate && v.latest) {
|
|
492
|
+
out.push(` ${dim('Running')} ${running} ${yellow(`v${v.latest} is available`)}`);
|
|
493
|
+
out.push(auto
|
|
494
|
+
? ` ${dim('It installs itself automatically. Press')} ${bold('c')} ${dim('to get it right now.')}`
|
|
495
|
+
: ` ${dim('Automatic updates are off. Press')} ${bold('c')} ${dim('to install it now, or')} ${bold('t')} ${dim('to turn automatic on.')}`);
|
|
496
|
+
} else {
|
|
497
|
+
out.push(` ${dim('Running')} ${running} ${green('up to date')}`);
|
|
498
|
+
out.push(` ${dim(auto ? 'New versions install themselves.' : 'Automatic updates are off — press t to turn them on.')}`);
|
|
499
|
+
}
|
|
500
|
+
if (this.updateBusy) out.push(` ${cyan(SPINNER[this.frame])} ${dim(this.updateBusy)}`);
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
|
|
470
504
|
async _toggleAutoUpdate() {
|
|
471
505
|
const turnOn = !this._autoUpdateOn();
|
|
472
506
|
// ON = the full hands-free chain; OFF = keep checking (banner still shows) but never
|
|
@@ -1158,7 +1192,10 @@ export class TUI {
|
|
|
1158
1192
|
}
|
|
1159
1193
|
|
|
1160
1194
|
// Completed log
|
|
1161
|
-
|
|
1195
|
+
// 2 = separator + footer; confirm adds its detail line; updates adds its detail block.
|
|
1196
|
+
const footerH = this.mode === 'confirm' ? 3
|
|
1197
|
+
: this.mode === 'updates' ? 2 + this._renderUpdatesDetail().length
|
|
1198
|
+
: 2;
|
|
1162
1199
|
const space = Math.max(0, H - lines.length - footerH);
|
|
1163
1200
|
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
1164
1201
|
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
@@ -1170,6 +1207,10 @@ export class TUI {
|
|
|
1170
1207
|
// ── Footer
|
|
1171
1208
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1172
1209
|
if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
|
|
1210
|
+
// Updates DETAIL — without this, pressing 'u' changed only the one-line footer at the
|
|
1211
|
+
// very bottom of a busy screen, which reads as "nothing happened" (reported twice).
|
|
1212
|
+
// Every other mode draws something visible; this one must too.
|
|
1213
|
+
if (this.mode === 'updates') lines.push(...this._renderUpdatesDetail());
|
|
1173
1214
|
lines.push(this._renderFooter());
|
|
1174
1215
|
|
|
1175
1216
|
// Write buffer
|