agent-nuvira 3.5.6 → 3.5.7

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 (110) hide show
  1. package/.agents/skills/index.json +16 -0
  2. package/.agents/skills/motion/SKILL.md +27 -0
  3. package/README.md +56 -1
  4. package/dist/cli/chat.d.ts +76 -17
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +258 -118
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/cli/doctor.d.ts +31 -2
  9. package/dist/cli/doctor.d.ts.map +1 -1
  10. package/dist/cli/doctor.js +143 -7
  11. package/dist/cli/doctor.js.map +1 -1
  12. package/dist/cli/loop-executor.d.ts.map +1 -1
  13. package/dist/cli/loop-executor.js +4 -1
  14. package/dist/cli/loop-executor.js.map +1 -1
  15. package/dist/cli/parity.d.ts +12 -0
  16. package/dist/cli/parity.d.ts.map +1 -1
  17. package/dist/cli/parity.js +46 -1
  18. package/dist/cli/parity.js.map +1 -1
  19. package/dist/cli/tool-install-prompt.d.ts +2 -7
  20. package/dist/cli/tool-install-prompt.d.ts.map +1 -1
  21. package/dist/cli/tool-install-prompt.js +11 -28
  22. package/dist/cli/tool-install-prompt.js.map +1 -1
  23. package/dist/config/scratch-workspace.d.ts +134 -0
  24. package/dist/config/scratch-workspace.d.ts.map +1 -0
  25. package/dist/config/scratch-workspace.js +213 -0
  26. package/dist/config/scratch-workspace.js.map +1 -0
  27. package/dist/gateway/adapters.d.ts +24 -0
  28. package/dist/gateway/adapters.d.ts.map +1 -1
  29. package/dist/gateway/adapters.js +12 -0
  30. package/dist/gateway/adapters.js.map +1 -1
  31. package/dist/gateway/oversized-reply.d.ts +39 -0
  32. package/dist/gateway/oversized-reply.d.ts.map +1 -0
  33. package/dist/gateway/oversized-reply.js +54 -0
  34. package/dist/gateway/oversized-reply.js.map +1 -0
  35. package/dist/gateway/registry.d.ts +0 -10
  36. package/dist/gateway/registry.d.ts.map +1 -1
  37. package/dist/gateway/registry.js +148 -4
  38. package/dist/gateway/registry.js.map +1 -1
  39. package/dist/learning/quota-ledger.d.ts +24 -0
  40. package/dist/learning/quota-ledger.d.ts.map +1 -1
  41. package/dist/learning/quota-ledger.js +40 -0
  42. package/dist/learning/quota-ledger.js.map +1 -1
  43. package/dist/learning/runtime-dependencies.d.ts +183 -0
  44. package/dist/learning/runtime-dependencies.d.ts.map +1 -0
  45. package/dist/learning/runtime-dependencies.js +349 -0
  46. package/dist/learning/runtime-dependencies.js.map +1 -0
  47. package/dist/learning/skill-store.d.ts.map +1 -1
  48. package/dist/learning/skill-store.js +6 -5
  49. package/dist/learning/skill-store.js.map +1 -1
  50. package/dist/skills/bundled-skills-phase3.d.ts +14 -0
  51. package/dist/skills/bundled-skills-phase3.d.ts.map +1 -1
  52. package/dist/skills/bundled-skills-phase3.js +50 -0
  53. package/dist/skills/bundled-skills-phase3.js.map +1 -1
  54. package/dist/tools/modality/motion-presets.d.ts +62 -0
  55. package/dist/tools/modality/motion-presets.d.ts.map +1 -0
  56. package/dist/tools/modality/motion-presets.js +399 -0
  57. package/dist/tools/modality/motion-presets.js.map +1 -0
  58. package/dist/tools/modality/motion-tool.d.ts +62 -0
  59. package/dist/tools/modality/motion-tool.d.ts.map +1 -0
  60. package/dist/tools/modality/motion-tool.js +220 -0
  61. package/dist/tools/modality/motion-tool.js.map +1 -0
  62. package/dist/tools/modality/motion.d.ts +298 -0
  63. package/dist/tools/modality/motion.d.ts.map +1 -0
  64. package/dist/tools/modality/motion.js +514 -0
  65. package/dist/tools/modality/motion.js.map +1 -0
  66. package/dist/tools/registry.d.ts.map +1 -1
  67. package/dist/tools/registry.js +75 -0
  68. package/dist/tools/registry.js.map +1 -1
  69. package/dist/tools/tool-loop.d.ts.map +1 -1
  70. package/dist/tools/tool-loop.js +66 -16
  71. package/dist/tools/tool-loop.js.map +1 -1
  72. package/dist/tools/toolsets.js +2 -2
  73. package/dist/tools/toolsets.js.map +1 -1
  74. package/dist/utils/workspace-need.d.ts +84 -0
  75. package/dist/utils/workspace-need.d.ts.map +1 -0
  76. package/dist/utils/workspace-need.js +227 -0
  77. package/dist/utils/workspace-need.js.map +1 -0
  78. package/dist/web-dashboard/chat-console.d.ts +22 -0
  79. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  80. package/dist/web-dashboard/chat-console.js +65 -11
  81. package/dist/web-dashboard/chat-console.js.map +1 -1
  82. package/dist/web-dashboard/hub-data.d.ts +9 -1
  83. package/dist/web-dashboard/hub-data.d.ts.map +1 -1
  84. package/dist/web-dashboard/hub-data.js +2 -0
  85. package/dist/web-dashboard/hub-data.js.map +1 -1
  86. package/dist/web-dashboard/project-context.d.ts +1 -1
  87. package/dist/web-dashboard/project-context.d.ts.map +1 -1
  88. package/dist/web-dashboard/project-context.js +7 -1
  89. package/dist/web-dashboard/project-context.js.map +1 -1
  90. package/dist/web-dashboard/server.d.ts +33 -0
  91. package/dist/web-dashboard/server.d.ts.map +1 -1
  92. package/dist/web-dashboard/server.js +204 -21
  93. package/dist/web-dashboard/server.js.map +1 -1
  94. package/dist/web-dashboard/src/types.d.ts +46 -0
  95. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  96. package/dist/web-dashboard/workspace-guard.d.ts +20 -21
  97. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  98. package/dist/web-dashboard/workspace-guard.js +20 -153
  99. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  100. package/dist/web-dashboard/workspace-resolution.d.ts +23 -5
  101. package/dist/web-dashboard/workspace-resolution.d.ts.map +1 -1
  102. package/dist/web-dashboard/workspace-resolution.js +22 -4
  103. package/dist/web-dashboard/workspace-resolution.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/web-dashboard/public/assets/{index-DeSsNU94.css → index-CzRU84ET.css} +1 -1
  106. package/src/web-dashboard/public/assets/index-V0rIqVbg.js +236 -0
  107. package/src/web-dashboard/public/assets/index-V0rIqVbg.js.map +1 -0
  108. package/src/web-dashboard/public/index.html +2 -2
  109. package/src/web-dashboard/public/assets/index-Do_O67Kv.js +0 -236
  110. package/src/web-dashboard/public/assets/index-Do_O67Kv.js.map +0 -1
package/dist/cli/chat.js CHANGED
@@ -36,6 +36,7 @@ import { TaskModelContinuity, taskSignature } from '../learning/task-model-conti
36
36
  import { describeFailureKind, renderExhaustionReport, } from '../learning/exhaustion-report.js';
37
37
  import { estimateTokens } from '../learning/cost-tracker.js';
38
38
  import { getModelRegistry } from '../learning/model-registry.js';
39
+ import { getQuotaLedger } from '../learning/quota-ledger.js';
39
40
  import { costSince } from '../learning/cost-tracker.js';
40
41
  import { refreshModelRegistry } from '../inference/model-probe.js';
41
42
  import { startWarmupDaemon } from '../learning/model-warmup.js';
@@ -484,6 +485,44 @@ export const PINNED_SAME_PAIR_GRACE_MS = 120_000;
484
485
  * these reach the {@link PINNED_SAME_PAIR_GRACE_MS} budget.
485
486
  */
486
487
  export const PINNED_GRACE_BACKOFF_MS = [5_000, 15_000, 30_000, 45_000];
488
+ /**
489
+ * The whole pinned-escalation budget, read as CONFIGURATION rather than a build
490
+ * property — the same rule {@link transientRetryDelaysMs} follows, and for the
491
+ * same reason.
492
+ *
493
+ * `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` is the dial every headless surface already
494
+ * sets (the gateway's lifecycle contracts, the parity driver, CI): it says "a
495
+ * flapping provider costs milliseconds here, not minutes". The pinned window is
496
+ * the SAME KIND of wait — a bounded retry of one pair, followed by a question no
497
+ * unattended surface can answer — so it has to honour that dial. Measured
498
+ * 2026-10-15: the dial was in force and four suites still timed out, because the
499
+ * pinned window ignored it — a gateway inbound message sat 95s on a dead pin and
500
+ * then held the turn open for the five-minute answer window.
501
+ *
502
+ * Set (and not nonsense) → the dial IS the pinned schedule: the window is the sum
503
+ * of the waits it declares, and the answer window collapses to the same budget,
504
+ * so a dialed run can never wait longer than it asked for. Unset → the shipped
505
+ * two-minute window and the five-minute answer window stand unchanged (see
506
+ * {@link PINNED_SAME_PAIR_GRACE_MS} / {@link PINNED_ANSWER_TIMEOUT_MS}).
507
+ */
508
+ export function pinnedGraceWindow(env = process.env) {
509
+ if (!transientRetryScheduleOverridden(env)) {
510
+ return {
511
+ graceMs: PINNED_SAME_PAIR_GRACE_MS,
512
+ backoffMs: PINNED_GRACE_BACKOFF_MS,
513
+ answerTimeoutMs: PINNED_ANSWER_TIMEOUT_MS,
514
+ };
515
+ }
516
+ const dialed = transientRetryDelaysMs(env);
517
+ // Floored at 1ms so a `0` dial still buys one retry rather than an empty window
518
+ // (the same attempt the undialed path always makes first).
519
+ const graceMs = Math.max(1, dialed.reduce((sum, ms) => sum + ms, 0));
520
+ return { graceMs, backoffMs: dialed, answerTimeoutMs: graceMs };
521
+ }
522
+ /** A wait as a human reads it — seconds below a minute, minutes above. */
523
+ function describeWait(ms) {
524
+ return ms >= 60_000 ? `${Math.round(ms / 60_000)} minutes` : `${Math.round(ms / 1000)}s`;
525
+ }
487
526
  /**
488
527
  * Failure kinds a WAIT can plausibly fix.
489
528
  *
@@ -2003,7 +2042,11 @@ export class ChatCommand extends BaseCommand {
2003
2042
  let machineBlock = '';
2004
2043
  try {
2005
2044
  const { buildMachineFactsBlock } = await import('../learning/machine-facts.js');
2006
- machineBlock = buildMachineFactsBlock();
2045
+ // …plus the optional tools this machine does NOT have, so a feature that
2046
+ // needs one is named as skipped (with its install command) instead of
2047
+ // being reported as done. Empty string when everything is installed.
2048
+ const { buildRuntimeDependenciesBlock } = await import('../learning/runtime-dependencies.js');
2049
+ machineBlock = buildMachineFactsBlock() + buildRuntimeDependenciesBlock();
2007
2050
  }
2008
2051
  catch {
2009
2052
  machineBlock = '';
@@ -2574,52 +2617,32 @@ export class ChatCommand extends BaseCommand {
2574
2617
  }
2575
2618
  return false;
2576
2619
  }
2577
- // A pinned turn: never substitute a model the user explicitly forbade.
2578
- if (strictModelMode())
2579
- return false;
2580
- try {
2581
- const chain = getProviderFallback(this.configManager, this.configManager.getAll().fallback)
2582
- .getFallbackChain(session.type);
2583
- /**
2584
- * PINNED + AUTO-FALLBACK stays in the PIN'S CAPABILITY CLASS.
2585
- *
2586
- * A user who pins a strong model and leaves auto-fallback on has said
2587
- * "keep me in this class" — they have not agreed to a small/fast model
2588
- * because it happens to have the best health score. `sameClassFallback`
2589
- * chooses within the class first and reports when it must step down, so
2590
- * the handoff can SAY so instead of arriving as a silent downgrade.
2591
- */
2592
- const pinModel = session.model;
2593
- for (const fbType of chain) {
2594
- if (fbType === session.type)
2595
- continue;
2596
- if (!hasCredentials(this.configManager, fbType))
2597
- continue;
2598
- if (!governanceVerdict(this.configManager, fbType, { taskText: message }).allowed)
2599
- continue;
2600
- try {
2601
- const resolved = resolveProvider(this.configManager, fbType);
2602
- const base = getAutoRouter().resolveModel(fbType, 'chat', this.configManager);
2603
- const pick = sameClassFallback(pinModel, [
2604
- ...(base ? [base] : []),
2605
- ...preferredModelsFor(resolved.type),
2606
- ]);
2607
- const model = pick?.model ?? base;
2608
- if (pick?.crossClass) {
2609
- logger.warn(` ⚠️ ${pinModel ?? 'the pinned model'} is not available anywhere; handing over to ` +
2610
- `${resolved.type}/${model} (${capabilityClassOf(model)} class — no same-class model was available).`);
2611
- }
2612
- if (install({ type: resolved.type, provider: resolved.provider, model }))
2613
- return true;
2614
- }
2615
- catch {
2616
- // Next fallback candidate.
2617
- }
2618
- }
2619
- }
2620
- catch {
2621
- // Fall through to `false` — the loop's bounded path still applies.
2622
- }
2620
+ /**
2621
+ * ── A PINNED TURN DOES NOT SUBSTITUTE ON AN UNUSABLE RESPONSE ────────
2622
+ *
2623
+ * This used to walk the configured fallback chain for every non-strict pin
2624
+ * (and refuse only under strict), so the pair the user pinned could be
2625
+ * replaced mid-turn by whatever the health ranking liked. Measured live
2626
+ * (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro` returned an empty
2627
+ * response twice, and the turn was answered by `local/deepseek-coder:latest`
2628
+ * — a weaker model the user never named, on a conversation that was pinned
2629
+ * with its "this model only" lock on but unpersisted (see
2630
+ * `ChatConsole`'s `pinnedStrict`). The switch also arrived as a handoff note
2631
+ * the model wrote to ITSELF; the user's only clue was the answer's wording.
2632
+ *
2633
+ * The pinned rule is now the same one {@link pinnedEscalation} implements for
2634
+ * a thrown failure: nothing is substituted without the user choosing it. An
2635
+ * EMPTY response cannot ask from here — the loop calls this hook but owns the
2636
+ * thread, so this function has no call to retry with — which is why the
2637
+ * caller's `emptyResponsePatienceMs` gives the pinned pair its full grace
2638
+ * window FIRST (the loop re-asks the SAME pair, nothing routed elsewhere), and
2639
+ * what is left when that window is spent is an honest stop, reported by the
2640
+ * surface with its retry offer. Routing a pinned turn to another model behind
2641
+ * the user's back is the defect, not the remedy.
2642
+ *
2643
+ * AUTO turns are untouched above: they are the case where substituting IS the
2644
+ * user's instruction (and the case the walk exists for).
2645
+ */
2623
2646
  return false;
2624
2647
  };
2625
2648
  try {
@@ -2680,6 +2703,32 @@ export class ChatCommand extends BaseCommand {
2680
2703
  provider: session.type,
2681
2704
  model: this.lastAttempt?.model ?? session.model ?? 'default',
2682
2705
  };
2706
+ const failedEarlierStep = this.turnAttempts.some((a) => a.provider === pair.provider && a.model === pair.model);
2707
+ /**
2708
+ * ── A PINNED PAIR GETS THE PIN'S OWN GRACE WINDOW ─────────────
2709
+ *
2710
+ * An empty 200 is a PROVIDER failure, and for a PIN it is the one
2711
+ * failure the user already told us how to handle: wait on the model
2712
+ * they named. The blip policy's [2s, 5s] is the wrong instrument for
2713
+ * that contract — it is sized for "is this provider having a
2714
+ * moment", not for "you asked for THIS model".
2715
+ *
2716
+ * Measured (trace-1792068695325-42wb1j): `deepseek/deepseek-v4-pro`
2717
+ * returned nothing, was re-asked after 2s and after 5s, and the turn
2718
+ * was then handed to `groq/openai/gpt-oss-120b` and finally answered
2719
+ * by `local/deepseek-coder:latest` — seven seconds of patience and a
2720
+ * model the user never picked. The strict path had a two-minute
2721
+ * window for this; an empty response could never reach it (the loop
2722
+ * never throws on one), so the pin got the blip schedule instead.
2723
+ *
2724
+ * So the pinned schedule is the SAME one {@link pinnedEscalation}
2725
+ * uses for a thrown failure — nothing is routed anywhere inside it —
2726
+ * and only for the FIRST streak on the pair: once the pair has
2727
+ * already failed a step, waiting again is a pattern, not a blip, and
2728
+ * the loop's own bounded stop follows.
2729
+ */
2730
+ if (!mode.auto && !failedEarlierStep)
2731
+ return [...pinnedGraceWindow().backoffMs];
2683
2732
  return planBlipPatience({
2684
2733
  current: pair,
2685
2734
  alternatives: failoverAlternatives(this.configManager, pair.provider),
@@ -2687,7 +2736,7 @@ export class ChatCommand extends BaseCommand {
2687
2736
  // An entry here means an EARLIER step of this turn already failed
2688
2737
  // on this pair — the same pair failing twice is a pattern, not a
2689
2738
  // blip, and patience collapses there.
2690
- failedEarlierStep: this.turnAttempts.some((a) => a.provider === pair.provider && a.model === pair.model),
2739
+ failedEarlierStep,
2691
2740
  deliveredWorkThisTurn,
2692
2741
  }).emptyResponseDelaysMs;
2693
2742
  }
@@ -3314,44 +3363,81 @@ export class ChatCommand extends BaseCommand {
3314
3363
  }
3315
3364
  }
3316
3365
  /**
3317
- * PINNED + STRICT and the pin did not answer: wait on the SAME model, then let
3318
- * the USER decide — never a silent substitution.
3366
+ * A PINNED turn whose pair did not answer: wait on the SAME model, then let the
3367
+ * USER decide — never a silent substitution.
3368
+ *
3369
+ * WHY THIS EXISTS. Pinning a model is a quality contract, and the old behaviour
3370
+ * broke it in the one direction the user explicitly forbade: rather than wait, or
3371
+ * say what was happening, a failed pin either dead-ended in "the language model
3372
+ * was unavailable" or was handed to whatever the health ranking liked — including
3373
+ * `local/qwen2.5:0.5b`, a model that cannot call tools at all, so its "answer" was
3374
+ * fabricated rather than merely weaker. Sending that work to a weak model is not a
3375
+ * degraded answer; for an agentic turn it is a wrong one.
3319
3376
  *
3320
- * WHY THIS EXISTS. The user pinned a model AND turned strict mode on. That is a
3321
- * quality contract, and the old behaviour broke it in the one direction they
3322
- * explicitly forbade: rather than wait, or say what was happening, a failed pin
3323
- * either dead-ended in "the language model was unavailable" or (non-strict) was
3324
- * handed to whatever the health ranking liked — including `local/qwen2.5:0.5b`, a
3325
- * model that cannot call tools at all, so its "answer" was fabricated rather
3326
- * than merely weaker. Sending that work to a weak model is not a degraded answer;
3327
- * for an agentic turn it is a wrong one.
3377
+ * WHO IT COVERS. Every pin, not only a strict one. It used to be gated on
3378
+ * `strictModelMode()` alone, which left the default state — the chat's picker pin
3379
+ * with its lock OFF, which is what the client sends for `pinnedModel && !strictPin`
3380
+ * — on the silent path: measured live (trace-1792068695325-42wb1j) `deepseek/
3381
+ * deepseek-v4-pro` returned nothing twice, was handed to `groq/openai/gpt-oss-120b`
3382
+ * and from there to `local/deepseek-coder:latest`, and the turn was answered by a
3383
+ * model the user never named. So the wait-then-ask rule is now the PIN's, and the
3384
+ * STRICT flag only decides what happens when nobody can be asked.
3328
3385
  *
3329
3386
  * So: the SAME pair is retried inside {@link PINNED_SAME_PAIR_GRACE_MS} first
3330
3387
  * (nothing is routed anywhere in that window), and if it still does not answer
3331
3388
  * the user is asked — keep trying, switch to the closest SAME-CLASS model the
3332
- * agent can find and name, type another model/provider, or release the pin to
3333
- * auto. The question says outright that nothing has been routed elsewhere.
3389
+ * agent can find and name, or type another model/provider. The question says
3390
+ * outright that nothing has been routed elsewhere.
3334
3391
  *
3335
- * Returns a served response when the user's choice works; `null` when the turn
3336
- * must fail honestly — headless (no one to ask: the strict refusal stands, as
3337
- * it must, because silently choosing FOR the user is the original defect), or
3338
- * when the chosen pair could not serve.
3392
+ * Outcomes (see {@link PinEscalationOutcome}): a served response when the user's
3393
+ * choice works; `stop` when the pin's own error is the honest outcome; and
3394
+ * `unattended` when NOBODY could be asked on a non-strict pin — the caller then
3395
+ * keeps its documented fallback walk, so a pipeline or worker that cannot answer
3396
+ * a question is not dead-ended by one.
3339
3397
  */
3340
- async pinnedStrictEscalation(input) {
3398
+ async pinnedEscalation(input) {
3341
3399
  const { session } = input;
3342
3400
  const pin = `${session.type}/${session.model ?? 'default'}`;
3343
3401
  const kind = classifyFallbackError(input.err);
3402
+ // How long this deployment is willing to wait on a pin — the two-minute
3403
+ // default, or the `NUVIRA_TRANSIENT_RETRY_DELAYS_MS` dial when it is set
3404
+ // (see `pinnedGraceWindow`).
3405
+ const { graceMs, backoffMs, answerTimeoutMs } = pinnedGraceWindow();
3406
+ /**
3407
+ * This path cannot serve the pin. STRICT stops on the pin's own error; a
3408
+ * non-strict pin falls through to the caller's fallback walk, which is the
3409
+ * behaviour it had before this rule existed. Consulted only for the cases
3410
+ * where NOBODY has been asked anything — a definitive failure, or a surface
3411
+ * with no one to ask. Once a question has actually been put to the user, the
3412
+ * answer (or their silence) is binding for both modes: substituting after
3413
+ * they chose to stay would be the same silent switch in a new disguise.
3414
+ */
3415
+ const cannotServe = () => strictModelMode() ? { kind: 'stop' } : { kind: 'unattended' };
3416
+ /**
3417
+ * NOBODY CAN BE ASKED, and the pin is not strict: hand the turn straight back
3418
+ * to the caller's documented fallback walk.
3419
+ *
3420
+ * The grace window below exists to bridge the wait to a QUESTION — "your pin has
3421
+ * not answered, would you rather switch?". With no surface to put that question
3422
+ * to, the window buys nothing but delay: a cron job or queue worker that pinned
3423
+ * a model would stall two minutes on every provider blip and then take the same
3424
+ * walk it could have taken immediately. STRICT is the other case and keeps its
3425
+ * window: "this model or nothing" has no earlier alternative, so waiting on the
3426
+ * named pair is the only thing that contract can do.
3427
+ */
3428
+ if (!input.askUser && !process.stdin.isTTY && !strictModelMode())
3429
+ return { kind: 'unattended' };
3344
3430
  // A DEFINITIVE failure is not a delay (see PINNED_WAITABLE_KINDS) — and a model
3345
3431
  // the provider does not serve, or a request-shape fault, is definitive even
3346
3432
  // though the classifier can only call it `unknown` (measured: a pin to a
3347
3433
  // nonexistent id spent the whole 2-minute grace window on a 400 that could
3348
3434
  // never change).
3349
3435
  if (!PINNED_WAITABLE_KINDS.has(kind))
3350
- return null;
3436
+ return cannotServe();
3351
3437
  if (kind === 'unknown') {
3352
3438
  const message = input.err instanceof Error ? input.err.message : String(input.err);
3353
3439
  if (isHarnessFault(input.err) || PINNED_DEFINITIVE_ERROR_RE.test(message))
3354
- return null;
3440
+ return cannotServe();
3355
3441
  }
3356
3442
  const attemptPin = async () => {
3357
3443
  try {
@@ -3371,30 +3457,32 @@ export class ChatCommand extends BaseCommand {
3371
3457
  // ── 1. The SAME pair gets its grace window. Nothing is routed anywhere. ──
3372
3458
  let served = await attemptPin();
3373
3459
  if (served)
3374
- return served;
3460
+ return { kind: 'served', response: served };
3375
3461
  const startedAt = Date.now();
3376
- for (const backoff of PINNED_GRACE_BACKOFF_MS) {
3462
+ for (const backoff of backoffMs) {
3377
3463
  if (input.signal?.aborted)
3378
- return null;
3379
- if (Date.now() - startedAt + backoff > PINNED_SAME_PAIR_GRACE_MS)
3464
+ return { kind: 'stop' };
3465
+ if (Date.now() - startedAt + backoff > graceMs)
3380
3466
  break;
3381
3467
  // Visible on BOTH surfaces: the CLI reads it on the console, the dashboard
3382
3468
  // renders the same line as a progress step.
3383
3469
  logger.warn(` ⏳ your pinned ${pin} has not answered (${describeFailureKind(kind)}) — retrying it in ` +
3384
3470
  `${Math.round(backoff / 1000)}s (${Math.round((Date.now() - startedAt) / 1000)}s of ` +
3385
- `${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s). Nothing has been sent to another model.`);
3471
+ `${Math.round(graceMs / 1000)}s). Nothing has been sent to another model.`);
3386
3472
  await new Promise((resolve) => setTimeout(resolve, backoff));
3387
3473
  served = await attemptPin();
3388
3474
  if (served)
3389
- return served;
3475
+ return { kind: 'served', response: served };
3390
3476
  }
3391
3477
  // ── 2. ASK. The pin is the user's contract; only they may release it. ────
3392
3478
  const alternatives = this.pinAlternatives(session.model, session.type);
3393
3479
  const switchTarget = alternatives.sameClass[0];
3394
- // Headless (a pipeline, a cron, a queue worker) has no one to ask: the strict
3395
- // refusal stands. Choosing silently FOR the user is the defect this fixes.
3480
+ // Headless (a pipeline, a cron, a queue worker) has no one to ask. Choosing
3481
+ // silently FOR the user is the defect this fixes, so the pin is never
3482
+ // released here: a strict pin stops on its own error, and a non-strict pin
3483
+ // hands back to the caller's fallback walk — see `cannotServe`.
3396
3484
  if (!input.askUser && !process.stdin.isTTY)
3397
- return null;
3485
+ return cannotServe();
3398
3486
  const ask = input.askUser ?? (await import('../tools/ask-user.js')).renderAskUser;
3399
3487
  const choices = [
3400
3488
  {
@@ -3417,11 +3505,11 @@ export class ChatCommand extends BaseCommand {
3417
3505
  const otherIdx = switchTarget ? 2 : 1;
3418
3506
  // NOTE — there is deliberately NO "go to auto" option. It was considered and
3419
3507
  // removed: it is the one choice that serves a model the user never named, and
3420
- // a strict pin exists precisely to rule that out. A user who wants routing to
3421
- // decide turns strict mode off, which is a decision they own and can see.
3422
- // Nothing in this method can therefore serve a pair the user did not pick.
3423
- const question = `I have NOT sent this to another model — you pinned ${pin} and strict mode is on, so that choice is yours.\n` +
3424
- `${pin} did not answer within ${Math.round(PINNED_SAME_PAIR_GRACE_MS / 1000)}s (${describeFailureKind(kind)}).\n` +
3508
+ // a pin exists precisely to rule that out. A user who wants routing to decide
3509
+ // clears the pin, which is a decision they own and can see. Nothing in this
3510
+ // method can therefore serve a pair the user did not pick.
3511
+ const question = `I have NOT sent this to another model — you pinned ${pin}, so that choice is yours.\n` +
3512
+ `${pin} did not answer within ${Math.round(graceMs / 1000)}s (${describeFailureKind(kind)}).\n` +
3425
3513
  (switchTarget
3426
3514
  ? `If you would rather switch, ${switchTarget.provider}/${switchTarget.model} is the closest same-class model available.`
3427
3515
  : 'No same-class model is available to switch to right now.');
@@ -3435,7 +3523,7 @@ export class ChatCommand extends BaseCommand {
3435
3523
  const raced = await Promise.race([
3436
3524
  ask(question, choices, false).then((a) => ({ answered: true, a })),
3437
3525
  new Promise((resolve) => {
3438
- answerTimer = setTimeout(() => resolve({ answered: false }), PINNED_ANSWER_TIMEOUT_MS);
3526
+ answerTimer = setTimeout(() => resolve({ answered: false }), answerTimeoutMs);
3439
3527
  // Never hold the process open for an answer that already arrived: an
3440
3528
  // un-unref'd 5-minute timer kept the CLI alive long after the turn was
3441
3529
  // finished (the `chat` command would appear to hang on exit).
@@ -3445,14 +3533,14 @@ export class ChatCommand extends BaseCommand {
3445
3533
  if (answerTimer)
3446
3534
  clearTimeout(answerTimer);
3447
3535
  if (!raced.answered) {
3448
- logger.warn(` ⌛ no answer to the pinned-model question after ${Math.round(PINNED_ANSWER_TIMEOUT_MS / 60_000)} minutes — ` +
3536
+ logger.warn(` ⌛ no answer to the pinned-model question after ${describeWait(answerTimeoutMs)} — ` +
3449
3537
  `stopping on ${pin} rather than choosing for you.`);
3450
- return null;
3538
+ return { kind: 'stop' };
3451
3539
  }
3452
3540
  answer = raced.a;
3453
3541
  }
3454
3542
  catch {
3455
- return null;
3543
+ return { kind: 'stop' };
3456
3544
  }
3457
3545
  const chosen = typeof answer.index === 'number' ? answer.index : -1;
3458
3546
  if (chosen === keepIdx) {
@@ -3462,18 +3550,22 @@ export class ChatCommand extends BaseCommand {
3462
3550
  const retryStart = Date.now();
3463
3551
  for (;;) {
3464
3552
  if (input.signal?.aborted)
3465
- return null;
3553
+ return { kind: 'stop' };
3466
3554
  const again = await attemptPin();
3467
3555
  if (again)
3468
- return again;
3469
- if (Date.now() - retryStart >= PINNED_SAME_PAIR_GRACE_MS)
3556
+ return { kind: 'served', response: again };
3557
+ if (Date.now() - retryStart >= graceMs)
3470
3558
  break;
3471
- await new Promise((resolve) => setTimeout(resolve, 15_000));
3559
+ await new Promise((resolve) => setTimeout(resolve, Math.min(15_000, graceMs)));
3472
3560
  }
3473
- return null;
3561
+ // The user chose to stay on the pin and it still did not answer: the pin's
3562
+ // own failure is the outcome. Substituting now would be the very switch
3563
+ // they just declined.
3564
+ return { kind: 'stop' };
3474
3565
  }
3475
3566
  if (switchIdx >= 0 && chosen === switchIdx && switchTarget) {
3476
- return await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
3567
+ const switched = await this.serveChosenPair(input, switchTarget.provider, switchTarget.model);
3568
+ return switched ? { kind: 'served', response: switched } : { kind: 'stop' };
3477
3569
  }
3478
3570
  if (chosen === otherIdx) {
3479
3571
  const custom = (answer.custom ?? '').trim();
@@ -3482,12 +3574,12 @@ export class ChatCommand extends BaseCommand {
3482
3574
  if (pair) {
3483
3575
  const servedCustom = await this.serveChosenPair(input, pair.provider, pair.model);
3484
3576
  if (servedCustom)
3485
- return servedCustom;
3577
+ return { kind: 'served', response: servedCustom };
3486
3578
  }
3487
3579
  logger.warn(` ⚠️ could not use "${custom}" — check that the model is served by that provider.`);
3488
3580
  }
3489
3581
  }
3490
- return null;
3582
+ return { kind: 'stop' };
3491
3583
  }
3492
3584
  /**
3493
3585
  * E3b — the model-call step for the tool loop:
@@ -3859,6 +3951,33 @@ export class ChatCommand extends BaseCommand {
3859
3951
  catch {
3860
3952
  // Best-effort — bookkeeping must never break the turn.
3861
3953
  }
3954
+ // ══ A PINNED TURN ASKS BEFORE IT LEAVES THE PIN ════════════════════
3955
+ //
3956
+ // The rule is the user's, not the mode's: a pin is a quality contract, so
3957
+ // the turn WAITS on the pair it was given and then puts the choice to the
3958
+ // user (keep trying / switch to a named same-class model / type another /
3959
+ // stop). It never slides onto a model they did not name — which is what
3960
+ // the non-strict pin used to do the moment the pair returned an error.
3961
+ //
3962
+ // STRICT keeps its extra meaning: when nobody can be asked (a pipeline, a
3963
+ // worker with no interactive surface) the refusal stands and the pin's own
3964
+ // error is the outcome, rather than a silent substitution. A NON-strict
3965
+ // pin in that position keeps the documented fallback walk below, so an
3966
+ // unattended run cannot be dead-ended by a question it cannot answer.
3967
+ if (!mode.auto) {
3968
+ const decision = await this.pinnedEscalation({
3969
+ session,
3970
+ err,
3971
+ tryGenerate,
3972
+ ...(askUser ? { askUser } : {}),
3973
+ signal: abort,
3974
+ });
3975
+ if (decision.kind === 'served')
3976
+ return decision.response;
3977
+ if (decision.kind === 'stop')
3978
+ throw err;
3979
+ // 'unattended' → fall through to the non-auto walk below.
3980
+ }
3862
3981
  // Auto mode: fail over across the ranked candidates (never stuck).
3863
3982
  if (mode.auto) {
3864
3983
  const firstType = session.type;
@@ -3894,13 +4013,52 @@ export class ChatCommand extends BaseCommand {
3894
4013
  const firstPair = `${this.lastAttempt?.provider ?? session.type}|${this.lastAttempt?.model ?? session.model ?? 'default'}`;
3895
4014
  const failedPairs = new Set([firstPair]);
3896
4015
  const failedProviders = new Set();
4016
+ /**
4017
+ * ── A PROVIDER-WIDE PARK IS EVIDENCE, NOT A HINT ─────────────────
4018
+ *
4019
+ * RULE: every provider the shared bookkeeping has escalated OUT is
4020
+ * left behind by this walk, whether the escalation lives in the
4021
+ * session's own map (this turn's failures) or in the quota ledger
4022
+ * (`parkProvider` — several of the provider's models rate-limited, so
4023
+ * the limit is shared; see `PROVIDER_RATE_LIMIT_ESCALATION_MODELS`).
4024
+ *
4025
+ * THE DEFECT THIS CLOSES (measured, trace-1792068452654-3e1w8d). The
4026
+ * walk read ONLY the session map, so gemini's ledger park — scope
4027
+ * `'provider'`, written at 12:48:23 and good until 16:48 — was
4028
+ * invisible to it. The walk then spent its whole attempt budget
4029
+ * descending gemini's OWN ladder (10 distinct gemini models, all of
4030
+ * them 429ing) and landed on its weakest rung, `gemma-4-26b-a4b-it`,
4031
+ * while `deepseek/deepseek-v4-pro` and `deepseek-flash` sat verified
4032
+ * and unused. One provider's exhaustion must not consume the walk.
4033
+ *
4034
+ * The walk's own rule is unchanged: a PAIR failure keeps the provider
4035
+ * routable (its siblings have their own limits). This only adds the
4036
+ * provider-wide verdict the router's own quota feed already uses, so
4037
+ * "the provider is out" is read from ONE source of truth instead of
4038
+ * two.
4039
+ */
4040
+ const providerIsOut = (providerType) => {
4041
+ try {
4042
+ if ((this.sessionFailedProviders.get(providerType) ?? 0) > Date.now())
4043
+ return true;
4044
+ }
4045
+ catch {
4046
+ // Unknown session state → fall through to the ledger verdict.
4047
+ }
4048
+ try {
4049
+ return getQuotaLedger().isProviderParked(providerType);
4050
+ }
4051
+ catch {
4052
+ // No ledger → no provider-wide verdict (pair-scoped walking stays).
4053
+ return false;
4054
+ }
4055
+ };
3897
4056
  try {
3898
4057
  // The bookkeeping already ran for the failure above: a provider still
3899
4058
  // carrying an ACTIVE provider-scoped exclusion is out for this walk,
3900
4059
  // whatever kind the failure was classified as.
3901
- if ((this.sessionFailedProviders.get(session.type) ?? 0) > Date.now()) {
4060
+ if (providerIsOut(session.type))
3902
4061
  failedProviders.add(session.type);
3903
- }
3904
4062
  }
3905
4063
  catch {
3906
4064
  // Best-effort — evidence must never break the walk.
@@ -4007,14 +4165,7 @@ export class ChatCommand extends BaseCommand {
4007
4165
  // Is the PROVIDER out, or only this PAIR? The bookkeeping just
4008
4166
  // wrote the answer; read it rather than re-deriving the rule, so
4009
4167
  // the walk and the exclusion filter can never disagree.
4010
- let providerOut = false;
4011
- try {
4012
- providerOut = (this.sessionFailedProviders.get(next.type) ?? 0) > Date.now();
4013
- }
4014
- catch {
4015
- // Unknown scope → treat it as pair-scoped (the conservative
4016
- // direction for staying routable).
4017
- }
4168
+ const providerOut = providerIsOut(next.type);
4018
4169
  if (providerOut)
4019
4170
  failedProviders.add(next.type);
4020
4171
  logger.debug(` ⚠️ ${next.type}/${next.model ?? 'default'} failed (${describeFailureKind(nextKind)}) — ` +
@@ -4237,21 +4388,10 @@ export class ChatCommand extends BaseCommand {
4237
4388
  .join('; ')}`);
4238
4389
  }
4239
4390
  }
4240
- else if (strictModelMode()) {
4241
- /**
4242
- * PINNED + STRICT — wait on the SAME model, then ASK. Never substitute:
4243
- * that is the whole meaning of the pin. See `pinnedStrictEscalation`.
4244
- */
4245
- const decided = await this.pinnedStrictEscalation({
4246
- session,
4247
- err,
4248
- tryGenerate,
4249
- ...(askUser ? { askUser } : {}),
4250
- signal: abort,
4251
- });
4252
- if (decided)
4253
- return decided;
4254
- }
4391
+ // NOTE: the pinned case is handled by `pinnedEscalation` ABOVE, before
4392
+ // this if/else — a strict pin therefore never reaches the non-auto walk
4393
+ // (it throws its own error), which is the behaviour that branch used to
4394
+ // implement on its own.
4255
4395
  // Answer-quality resilience: every candidate failed (or none was
4256
4396
  // tried) and the error carries the model's raw confused reply —
4257
4397
  // deliver THAT instead of failing the whole turn. A confusing answer