create-walle 0.9.30 → 0.9.32

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.
Files changed (112) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/api-prompts.js +410 -5
  3. package/template/claude-task-manager/api-reviews.js +251 -3
  4. package/template/claude-task-manager/approval-agent.js +322 -24
  5. package/template/claude-task-manager/db.js +77 -13
  6. package/template/claude-task-manager/docs/codex-app-server-approvals.md +35 -1
  7. package/template/claude-task-manager/docs/postgres-concurrency-dgx-spark.html +407 -0
  8. package/template/claude-task-manager/docs/prompt-manager-redesign-proposal.html +667 -0
  9. package/template/claude-task-manager/docs/resume-ux-redesign.html +493 -0
  10. package/template/claude-task-manager/docs/review-redesign-proposal.html +892 -0
  11. package/template/claude-task-manager/docs/unified-auto-approver-proposal.html +520 -0
  12. package/template/claude-task-manager/git-utils.js +164 -26
  13. package/template/claude-task-manager/lib/agent-capabilities.js +26 -5
  14. package/template/claude-task-manager/lib/agent-cli-cache.js +1 -1
  15. package/template/claude-task-manager/lib/agent-presets.js +12 -0
  16. package/template/claude-task-manager/lib/agent-version-service.js +650 -0
  17. package/template/claude-task-manager/lib/approval-drift.js +57 -0
  18. package/template/claude-task-manager/lib/approval-hook.js +13 -0
  19. package/template/claude-task-manager/lib/auth-rules.js +6 -0
  20. package/template/claude-task-manager/lib/broadcast-payload-memo.js +109 -0
  21. package/template/claude-task-manager/lib/coding-agent-models.js +89 -8
  22. package/template/claude-task-manager/lib/db-owner-cooperative-scheduler.js +10 -1
  23. package/template/claude-task-manager/lib/document-review.js +75 -1
  24. package/template/claude-task-manager/lib/escalation-review.js +33 -3
  25. package/template/claude-task-manager/lib/headless-term-service.js +54 -9
  26. package/template/claude-task-manager/lib/mcp-risk.js +128 -0
  27. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +51 -2
  28. package/template/claude-task-manager/lib/native-agent-model-args.js +149 -0
  29. package/template/claude-task-manager/lib/opencode-conversation-store.js +269 -0
  30. package/template/claude-task-manager/lib/permission-match.js +77 -5
  31. package/template/claude-task-manager/lib/read-pool-client.js +15 -6
  32. package/template/claude-task-manager/lib/restore-backoff.js +82 -0
  33. package/template/claude-task-manager/lib/runtime-registry.js +1 -0
  34. package/template/claude-task-manager/lib/scheduled-wake.js +42 -0
  35. package/template/claude-task-manager/lib/session-capture.js +33 -6
  36. package/template/claude-task-manager/lib/session-history.js +60 -3
  37. package/template/claude-task-manager/lib/session-jobs.js +19 -0
  38. package/template/claude-task-manager/lib/session-restore.js +5 -4
  39. package/template/claude-task-manager/lib/session-standup.js +2 -0
  40. package/template/claude-task-manager/lib/session-stream.js +9 -0
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +31 -4
  42. package/template/claude-task-manager/lib/state-sync/restore-frame-hold.js +39 -0
  43. package/template/claude-task-manager/lib/tui-input-modes.js +40 -0
  44. package/template/claude-task-manager/lib/walle-ctm-history.js +103 -36
  45. package/template/claude-task-manager/lib/walle-session-model-catalog.js +7 -1
  46. package/template/claude-task-manager/lib/walle-token-chip.js +65 -0
  47. package/template/claude-task-manager/package.json +1 -1
  48. package/template/claude-task-manager/providers/claude-code.js +20 -1
  49. package/template/claude-task-manager/providers/codex-mcp-extract.js +33 -0
  50. package/template/claude-task-manager/providers/codex-mcp.js +23 -20
  51. package/template/claude-task-manager/providers/codex.js +65 -1
  52. package/template/claude-task-manager/public/css/prompts.css +345 -0
  53. package/template/claude-task-manager/public/css/reviews.css +248 -40
  54. package/template/claude-task-manager/public/css/walle-session.css +90 -26
  55. package/template/claude-task-manager/public/index.html +2031 -428
  56. package/template/claude-task-manager/public/js/document-review-links.js +255 -13
  57. package/template/claude-task-manager/public/js/file-context-menu.js +185 -0
  58. package/template/claude-task-manager/public/js/message-renderer.js +46 -1
  59. package/template/claude-task-manager/public/js/mobile-review-core.js +54 -0
  60. package/template/claude-task-manager/public/js/prompt-diff.js +55 -0
  61. package/template/claude-task-manager/public/js/prompt-editor-chrome.js +108 -0
  62. package/template/claude-task-manager/public/js/prompt-editor-modes.js +87 -0
  63. package/template/claude-task-manager/public/js/prompt-organize.js +179 -0
  64. package/template/claude-task-manager/public/js/prompt-reuse.js +77 -0
  65. package/template/claude-task-manager/public/js/prompts.js +683 -215
  66. package/template/claude-task-manager/public/js/resume-state.js +186 -0
  67. package/template/claude-task-manager/public/js/reviews.js +402 -121
  68. package/template/claude-task-manager/public/js/screenshot-router.js +91 -0
  69. package/template/claude-task-manager/public/js/session-search-utils.js +81 -0
  70. package/template/claude-task-manager/public/js/state-sync-client.js +134 -17
  71. package/template/claude-task-manager/public/js/stream-view.js +93 -20
  72. package/template/claude-task-manager/public/js/walle-session.js +183 -32
  73. package/template/claude-task-manager/public/js/walle.js +21 -9
  74. package/template/claude-task-manager/public/m/app.css +117 -0
  75. package/template/claude-task-manager/public/m/app.js +309 -3
  76. package/template/claude-task-manager/public/m/index.html +56 -1
  77. package/template/claude-task-manager/server.js +724 -305
  78. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  79. package/template/claude-task-manager/workers/read-pool-worker.js +4 -0
  80. package/template/claude-task-manager/workers/state-detectors/claude-code.js +10 -1
  81. package/template/docs/proposals/2026-06-23-local-model-right-path.html +340 -0
  82. package/template/docs/proposals/2026-06-24-qlora-done-right.md +255 -0
  83. package/template/package.json +1 -1
  84. package/template/wall-e/api-walle.js +21 -3
  85. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +27 -4
  86. package/template/wall-e/brain.js +50 -5
  87. package/template/wall-e/chat/force-compact.js +73 -0
  88. package/template/wall-e/chat.js +137 -9
  89. package/template/wall-e/coding/action-memory-policy.js +120 -1
  90. package/template/wall-e/coding/compaction-service.js +10 -3
  91. package/template/wall-e/coding/model-router.js +116 -0
  92. package/template/wall-e/coding-orchestrator.js +303 -11
  93. package/template/wall-e/coding-prompts.js +1 -1
  94. package/template/wall-e/docs/skill-self-heal-design.html +429 -0
  95. package/template/wall-e/llm/client.js +115 -6
  96. package/template/wall-e/llm/codex-cli.js +71 -23
  97. package/template/wall-e/llm/codex-cli.plugin.json +1 -0
  98. package/template/wall-e/llm/default-fallback.js +71 -11
  99. package/template/wall-e/llm/mlx-worker.js +0 -0
  100. package/template/wall-e/llm/mlx.js +46 -12
  101. package/template/wall-e/llm/provider-error.js +9 -3
  102. package/template/wall-e/llm/provider-health-state.js +561 -11
  103. package/template/wall-e/llm/registry.js +6 -0
  104. package/template/wall-e/llm/routing-policy.js +255 -25
  105. package/template/wall-e/llm/tool-call-validator.js +156 -0
  106. package/template/wall-e/skills/skill-dispatch-decision.js +82 -2
  107. package/template/wall-e/skills/skill-planner.js +72 -5
  108. package/template/wall-e/tools/command-registry.js +34 -0
  109. package/template/wall-e/training/gemma-e4b-qlora.js +197 -1
  110. package/template/wall-e/training/mlx_lora_launch.py +196 -0
  111. package/template/wall-e/training/training-coexistence.sh +87 -0
  112. package/template/wall-e/training/training-mem-guard.sh +76 -0
@@ -25,14 +25,12 @@ const MODELS = [
25
25
  name: 'GPT-5.4 Mini (via ChatGPT subscription)',
26
26
  capabilities: { vision: false, tools: false, streaming: false, reasoning: true },
27
27
  },
28
- {
29
- id: 'gpt-5.3-codex',
30
- name: 'GPT-5.3 Codex (via ChatGPT subscription)',
31
- capabilities: { vision: false, tools: false, streaming: false, reasoning: true },
32
- },
33
28
  ];
34
29
 
35
- const CODEX_CLI_MODEL_RE = /^(gpt-5\.5|gpt-5\.4|gpt-5\.4-mini|gpt-5\.3-codex)$/i;
30
+ const DEFAULT_CODEX_CLI_MODEL = 'gpt-5.5';
31
+ const CODEX_CLI_MODEL_RE = /^(gpt-5\.5|gpt-5\.4|gpt-5\.4-mini)$/i;
32
+ const DEFAULT_CODEX_CLI_REASONING_EFFORT = 'low';
33
+ const CODEX_CLI_REASONING_EFFORT_RE = /^(minimal|low|medium|high|xhigh)$/i;
36
34
 
37
35
  const CODEX_APP_BINARY = '/Applications/Codex.app/Contents/Resources/codex';
38
36
 
@@ -76,7 +74,58 @@ function _serializeMessages(messages, system) {
76
74
  return parts.join('\n\n');
77
75
  }
78
76
 
79
- async function _spawnCodex(prompt, { model, signal, timeoutMs = 300000, cwd, binaryPath } = {}) {
77
+ function resolveCodexCliModel(model) {
78
+ const text = String(model || '').trim();
79
+ return text && CODEX_CLI_MODEL_RE.test(text) ? text : undefined;
80
+ }
81
+
82
+ function resolveCodexCliReasoningEffort(value) {
83
+ if (value === false || value == null) return undefined;
84
+ const text = String(value || '').trim();
85
+ return text && CODEX_CLI_REASONING_EFFORT_RE.test(text) ? text : undefined;
86
+ }
87
+
88
+ function buildCodexExecArgs(prompt, {
89
+ model,
90
+ ignoreUserConfig = true,
91
+ reasoningEffort = DEFAULT_CODEX_CLI_REASONING_EFFORT,
92
+ outFile,
93
+ } = {}) {
94
+ const args = ['exec'];
95
+ // Wall-E uses Codex CLI as a backend LLM provider, not as an interactive
96
+ // coding session. Keep ChatGPT auth, but avoid inheriting user/project
97
+ // coding-agent config such as xhigh reasoning or large skill/MCP contexts.
98
+ if (ignoreUserConfig !== false) args.push('--ignore-user-config');
99
+ args.push(
100
+ '--skip-git-repo-check',
101
+ '--ephemeral',
102
+ '--output-last-message', outFile,
103
+ );
104
+ const resolvedReasoningEffort = resolveCodexCliReasoningEffort(reasoningEffort);
105
+ if (resolvedReasoningEffort) {
106
+ args.push('-c', `model_reasoning_effort="${resolvedReasoningEffort}"`);
107
+ }
108
+ // ChatGPT-authed codex restricts which models work. Only forward `-m` for
109
+ // known-subscription-compatible models — otherwise let codex pick its own
110
+ // default. The Wall-E caller's model preference is best-effort; the trade-off
111
+ // for subscription billing.
112
+ const forwardedModel = resolveCodexCliModel(model);
113
+ if (forwardedModel) {
114
+ args.push('-m', forwardedModel);
115
+ }
116
+ args.push(prompt);
117
+ return { args, forwardedModel, resolvedReasoningEffort };
118
+ }
119
+
120
+ async function _spawnCodex(prompt, {
121
+ model,
122
+ signal,
123
+ timeoutMs = 300000,
124
+ cwd,
125
+ binaryPath,
126
+ ignoreUserConfig = true,
127
+ reasoningEffort = DEFAULT_CODEX_CLI_REASONING_EFFORT,
128
+ } = {}) {
80
129
  // Codex writes its session journal to ~/.codex/. We pass --ephemeral to
81
130
  // suppress that. We also need to capture the final assistant message
82
131
  // somehow — `--output-last-message <FILE>` is the cleanest path. Use a
@@ -89,20 +138,7 @@ async function _spawnCodex(prompt, { model, signal, timeoutMs = 300000, cwd, bin
89
138
  rm(dir, { recursive: true, force: true }).catch(() => {});
90
139
  return reject(new Error('Aborted'));
91
140
  }
92
- const args = [
93
- 'exec',
94
- '--skip-git-repo-check',
95
- '--ephemeral',
96
- '--output-last-message', outFile,
97
- ];
98
- // ChatGPT-authed codex restricts which models work. Only forward `-m` for
99
- // known-subscription-compatible
100
- // models — otherwise let codex pick its own default. The wall-e caller's
101
- // model preference is best-effort; the trade-off for subscription billing.
102
- if (model && CODEX_CLI_MODEL_RE.test(model)) {
103
- args.push('-m', model);
104
- }
105
- args.push(prompt);
141
+ const { args } = buildCodexExecArgs(prompt, { model, ignoreUserConfig, reasoningEffort, outFile });
106
142
 
107
143
  // Force ChatGPT auth path — strip OPENAI_API_KEY so codex doesn't
108
144
  // silently fall back to API-key billing when env exposes one.
@@ -239,13 +275,15 @@ function createCodexCliProvider(config = {}) {
239
275
  };
240
276
  }
241
277
  const start = Date.now();
242
- const forwardedModel = model && CODEX_CLI_MODEL_RE.test(model) ? model : undefined;
278
+ const forwardedModel = resolveCodexCliModel(model);
243
279
  const { reply, stdout } = await _spawnCodex(prompt, {
244
280
  model: forwardedModel,
245
281
  signal,
246
282
  timeoutMs: config.timeoutMs,
247
283
  cwd: cwd || config.cwd,
248
284
  binaryPath: config.binaryPath,
285
+ ignoreUserConfig: config.ignoreUserConfig,
286
+ reasoningEffort: config.reasoningEffort,
249
287
  });
250
288
  const latencyMs = Date.now() - start;
251
289
 
@@ -263,7 +301,7 @@ function createCodexCliProvider(config = {}) {
263
301
  stopReason: 'end_turn',
264
302
  usage: totalTokens > 0 ? { input: 0, output: totalTokens } : undefined,
265
303
  latencyMs,
266
- model: forwardedModel || 'gpt-5.5',
304
+ model: forwardedModel || DEFAULT_CODEX_CLI_MODEL,
267
305
  provider: 'codex-cli',
268
306
  raw: { reply, totalTokens },
269
307
  };
@@ -283,4 +321,14 @@ module.exports = {
283
321
  createCodexCliProvider,
284
322
  createCodexCliFromEnv,
285
323
  _spawnCodex,
324
+ _internals: {
325
+ CODEX_CLI_MODEL_RE,
326
+ CODEX_CLI_REASONING_EFFORT_RE,
327
+ DEFAULT_CODEX_CLI_MODEL,
328
+ DEFAULT_CODEX_CLI_REASONING_EFFORT,
329
+ MODELS,
330
+ buildCodexExecArgs,
331
+ resolveCodexCliModel,
332
+ resolveCodexCliReasoningEffort,
333
+ },
286
334
  };
@@ -10,6 +10,7 @@
10
10
  "label": "OpenAI Codex (subscription)",
11
11
  "defaultModel": "gpt-5.5",
12
12
  "modelPrefixes": [],
13
+ "modelAliases": ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"],
13
14
  "capabilities": {
14
15
  "chat": true,
15
16
  "tools": false,
@@ -36,10 +36,11 @@ const TRANSIENT_CLASSES = new Set(['network', 'timeout', 'provider_unavailable',
36
36
  // fall through to the next candidate. auth_error is included (misconfigured /
37
37
  // expired credential) but is NOT retried on the same provider; likewise
38
38
  // quota_exceeded (depleted account/credits, e.g. DeepSeek "402 Insufficient
39
- // Balance") retrying the same provider can't help, but another candidate can
40
- // serve. Anything else (invalid_request, content filter, unknown) is a real
41
- // error we must surface.
42
- const FALLTHROUGH_CLASSES = new Set(['network', 'timeout', 'provider_unavailable', 'rate_limited', 'auth_error', 'quota_exceeded']);
39
+ // Balance") and model_unavailable (the selected provider/model cannot serve)
40
+ // can't be fixed by retrying the same provider, but another candidate can serve.
41
+ // Anything else (invalid_request, content filter, unknown) is a real error we
42
+ // must surface.
43
+ const FALLTHROUGH_CLASSES = new Set(['network', 'timeout', 'provider_unavailable', 'rate_limited', 'auth_error', 'quota_exceeded', 'model_unavailable']);
43
44
 
44
45
  function classify(err) {
45
46
  try {
@@ -53,6 +54,26 @@ function isTransientClass(cls) { return TRANSIENT_CLASSES.has(cls); }
53
54
  function shouldFallThroughClass(cls) { return FALLTHROUGH_CLASSES.has(cls); }
54
55
  function defaultDelay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
55
56
 
57
+ function noEligibleProvidersError(chain) {
58
+ const labels = (Array.isArray(chain) ? chain : [])
59
+ .map((candidate) => candidate?.label || candidate?.type || candidate?.id)
60
+ .filter(Boolean);
61
+ const detail = labels.length ? ` Skipped routes: ${labels.join(', ')}.` : '';
62
+ const err = new Error(`No configured AI provider/model route is currently eligible. Waiting for health recovery probes or Setup changes.${detail}`);
63
+ err.status = 503;
64
+ err.code = 'AI_PROVIDER_UNAVAILABLE_LOCAL';
65
+ err.suppressProviderHealthRecord = true;
66
+ try {
67
+ err.providerError = classifyProviderError(err, {
68
+ type: 'provider_unavailable_local',
69
+ provider: chain?.[0]?.type || chain?.[0]?.id || null,
70
+ model: chain?.[0]?.model || null,
71
+ providerId: chain?.[0]?.id || null,
72
+ });
73
+ } catch {}
74
+ return err;
75
+ }
76
+
56
77
  // A request that was CANCELLED (caller aborted, or an AbortError) is not a provider-health
57
78
  // signal — the caller no longer wants a result. classifyProviderError() lumps "aborted" into
58
79
  // 'timeout' (transient), which would otherwise make us retry + fall through the whole chain
@@ -96,9 +117,13 @@ function isCliBinaryAvailable(bin, opts = {}) {
96
117
  * normal provider client (chat / chatStream / capabilities / type).
97
118
  * @param {object} deps
98
119
  * @param {object} [deps.health] ProviderHealth instance (trackCall used).
99
- * @param {Function} [deps.isDown] (id) => bool — skip providers already down.
120
+ * @param {Function} [deps.isDown] (id,candidate,opts) => bool — skip providers/models already down.
100
121
  * @param {Function} [deps.onFallback] ({from,to,error}) => void — fired once when
101
122
  * a non-primary candidate ends up serving the call.
123
+ * @param {Function} [deps.onCandidateOk] ({provider,model}) => void — fired when any
124
+ * candidate succeeds.
125
+ * @param {Function} [deps.onCandidateFailure] ({provider,model,error,type,attempt}) => void —
126
+ * fired for each real provider attempt that fails.
102
127
  * @param {Function} [deps.onExhausted] ({chain,error}) => void — every candidate failed.
103
128
  * @param {number} [deps.maxRetries=2] Per-candidate transient retries.
104
129
  * @param {number} [deps.baseDelayMs=400] Backoff base (exponential).
@@ -114,6 +139,8 @@ function makeResilientClient(chain, deps = {}) {
114
139
  const baseDelayMs = Number.isFinite(deps.baseDelayMs) ? deps.baseDelayMs : 400;
115
140
  const onFallback = typeof deps.onFallback === 'function' ? deps.onFallback : () => {};
116
141
  const onPrimaryOk = typeof deps.onPrimaryOk === 'function' ? deps.onPrimaryOk : () => {};
142
+ const onCandidateOk = typeof deps.onCandidateOk === 'function' ? deps.onCandidateOk : () => {};
143
+ const onCandidateFailure = typeof deps.onCandidateFailure === 'function' ? deps.onCandidateFailure : () => {};
117
144
  const onExhausted = typeof deps.onExhausted === 'function' ? deps.onExhausted : () => {};
118
145
  const sleep = typeof deps.sleep === 'function' ? deps.sleep : defaultDelay;
119
146
  const primary = chain[0];
@@ -137,16 +164,16 @@ function makeResilientClient(chain, deps = {}) {
137
164
  // Active candidates for this call: drop providers health marked 'down', and —
138
165
  // when the call needs tool calls — drop providers that can't do tools (e.g. the
139
166
  // claude-cli tail, capabilities.tools === false), leaving those to the coding
140
- // orchestrator's own CLI fallback. Never return empty: fall back to the full
141
- // chain so we always attempt something.
167
+ // orchestrator's own CLI fallback. Returning empty is intentional: a circuit-open
168
+ // model must only be retried through its explicit recovery-probe path.
142
169
  function activeChain(opts = {}) {
143
170
  const wantsTools = Array.isArray(opts.tools) && opts.tools.length > 0;
144
- let cands = chain.filter((c) => !isDown(c.id));
171
+ let cands = chain.filter((c) => !isDown(c.id, c, optsForCandidate(c, opts)));
145
172
  if (wantsTools) {
146
173
  const toolCapable = cands.filter((c) => c.client?.capabilities?.tools !== false);
147
174
  if (toolCapable.length) cands = toolCapable;
148
175
  }
149
- return cands.length ? cands : chain.slice(-1);
176
+ return cands;
150
177
  }
151
178
 
152
179
  // Run one candidate with transient retries. Returns {ok, res} or {ok:false, err, cls}.
@@ -157,6 +184,7 @@ function makeResilientClient(chain, deps = {}) {
157
184
  try {
158
185
  const res = await invoke(cand.client, callOpts);
159
186
  track(cand.id, true);
187
+ onCandidateOk({ provider: cand, model: callOpts?.model || cand.model || null });
160
188
  return { ok: true, res };
161
189
  } catch (err) {
162
190
  // Caller cancelled — propagate at once (no retry/fallthrough/health-failure).
@@ -165,7 +193,17 @@ function makeResilientClient(chain, deps = {}) {
165
193
  lastErr = err;
166
194
  lastCls = cls;
167
195
  track(cand.id, false, err);
168
- if (isTransientClass(cls) && attempt < maxRetries) {
196
+ const willRetry = isTransientClass(cls) && attempt < maxRetries;
197
+ onCandidateFailure({
198
+ provider: cand,
199
+ model: callOpts?.model || cand.model || null,
200
+ error: err,
201
+ type: cls,
202
+ attempt,
203
+ willRetry,
204
+ willFallThrough: !willRetry && shouldFallThroughClass(cls),
205
+ });
206
+ if (willRetry) {
169
207
  await sleep(baseDelayMs * (2 ** attempt));
170
208
  continue;
171
209
  }
@@ -177,6 +215,11 @@ function makeResilientClient(chain, deps = {}) {
177
215
 
178
216
  async function callWithFallback(opts, invoke) {
179
217
  const cands = activeChain(opts);
218
+ if (!cands.length) {
219
+ const err = noEligibleProvidersError(chain);
220
+ onExhausted({ chain: cands, skippedChain: chain, error: err, noEligible: true });
221
+ throw err;
222
+ }
180
223
  let lastErr;
181
224
  for (let i = 0; i < cands.length; i++) {
182
225
  const cand = cands[i];
@@ -197,6 +240,11 @@ function makeResilientClient(chain, deps = {}) {
197
240
 
198
241
  async function* streamWithFallback(opts) {
199
242
  const cands = activeChain(opts);
243
+ if (!cands.length) {
244
+ const err = noEligibleProvidersError(chain);
245
+ onExhausted({ chain: cands, skippedChain: chain, error: err, noEligible: true });
246
+ throw err;
247
+ }
200
248
  let lastErr;
201
249
  for (let i = 0; i < cands.length; i++) {
202
250
  const cand = cands[i];
@@ -208,6 +256,7 @@ function makeResilientClient(chain, deps = {}) {
208
256
  if (!started) {
209
257
  started = true;
210
258
  track(cand.id, true);
259
+ onCandidateOk({ provider: cand, model: callOpts?.model || cand.model || null });
211
260
  if (cand.id !== primary.id) onFallback({ from: primary, to: cand, error: lastErr });
212
261
  else onPrimaryOk({ provider: cand });
213
262
  }
@@ -219,10 +268,20 @@ function makeResilientClient(chain, deps = {}) {
219
268
  if (isAbortError(err, callOpts)) throw err;
220
269
  const cls = classify(err);
221
270
  track(cand.id, false, err);
271
+ const willRetry = isTransientClass(cls) && attempt < maxRetries;
272
+ onCandidateFailure({
273
+ provider: cand,
274
+ model: callOpts?.model || cand.model || null,
275
+ error: err,
276
+ type: cls,
277
+ attempt,
278
+ willRetry,
279
+ willFallThrough: !willRetry && shouldFallThroughClass(cls),
280
+ });
222
281
  // Mid-stream we can't switch providers without re-emitting — propagate.
223
282
  if (started) throw err;
224
283
  lastErr = err;
225
- if (isTransientClass(cls) && attempt < maxRetries) {
284
+ if (willRetry) {
226
285
  await sleep(baseDelayMs * (2 ** attempt));
227
286
  continue; // retry same candidate
228
287
  }
@@ -425,6 +484,7 @@ module.exports = {
425
484
  _internals: {
426
485
  classify,
427
486
  isTransientClass,
487
+ noEligibleProvidersError,
428
488
  shouldFallThroughClass,
429
489
  _configFromRow,
430
490
  _hasEnvKey,
@@ -4,6 +4,15 @@ const { execFile } = require('child_process');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
7
+ const { workerKey, sharedPool } = require('./mlx-worker');
8
+
9
+ // Tear down all persistent MLX workers. Long-running hosts rely on the
10
+ // process-exit handler, but short-lived CLIs (the eval harness) must call this
11
+ // explicitly — idle worker stdio pipes keep node's event loop alive, so without
12
+ // it the process hangs after the run completes.
13
+ function shutdownMlxWorkers() {
14
+ try { sharedPool().shutdownAll(); } catch { /* nothing to tear down */ }
15
+ }
7
16
 
8
17
  const MLX_VENV = path.join(os.homedir(), '.mlx-env');
9
18
  const MLX_PYTHON = path.join(MLX_VENV, 'bin', 'python3');
@@ -139,6 +148,15 @@ function resolveInferenceTimeoutMs(config = {}) {
139
148
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 300000;
140
149
  }
141
150
 
151
+ // Whether to route inference through the persistent warm-worker pool. Default
152
+ // OFF so the primary Wall-E runtime keeps the conservative one-shot subprocess
153
+ // behavior; opt in via config.persistentWorker or MLX_PERSISTENT_WORKER=1
154
+ // (used by the eval harness to avoid reloading the model every turn).
155
+ function resolveUsePersistentWorker(config = {}) {
156
+ if (config.persistentWorker !== undefined) return !!config.persistentWorker;
157
+ return process.env.MLX_PERSISTENT_WORKER === '1';
158
+ }
159
+
142
160
  function buildInferenceExecOptions(config = {}, signal) {
143
161
  return {
144
162
  timeout: resolveInferenceTimeoutMs(config),
@@ -589,20 +607,34 @@ print(json.dumps({
589
607
 
590
608
  const start = Date.now();
591
609
  const maxTok = maxTokens || 1024;
610
+ const python = resolveMlxPython(config);
592
611
 
593
612
  try {
594
- const { stdout, stderr } = await execAsync(
595
- resolveMlxPython(config),
596
- ['-c', pyScript, JSON.stringify(compactedChatMessages), String(maxTok), mlxModel, adapterPath || '', forcedGenerationPrefix],
597
- buildInferenceExecOptions(config, signal)
598
- );
599
-
600
- // Find JSON line in output
601
- const lines = stdout.trim().split('\n');
602
- const jsonLine = lines.find(l => l.startsWith('{'));
603
- if (!jsonLine) throw new Error(`MLX: no JSON in output. stderr: ${stderr?.slice(0, 200)}`);
604
-
605
- const data = JSON.parse(jsonLine);
613
+ let data;
614
+ if (resolveUsePersistentWorker(config)) {
615
+ // Warm path: reuse a long-lived worker so the model loads once instead
616
+ // of once per turn. Falls back to the cold subprocess on worker failure.
617
+ const pool = config.mlxWorkerPool || sharedPool();
618
+ const key = workerKey(python, mlxModel, adapterPath);
619
+ data = await pool.request(
620
+ key,
621
+ { python, model: mlxModel, adapterPath: adapterPath || '' },
622
+ { messages: compactedChatMessages, max_tokens: maxTok, forced_generation_prefix: forcedGenerationPrefix },
623
+ { timeoutMs: resolveInferenceTimeoutMs(config), signal }
624
+ );
625
+ } else {
626
+ const { stdout, stderr } = await execAsync(
627
+ python,
628
+ ['-c', pyScript, JSON.stringify(compactedChatMessages), String(maxTok), mlxModel, adapterPath || '', forcedGenerationPrefix],
629
+ buildInferenceExecOptions(config, signal)
630
+ );
631
+
632
+ // Find JSON line in output
633
+ const lines = stdout.trim().split('\n');
634
+ const jsonLine = lines.find(l => l.startsWith('{'));
635
+ if (!jsonLine) throw new Error(`MLX: no JSON in output. stderr: ${stderr?.slice(0, 200)}`);
636
+ data = JSON.parse(jsonLine);
637
+ }
606
638
  const latencyMs = Date.now() - start;
607
639
 
608
640
  // Parse tool calls from output if tools were provided
@@ -691,6 +723,8 @@ module.exports = {
691
723
  resolveModelSpec,
692
724
  resolveMlxPython,
693
725
  resolveInferenceTimeoutMs,
726
+ resolveUsePersistentWorker,
727
+ shutdownMlxWorkers,
694
728
  buildInferenceExecOptions,
695
729
  normalizeToolCallFormat,
696
730
  normalizeToolSpec,
@@ -112,7 +112,12 @@ function classifyType(status, lower) {
112
112
  || status === 403
113
113
  || /invalid[_ -]?(api[_ -]?)?key|unauthori[sz]ed|forbidden|authentication|auth token|authtoken|api[_ -]?key|could not resolve authentication method|expected either apikey or authtoken|oauth token not found|claude auth login|anthropic_api_key|token refresh failed|permission denied/i.test(lower)
114
114
  ) return 'auth_error';
115
- if (status === 404 || /model .*not found|not_found_error|model_not_found|does not exist|unknown model/i.test(lower)) return 'model_unavailable';
115
+ if (
116
+ status === 404
117
+ || /model .*not found|not_found_error|model_not_found|does not exist|unknown model/i.test(lower)
118
+ || /(?:model|mode)[^.\n]*(?:not available|not supported|unavailable|unsupported|invalid|unknown|does not exist)/i.test(lower)
119
+ || /invalid_request_error[^.\n]*(?:model|mode)|(?:model|mode)[^.\n]*invalid_request_error/i.test(lower)
120
+ ) return 'model_unavailable';
116
121
  if (/context length exceeded|context_length_exceeded|maximum context|token limit/i.test(lower)) return 'context_window';
117
122
  // Connect-phase failures mean the endpoint was never reached (DNS, routing,
118
123
  // VPN-only gateways). They must classify as 'network' even though undici
@@ -260,13 +265,14 @@ function toApiPayload(err, context = {}) {
260
265
  };
261
266
  }
262
267
 
263
- function recordProviderFailureAlert(providerError, brain) {
268
+ function recordProviderFailureAlert(providerError, brain, opts = {}) {
264
269
  if (!providerError) return { alerted: false };
265
270
  try {
266
- try {
271
+ if (opts.recordHealth !== false) try {
267
272
  const { recordProviderHealthStatus } = require('./provider-health-state');
268
273
  recordProviderHealthStatus({
269
274
  provider: providerError.provider || 'default',
275
+ provider_id: providerError.providerId || null,
270
276
  model: providerError.model || '',
271
277
  ok: false,
272
278
  source: 'provider-error',