maxpool 1.5.45 → 1.5.47
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/README.md +1 -1
- package/package.json +1 -1
- package/src/account-manager.js +55 -4
- package/src/config.js +8 -0
- package/src/index.js +6 -2
- package/src/server.js +34 -19
- package/src/tui.js +79 -7
package/README.md
CHANGED
|
@@ -194,7 +194,7 @@ Provider fallback credentials can be supplied per Claude Code process with `ANTH
|
|
|
194
194
|
|
|
195
195
|
`x-maxpool-session: <id>` enables session affinity. With this header, the first request for a Claude Code process is routed by the adaptive load balancer, then later requests from the same process keep using that home account while it remains available. If the home account is rate-limited, exhausted, in cooldown, or removed, the session temporarily uses another eligible route. When the home account becomes available again, the session returns to it. For the `all` profile, a Claude session that had to spill onto GLM/Kimi moves back to Claude once a Claude account frees up — **as long as it produced no provider-format tool calls while there**.
|
|
196
196
|
|
|
197
|
-
**Cross-provider fallback + compatibility (`all` profile).** Claude, GLM (z.ai), and Kimi (Moonshot) interoperate for ordinary sessions — a regular tool-use id (`call_`/`tool_`) passes Anthropic's loose validation, so a Kimi or GLM session runs
|
|
197
|
+
**Cross-provider fallback + compatibility (`all` profile).** Claude, GLM (z.ai), and Kimi (Moonshot) interoperate for ordinary sessions — a regular tool-use id (`call_`/`tool_`) passes Anthropic's loose validation, so a Kimi or GLM session that used no server tools runs on Claude (its *thinking* blocks are rejected, but Maxpool strips them automatically — see self-heal below) and a Claude session spills onto GLM/Kimi when Claude is unavailable. `scheduler.crossProviderFallbackPolicy` controls it, cyclable live in the TUI Routing menu (`m` → `f`): `never` (default) = **Claude sessions never spill onto a provider** (note: this governs the Claude→provider direction only — it is NOT a same-family pin), `when-exhausted` = cross only once the home family is exhausted, `always` = providers peer with Claude.
|
|
198
198
|
|
|
199
199
|
The one hard incompatibility Maxpool guards is a **`server_tool_use` id** that isn't `srvtoolu_` — Anthropic 400s that on replay (e.g. a `cc glm` session that used a server tool, resumed under `cc all`). Maxpool predicts that case ahead (pins the session to GLM/Kimi) and, for anything it can't predict (a rejected thinking signature), **self-heals**: the 400 is pre-stream, so Maxpool latches the session provider-only and transparently retries on GLM/Kimi — the client never sees the error. (Note: Claude Code may warn a resumed `glm-*` model "could not be restored" and fall back to `claude-opus-4-8`; that's a client-side message — routing is unaffected.)
|
|
200
200
|
|
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -136,8 +136,9 @@ const DEFAULT_SCHEDULER = {
|
|
|
136
136
|
crossAccountThinkingMigration: true,
|
|
137
137
|
// Cross-PROVIDER fallback policy for 'cc all' (profile=all), i.e. whether a session
|
|
138
138
|
// may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
|
|
139
|
-
// 'never' —
|
|
140
|
-
//
|
|
139
|
+
// 'never' — (DEFAULT) a Claude session never spills onto a provider. NOTE:
|
|
140
|
+
// this governs the Claude→provider direction ONLY — it is NOT a
|
|
141
|
+
// same-family pin (a provider-origin session still reaches Claude).
|
|
141
142
|
// 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
|
|
142
143
|
// only once all Claude accounts are unavailable, and a GLM session
|
|
143
144
|
// may fall to Kimi once GLM is exhausted.
|
|
@@ -157,6 +158,18 @@ const DEFAULT_SCHEDULER = {
|
|
|
157
158
|
// kept even while Claude→provider is 'never'. Only has effect under policy:'never' (the
|
|
158
159
|
// 'when-exhausted'/'always' paths never pinned to home). Set false for a strict home-pin.
|
|
159
160
|
providerCrossFallback: true,
|
|
161
|
+
// PER-PROVIDER Claude→provider control. One knob per provider so GLM and Kimi are
|
|
162
|
+
// steered independently (their reliability and quota differ). Each takes the same
|
|
163
|
+
// 'never' | 'when-exhausted' | 'always'. Undefined ⇒ inherit crossProviderFallbackPolicy,
|
|
164
|
+
// so an existing config upgrades to byte-identical behavior. Default 'never': letting a
|
|
165
|
+
// Claude session finish on a provider contaminates its transcript (BOTH providers emit
|
|
166
|
+
// thinking blocks Anthropic rejects — measured 2026-07-25), and while Maxpool now repairs
|
|
167
|
+
// that automatically, a provider server-tool call is NOT repairable.
|
|
168
|
+
// Ships EMPTY on purpose: an unset provider INHERITS crossProviderFallbackPolicy (whose
|
|
169
|
+
// default is already 'never'), so off-by-default holds without a per-provider entry
|
|
170
|
+
// shadowing the global one. Seeding explicit values here would mean cycling the global
|
|
171
|
+
// policy silently did nothing — the two-gate trap.
|
|
172
|
+
providers: {},
|
|
160
173
|
};
|
|
161
174
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
162
175
|
// Consecutive failed recovery probes before we stop trusting the SHARED breaker and
|
|
@@ -1717,7 +1730,7 @@ export class AccountManager {
|
|
|
1717
1730
|
_effectivePriority(account, requestInfo = {}) {
|
|
1718
1731
|
const base = Number.isFinite(account.priority) ? account.priority : 0;
|
|
1719
1732
|
if (account.type === 'provider'
|
|
1720
|
-
&& this.
|
|
1733
|
+
&& this._claudeFallbackFor(account.provider) === 'always'
|
|
1721
1734
|
&& !this._effectiveIncompatible(requestInfo).incompatible) {
|
|
1722
1735
|
return 0;
|
|
1723
1736
|
}
|
|
@@ -1745,6 +1758,23 @@ export class AccountManager {
|
|
|
1745
1758
|
};
|
|
1746
1759
|
}
|
|
1747
1760
|
|
|
1761
|
+
/** Claude→provider policy for ONE provider. Falls back to the legacy global policy when
|
|
1762
|
+
* unset, so an existing config keeps its exact behavior on upgrade. */
|
|
1763
|
+
_claudeFallbackFor(providerKey) {
|
|
1764
|
+
const per = this.scheduler.providers?.[providerKey]?.claudeFallback;
|
|
1765
|
+
const valid = new Set(['never', 'when-exhausted', 'always']);
|
|
1766
|
+
return valid.has(per) ? per : this._crossProviderFallbackPolicy();
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
setClaudeFallbackForProvider(providerKey, policy) {
|
|
1770
|
+
const valid = new Set(['never', 'when-exhausted', 'always']);
|
|
1771
|
+
if (!providerKey || !valid.has(policy)) return false;
|
|
1772
|
+
const providers = { ...(this.scheduler.providers || {}) };
|
|
1773
|
+
providers[providerKey] = { ...(providers[providerKey] || {}), claudeFallback: policy };
|
|
1774
|
+
this.scheduler.providers = providers;
|
|
1775
|
+
return true;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1748
1778
|
_isRequestCompatible(account, profile, requestInfo = {}) {
|
|
1749
1779
|
if (!this._matchesProfile(account, profile)) return false;
|
|
1750
1780
|
|
|
@@ -1789,7 +1819,9 @@ export class AccountManager {
|
|
|
1789
1819
|
// lets providers serve as a priority-fallback; 'always' peers them
|
|
1790
1820
|
// (_effectivePriority). Signed thinking no longer bars providers here — but its
|
|
1791
1821
|
// live MIGRATION stays Claude-only (see the rebalance guard).
|
|
1792
|
-
|
|
1822
|
+
// PER-PROVIDER Claude→provider gate (GLM and Kimi steer independently). Unset ⇒
|
|
1823
|
+
// inherits the legacy global policy, so behavior is unchanged on upgrade.
|
|
1824
|
+
if (account.type === 'provider' && this._claudeFallbackFor(account.provider) === 'never') return false;
|
|
1793
1825
|
return true;
|
|
1794
1826
|
}
|
|
1795
1827
|
|
|
@@ -1835,6 +1867,25 @@ export class AccountManager {
|
|
|
1835
1867
|
this.sessionPolicies.set(sessionKey, { ...existing, largeContext: true });
|
|
1836
1868
|
}
|
|
1837
1869
|
|
|
1870
|
+
// Latch a session whose transcript carries provider-authored thinking blocks Anthropic
|
|
1871
|
+
// rejects. Without this the repair is per-REQUEST: the client resends the whole poisoned
|
|
1872
|
+
// history every turn, so each turn pays another rejected round-trip before the strip
|
|
1873
|
+
// (measured: ~9 rejections per contaminated session). Sticky → later turns are stripped
|
|
1874
|
+
// BEFORE the first attempt. In-memory only, like the other session policies.
|
|
1875
|
+
markSessionThinkingContaminated(sessionKey) {
|
|
1876
|
+
if (!sessionKey) return;
|
|
1877
|
+
const existing = this.sessionPolicies.get(sessionKey) || {};
|
|
1878
|
+
if (!existing.thinkingContaminated) {
|
|
1879
|
+
console.log(`[Maxpool] Session "${sessionKey}" carries provider-authored thinking — stripping it up front from now on`);
|
|
1880
|
+
}
|
|
1881
|
+
this.sessionPolicies.set(sessionKey, { ...existing, thinkingContaminated: true });
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
isSessionThinkingContaminated(sessionKey) {
|
|
1885
|
+
if (!sessionKey) return false;
|
|
1886
|
+
return Boolean(this.sessionPolicies.get(sessionKey)?.thinkingContaminated);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1838
1889
|
_isSessionLargeContext(requestInfo = {}) {
|
|
1839
1890
|
if (requestInfo.largeContext) return true;
|
|
1840
1891
|
if (!requestInfo.sessionKey) return false;
|
package/src/config.js
CHANGED
|
@@ -110,6 +110,14 @@ export function createDefaultConfig() {
|
|
|
110
110
|
// (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — reliable,
|
|
111
111
|
// both legs accept each other's ids. Only has effect under policy:'never'.
|
|
112
112
|
providerCrossFallback: true,
|
|
113
|
+
// Per-provider Claude→provider control (TUI routing: g = GLM, k = Kimi). Same values
|
|
114
|
+
// as the policy above; unset inherits it. Default 'never' — a Claude session that
|
|
115
|
+
// finishes on a provider comes back with thinking blocks Anthropic rejects (Maxpool
|
|
116
|
+
// repairs that automatically, but a provider server-tool call is unrepairable).
|
|
117
|
+
// Left EMPTY: an unset provider inherits the policy above, so off-by-default holds
|
|
118
|
+
// without shadowing it. The TUI writes an entry here only when you steer one
|
|
119
|
+
// provider differently from the other.
|
|
120
|
+
providers: {},
|
|
113
121
|
},
|
|
114
122
|
retry: {
|
|
115
123
|
maxAttemptsPerRequest: 0,
|
package/src/index.js
CHANGED
|
@@ -974,10 +974,14 @@ async function serverWorkerCommand() {
|
|
|
974
974
|
// policy cycled with the TUI 'f' key). Without this, the toggle takes effect
|
|
975
975
|
// in memory but silently reverts on the next config write / restart. Merge
|
|
976
976
|
// onto the existing disk scheduler block so other scheduler keys survive.
|
|
977
|
-
if (config.scheduler?.crossProviderFallbackPolicy) {
|
|
977
|
+
if (config.scheduler?.crossProviderFallbackPolicy || config.scheduler?.providers) {
|
|
978
978
|
diskConfig.scheduler = {
|
|
979
979
|
...diskConfig.scheduler,
|
|
980
|
-
|
|
980
|
+
...(config.scheduler.crossProviderFallbackPolicy
|
|
981
|
+
? { crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy } : {}),
|
|
982
|
+
// Per-provider Claude→provider settings (TUI routing g / k). Must be listed
|
|
983
|
+
// here explicitly or the toggle takes effect in memory and silently reverts.
|
|
984
|
+
...(config.scheduler.providers ? { providers: config.scheduler.providers } : {}),
|
|
981
985
|
};
|
|
982
986
|
}
|
|
983
987
|
// Persist live-toggled automatic-update flags (the TUI 'u' Updates menu). Same
|
package/src/server.js
CHANGED
|
@@ -354,6 +354,18 @@ async function forwardRequest(
|
|
|
354
354
|
const configuredAttempts = Number(retryConfig.maxAttemptsPerRequest) || accountManager.accounts.length;
|
|
355
355
|
const maxAttempts = Math.max(1, configuredAttempts);
|
|
356
356
|
|
|
357
|
+
// PRE-STRIP a session already known to carry provider-authored thinking. The client
|
|
358
|
+
// resends the whole poisoned history every turn, so without this each turn pays another
|
|
359
|
+
// rejected round-trip before the reactive repair kicks in. Latched by the first repair.
|
|
360
|
+
if (retryCount === 0 && !requestInfo.thinkingStripped
|
|
361
|
+
&& accountManager.isSessionThinkingContaminated?.(requestInfo.sessionKey)) {
|
|
362
|
+
const pre = stripForeignThinkingBlocks(body);
|
|
363
|
+
if (pre.body) {
|
|
364
|
+
body = pre.body;
|
|
365
|
+
requestInfo = { ...requestInfo, thinkingStripped: true };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
357
369
|
// Select account
|
|
358
370
|
const lease = accountManager.acquireAccount(requestInfo, excludedIndexes);
|
|
359
371
|
const account = lease?.account;
|
|
@@ -896,8 +908,14 @@ async function forwardRequest(
|
|
|
896
908
|
// for the strip-and-recover retry below. Deliberately NOT the fuzzy
|
|
897
909
|
// isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
|
|
898
910
|
// us to rewrite a user's transcript.
|
|
911
|
+
// Two deterministic shapes, both repairable by stripping: a signature Anthropic
|
|
912
|
+
// can't validate, AND a provider block that carries NO signature field at all
|
|
913
|
+
// ("messages.4.content.0.thinking.signature: Field required"). The second variant
|
|
914
|
+
// used to fall straight through to a PERMANENT provider pin even though stripping
|
|
915
|
+
// fixes it. Still deterministic — never the fuzzy isAnthropicIncompatBody heuristic.
|
|
899
916
|
const isSignatureRejection = account.type !== 'provider'
|
|
900
|
-
&& /invalid `signature` in `thinking`/i.test(errorBody)
|
|
917
|
+
&& (/invalid `signature` in `thinking`/i.test(errorBody)
|
|
918
|
+
|| /content\.\d+\.thinking\.signature/i.test(errorBody));
|
|
901
919
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
902
920
|
? 'invalid_thinking_signature'
|
|
903
921
|
: anthropicIncompat ? 'anthropic_incompatible_transcript'
|
|
@@ -943,6 +961,9 @@ async function forwardRequest(
|
|
|
943
961
|
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
944
962
|
const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
|
|
945
963
|
if (cleanBody) {
|
|
964
|
+
// Latch it so EVERY later turn is stripped up front instead of re-paying this
|
|
965
|
+
// rejected round-trip (the client resends the full poisoned history each turn).
|
|
966
|
+
accountManager.markSessionThinkingContaminated?.(requestInfo.sessionKey);
|
|
946
967
|
console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
|
|
947
968
|
return forwardRequest(
|
|
948
969
|
req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
@@ -1334,7 +1355,7 @@ function isContextLengthError(errorBody) {
|
|
|
1334
1355
|
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);
|
|
1335
1356
|
}
|
|
1336
1357
|
|
|
1337
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus,
|
|
1358
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1338
1359
|
|
|
1339
1360
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1340
1361
|
if (!upstreamRes.body) return '';
|
|
@@ -1510,17 +1531,16 @@ function secondsUntilParsedTime(value) {
|
|
|
1510
1531
|
return null;
|
|
1511
1532
|
}
|
|
1512
1533
|
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
// digest
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
return /^[0-9a-f]+$/i.test(sig);
|
|
1534
|
+
// Provider thinking signatures CANNOT be told apart from Anthropic's by shape — measured
|
|
1535
|
+
// against the live APIs 2026-07-25: Anthropic ~200-400 char base64, GLM a 24-char hex
|
|
1536
|
+
// digest, Kimi(K3) a 12,946-char base64 blob. A shape heuristic tuned to GLM silently
|
|
1537
|
+
// missed Kimi entirely. So the repair below does NOT guess: it strips EVERY `thinking`
|
|
1538
|
+
// block. That is safe precisely because it only ever runs in response to Anthropic's own
|
|
1539
|
+
// signature-rejection 400 — a healthy Claude-only session never reaches it — and a history
|
|
1540
|
+
// with thinking removed is accepted (200 OK, verified, incl. tool_use → tool_result).
|
|
1541
|
+
// `redacted_thinking` is never touched: it legitimately carries `data` and no signature.
|
|
1542
|
+
function isStrippableThinkingBlock(block) {
|
|
1543
|
+
return block?.type === 'thinking';
|
|
1524
1544
|
}
|
|
1525
1545
|
|
|
1526
1546
|
/**
|
|
@@ -1546,12 +1566,7 @@ function stripForeignThinkingBlocks(body) {
|
|
|
1546
1566
|
if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
|
|
1547
1567
|
let localRemoved = 0;
|
|
1548
1568
|
const kept = msg.content.filter(block => {
|
|
1549
|
-
|
|
1550
|
-
// legitimately has NO signature, so signature-based judging would strip 100% of
|
|
1551
|
-
// them (verified against the live API). GLM's contamination is always a
|
|
1552
|
-
// `thinking` block with a hex signature.
|
|
1553
|
-
if (block?.type !== 'thinking') return true;
|
|
1554
|
-
if (!isForeignThinkingSignature(block.signature)) return true;
|
|
1569
|
+
if (!isStrippableThinkingBlock(block)) return true;
|
|
1555
1570
|
localRemoved++;
|
|
1556
1571
|
return false;
|
|
1557
1572
|
});
|
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
|
|
@@ -525,6 +559,8 @@ export class TUI {
|
|
|
525
559
|
);
|
|
526
560
|
} else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
|
|
527
561
|
this._startSelection('prefer');
|
|
562
|
+
} else if (k === 'g' || k === 'k') {
|
|
563
|
+
this._cycleProviderClaudeFallback(k === 'g' ? 'zai' : 'kimi');
|
|
528
564
|
} else if (k === 'f' || k === 'F') {
|
|
529
565
|
// Cycle the cross-provider fallback policy in place — reversible + non-destructive,
|
|
530
566
|
// so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
|
|
@@ -534,6 +570,34 @@ export class TUI {
|
|
|
534
570
|
}
|
|
535
571
|
}
|
|
536
572
|
|
|
573
|
+
/** Cycle ONE provider's Claude→provider setting. Turning it ON is the risky direction
|
|
574
|
+
* (a Claude session can finish on that provider), so it asks first; turning it back off
|
|
575
|
+
* is always safe and never prompts. */
|
|
576
|
+
_cycleProviderClaudeFallback(providerKey) {
|
|
577
|
+
const order = ['never', 'when-exhausted', 'always'];
|
|
578
|
+
const label = providerKey === 'zai' ? 'GLM' : 'Kimi';
|
|
579
|
+
const cur = this.am._claudeFallbackFor?.(providerKey) || 'never';
|
|
580
|
+
const next = order[(order.indexOf(cur) + 1) % order.length];
|
|
581
|
+
const apply = async () => {
|
|
582
|
+
this.am.setClaudeFallbackForProvider?.(providerKey, next);
|
|
583
|
+
const sched = { ...(this.config.scheduler || {}) };
|
|
584
|
+
sched.providers = { ...(sched.providers || {}) };
|
|
585
|
+
sched.providers[providerKey] = { ...(sched.providers[providerKey] || {}), claudeFallback: next };
|
|
586
|
+
this.config.scheduler = sched;
|
|
587
|
+
try { await this.saveConfig(this.config); } catch (e) { this._addLog(`Could not save: ${e.message}`); }
|
|
588
|
+
this._addLog(`${label} takes over when Claude is out: ${next}`);
|
|
589
|
+
};
|
|
590
|
+
if (cur === 'never') {
|
|
591
|
+
this._confirm(
|
|
592
|
+
`Let ${label} take over when Claude is out?`,
|
|
593
|
+
`A session that starts on Claude can finish on ${label}. Most move back to Claude fine. If ${label} runs a web search, that session stays on ${label} until you start a new one.`,
|
|
594
|
+
apply,
|
|
595
|
+
);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
apply();
|
|
599
|
+
}
|
|
600
|
+
|
|
537
601
|
async _cycleCrossProviderPolicy() {
|
|
538
602
|
const order = ['never', 'when-exhausted', 'always'];
|
|
539
603
|
const cur = this.am._crossProviderFallbackPolicy();
|
|
@@ -1158,7 +1222,10 @@ export class TUI {
|
|
|
1158
1222
|
}
|
|
1159
1223
|
|
|
1160
1224
|
// Completed log
|
|
1161
|
-
|
|
1225
|
+
// 2 = separator + footer; confirm adds its detail line; updates adds its detail block.
|
|
1226
|
+
const footerH = this.mode === 'confirm' ? 3
|
|
1227
|
+
: this.mode === 'updates' ? 2 + this._renderUpdatesDetail().length
|
|
1228
|
+
: 2;
|
|
1162
1229
|
const space = Math.max(0, H - lines.length - footerH);
|
|
1163
1230
|
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
1164
1231
|
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
@@ -1170,6 +1237,10 @@ export class TUI {
|
|
|
1170
1237
|
// ── Footer
|
|
1171
1238
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1172
1239
|
if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
|
|
1240
|
+
// Updates DETAIL — without this, pressing 'u' changed only the one-line footer at the
|
|
1241
|
+
// very bottom of a busy screen, which reads as "nothing happened" (reported twice).
|
|
1242
|
+
// Every other mode draws something visible; this one must too.
|
|
1243
|
+
if (this.mode === 'updates') lines.push(...this._renderUpdatesDetail());
|
|
1173
1244
|
lines.push(this._renderFooter());
|
|
1174
1245
|
|
|
1175
1246
|
// Write buffer
|
|
@@ -1418,8 +1489,9 @@ export class TUI {
|
|
|
1418
1489
|
// Show the CURRENT cross-provider policy inline so pressing f visibly changes it
|
|
1419
1490
|
// right here at the footer (the policy also renders in the header, far from the
|
|
1420
1491
|
// keypress — the "f does nothing" report).
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1492
|
+
const g = this.am._claudeFallbackFor?.('zai') || 'never';
|
|
1493
|
+
const k = this.am._claudeFallbackFor?.('kimi') || 'never';
|
|
1494
|
+
return ` ${bold('a')} Automatic ${bold('p')} Preference ${bold('g')} GLM: ${cyan(g)} ↻ ${bold('k')} Kimi: ${cyan(k)} ↻ ${bold('Esc')} Back`;
|
|
1423
1495
|
}
|
|
1424
1496
|
case 'select': {
|
|
1425
1497
|
const act = this.selAction === 'prefer'
|