pi-lemonade-link 1.0.2 → 2.0.0

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/index.ts CHANGED
@@ -9,24 +9,30 @@
9
9
  * with capability metadata (reasoning / vision / context window) and
10
10
  * live loaded-state annotations. Chat-capable models only, by default.
11
11
  * 2. Multimodal agent tools — transcribe_audio, generate_image, edit_image,
12
- * vary_image, upscale_image, text_to_speech, generate_audio, and
13
- * generate_3d_model, all backed by lemonade's OpenAI-compatible and
14
- * Lemonade-specific endpoints. Unloaded models auto-load on demand;
15
- * container audio formats are converted to wav via ffmpeg first.
12
+ * vary_image, upscale_image, text_to_speech (incl. voice cloning and
13
+ * voice design), generate_audio, and generate_3d_model, all backed by
14
+ * lemonade's OpenAI-compatible and Lemonade-specific endpoints. Unloaded
15
+ * models auto-load on demand; container audio formats are converted to
16
+ * wav via ffmpeg first.
16
17
  * 3. /lemonade-setup — a navigable TUI menu for live status, endpoint
17
- * configuration, server discovery, model management (load, unload,
18
- * pull with live progress + cancel, delete with typed-phrase
19
- * confirmation, change-ctx, Hugging Face search & install, filter,
20
- * refresh).
18
+ * configuration, server discovery, and administration: a live model
19
+ * browser (inline status, type-to-search, contextual actions, animated
20
+ * busy markers with esc-to-detach), download-job management (terminal
21
+ * jobs auto-removed), a recipe-options editor (per-model ctx/backend/
22
+ * args, saved without loading), pin/unpin, model updates (check + bulk
23
+ * sync or per-model pull), recipe-backend installs with live progress,
24
+ * Hugging Face search & install, filter, refresh.
21
25
  * 4. A lemonade status bar — while a lemonade model is active, an extra
22
26
  * below-editor row appears (never touching the footer): instance name,
23
27
  * tok/s and prefix-cache hit % derived locally from the session's own
24
- * messages, plus polled busy/queue, CPU/GPU/NPU and VRAM from the active
25
- * instance.
28
+ * messages, plus polled busy/queue, whole-box active requests + live
29
+ * tok/s, CPU/GPU/NPU and VRAM from the active instance.
26
30
  * Switching to a non-lemonade model removes the row.
27
31
  * 5. Pulls use lemonade's server-owned download jobs (stream + subscribe:
28
32
  * false + GET /v1/downloads) with a live progress view, Esc to cancel —
29
- * no blocking 30-minute waits. Blocking pull is kept as a fallback.
33
+ * no blocking 30-minute waits. Errored/cancelled jobs are removed from
34
+ * the server's list when the view closes (the spec keeps them until a
35
+ * client does). Blocking pull is kept as a fallback.
30
36
  *
31
37
  * Nothing is hardcoded: every endpoint lives in ~/.pi/agent/lemonade.json,
32
38
  * editable at runtime via /lemonade-setup (changes re-register the provider
@@ -38,7 +44,7 @@
38
44
  * are allowed in the config file; note that /lemonade-setup rewrites strip
39
45
  * them.
40
46
  *
41
- * Endpoints confirmed against lemonade 11.7.0 (see README for full list).
47
+ * Endpoints confirmed against lemonade 2026.46.2 (see README for full list).
42
48
  */
43
49
 
44
50
  import * as fs from "node:fs";
@@ -81,6 +87,24 @@ interface LemonadeConfig {
81
87
  audioGenerationPath: string;
82
88
  mesh3dPath: string;
83
89
  classifyPath: string;
90
+ /** LEMONADE_ADMIN_API_KEY — required by /internal/* endpoints (pin, model
91
+ * sync, config) on servers that set one. Empty = fall back to apiKey. */
92
+ adminApiKey: string;
93
+ /** Per-model recipe options: read/save/reset without loading. `{id}` is
94
+ * replaced with the model id. */
95
+ modelOptionsPath: string;
96
+ /** Check downloaded models for newer registry commits (no download). */
97
+ checkUpdatesPath: string;
98
+ /** Pin/unpin a loaded model without reloading it (admin). */
99
+ pinPath: string;
100
+ /** Bulk model-update sync — check + download in one job (admin). */
101
+ modelSyncPath: string;
102
+ /** Install/update a recipe backend (or register a cloud provider). */
103
+ installPath: string;
104
+ /** Resolve backend install metadata without downloading. */
105
+ installDryRunPath: string;
106
+ /** Remove a backend or cloud provider registration. */
107
+ uninstallPath: string;
84
108
  beaconPort: number;
85
109
  chatOnly: boolean;
86
110
  defaultTranscriptionModel: string;
@@ -108,6 +132,11 @@ interface LemonadeConfig {
108
132
  // info notice, deduplicated to once per (instance, new release) — the
109
133
  // TUI always shows the live truth. Separately toggleable; default on.
110
134
  upstreamVersionCheck: boolean;
135
+ // Outage cache: when a probe can't reach an instance, register its last
136
+ // good catalog (persisted on every successful probe) instead of nothing —
137
+ // provenance-marked "(cached <time>)" in /model, warned at session start,
138
+ // and re-synced with a diff notice once the box answers again.
139
+ cacheOnOutage: boolean;
111
140
  discoveryTimeoutMs: number;
112
141
  beaconTimeoutMs: number;
113
142
  loadTimeoutMs: number;
@@ -126,6 +155,7 @@ export interface ServerEntry {
126
155
  name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
127
156
  baseUrl: string;
128
157
  apiKey?: string; // falls back to the top-level (shared) apiKey when omitted
158
+ adminApiKey?: string; // falls back to the top-level adminApiKey, then the apiKey
129
159
  description?: string; // free-form context for the user's own reminder's sake
130
160
  }
131
161
 
@@ -154,8 +184,17 @@ const DEFAULT_CONFIG: LemonadeConfig = {
154
184
  audioGenerationPath: "/v1/audio/generations",
155
185
  mesh3dPath: "/v1/3d/generations",
156
186
  classifyPath: "/v1/classify",
187
+ adminApiKey: "", // empty = use apiKey for admin endpoints too
188
+ modelOptionsPath: "/v1/models/{id}/options",
189
+ checkUpdatesPath: "/v1/models/check-updates",
190
+ pinPath: "/internal/pin",
191
+ modelSyncPath: "/internal/models/sync",
192
+ installPath: "/v1/install",
193
+ installDryRunPath: "/v1/install/dry-run",
194
+ uninstallPath: "/v1/uninstall",
157
195
  beaconPort: 13305,
158
196
  chatOnly: true,
197
+ cacheOnOutage: false,
159
198
  defaultTranscriptionModel: "Whisper-Large-v3",
160
199
  defaultImageModel: "",
161
200
  defaultUpscaleModel: "RealESRGAN-x4plus",
@@ -309,7 +348,12 @@ function listInstances(config: LemonadeConfig): InstanceView[] {
309
348
  name: s.name,
310
349
  description: s.description ?? "",
311
350
  isDefault: i === 0,
312
- config: { ...config, baseUrl: s.baseUrl, apiKey: s.apiKey || config.apiKey },
351
+ config: {
352
+ ...config,
353
+ baseUrl: s.baseUrl,
354
+ apiKey: s.apiKey || config.apiKey,
355
+ adminApiKey: s.adminApiKey || config.adminApiKey || s.apiKey || config.apiKey,
356
+ },
313
357
  }));
314
358
  }
315
359
 
@@ -323,7 +367,12 @@ function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | s
323
367
  if (!target) {
324
368
  return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
325
369
  }
326
- return { ...config, baseUrl: target.baseUrl, apiKey: target.apiKey || config.apiKey };
370
+ return {
371
+ ...config,
372
+ baseUrl: target.baseUrl,
373
+ apiKey: target.apiKey || config.apiKey,
374
+ adminApiKey: target.adminApiKey || config.adminApiKey || target.apiKey || config.apiKey,
375
+ };
327
376
  }
328
377
 
329
378
  /** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
@@ -343,6 +392,22 @@ interface LemonadeModel {
343
392
  size?: number;
344
393
  downloaded?: boolean;
345
394
  max_context_window?: number;
395
+ /** Tokens the model handles in one request: loaded/configured ctx_size,
396
+ * else max_context_window; more current than max_context_window alone. */
397
+ context_length?: number;
398
+ /** Whether the model's registry has a newer commit than the local copy. */
399
+ update_available?: boolean;
400
+ /** Whether the model is recommended for general use. */
401
+ suggested?: boolean;
402
+ checkpoint?: string;
403
+ /** Every checkpoint by role (main, mmproj, vae, …). */
404
+ checkpoints?: Record<string, string>;
405
+ /** The model's own options: registry defaults with saved recipe_options on top. */
406
+ recipe_options?: Record<string, unknown>;
407
+ /** One entry per checkpoint slot: present/required/registry — an
408
+ * optional checkpoint (npu_cache, voicegen, …) can be missing without
409
+ * blocking loads. */
410
+ checkpoint_status?: Array<{ type?: string; present?: boolean; required?: boolean; registry?: string }>;
346
411
  }
347
412
 
348
413
  interface LoadedModelInfo {
@@ -363,6 +428,8 @@ interface LoadedModelInfo {
363
428
  slot_pool?: string;
364
429
  residency_class?: string;
365
430
  watchdog_reset?: boolean;
431
+ watchdog_reset_reason?: string;
432
+ launch_command?: Array<string | number>;
366
433
  }
367
434
 
368
435
  interface LemonadeHealth {
@@ -375,6 +442,14 @@ interface LemonadeHealth {
375
442
  pinned_models?: Record<string, number>;
376
443
  pinned_helper_models?: Record<string, number>;
377
444
  telemetry?: { enabled?: boolean; captures?: string[] };
445
+ /** Rises whenever the model list changes (downloaded/update_available
446
+ * flip) — a polling client re-reads /v1/models when it moves. */
447
+ models_generation?: number;
448
+ /** The model sync in flight, if any (status/sync_id/completed_sync_id). */
449
+ model_sync?: { status?: string; sync_id?: number; completed_sync_id?: number };
450
+ /** Whether the startup model-update check has finished — until then,
451
+ * per-model update_available flags can be incomplete. */
452
+ update_check_done?: boolean;
378
453
  }
379
454
 
380
455
  interface SystemStats {
@@ -383,6 +458,18 @@ interface SystemStats {
383
458
  gpu_percent?: number | null;
384
459
  vram_gb?: number | null;
385
460
  npu_percent?: number | null;
461
+ /** Per-GPU detail when the host reports more than one (id, name,
462
+ * memory_total_gb/memory_used_gb, memory_type, vendor). */
463
+ gpus?: Array<{
464
+ id?: string;
465
+ name?: string;
466
+ gpu_percent?: number;
467
+ memory_total_gb?: number;
468
+ memory_used_gb?: number;
469
+ memory_type?: string;
470
+ vendor?: string;
471
+ }>;
472
+ primary_gpu_id?: string;
386
473
  }
387
474
 
388
475
  interface PerfStats {
@@ -391,6 +478,10 @@ interface PerfStats {
391
478
  input_tokens?: number;
392
479
  output_tokens?: number;
393
480
  prompt_tokens?: number | null;
481
+ /** Whole-box liveness (≥2026.46): requests in flight right now, and the
482
+ * server's own live throughput estimate across ALL clients — complements
483
+ * this session's own tok/s, which is timed locally from message events. */
484
+ live?: { active_requests?: number; estimated_tokens_per_second?: number };
394
485
  cache_tokens?: number | null;
395
486
  request_count_total?: number;
396
487
  input_tokens_total?: number;
@@ -417,13 +508,19 @@ interface SystemInfo {
417
508
  Processor?: string;
418
509
  "Physical Memory"?: string;
419
510
  "Windows Power Setting"?: string;
420
- model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number };
511
+ model_storage?: { path?: string; used_bytes?: number; total_bytes?: number; free_bytes?: number; error?: string };
421
512
  devices?: {
422
513
  cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
423
514
  amd_gpu?: GpuInfo[];
424
515
  nvidia_gpu?: GpuInfo[];
425
516
  amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
426
517
  };
518
+ /** Recipe → backend support matrix (lemonade ≥2026.46): every backend the
519
+ * recipe can use on THIS system, with install state, tier, version. */
520
+ recipes?: Record<string, { default_backend?: string; backends?: Record<string, BackendInfo> }>;
521
+ /** Recipes whose backends are all unavailable/unsupported on this system. */
522
+ unavailable_recipes?: string[];
523
+ cloud?: { providers?: Array<Record<string, unknown>> };
427
524
  }
428
525
 
429
526
  interface DiscoveredServer {
@@ -437,6 +534,7 @@ interface DownloadJob {
437
534
  model_name?: string;
438
535
  status: string; // downloading | paused | cancelled | completed | error
439
536
  running?: boolean;
537
+ type?: string; // currently "model" for server-owned jobs
440
538
  file?: string;
441
539
  file_index?: number;
442
540
  total_files?: number;
@@ -444,7 +542,10 @@ interface DownloadJob {
444
542
  bytes_total?: number;
445
543
  percent?: number;
446
544
  cumulative_bytes_downloaded?: number;
545
+ overall_bytes_downloaded?: number;
447
546
  total_download_size?: number;
547
+ bytes_previously_downloaded?: number;
548
+ completed_files_bytes?: number;
448
549
  complete?: boolean;
449
550
  error?: string;
450
551
  }
@@ -456,6 +557,10 @@ interface RegistryResult {
456
557
  downloads?: number;
457
558
  likes?: number;
458
559
  tags?: string[];
560
+ has_gguf?: boolean;
561
+ task?: string;
562
+ source?: string;
563
+ repository_type?: string;
459
564
  }
460
565
 
461
566
  interface PullVariants {
@@ -464,6 +569,9 @@ interface PullVariants {
464
569
  suggested_name?: string;
465
570
  suggested_labels?: string[];
466
571
  mmproj_files?: string[];
572
+ draft_files?: string[];
573
+ repo_kind?: string;
574
+ source?: string;
467
575
  variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
468
576
  }
469
577
 
@@ -583,10 +691,200 @@ async function deleteModel(config: LemonadeConfig, id: string): Promise<string>
583
691
  return `Deleted ${id} from disk.`;
584
692
  }
585
693
 
586
- /** Change ctx for a loaded model: unload → reload with new ctx_size + save_options. */
587
- async function changeModelContext(config: LemonadeConfig, id: string, ctxSize: number): Promise<string> {
588
- await unloadModel(config, id);
589
- return loadModel(config, id, ctxSize);
694
+ // ─── Recipe options, pinning, updates, backends (lemonade ≥2026.46) ─────────
695
+
696
+ /** GET /v1/models/{id}/options — a model's recipe options by layer. */
697
+ interface ModelOptionsLayers {
698
+ model_name?: string;
699
+ recipe?: string;
700
+ saved?: Record<string, unknown>;
701
+ effective?: Record<string, unknown>;
702
+ defaults?: Record<string, unknown>;
703
+ resolved_ctx_size?: number;
704
+ }
705
+
706
+ /** Headers for /internal/* endpoints: they demand LEMONADE_ADMIN_API_KEY
707
+ * when the server sets one; otherwise the ordinary key is accepted. The
708
+ * config view already resolved the fallback chain, so this is a plain
709
+ * Bearer of adminApiKey. */
710
+ function adminHeaders(config: LemonadeConfig): Record<string, string> {
711
+ return config.adminApiKey
712
+ ? { Authorization: `Bearer ${config.adminApiKey}` }
713
+ : authHeaders(config);
714
+ }
715
+
716
+ /** The server rejects /internal/* calls without its admin key with a plain
717
+ * 403-shaped body — surface that as actionable text, not a raw dump. */
718
+ function adminKeyErrorMessage(config: LemonadeConfig, body: string): string | null {
719
+ return /admin API key/i.test(body)
720
+ ? "The server requires LEMONADE_ADMIN_API_KEY for this action, and the configured key is missing or wrong — " +
721
+ "set adminApiKey in ~/.pi/agent/lemonade.json (or Server settings → Admin API key), then retry."
722
+ : null;
723
+ }
724
+
725
+ function modelOptionsUrl(config: LemonadeConfig, id: string): string {
726
+ return url(config, config.modelOptionsPath.replace("{id}", encodeURIComponent(id)));
727
+ }
728
+
729
+ /** Read a model's recipe options without loading it (saved/effective/defaults). */
730
+ async function fetchModelOptions(config: LemonadeConfig, id: string): Promise<ModelOptionsLayers> {
731
+ const res = await fetch(modelOptionsUrl(config, id), {
732
+ headers: authHeaders(config),
733
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
734
+ });
735
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
736
+ return (await res.json()) as ModelOptionsLayers;
737
+ }
738
+
739
+ /** Save (merge) recipe options for a model without loading it.
740
+ * `dryRun: true` validates + resolves but persists nothing. */
741
+ async function saveModelOptions(
742
+ config: LemonadeConfig,
743
+ id: string,
744
+ options: Record<string, unknown>,
745
+ dryRun = false
746
+ ): Promise<Record<string, unknown>> {
747
+ const res = await fetch(modelOptionsUrl(config, id), {
748
+ method: "POST",
749
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
750
+ body: JSON.stringify({ ...options, ...(dryRun ? { dry_run: true } : {}) }),
751
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs * 2),
752
+ });
753
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
754
+ return (await res.json()) as Record<string, unknown>;
755
+ }
756
+
757
+ /** Reset a model's saved recipe options to defaults. */
758
+ async function resetModelOptions(config: LemonadeConfig, id: string): Promise<void> {
759
+ const res = await fetch(modelOptionsUrl(config, id), {
760
+ method: "DELETE",
761
+ headers: authHeaders(config),
762
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
763
+ });
764
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
765
+ }
766
+
767
+ /** POST /internal/pin — pin/unpin a loaded model in place (no reload). */
768
+ async function setPinned(config: LemonadeConfig, id: string, pinned: boolean): Promise<void> {
769
+ const res = await fetch(url(config, config.pinPath), {
770
+ method: "POST",
771
+ headers: { ...adminHeaders(config), "Content-Type": "application/json" },
772
+ body: JSON.stringify({ model_name: id, pinned }),
773
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
774
+ });
775
+ if (!res.ok) {
776
+ const body = await res.text();
777
+ throw new Error(adminKeyErrorMessage(config, body) ?? `HTTP ${res.status}: ${body.slice(0, 200)}`);
778
+ }
779
+ }
780
+
781
+ /** POST /v1/models/check-updates — report downloaded models with newer
782
+ * upstream commits (no download happens). */
783
+ interface CheckUpdatesResult {
784
+ status?: string;
785
+ updates_available?: number;
786
+ models?: string[];
787
+ failed_models?: Record<string, string>;
788
+ }
789
+
790
+ async function checkModelUpdates(config: LemonadeConfig): Promise<CheckUpdatesResult> {
791
+ const res = await fetch(url(config, config.checkUpdatesPath), {
792
+ method: "POST",
793
+ headers: authHeaders(config),
794
+ signal: AbortSignal.timeout(120_000),
795
+ });
796
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
797
+ return (await res.json()) as CheckUpdatesResult;
798
+ }
799
+
800
+ /** POST /internal/models/sync — bulk update downloaded models from their
801
+ * registries (admin). dryRun reports without downloading. */
802
+ interface ModelSyncResult {
803
+ status?: string;
804
+ sync_id?: number;
805
+ completed_sync_id?: number;
806
+ already_in_progress?: boolean;
807
+ dry_run?: boolean;
808
+ models_updated?: string[];
809
+ models_up_to_date?: string[];
810
+ failed_models?: Record<string, string>;
811
+ checked_count?: number;
812
+ updated_count?: number;
813
+ }
814
+
815
+ async function syncModelUpdates(
816
+ config: LemonadeConfig,
817
+ options: { models?: string[]; dryRun?: boolean } = {}
818
+ ): Promise<ModelSyncResult> {
819
+ const res = await fetch(url(config, config.modelSyncPath), {
820
+ method: "POST",
821
+ headers: { ...adminHeaders(config), "Content-Type": "application/json" },
822
+ body: JSON.stringify(options),
823
+ signal: AbortSignal.timeout(300_000),
824
+ });
825
+ if (!res.ok) {
826
+ const body = await res.text();
827
+ throw new Error(adminKeyErrorMessage(config, body) ?? `HTTP ${res.status}: ${body.slice(0, 200)}`);
828
+ }
829
+ return (await res.json()) as ModelSyncResult;
830
+ }
831
+
832
+ /** One backend of a recipe, as system-info reports it. */
833
+ interface BackendInfo {
834
+ state?: "installed" | "installable" | "unsupported" | string;
835
+ tier?: string;
836
+ format?: string;
837
+ version?: string;
838
+ message?: string;
839
+ action?: string;
840
+ can_uninstall?: boolean;
841
+ devices?: string[];
842
+ download_filename?: string;
843
+ release_url?: string;
844
+ }
845
+
846
+ /** Install (or update) a recipe backend as a server-owned download job —
847
+ * same /v1/downloads machinery as pulls, so the same progress view tracks it. */
848
+ async function installBackendJob(
849
+ config: LemonadeConfig,
850
+ recipe: string,
851
+ backend: string
852
+ ): Promise<DownloadJob> {
853
+ const res = await fetch(url(config, config.installPath), {
854
+ method: "POST",
855
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
856
+ body: JSON.stringify({ recipe, backend, stream: true, subscribe: false }),
857
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
858
+ });
859
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
860
+ return (await res.json()) as DownloadJob;
861
+ }
862
+
863
+ /** Resolve what installing a backend would fetch, without downloading. */
864
+ async function installBackendDryRun(
865
+ config: LemonadeConfig,
866
+ recipe: string,
867
+ backend: string
868
+ ): Promise<Record<string, unknown>> {
869
+ const res = await fetch(url(config, config.installDryRunPath), {
870
+ method: "POST",
871
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
872
+ body: JSON.stringify({ recipe, backend }),
873
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
874
+ });
875
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
876
+ return (await res.json()) as Record<string, unknown>;
877
+ }
878
+
879
+ /** Uninstall a backend (or remove a cloud provider registration). */
880
+ async function uninstallBackend(config: LemonadeConfig, recipe: string, backend: string): Promise<void> {
881
+ const res = await fetch(url(config, config.uninstallPath), {
882
+ method: "POST",
883
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
884
+ body: JSON.stringify({ recipe, backend }),
885
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4),
886
+ });
887
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
590
888
  }
591
889
 
592
890
  /** Parse "32k", "1m", "262144" → number of tokens. */
@@ -705,7 +1003,17 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
705
1003
  * network failure — a hygiene check must never cry wolf on offline
706
1004
  * startups: unknown ≠ out of date. */
707
1005
 
708
- const DOCS_FILES = ["README.md", "anthropic.md", "lemonade.md", "llamacpp.md", "mcp.md", "ollama.md", "openai.md"];
1006
+ const DOCS_FILES = [
1007
+ "README.md",
1008
+ "anthropic.md",
1009
+ "internal.md",
1010
+ "lemonade.md",
1011
+ "llamacpp.md",
1012
+ "mcp.md",
1013
+ "ollama.md",
1014
+ "openai.md",
1015
+ "router.md",
1016
+ ];
709
1017
  const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
710
1018
  const DOCS_GH_CONTENTS = (file: string) =>
711
1019
  `https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
@@ -752,6 +1060,105 @@ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
752
1060
  }
753
1061
  }
754
1062
 
1063
+ // ─── Outage catalog cache (cacheOnOutage) ────────────────────────────────
1064
+
1065
+ interface CatalogCacheEntry {
1066
+ fetchedAt: number; // epoch ms of the successful probe this snapshot came from
1067
+ catalog: LemonadeModel[];
1068
+ health: LemonadeHealth | null;
1069
+ /** Durable last-known-state: the most recent probe pass for this instance
1070
+ * FAILED. Gates instant cache-serving (hybrid SWR) at the next activation —
1071
+ * an instance known down serves its cached listing with zero probe wait,
1072
+ * then reconciles in the background. Cleared on every successful probe.
1073
+ * Only meaningful alongside a catalog: a snapshotless instance is never
1074
+ * flagged (there is nothing to serve anyway). */
1075
+ unreachableAtLastProbe?: boolean;
1076
+ }
1077
+
1078
+ interface CatalogCacheFile {
1079
+ instances: Record<string, CatalogCacheEntry>;
1080
+ }
1081
+
1082
+ function catalogCachePath(): string {
1083
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "catalog-cache.json");
1084
+ }
1085
+
1086
+ /** Defensively validated load — a corrupt/absent cache is an empty cache,
1087
+ * never a load failure. */
1088
+ function loadCatalogCache(): CatalogCacheFile {
1089
+ try {
1090
+ const raw = JSON.parse(fs.readFileSync(catalogCachePath(), "utf8")) as CatalogCacheFile;
1091
+ if (!raw || typeof raw !== "object" || typeof raw.instances !== "object" || raw.instances === null) {
1092
+ return { instances: {} };
1093
+ }
1094
+ const instances: Record<string, CatalogCacheEntry> = {};
1095
+ for (const [name, e] of Object.entries(raw.instances)) {
1096
+ if (
1097
+ e && typeof e === "object" && typeof e.fetchedAt === "number" && Array.isArray(e.catalog) &&
1098
+ e.catalog.every((m) => m && typeof (m as LemonadeModel).id === "string")
1099
+ ) {
1100
+ instances[name] = {
1101
+ fetchedAt: e.fetchedAt,
1102
+ catalog: e.catalog as LemonadeModel[],
1103
+ health: (e.health ?? null) as LemonadeHealth | null,
1104
+ ...(e.unreachableAtLastProbe === true ? { unreachableAtLastProbe: true } : {}),
1105
+ };
1106
+ }
1107
+ }
1108
+ return { instances };
1109
+ } catch {
1110
+ return { instances: {} };
1111
+ }
1112
+ }
1113
+
1114
+ function saveCatalogCacheEntry(name: string, entry: CatalogCacheEntry): void {
1115
+ try {
1116
+ const cache = loadCatalogCache();
1117
+ cache.instances[name] = entry;
1118
+ fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
1119
+ fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
1120
+ } catch {
1121
+ /* cache dir unwritable → the outage fallback simply has nothing to serve */
1122
+ }
1123
+ }
1124
+
1125
+ /** Mark the durable last-known-state WITHOUT touching the snapshot: the
1126
+ * most recent probe for this instance failed (true) / succeeded (false).
1127
+ * Keeps the catalog/fetchedAt/health of the last successful probe — the
1128
+ * flag and the snapshot age independently by design. */
1129
+ function flagCatalogCacheEntry(name: string, unreachableAtLastProbe: boolean): void {
1130
+ try {
1131
+ const cache = loadCatalogCache();
1132
+ const entry = cache.instances[name];
1133
+ if (!entry) return; // no snapshot → nothing to serve, flag would be meaningless
1134
+ entry.unreachableAtLastProbe = unreachableAtLastProbe;
1135
+ fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
1136
+ fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
1137
+ } catch {
1138
+ /* unwritable cache — the next activation just probes live instead */
1139
+ }
1140
+ }
1141
+
1142
+ function clearCatalogCache(): void {
1143
+ try {
1144
+ fs.rmSync(catalogCachePath(), { force: true });
1145
+ } catch {
1146
+ /* nothing to clear / unwritable — loadCatalogCache still returns {} */
1147
+ }
1148
+ }
1149
+
1150
+ /** Human stamp for a cached snapshot: "14:32" same-day, "Sep 12 14:32"
1151
+ * older. The age IS the provenance — users discount old snapshots themselves. */
1152
+ function formatCacheStamp(ts: number): string {
1153
+ const d = new Date(ts);
1154
+ const pad = (n: number) => String(n).padStart(2, "0");
1155
+ const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1156
+ const now = new Date();
1157
+ const sameDay =
1158
+ d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
1159
+ return sameDay ? hms : `${d.toLocaleString("en", { month: "short" })} ${d.getDate()}, ${hms}`;
1160
+ }
1161
+
755
1162
  /** The latest published lemonade version, or undefined when unreachable
756
1163
  * (unknown ≠ outdated — never cry wolf). */
757
1164
  async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
@@ -822,7 +1229,7 @@ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
822
1229
  * config: it is a property of the build — updated with each extension
823
1230
  * release after re-verification — never a user preference to move. The
824
1231
  * server's own version is probed live from /v1/health; this is the peg. */
825
- const EXTENSION_CONFIRMED_SERVER_VERSION = "11.7.0";
1232
+ const EXTENSION_CONFIRMED_SERVER_VERSION = "2026.46.2";
826
1233
 
827
1234
  /** This extension's own version, from package.json (kept beside the
828
1235
  * provider.apiVerifiedAgainst record — see the README versioning
@@ -839,11 +1246,20 @@ const PKG_VERSION = (() => {
839
1246
  }
840
1247
  })();
841
1248
 
1249
+ /** Canonical comparable form of a lemonade version: strips dev-build
1250
+ * suffixes ("2026.45.0~1719.034d3f52" → "2026.45.0") so a dev build of a
1251
+ * release compares against that release. Lemonade switched to calendar
1252
+ * versioning (2026.MM.patch) after 11.7.0; both dotted forms compare. Pure. */
1253
+ function canonicalVersion(v: string): string {
1254
+ return v.split("~")[0].trim();
1255
+ }
1256
+
842
1257
  /** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
843
- * null when either side isn't dotted-numeric (no direction, just "differs"). Pure. */
1258
+ * null when either side isn't dotted-numeric (no direction, just "differs").
1259
+ * Dev-build suffixes are canonicalized away first. Pure. */
844
1260
  function versionCompare(a: string, b: string): number | null {
845
- const pa = a.split(".").map(Number);
846
- const pb = b.split(".").map(Number);
1261
+ const pa = canonicalVersion(a).split(".").map(Number);
1262
+ const pb = canonicalVersion(b).split(".").map(Number);
847
1263
  if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
848
1264
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
849
1265
  const d = (pa[i] ?? 0) - (pb[i] ?? 0);
@@ -882,7 +1298,11 @@ const serverVersionsSeen = new Map<string, string>();
882
1298
  function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
883
1299
  const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
884
1300
  const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
885
- const mismatched = config.serverVersionCheck && !!version && version !== EXTENSION_CONFIRMED_SERVER_VERSION;
1301
+ // Compare the canonical form: a dev build of the verified release
1302
+ // ("2026.46.2~1719.034d3f52") IS that release for endpoint purposes —
1303
+ // only a genuinely different version warns.
1304
+ const mismatched =
1305
+ config.serverVersionCheck && !!version && canonicalVersion(version) !== EXTENSION_CONFIRMED_SERVER_VERSION;
886
1306
  if (version) serverVersionsSeen.set(instance, version);
887
1307
  else serverVersionsSeen.delete(instance);
888
1308
  if (mismatched && version) {
@@ -1066,59 +1486,133 @@ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: Lemon
1066
1486
 
1067
1487
  // ─── Provider registration ──────────────────────────────────────────────────
1068
1488
 
1069
- function isChatModel(m: LemonadeModel): boolean {
1070
- return (
1071
- (m.labels ?? []).includes("chat") ||
1072
- m.recipe === "llamacpp" ||
1073
- // Omni collections bundle a chat LLM with server-side tools (image gen,
1074
- // TTS) and are driven through chat completions like any chat model.
1075
- m.recipe === "collection.omni"
1076
- );
1489
+ /** One instance's registration-pass probe: catalog + health fetched in
1490
+ * parallel (null = unreachable). Probes are fetched once per pass — all
1491
+ * instances at once — and shared by every consumer of that pass (provider
1492
+ * registration AND the tool-registration classifier hint), so a full
1493
+ * activation costs one network round per instance even though two
1494
+ * consumers need the catalog: an unreachable box stalls startup once,
1495
+ * not once per consumer, and a fleet of instances stalls in parallel,
1496
+ * not serially. */
1497
+ export interface InstanceProbe {
1498
+ view: InstanceView;
1499
+ catalog: LemonadeModel[] | null;
1500
+ health: LemonadeHealth | null;
1501
+ /** Set when catalog/health come from the on-disk outage cache rather
1502
+ * than a live probe (see withCacheFallback): the epoch ms of the fetch
1503
+ * the cache entry was written at. */
1504
+ cachedAt?: number;
1077
1505
  }
1078
1506
 
1079
- function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade") {
1080
- const labels = m.labels ?? [];
1081
- const contextWindow = m.max_context_window ?? 128000;
1082
- return {
1083
- id: m.id,
1084
- name: loaded.has(m.id)
1085
- ? `${m.id} (${providerId}, loaded)`
1086
- : `${m.id} (${providerId}, on-demand)`,
1087
- reasoning: labels.includes("reasoning"),
1088
- input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
1089
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1090
- contextWindow,
1091
- maxTokens: Math.min(contextWindow, 32768),
1092
- compat: {
1093
- supportsDeveloperRole: false,
1094
- supportsReasoningEffort: false,
1095
- maxTokensField: "max_tokens" as const,
1096
- },
1097
- };
1507
+ async function probeInstance(view: InstanceView): Promise<InstanceProbe> {
1508
+ try {
1509
+ const [catalog, health] = await Promise.all([
1510
+ fetchCatalog(view.config),
1511
+ fetchHealth(view.config),
1512
+ ]);
1513
+ if (view.config.cacheOnOutage) {
1514
+ // Fresh truth — persist the snapshot the outage fallback will serve
1515
+ // if a future probe can't reach this box, and clear the down-flag.
1516
+ saveCatalogCacheEntry(view.name, { fetchedAt: Date.now(), catalog, health, unreachableAtLastProbe: false });
1517
+ }
1518
+ return { view, catalog, health };
1519
+ } catch {
1520
+ // Unreachable. What that means for /model depends on the outage cache
1521
+ // (withCacheFallback decides right after) — here we only report the
1522
+ // network truth: the box did not answer within discoveryTimeoutMs.
1523
+ // Durable last-known-state for the hybrid SWR activation path.
1524
+ if (view.config.cacheOnOutage) flagCatalogCacheEntry(view.name, true);
1525
+ console.error(`[pi-lemonade-link] instance "${view.name}" unreachable at ${view.config.baseUrl}`);
1526
+ return { view, catalog: null, health: null };
1527
+ }
1528
+ }
1529
+
1530
+ /** Probe every configured instance in one parallel pass. */
1531
+ function probeInstances(config: LemonadeConfig): Promise<InstanceProbe[]> {
1532
+ return Promise.all(listInstances(config).map(probeInstance));
1533
+ }
1534
+
1535
+ /** Outage fallback (cacheOnOutage): an unreachable instance serves its last
1536
+ * successfully cached catalog — provenance-marked "(cached <stamp>)" at
1537
+ * registration time — instead of listing nothing. Live truth always wins:
1538
+ * reachable instances pass through untouched, and a box with no cached
1539
+ * snapshot yet still lists nothing (no cache, nothing honest to serve).
1540
+ * Idempotent: probes that already carry a cached catalog pass through. */
1541
+ function withCacheFallback(config: LemonadeConfig, probes: InstanceProbe[]): InstanceProbe[] {
1542
+ if (!config.cacheOnOutage) return probes;
1543
+ const cache = loadCatalogCache();
1544
+ return probes.map((p) => {
1545
+ if (p.catalog) return p;
1546
+ const entry = cache.instances[p.view.name];
1547
+ if (!entry || !entry.catalog.length) return p;
1548
+ return { ...p, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt };
1549
+ });
1550
+ }
1551
+
1552
+ /** Hybrid stale-while-revalidate probe pass (cacheOnOutage): instances whose
1553
+ * LAST probe failed (durable flag, see flagCatalogCacheEntry) serve their
1554
+ * cached catalog instantly — no probe wait, no discoveryTimeoutMs stall —
1555
+ * while the rest probe live in one parallel pass (failures still get the
1556
+ * withCacheFallback safety net, so the transition INTO an outage is the
1557
+ * only stall left). The caller reconciles instant-served instances with a
1558
+ * background probe pass. Without the flag (or without a snapshot)
1559
+ * everything probes live: probe-first is both the honest and the fast path
1560
+ * when the box was up last time. */
1561
+ async function instantOrLiveProbes(config: LemonadeConfig): Promise<InstanceProbe[]> {
1562
+ if (!config.cacheOnOutage) return await probeInstances(config);
1563
+ const cache = loadCatalogCache();
1564
+ const instant: InstanceProbe[] = [];
1565
+ const live: InstanceView[] = [];
1566
+ for (const view of listInstances(config)) {
1567
+ const entry = cache.instances[view.name];
1568
+ if (entry && entry.catalog.length && entry.unreachableAtLastProbe) {
1569
+ instant.push({ view, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt });
1570
+ } else {
1571
+ live.push(view);
1572
+ }
1573
+ }
1574
+ if (!instant.length) return withCacheFallback(config, await probeInstances(config));
1575
+ return withCacheFallback(config, [...instant, ...(await Promise.all(live.map(probeInstance)))]);
1098
1576
  }
1099
1577
 
1100
- async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): Promise<number> {
1578
+ /** Instances whose current /model listing is served from the outage cache
1579
+ * (probe failed, cache served): name → when it was cached + the listed
1580
+ * ids. Single source of truth: every registration pass (registerInstanceProvider
1581
+ * records; registerLemonadeProvider resets) — same discipline as
1582
+ * serverVersionMismatches. Drives the session-start warning and the
1583
+ * background re-sync's recovery diff. */
1584
+ const cachedServing = new Map<string, { fetchedAt: number; ids: string[] }>();
1585
+
1586
+ /** Register one instance's provider from a probe that already ran — no
1587
+ * network here; the probe pass owns all registration-time I/O. */
1588
+ function registerInstanceProvider(pi: ExtensionAPI, probe: InstanceProbe): number {
1589
+ const view = probe.view;
1101
1590
  const config = view.config;
1102
1591
  const providerId = instanceProviderId(view);
1103
- let catalog: LemonadeModel[];
1104
- let loaded = new Set<string>();
1105
- try {
1106
- const [catalogRes, health] = await Promise.all([
1107
- fetchCatalog(config),
1108
- fetchHealth(config),
1109
- ]);
1110
- catalog = catalogRes;
1111
- loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
1112
- recordServerVersion(config, view.name, health?.version);
1113
- } catch {
1114
- // Unreachable => contribute nothing to /model. No placeholders: an
1115
- // unreachable model is not listable. Sessions pinned to a lemonade model
1116
- // will hard-fail model resolution until the box is reachable again —
1117
- // the accepted tradeoff of "fully automated, fully honest" listings.
1118
- catalog = [];
1592
+ const catalog = probe.catalog ?? [];
1593
+ const loaded = new Set((probe.health?.all_models_loaded ?? []).map((m) => m.model_name));
1594
+ const cachedStamp = probe.cachedAt !== undefined ? formatCacheStamp(probe.cachedAt) : undefined;
1595
+ if (cachedStamp) {
1596
+ // Outage-cache serving: the listing is a provenance-marked snapshot, not
1597
+ // live truth — record it so the session-start warning and the background
1598
+ // re-sync know this instance is listed from cache.
1599
+ console.error(
1600
+ `[pi-lemonade-link] instance "${view.name}" unreachable — serving cached catalog from ${cachedStamp}`
1601
+ );
1602
+ cachedServing.set(view.name, { fetchedAt: probe.cachedAt!, ids: catalog.map((m) => m.id) });
1603
+ recordServerVersion(config, view.name, probe.health?.version); // cached version — self-heals on re-sync
1604
+ } else if (probe.catalog) {
1605
+ cachedServing.delete(view.name);
1606
+ recordServerVersion(config, view.name, probe.health?.version);
1607
+ } else {
1608
+ // Unreachable with no cache to serve: contribute nothing to /model. No
1609
+ // placeholders: an unreachable model is not listable. Sessions pinned to
1610
+ // a lemonade model will hard-fail model resolution until the box is
1611
+ // reachable again — the honest tradeoff when there is no cached truth.
1119
1612
  console.error(
1120
- `[pi-lemonade-link] instance "${view.name}" unreachable at ${config.baseUrl}; registering no models`
1613
+ `[pi-lemonade-link] instance "${view.name}" has no cached catalog — registering no models`
1121
1614
  );
1615
+ cachedServing.delete(view.name);
1122
1616
  }
1123
1617
 
1124
1618
  const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
@@ -1135,12 +1629,53 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
1135
1629
  apiKey: config.apiKey || "lemonade",
1136
1630
  authHeader: true,
1137
1631
  api: "openai-completions",
1138
- models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
1632
+ models: advertised.map((m) => toProviderModel(m, loaded, providerId, cachedStamp)),
1139
1633
  });
1140
1634
 
1141
1635
  return advertised.length;
1142
1636
  }
1143
1637
 
1638
+ function isChatModel(m: LemonadeModel): boolean {
1639
+ return (
1640
+ (m.labels ?? []).includes("chat") ||
1641
+ m.recipe === "llamacpp" ||
1642
+ // Omni collections bundle a chat LLM with server-side tools (image gen,
1643
+ // TTS) and are driven through chat completions like any chat model.
1644
+ m.recipe === "collection.omni"
1645
+ );
1646
+ }
1647
+
1648
+ function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade", cachedStamp?: string) {
1649
+ const labels = m.labels ?? [];
1650
+ // context_length is the live truth (loaded/configured ctx_size, else the
1651
+ // metadata ceiling); max_context_window is the static ceiling alone.
1652
+ const contextWindow = m.context_length ?? m.max_context_window ?? 128000;
1653
+ const badges = [
1654
+ ...(labels.includes("hot") ? ["★"] : []),
1655
+ ...(labels.includes("tool-calling") ? ["tools"] : []),
1656
+ ...(labels.includes("chat-transcription") ? ["audio-in-chat"] : []),
1657
+ ];
1658
+ const badgeText = badges.length ? ` ${badges.join(" ")}` : "";
1659
+ return {
1660
+ id: m.id,
1661
+ name: cachedStamp
1662
+ ? `${m.id}${badgeText} (${providerId}, cached ${cachedStamp})`
1663
+ : loaded.has(m.id)
1664
+ ? `${m.id}${badgeText} (${providerId}, loaded)`
1665
+ : `${m.id}${badgeText} (${providerId}, on-demand)`,
1666
+ reasoning: labels.includes("reasoning"),
1667
+ input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
1668
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1669
+ contextWindow,
1670
+ maxTokens: Math.min(contextWindow, 32768),
1671
+ compat: {
1672
+ supportsDeveloperRole: false,
1673
+ supportsReasoningEffort: false,
1674
+ maxTokensField: "max_tokens" as const,
1675
+ },
1676
+ };
1677
+ }
1678
+
1144
1679
  /** Provider ids used by past versions of this extension. unregisterProvider
1145
1680
  * only works within the running pi process, and ids changed over the
1146
1681
  * extension's evolution, so a long-lived session (or /reload after an
@@ -1150,22 +1685,114 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
1150
1685
  const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
1151
1686
 
1152
1687
  /** Register (or re-register) every configured instance. Each becomes its own
1153
- * pi provider under the uniform lemonade-<name> id. */
1154
- async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
1688
+ * pi provider under the uniform lemonade-<name> id. Probes are shared per
1689
+ * pass: pass the caller's probeInstances() result to reuse the network
1690
+ * round (activation does exactly that — one pass feeds both provider and
1691
+ * tool registration); omitted, a fresh parallel pass runs. */
1692
+ async function registerLemonadeProvider(
1693
+ pi: ExtensionAPI,
1694
+ config: LemonadeConfig,
1695
+ probes?: InstanceProbe[]
1696
+ ): Promise<number> {
1155
1697
  serverVersionMismatches.length = 0; // (re)collected per registration pass
1156
1698
  serverVersionsSeen.clear();
1699
+ cachedServing.clear();
1157
1700
  for (const staleId of LEGACY_PROVIDER_IDS) {
1158
1701
  try {
1159
1702
  pi.unregisterProvider(staleId);
1160
1703
  } catch { /* never registered; fine */ }
1161
1704
  }
1162
1705
  let total = 0;
1163
- for (const view of listInstances(config)) {
1164
- total += await registerInstanceProvider(pi, view);
1706
+ for (const probe of probes ?? withCacheFallback(config, await probeInstances(config))) {
1707
+ total += registerInstanceProvider(pi, probe);
1165
1708
  }
1166
1709
  return total;
1167
1710
  }
1168
1711
 
1712
+ // ─── Outage re-sync (cacheOnOutage) ─────────────────────────────────────
1713
+
1714
+ let resyncBusy = false;
1715
+ let lastResyncProbeAt = 0;
1716
+
1717
+ /** Fire-and-forget outage re-sync: probe all instances and re-register.
1718
+ * Throttled to one probe pass per discoveryTimeoutMs window (turn_start
1719
+ * fires once per turn — agentic loops fire it rapidly) and a no-op while
1720
+ * every instance is live (cachedServing empty). Recovery is announced
1721
+ * only when the live catalog differs from the cached one — state-steady
1722
+ * means silent. force (the setup menu's Refresh connection) bypasses both
1723
+ * guards and always re-registers. Returns the registered model count
1724
+ * (−1 when a pass is already in flight). */
1725
+ async function resyncFromOutage(
1726
+ pi: ExtensionAPI,
1727
+ configRef: { current: LemonadeConfig },
1728
+ notify?: (message: string) => void,
1729
+ force = false
1730
+ ): Promise<number> {
1731
+ if (resyncBusy) return -1;
1732
+ if (!force) {
1733
+ if (cachedServing.size === 0) return 0;
1734
+ if (Date.now() - lastResyncProbeAt < configRef.current.discoveryTimeoutMs) return 0;
1735
+ }
1736
+ resyncBusy = true;
1737
+ lastResyncProbeAt = Date.now();
1738
+ try {
1739
+ const cachedBefore = new Map(cachedServing);
1740
+ const probes = await probeInstances(configRef.current);
1741
+ const registered = await registerLemonadeProvider(
1742
+ pi,
1743
+ configRef.current,
1744
+ withCacheFallback(configRef.current, probes)
1745
+ );
1746
+ // Recovery notice — only for instances that answered, and only when
1747
+ // their live catalog actually differs from the served snapshot.
1748
+ for (const [name, before] of cachedBefore) {
1749
+ const recovered = probes.find((p) => p.view.name === name && p.catalog && p.cachedAt === undefined);
1750
+ if (!recovered?.catalog) continue; // still unreachable (or still cache-served)
1751
+ const beforeIds = new Set(before.ids);
1752
+ const afterIds = new Set(recovered.catalog.map((m) => m.id));
1753
+ const added = recovered.catalog.filter((m) => !beforeIds.has(m.id)).map((m) => m.id);
1754
+ const removed = before.ids.filter((id) => !afterIds.has(id));
1755
+ if (!added.length && !removed.length) continue;
1756
+ notify?.(
1757
+ `pi-lemonade-link: instance "${name}" reachable again — re-synced: ` +
1758
+ `${added.length ? `+${added.join(", ")}` : ""}${added.length && removed.length ? "; " : ""}${removed.length ? `−${removed.join(", ")}` : ""}`
1759
+ );
1760
+ }
1761
+ return registered;
1762
+ } finally {
1763
+ resyncBusy = false;
1764
+ }
1765
+ }
1766
+
1767
+ /** Event wiring for the outage re-sync: prompt submit (turn_start), model
1768
+ * selection, and session start/resume each trigger a throttled,
1769
+ * cached-state-only probe pass (never blocking the turn). The status bar's
1770
+ * own health poll is the free continuous detector (see registerLemonadeBar's
1771
+ * tick). The session-start handler also warns, one line per outage-cached
1772
+ * instance, while the state persists — same shape as the tier-1 version
1773
+ * mismatch warning. */
1774
+ function registerOutageResync(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
1775
+ type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
1776
+ const trigger = (ctx: ExtCtx) => {
1777
+ if (cachedServing.size === 0) return; // every instance live — nothing to re-sync
1778
+ void resyncFromOutage(pi, configRef, ctx.hasUI ? (m) => ctx.ui.notify(m, "info") : undefined);
1779
+ };
1780
+ pi.on("turn_start", (_e, ctx) => trigger(ctx as ExtCtx));
1781
+ pi.on("model_select", (_e, ctx) => trigger(ctx as ExtCtx));
1782
+ pi.on("session_start", (_e, ctx) => {
1783
+ trigger(ctx as ExtCtx);
1784
+ if (!(ctx as ExtCtx).hasUI) return;
1785
+ for (const [instance, served] of cachedServing) {
1786
+ (ctx as ExtCtx).ui.notify(
1787
+ `pi-lemonade-link: instance "${instance}" is unreachable — /model lists its cached catalog from ` +
1788
+ `${formatCacheStamp(served.fetchedAt)}; requests to it will fail until the box answers. ` +
1789
+ `It re-syncs automatically on the next prompt, model selection, or session start. cacheOnOutage: false disables.`,
1790
+ "warning"
1791
+ );
1792
+ }
1793
+ });
1794
+ }
1795
+
1169
1796
  // ─── Multimodal agent tools ──────────────────────────────────────────────────
1170
1797
 
1171
1798
  /** Combine an operation timeout with the caller's cancel signal (if any),
@@ -1427,7 +2054,11 @@ function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
1427
2054
  });
1428
2055
  }
1429
2056
 
1430
- async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Promise<void> {
2057
+ async function registerAgentTools(
2058
+ pi: ExtensionAPI,
2059
+ gconfig: LemonadeConfig,
2060
+ probes?: InstanceProbe[]
2061
+ ): Promise<void> {
1431
2062
  // Harness-level self-awareness: these lines are merged into the Guidelines
1432
2063
  // section of every chat's system prompt, so even a virgin chat knows where
1433
2064
  // its lemonade machinery lives without having to hunt for it. (pi dedupes
@@ -1456,19 +2087,20 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1456
2087
  {
1457
2088
  const ids = new Set<string>();
1458
2089
  const pulled = new Set<string>();
1459
- await Promise.all(
1460
- listInstances(gconfig).map(async (v) => {
1461
- try {
1462
- const catalog = await fetchCatalog(v.config);
1463
- for (const m of catalog.filter((m) => m.recipe === "onnxruntime")) {
1464
- ids.add(m.id);
1465
- if (m.downloaded !== false) pulled.add(m.id);
1466
- }
1467
- } catch {
1468
- // instance unreachable at registration time — hint stays generic for it
1469
- }
1470
- })
1471
- );
2090
+ // Share the activation-pass probes when the caller has them (provider
2091
+ // registration just fetched the same catalogs); otherwise one fresh
2092
+ // parallel pass — never a serial per-instance round.
2093
+ for (const probe of probes ?? withCacheFallback(gconfig, await probeInstances(gconfig))) {
2094
+ if (!probe.catalog) {
2095
+ continue; // instance unreachable at registration time — hint stays generic for it
2096
+ }
2097
+ for (const m of probe.catalog.filter(
2098
+ (m) => m.recipe === "onnxruntime" || (m.labels ?? []).includes("classification")
2099
+ )) {
2100
+ ids.add(m.id);
2101
+ if (m.downloaded !== false) pulled.add(m.id);
2102
+ }
2103
+ }
1472
2104
  if (ids.size) {
1473
2105
  classifierHint =
1474
2106
  `Classifiers on this server: ${[...ids].join(", ")}. ` +
@@ -1676,13 +2308,39 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1676
2308
  label: "Text to Speech",
1677
2309
  description:
1678
2310
  "Convert text to spoken audio using a TTS model on the lemonade server " +
1679
- "(e.g. OpenMOSS-TTS). Saves the audio file and returns its path.",
2311
+ "(e.g. OpenMOSS-TTS). Supports three voice modes: a named voice, cloning " +
2312
+ "a voice from a reference audio file, or designing one from a free-text " +
2313
+ "description (OpenMOSS models). Saves the audio file and returns its path.",
1680
2314
  promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
1681
2315
  parameters: Type.Object({
1682
2316
  server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1683
2317
  text: Type.String({ description: "The text to speak." }),
1684
2318
  model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
1685
- voice: Type.Optional(Type.String({ description: "Voice name (e.g. 'alloy', 'af_sky')." })),
2319
+ voice: Type.Optional(
2320
+ Type.String({
2321
+ description:
2322
+ "Voice selection. Kokoro-style models take a named voice ('alloy', " +
2323
+ "'af_sky'); OpenMOSS models take free-text voice or style instruction, " +
2324
+ "e.g. 'a calm, deep male narrator voice'.",
2325
+ })
2326
+ ),
2327
+ reference_voice_path: Type.Optional(
2328
+ Type.String({
2329
+ description:
2330
+ "Voice cloning (OpenMOSS models): path to a local WAV sample of the " +
2331
+ "voice to clone, e.g. a few seconds of clean speech. Takes precedence " +
2332
+ "over voice_design_description.",
2333
+ })
2334
+ ),
2335
+ voice_design_description: Type.Optional(
2336
+ Type.String({
2337
+ description:
2338
+ "Voice design (OpenMOSS models): describe the voice to invent, e.g. " +
2339
+ "'a warm low female voice with a British accent'. The server renders a " +
2340
+ "short sample in that voice and uses it as the reference. Ignored when " +
2341
+ "reference_voice_path is given.",
2342
+ })
2343
+ ),
1686
2344
  speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
1687
2345
  response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
1688
2346
  }),
@@ -1698,6 +2356,22 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1698
2356
  const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
1699
2357
  if (params.voice) body.voice = params.voice;
1700
2358
  if (params.speed) body.speed = params.speed;
2359
+ if (params.reference_voice_path) {
2360
+ // Voice cloning: the server wants the sample inline as base64 WAV.
2361
+ try {
2362
+ const refPath = path.resolve(params.reference_voice_path);
2363
+ const refBuf = await readRequiredFile(refPath);
2364
+ body.reference_wav_b64 = refBuf.toString("base64");
2365
+ } catch (error) {
2366
+ return toolError(
2367
+ `Could not read the voice sample '${params.reference_voice_path}': ${
2368
+ error instanceof Error ? error.message : String(error)
2369
+ }`
2370
+ );
2371
+ }
2372
+ } else if (params.voice_design_description) {
2373
+ body.voice_design_description = params.voice_design_description;
2374
+ }
1701
2375
  return postJsonSaveBinary(config, config.speechPath, body, "speech", fmt === "pcm" ? "pcm" : fmt, signal, `model: ${model}`);
1702
2376
  },
1703
2377
  });
@@ -1716,6 +2390,8 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1716
2390
  prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
1717
2391
  model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
1718
2392
  duration: Type.Optional(Type.Number({ description: "Clip length in seconds." })),
2393
+ steps: Type.Optional(Type.Number({ description: "Inference steps; lower is faster, higher can improve quality." })),
2394
+ cfg: Type.Optional(Type.Number({ description: "Classifier-free guidance strength (ThinkSound only)." })),
1719
2395
  lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
1720
2396
  vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
1721
2397
  seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
@@ -1724,12 +2400,14 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1724
2400
  const instCfg = instanceView(gconfig, params.server);
1725
2401
  if (typeof instCfg === "string") return toolError(instCfg);
1726
2402
  const config = instCfg; // instance view — every reference below targets the selected box
1727
- const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
2403
+ const auto = await pickModelByLabels(config, ["audio-generation", "music", "sfx", "audio", "tts-audio"]);
1728
2404
  if (auto === null) return toolError(unreachableMessage(config));
1729
2405
  const model = params.model || auto;
1730
2406
  if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
1731
2407
  const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
1732
2408
  if (params.duration) body.duration = params.duration;
2409
+ if (params.steps) body.steps = params.steps;
2410
+ if (params.cfg) body.cfg = params.cfg;
1733
2411
  if (params.lyrics) body.lyrics = params.lyrics;
1734
2412
  if (params.vocal_language) body.vocal_language = params.vocal_language;
1735
2413
  if (params.seed !== undefined) body.seed = params.seed;
@@ -1779,12 +2457,15 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1779
2457
  name: "classify_text",
1780
2458
  label: "Classify Text",
1781
2459
  description:
1782
- "Classify input TEXT with an encoder-classifier model on the lemonade server " +
1783
- "(recipe: onnxruntime). Each classifier answers exactly one question, decided by " +
1784
- "its baked-in label set, and the response's labels reveal that universe — if they " +
1785
- "don't fit the question, retry with a different model. Text-only by architecture: " +
1786
- "image questions go to vision-capable chat models (read the image), audio must be " +
1787
- "transcribed first (transcribe_audio). " +
2460
+ "Classify input TEXT with a classifier model on the lemonade server. Two " +
2461
+ "kinds exist: fixed-label encoders (recipe onnxruntime — BERT-family), " +
2462
+ "each answering exactly one question decided by its baked-in label set " +
2463
+ "(the response's labels reveal that universe — if they don't fit the " +
2464
+ "question, retry with a different model), and zero-shot decision models " +
2465
+ "(e.g. LFM2 prompt routers) that REQUIRE the labels parameter to name the " +
2466
+ "choices per request. Text-only by architecture: image questions go to " +
2467
+ "vision-capable chat models (read the image), audio must be transcribed " +
2468
+ "first (transcribe_audio). " +
1788
2469
  (classifierHint ||
1789
2470
  "Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
1790
2471
  " Returns all labels with confidence scores, ranked highest to lowest.",
@@ -1794,7 +2475,19 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1794
2475
  server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1795
2476
  input: Type.String({ description: "The text to classify." }),
1796
2477
  model: Type.Optional(
1797
- Type.String({ description: "Classifier model id (recipe onnxruntime). Omit to auto-pick." })
2478
+ Type.String({
2479
+ description:
2480
+ "Classifier model id. Omit to auto-pick (prefers pulled fixed-label " +
2481
+ "onnxruntime classifiers). Pass a zero-shot model's id when using labels.",
2482
+ })
2483
+ ),
2484
+ labels: Type.Optional(
2485
+ Type.Array(Type.String(), {
2486
+ description:
2487
+ "Zero-shot models only: the labels (candidate answers) to score the " +
2488
+ "input against, e.g. [\"phishing\", \"legitimate\"]. Required by zero-shot " +
2489
+ "decision models; refused by fixed-label encoders — omit it for those.",
2490
+ })
1798
2491
  ),
1799
2492
  top_k: Type.Optional(
1800
2493
  Type.Number({ description: "Only return the highest-scoring k labels." })
@@ -1804,16 +2497,23 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1804
2497
  const instCfg = instanceView(gconfig, params.server);
1805
2498
  if (typeof instCfg === "string") return toolError(instCfg);
1806
2499
  const config = instCfg; // instance view — every reference below targets the selected box
1807
- const auto = await pickModelByRecipe(config, "onnxruntime");
2500
+ // Zero-shot requests (labels given) auto-pick a zero-shot decision model
2501
+ // (LFM2 routers, llama.cpp/FLM decision models); fixed-label requests
2502
+ // auto-pick an onnxruntime encoder. An explicit model id always wins.
2503
+ const auto = params.labels?.length
2504
+ ? ((await pickModelByLabels(config, ["zero-shot"])) ?? (await pickModelByRecipe(config, "onnxruntime")))
2505
+ : await pickModelByRecipe(config, "onnxruntime");
1808
2506
  if (auto === null) return toolError(unreachableMessage(config));
1809
2507
  const model = params.model || config.defaultClassifierModel || auto;
1810
2508
  if (!model) {
1811
2509
  return toolError(
1812
- "No classifier model (onnxruntime recipe) found on the lemonade server. " +
1813
- "Pull one first, e.g. Bert-Phishing-ONNX or Phishing-Email-Detection-ONNX."
2510
+ "No classifier model found on the lemonade server (no onnxruntime " +
2511
+ "encoder and no zero-shot decision model). Pull one first, e.g. " +
2512
+ "Bert-Phishing-ONNX or Phishing-Email-Detection-ONNX."
1814
2513
  );
1815
2514
  }
1816
2515
  const body: Record<string, unknown> = { model, input: params.input };
2516
+ if (params.labels?.length) body.labels = params.labels;
1817
2517
  if (params.top_k) body.top_k = params.top_k;
1818
2518
  const res = await toolFetch(config, config.classifyPath, {
1819
2519
  method: "POST",
@@ -1870,6 +2570,11 @@ interface BarMetrics {
1870
2570
  npuPct: number | null;
1871
2571
  npuSupported: boolean | null; // null = unknown yet; false = box has no NPU (segment dropped)
1872
2572
  vramGb: number | null;
2573
+ /** Whole-box liveness from /v1/stats (≥2026.46): requests in flight and
2574
+ * the server's own live tok/s estimate across ALL clients — null until
2575
+ * the first poll lands (placeholder dots, same as every other segment). */
2576
+ activeReqs: number | null;
2577
+ serverToksPerSec: number | null;
1873
2578
  }
1874
2579
 
1875
2580
  /**
@@ -1883,9 +2588,10 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1883
2588
  const KEY = "lemonade-status-bar";
1884
2589
  type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
1885
2590
 
1886
- const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null };
2591
+ const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null, activeReqs: null, serverToksPerSec: null };
1887
2592
  let activeInstance: string | null = null;
1888
2593
  let timer: ReturnType<typeof setInterval> | undefined;
2594
+ let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
1889
2595
  let tuiRef: { requestRender(): void } | undefined;
1890
2596
  let msgStartAt: number | null = null;
1891
2597
 
@@ -1905,6 +2611,8 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1905
2611
  metrics.npuPct = null;
1906
2612
  metrics.npuSupported = null;
1907
2613
  metrics.vramGb = null;
2614
+ metrics.activeReqs = null;
2615
+ metrics.serverToksPerSec = null;
1908
2616
  msgStartAt = null; // a stream started under another/removed row must not leak into the next
1909
2617
  };
1910
2618
 
@@ -1920,10 +2628,11 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1920
2628
  tuiRef?.requestRender();
1921
2629
  return;
1922
2630
  }
1923
- const [health, sys, metricsBody] = await Promise.all([
2631
+ const [health, sys, metricsBody, perf] = await Promise.all([
1924
2632
  fetchHealth(view),
1925
2633
  fetchJsonOrNull<SystemStats>(view, "/v1/system-stats"),
1926
2634
  fetchTextOrNull(view, "/metrics"),
2635
+ fetchJsonOrNull<PerfStats>(view, "/v1/stats"),
1927
2636
  ]);
1928
2637
  if (activeInstance !== instance) return; // row switched while fetching: discard
1929
2638
  if (!health) {
@@ -1947,6 +2656,26 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1947
2656
  metrics.npuSupported = false; // box reported system-stats with no NPU
1948
2657
  }
1949
2658
  if (typeof sys?.vram_gb === "number") metrics.vramGb = sys.vram_gb;
2659
+ // Whole-box liveness (≥2026.46): requests in flight and the server's own
2660
+ // tok/s estimate across every client — the bar's own tok/s segment stays
2661
+ // session-local; this one says whether the box is streaming for ANYONE.
2662
+ if (perf?.live) {
2663
+ if (typeof perf.live.active_requests === "number") metrics.activeReqs = perf.live.active_requests;
2664
+ if (typeof perf.live.estimated_tokens_per_second === "number") {
2665
+ metrics.serverToksPerSec = perf.live.estimated_tokens_per_second;
2666
+ }
2667
+ } else {
2668
+ // Pre-2026.46 servers have no live block — drop the placeholders.
2669
+ metrics.activeReqs = metrics.activeReqs ?? 0;
2670
+ metrics.serverToksPerSec = metrics.serverToksPerSec ?? 0;
2671
+ }
2672
+ // Free liveness detector: this poll already proves the box answers —
2673
+ // while its /model listing is served from the outage cache, trigger
2674
+ // the throttled re-sync (throttle/in-flight guards live inside
2675
+ // resyncFromOutage; the notice goes through barCtx while installed).
2676
+ if (cachedServing.has(instance)) {
2677
+ void resyncFromOutage(pi, configRef, (m) => barCtx?.ui.notify(m, "info"));
2678
+ }
1950
2679
  }
1951
2680
  tuiRef?.requestRender();
1952
2681
  };
@@ -1954,6 +2683,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1954
2683
  const teardown = (ctx: ExtCtx) => {
1955
2684
  stopTimer();
1956
2685
  tuiRef = undefined;
2686
+ barCtx = undefined;
1957
2687
  activeInstance = null;
1958
2688
  resetLocal();
1959
2689
  try {
@@ -1967,6 +2697,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1967
2697
  stopTimer();
1968
2698
  resetLocal();
1969
2699
  activeInstance = instance;
2700
+ barCtx = ctx;
1970
2701
  ctx.ui.setWidget(
1971
2702
  KEY,
1972
2703
  (tui, theme) => {
@@ -1989,6 +2720,15 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1989
2720
  if (metrics.busy === null) parts.push(dim("…"));
1990
2721
  else if (metrics.busy === "idle") parts.push(dim("idle"));
1991
2722
  else parts.push(theme.fg("warning", metrics.busy));
2723
+ // Whole-box segment (≥2026.46): in-flight requests + the server's
2724
+ // live tok/s across all clients. 0/0 is honest quiet, not "…".
2725
+ if (metrics.activeReqs !== null) {
2726
+ const liveBits: string[] = [];
2727
+ if (metrics.activeReqs > 0) liveBits.push(`${metrics.activeReqs} active`);
2728
+ if (metrics.serverToksPerSec !== null && metrics.serverToksPerSec > 0)
2729
+ liveBits.push(`${metrics.serverToksPerSec.toFixed(0)} tok/s (box)`);
2730
+ if (liveBits.length) parts.push(theme.fg("warning", liveBits.join(" · ")));
2731
+ }
1992
2732
  parts.push(dim(`CPU ${metrics.cpuPct !== null ? `${metrics.cpuPct.toFixed(0)}%` : "…"}`));
1993
2733
  parts.push(dim(`GPU ${metrics.gpuPct !== null ? `${metrics.gpuPct.toFixed(0)}%` : "…"}`));
1994
2734
  // Boxes without an NPU report npu_percent: null — the segment is
@@ -2082,6 +2822,8 @@ interface MenuItem<T extends string> {
2082
2822
 
2083
2823
  /** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
2084
2824
  function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[]): Promise<T | null> {
2825
+ // Long lists get type-to-search for free — same component, one threshold.
2826
+ if (items.length > 12) return filterMenu(ctx, { title, items });
2085
2827
  return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
2086
2828
  const container = new Container();
2087
2829
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
@@ -2106,27 +2848,294 @@ function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[])
2106
2848
  });
2107
2849
  }
2108
2850
 
2109
- /** Live-refresh wiring for the scrollable textView (status panel). */
2110
- interface TextViewLive {
2111
- /** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
2851
+ /** Options for filterMenu — the live, type-to-search menu. */
2852
+ interface FilterMenuOptions<T extends string> {
2853
+ title: string;
2854
+ items: MenuItem<T>[];
2855
+ /** Rebuild the item list in place while the menu is open (live status).
2856
+ * The filter and the cursor (matched by value) survive a refresh. */
2857
+ refetch?: () => Promise<MenuItem<T>[]>;
2858
+ /** Refresh cadence; default 2000 ms. Ignored without refetch. */
2112
2859
  pollMs?: number;
2113
- refetch?: () => Promise<string[]>;
2114
- /** Extra footer key hint (e.g. "n/p instance"), shown when multiple targets exist. */
2115
- extraHint?: string;
2116
- /** Hook for keys beyond scroll/quit; return true when the key was consumed. */
2117
- onKey?: (data: string, api: { setTitle: (t: string) => void }) => boolean;
2860
+ /** Extra single-key actions, tried before typing/filtering; return true when
2861
+ * consumed. The banner line above the list is the natural status outlet. */
2862
+ onKey?: (data: string, api: { setBanner: (t: string) => void }) => boolean;
2863
+ /** Initial banner line (updated via onKey's setBanner). */
2864
+ banner?: string;
2865
+ /** Extra footer hint text. */
2866
+ hint?: string;
2867
+ /** maxVisible; defaults to min(items, 14). */
2868
+ maxVisible?: number;
2118
2869
  }
2119
2870
 
2120
- /** Full-screen framed text view; any key returns. `scrollable` adds keyboard
2121
- * paging; `live` adds polling + a key hook (scrollable mode only). */
2122
- async function textView(
2123
- ctx: UiCtx,
2124
- title: string,
2125
- lines: string[],
2126
- scrollable = false,
2127
- live?: TextViewLive
2128
- ): Promise<void> {
2129
- await ctx.ui.custom<null>((tui, theme, _kb, done) => {
2871
+ /** A menu you can TYPE into: printable input filters items live (substring,
2872
+ * case-insensitive, over label + description); backspace edits; Esc clears
2873
+ * the filter first, then exits. Arrows/Enter go to the list. Optional
2874
+ * refetch keeps items live (inline statuses refresh while you scroll). */
2875
+ function filterMenu<T extends string>(ctx: UiCtx, opts: FilterMenuOptions<T>): Promise<T | null> {
2876
+ return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
2877
+ const container = new Container();
2878
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2879
+ const titleText = new Text(theme.fg("accent", theme.bold(opts.title)), 1, 0);
2880
+ container.addChild(titleText);
2881
+ const bannerText = new Text(opts.banner ? theme.fg("muted", opts.banner) : "", 0, 0);
2882
+ container.addChild(bannerText);
2883
+ const filterText = new Text(theme.fg("dim", ""), 0, 0);
2884
+ container.addChild(filterText);
2885
+ const list = new SelectList(opts.items, opts.maxVisible ?? 14, {
2886
+ selectedPrefix: (t) => theme.fg("accent", t),
2887
+ selectedText: (t) => theme.fg("accent", t),
2888
+ description: (t) => theme.fg("muted", t),
2889
+ scrollInfo: (t) => theme.fg("dim", t),
2890
+ noMatch: (t) => theme.fg("warning", t),
2891
+ });
2892
+ // The list's own confirm path — without this, Enter is a silent no-op
2893
+ // (SelectList fires onSelect only if it exists; arrows keep working,
2894
+ // which is exactly why a missing wire is easy to ship by accident).
2895
+ list.onSelect = (item) => { cleanup(); done(item.value as T); };
2896
+ list.onCancel = () => { cleanup(); done(null); };
2897
+ container.addChild(list);
2898
+ const footer = new Text("", 1, 0);
2899
+ container.addChild(footer);
2900
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2901
+
2902
+ let filter = "";
2903
+ let items = opts.items;
2904
+ let closed = false;
2905
+
2906
+ // SelectList's declared surface hides its item state (private in the
2907
+ // .d.ts, plain fields at runtime). This narrow structural view is the
2908
+ // minimum needed for live item swaps + substring filtering over
2909
+ // label+description — setFilter() alone only does startsWith-on-value.
2910
+ const listState = list as unknown as {
2911
+ items: MenuItem<T>[];
2912
+ filteredItems: MenuItem<T>[];
2913
+ selectedIndex: number;
2914
+ };
2915
+
2916
+ const applyFilter = (): void => {
2917
+ const f = filter.toLowerCase();
2918
+ const matched = f
2919
+ ? items.filter(
2920
+ (it) =>
2921
+ it.label.toLowerCase().includes(f) ||
2922
+ (it.description ?? "").toLowerCase().includes(f)
2923
+ )
2924
+ : items;
2925
+ listState.filteredItems = matched;
2926
+ list.setSelectedIndex(0);
2927
+ filterText.setText(
2928
+ theme.fg("dim", `filter: ${filter}${filter ? " · " : ""}${matched.length}/${items.length} — type to search, ⌫ edit`)
2929
+ );
2930
+ footer.setText(
2931
+ theme.fg(
2932
+ "dim",
2933
+ `↑↓ navigate • enter select • esc ${filter ? "clear filter" : "back"}${opts.hint ? ` • ${opts.hint}` : ""}`
2934
+ )
2935
+ );
2936
+ };
2937
+ applyFilter();
2938
+
2939
+ // Live refresh: swap items in place; keep the filter and the cursor
2940
+ // (matched by value) so nothing jumps while statuses update underneath.
2941
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
2942
+ const refetch = opts.refetch;
2943
+ if (refetch) {
2944
+ const tickRefresh = async (): Promise<void> => {
2945
+ if (closed) return;
2946
+ try {
2947
+ const fresh = await refetch();
2948
+ if (closed) return;
2949
+ const prevValue = listState.filteredItems[listState.selectedIndex]?.value;
2950
+ items = fresh;
2951
+ listState.items = fresh;
2952
+ applyFilter();
2953
+ if (prevValue !== undefined) {
2954
+ const idx = listState.filteredItems.findIndex((it) => it.value === prevValue);
2955
+ if (idx >= 0) list.setSelectedIndex(idx);
2956
+ }
2957
+ tui.requestRender();
2958
+ } catch {
2959
+ /* a failed refresh keeps the last good snapshot */
2960
+ }
2961
+ };
2962
+ refreshTimer = setInterval(() => void tickRefresh(), Math.max(500, opts.pollMs ?? 2000));
2963
+ void tickRefresh(); // first refresh immediately — a 2s empty-gap on open reads as broken
2964
+ }
2965
+ const cleanup = (): void => {
2966
+ closed = true;
2967
+ if (refreshTimer !== undefined) clearInterval(refreshTimer);
2968
+ };
2969
+
2970
+ return {
2971
+ render: (w) => container.render(w),
2972
+ invalidate: () => container.invalidate(),
2973
+ dispose: cleanup,
2974
+ handleInput: (data) => {
2975
+ try {
2976
+ if (matchesKey(data, Key.backspace)) {
2977
+ filter = filter.slice(0, -1);
2978
+ applyFilter();
2979
+ } else if (matchesKey(data, Key.escape)) {
2980
+ if (filter) {
2981
+ filter = "";
2982
+ applyFilter();
2983
+ } else {
2984
+ cleanup();
2985
+ done(null);
2986
+ }
2987
+ } else if (
2988
+ opts.onKey &&
2989
+ opts.onKey(data, {
2990
+ setBanner: (t) => {
2991
+ bannerText.setText(t ? theme.fg("muted", t) : "");
2992
+ },
2993
+ })
2994
+ ) {
2995
+ /* consumed by the caller's key hook — hotkeys run before typing */
2996
+ } else if (
2997
+ // Printable input (single keypress, no ctrl/meta, not an escape
2998
+ // sequence) feeds the filter. Space is typeable; use ctrl-combos
2999
+ // for menu hotkeys so they never collide with search text.
3000
+ data.length >= 1 &&
3001
+ !/[\x00-\x1f\x7f]/.test(data) &&
3002
+ !data.startsWith("\x1b")
3003
+ ) {
3004
+ filter += data;
3005
+ applyFilter();
3006
+ } else {
3007
+ list.handleInput(data); // arrows, enter, page keys
3008
+ }
3009
+ tui.requestRender();
3010
+ } catch {
3011
+ /* never let a key crash the process */
3012
+ }
3013
+ },
3014
+ };
3015
+ });
3016
+ }
3017
+
3018
+ /** Braille spinner frames for busyView. */
3019
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3020
+
3021
+ /** Outcome of a busyView run. */
3022
+ interface BusyOutcome<T> {
3023
+ /** The operation's result on success. */
3024
+ result?: T;
3025
+ /** The operation's error text on failure. */
3026
+ error?: string;
3027
+ /** True when the user pressed Esc and the operation was allowed to keep
3028
+ * running server-side (loads, pulls) — the caller returns to its menu and
3029
+ * live views pick up the state change from polling. */
3030
+ detached: boolean;
3031
+ }
3032
+
3033
+ /** A visible, animated blocking indicator: spinner + label, plus an optional
3034
+ * live status line polled from the server (e.g. a model's load state), and
3035
+ * optional Esc-to-detach for operations that safely continue server-side.
3036
+ * Replaces the invisible setStatus-only wait for anything that can take
3037
+ * more than a moment. */
3038
+ function busyView<T>(
3039
+ ctx: UiCtx,
3040
+ label: string,
3041
+ op: () => Promise<T>,
3042
+ opts: { liveStatus?: () => Promise<string | null>; livePollMs?: number; escDetach?: boolean } = {}
3043
+ ): Promise<BusyOutcome<T>> {
3044
+ return ctx.ui.custom<BusyOutcome<T>>((tui, theme, _kb, done) => {
3045
+ const container = new Container();
3046
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
3047
+ const spinnerText = new Text(theme.fg("accent", `${SPINNER_FRAMES[0]} ${label}`), 1, 0);
3048
+ container.addChild(spinnerText);
3049
+ const liveText = new Text("", 0, 0);
3050
+ container.addChild(liveText);
3051
+ container.addChild(
3052
+ new Text(theme.fg("dim", opts.escDetach ? "esc continue in background" : "esc cancel"), 1, 0)
3053
+ );
3054
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
3055
+
3056
+ let finished = false;
3057
+ let frame = 0;
3058
+ const finish = (outcome: BusyOutcome<T>): void => {
3059
+ if (finished) return;
3060
+ finished = true;
3061
+ clearInterval(spinTimer);
3062
+ clearInterval(liveTimer);
3063
+ done(outcome);
3064
+ };
3065
+
3066
+ const spinTimer = setInterval(() => {
3067
+ frame = (frame + 1) % SPINNER_FRAMES.length;
3068
+ spinnerText.setText(theme.fg("accent", `${SPINNER_FRAMES[frame]} ${label}`));
3069
+ tui.requestRender();
3070
+ }, 90);
3071
+
3072
+ let liveTimer: ReturnType<typeof setInterval> | undefined;
3073
+ if (opts.liveStatus) {
3074
+ const pollLive = async (): Promise<void> => {
3075
+ if (finished) return;
3076
+ try {
3077
+ const s = await opts.liveStatus!();
3078
+ if (finished) return;
3079
+ liveText.setText(s ? theme.fg("muted", s) : "");
3080
+ tui.requestRender();
3081
+ } catch {
3082
+ /* keep the last good live line */
3083
+ }
3084
+ };
3085
+ void pollLive();
3086
+ liveTimer = setInterval(() => void pollLive(), Math.max(500, opts.livePollMs ?? 2000));
3087
+ }
3088
+
3089
+ void op()
3090
+ .then((result) => finish({ result, detached: false }))
3091
+ .catch((error) => finish({ error: error instanceof Error ? error.message : String(error), detached: false }));
3092
+
3093
+ return {
3094
+ render: (w) => container.render(w),
3095
+ invalidate: () => container.invalidate(),
3096
+ dispose: () => {
3097
+ finished = true;
3098
+ clearInterval(spinTimer);
3099
+ if (liveTimer !== undefined) clearInterval(liveTimer);
3100
+ },
3101
+ handleInput: (data) => {
3102
+ if (matchesKey(data, Key.escape)) {
3103
+ if (opts.escDetach) {
3104
+ // Leave the operation running; the server keeps its state and
3105
+ // live views pick it up. There is no way back to this promise —
3106
+ // the caller decides how to report the eventual outcome.
3107
+ finish({ detached: true });
3108
+ } else {
3109
+ spinnerText.setText(theme.fg("warning", "⏳ working — this step can't be interrupted, please wait"));
3110
+ tui.requestRender();
3111
+ }
3112
+ }
3113
+ },
3114
+ };
3115
+ });
3116
+ }
3117
+
3118
+ /** Live-refresh wiring for the scrollable textView (status panel). */
3119
+ interface TextViewLive {
3120
+ /** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
3121
+ pollMs?: number;
3122
+ refetch?: () => Promise<string[]>;
3123
+ /** Extra footer key hint (e.g. "n/p instance"), shown when multiple targets exist. */
3124
+ extraHint?: string;
3125
+ /** Hook for keys beyond scroll/quit; return true when the key was consumed. */
3126
+ onKey?: (data: string, api: { setTitle: (t: string) => void }) => boolean;
3127
+ }
3128
+
3129
+ /** Full-screen framed text view; any key returns. `scrollable` adds keyboard
3130
+ * paging; `live` adds polling + a key hook (scrollable mode only). */
3131
+ async function textView(
3132
+ ctx: UiCtx,
3133
+ title: string,
3134
+ lines: string[],
3135
+ scrollable = false,
3136
+ live?: TextViewLive
3137
+ ): Promise<void> {
3138
+ await ctx.ui.custom<null>((tui, theme, _kb, done) => {
2130
3139
  const container = new Container();
2131
3140
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2132
3141
  const titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
@@ -2356,6 +3365,15 @@ async function trackDownload(
2356
3365
  const finish = (v: "complete" | "cancelled" | "error") => {
2357
3366
  if (finished) return;
2358
3367
  finished = true;
3368
+ // The server keeps errored/cancelled jobs listed until a client removes
3369
+ // them (spec: GET /v1/downloads) — this extension is that client, so a
3370
+ // terminal outcome here also clears the job from the registry. Partial
3371
+ // files stay on disk; a retry resumes from them.
3372
+ if (v === "error" || v === "cancelled") {
3373
+ void controlDownload(config, jobId, "remove").catch(() => {
3374
+ /* removal is best-effort cleanup, never a failure of the outcome */
3375
+ });
3376
+ }
2359
3377
  done(v);
2360
3378
  };
2361
3379
 
@@ -2651,6 +3669,14 @@ function formatStatus(
2651
3669
  if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
2652
3670
  if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
2653
3671
  if (typeof perf.cache_tokens_total === "number") lifetime.push(`cache ${fmtTokens(perf.cache_tokens_total)}`);
3672
+ if (perf.live && (typeof perf.live.active_requests === "number" || typeof perf.live.estimated_tokens_per_second === "number")) {
3673
+ lines.push(
3674
+ ` live: ${perf.live.active_requests ?? 0} active request(s)` +
3675
+ (typeof perf.live.estimated_tokens_per_second === "number"
3676
+ ? ` · ${(perf.live.estimated_tokens_per_second || 0).toFixed(1)} tok/s (whole box)`
3677
+ : "")
3678
+ );
3679
+ }
2654
3680
  if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
2655
3681
  if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
2656
3682
  lines.push(
@@ -2937,6 +3963,751 @@ async function addInstanceFlow(
2937
3963
  return nameInput;
2938
3964
  }
2939
3965
 
3966
+ // ─── Model browser, downloads, backends, updates (lemonade ≥2026.46 TUI) ────
3967
+
3968
+ /** One live snapshot of everything the model browser shows. */
3969
+ interface BrowserState {
3970
+ catalog: LemonadeModel[];
3971
+ /** Loaded models by name, with load state (status/pinned/busy/backend). */
3972
+ loaded: Map<string, LoadedModelInfo>;
3973
+ /** Server-owned download jobs, or null when the box was unreachable. */
3974
+ jobs: DownloadJob[] | null;
3975
+ /** False when health didn't answer — the snapshot may be partial. */
3976
+ reachable: boolean;
3977
+ }
3978
+
3979
+ async function fetchBrowserState(config: LemonadeConfig): Promise<BrowserState> {
3980
+ const [health, jobs, catalog] = await Promise.all([
3981
+ fetchHealth(config),
3982
+ listDownloads(config),
3983
+ fetchCatalog(config, true).catch(() => [] as LemonadeModel[]),
3984
+ ]);
3985
+ const loaded = new Map<string, LoadedModelInfo>();
3986
+ for (const m of health?.all_models_loaded ?? []) loaded.set(m.model_name, m);
3987
+ return { catalog, loaded, jobs, reachable: health != null };
3988
+ }
3989
+
3990
+ /** The download job (if any) currently downloading a model. */
3991
+ function jobForModel(state: BrowserState, id: string): DownloadJob | undefined {
3992
+ return (state.jobs ?? []).find((j) => j.model_name === id && j.status !== "completed" && j.status !== "error" && j.status !== "cancelled");
3993
+ }
3994
+
3995
+ /** Inline status glyph for one catalog row. */
3996
+ function modelGlyph(m: LemonadeModel, state: BrowserState): string {
3997
+ const job = jobForModel(state, m.id);
3998
+ if (job) return job.status === "paused" ? "⏸" : "↓";
3999
+ if (state.loaded.has(m.id)) return "●";
4000
+ if (m.downloaded === false) return "◌";
4001
+ return "○";
4002
+ }
4003
+
4004
+ const MODEL_ORDER = (m: LemonadeModel, state: BrowserState): number => {
4005
+ const job = jobForModel(state, m.id);
4006
+ if (job && job.status !== "paused") return 0; // actively downloading first
4007
+ if (job) return 1; // paused
4008
+ if (state.loaded.has(m.id)) return 2;
4009
+ if (m.downloaded === false) return 4;
4010
+ return 3; // downloaded, not loaded
4011
+ };
4012
+
4013
+ /** Build the browser's live menu items from one snapshot. */
4014
+ function browserItems(state: BrowserState, downloadedOnly: boolean): MenuItem<string>[] {
4015
+ const models = state.catalog
4016
+ .filter((m) => !downloadedOnly || m.downloaded !== false)
4017
+ .sort((a, b) => MODEL_ORDER(a, state) - MODEL_ORDER(b, state) || a.id.localeCompare(b.id));
4018
+ return models.map((m) => {
4019
+ const job = jobForModel(state, m.id);
4020
+ const info = state.loaded.get(m.id);
4021
+ const bits: string[] = [];
4022
+ if (job) bits.push(`${job.status === "paused" ? "download paused" : `downloading ${typeof job.percent === "number" ? job.percent.toFixed(0) : "?"}%`}${typeof job.total_download_size === "number" ? ` of ${formatBytes(job.total_download_size)}` : ""}`);
4023
+ if (info) {
4024
+ bits.push(
4025
+ `loaded (${info.status ?? "ready"}${info.backend_health && info.backend_health !== "ready" ? `, backend ${info.backend_health}` : ""})` +
4026
+ `${info.pinned ? " · pinned" : ""}${info.is_busy || info.is_streaming ? " · busy" : ""}`
4027
+ );
4028
+ }
4029
+ if (m.downloaded === false) bits.push("not downloaded");
4030
+ if (m.update_available) bits.push("⤴ update available");
4031
+ if ((m.labels ?? []).includes("on-demand")) bits.push("on-demand (no load — serves its endpoint directly)");
4032
+ const ctx = m.context_length ?? m.max_context_window;
4033
+ bits.push(
4034
+ [
4035
+ m.size !== undefined ? `${m.size} GB` : undefined,
4036
+ ctx ? `ctx ${ctx.toLocaleString("en-US")}` : undefined,
4037
+ m.recipe,
4038
+ ...(m.labels ?? []).filter((l) => ![
4039
+ "chat", "hot", "on-demand", "custom", "image", "edit",
4040
+ ].includes(l)).slice(0, 4),
4041
+ ]
4042
+ .filter(Boolean)
4043
+ .join(" • ")
4044
+ );
4045
+ return {
4046
+ value: m.id,
4047
+ label: `${modelGlyph(m, state)} ${m.id}${(m.labels ?? []).includes("hot") ? " ★" : ""}${m.update_available ? " ⤴" : ""}`,
4048
+ description: bits.join(" — "),
4049
+ };
4050
+ });
4051
+ }
4052
+
4053
+ /** Full model detail sheet (textView): everything the server knows. */
4054
+ async function modelDetails(ctx: UiCtx, config: LemonadeConfig, m: LemonadeModel, state: BrowserState): Promise<void> {
4055
+ const info = state.loaded.get(m.id);
4056
+ const job = (state.jobs ?? []).find((j) => j.model_name === m.id);
4057
+ const lines: string[] = [
4058
+ `Model: ${m.id}`,
4059
+ `Recipe: ${m.recipe ?? "?"}${(m.labels ?? []).length ? ` · labels: ${m.labels!.join(", ")}` : ""}`,
4060
+ `Checkpoint: ${m.checkpoint ?? "?"}`,
4061
+ ...(Object.entries(m.checkpoints ?? {} as Record<string, string>).length
4062
+ ? ["Checkpoints:", ...Object.entries(m.checkpoints as Record<string, string>).map(([k, v]) => ` ${k}: ${v}`)]
4063
+ : []),
4064
+ ];
4065
+ if (m.checkpoint_status?.length) {
4066
+ lines.push("Checkpoint files:");
4067
+ for (const cs of m.checkpoint_status) {
4068
+ lines.push(` ${cs.type ?? "?"}: ${cs.present ? "present" : "MISSING"}${cs.required === false ? " (optional)" : ""} [${cs.registry ?? "local"}]`);
4069
+ }
4070
+ }
4071
+ lines.push(
4072
+ `Size: ${m.size !== undefined ? `${m.size} GB` : "?"} · Downloaded: ${m.downloaded === false ? "no" : "yes"}` +
4073
+ (m.update_available ? " · ⤴ UPDATE AVAILABLE" : ""),
4074
+ `Context: ${m.context_length ? `${m.context_length.toLocaleString("en-US")} (live)` : "?"}${m.max_context_window ? ` / max ${m.max_context_window.toLocaleString("en-US")}` : ""}`
4075
+ );
4076
+ if (Object.keys(m.recipe_options ?? {}).length) {
4077
+ lines.push("Recipe options (saved): " + JSON.stringify(m.recipe_options));
4078
+ }
4079
+ if (info) {
4080
+ lines.push(
4081
+ "",
4082
+ "Loaded state:",
4083
+ ` status: ${info.status ?? "?"} · device: ${info.device ?? "?"} · backend: ${info.backend_health ?? "?"}`,
4084
+ ` pinned: ${info.pinned ? "yes" : "no"} · busy: ${info.is_busy ? "yes" : "no"} · streaming: ${info.is_streaming ? "yes" : "no"}`,
4085
+ ...(info.pid ? [` pid: ${info.pid}`] : []),
4086
+ ...(info.slot_pool ? [` slot pool: ${info.slot_pool} (${info.residency_class ?? "standard"})`] : []),
4087
+ ...(info.launch_command ? [" launch: " + info.launch_command.join(" ")] : []),
4088
+ ...(info.watchdog_reset_reason ? [` watchdog reset: ${info.watchdog_reset_reason}`] : [])
4089
+ );
4090
+ }
4091
+ if (job) {
4092
+ lines.push(
4093
+ "",
4094
+ `Download job ${job.id}: ${job.status}${typeof job.percent === "number" ? ` — ${job.percent.toFixed(1)}%` : ""}`,
4095
+ ...(job.error ? [` error: ${job.error}`] : [])
4096
+ );
4097
+ }
4098
+ await textView(ctx, `Model — ${m.id}`, lines, true);
4099
+ }
4100
+
4101
+ /** The per-model action menu: everything you can do with a model, offered
4102
+ * contextually from its live state. */
4103
+ async function modelActions(
4104
+ ctx: UiCtx,
4105
+ pi: ExtensionAPI,
4106
+ config: LemonadeConfig,
4107
+ fullConfig: LemonadeConfig,
4108
+ id: string
4109
+ ): Promise<void> {
4110
+ for (;;) {
4111
+ const state = await fetchBrowserState(config); // fresh state per action
4112
+ if (!state.reachable) {
4113
+ ctx.ui.notify(`Lemonade server at ${config.baseUrl} is unreachable right now — actions need a live box.`, "error");
4114
+ return;
4115
+ }
4116
+ const m = state.catalog.find((x) => x.id === id);
4117
+ if (!m) {
4118
+ ctx.ui.notify(`"${id}" is no longer in the catalog (deleted or renamed?).`, "warning");
4119
+ return;
4120
+ }
4121
+ const info = state.loaded.get(id);
4122
+ const job = jobForModel(state, id);
4123
+ const labels = m.labels ?? [];
4124
+ const onDemand = labels.includes("on-demand");
4125
+
4126
+ const items: MenuItem<string>[] = [
4127
+ { value: "view", label: "View details", description: "Checkpoints, context, load state, launch command" },
4128
+ ];
4129
+ if (job) {
4130
+ items.push({
4131
+ value: "canceljob",
4132
+ label: `Cancel download (${typeof job.percent === "number" ? `${job.percent.toFixed(0)}%` : "?"})`,
4133
+ description: "Partial files stay on disk; a later pull resumes from them",
4134
+ });
4135
+ }
4136
+ if (!info && !onDemand && m.downloaded !== false) {
4137
+ items.push({ value: "load", label: "Load into memory", description: "May take minutes — esc continues in background" });
4138
+ }
4139
+ if (info) {
4140
+ items.push({ value: "unload", label: "Unload", description: "Free its slot (evictable again)" });
4141
+ items.push({
4142
+ value: "pin",
4143
+ label: info.pinned ? "Unpin" : "Pin",
4144
+ description: info.pinned
4145
+ ? "Pinned models are skipped by LRU eviction — unpin to make it evictable"
4146
+ : "Skip LRU eviction while loaded (admin key)",
4147
+ });
4148
+ }
4149
+ if (m.downloaded === false && !job) {
4150
+ items.push({ value: "pull", label: "Pull (download)", description: m.size !== undefined ? `~${m.size} GB — live progress, esc cancels` : "Live progress, esc cancels" });
4151
+ }
4152
+ if (m.update_available) {
4153
+ items.push({ value: "update", label: "⤴ Update to latest commit", description: "Re-pulls from the registry — live progress" });
4154
+ }
4155
+ items.push({ value: "options", label: "Recipe options", description: `ctx size, backend, args — saved per model (recipe: ${m.recipe ?? "?"})` });
4156
+ if (m.downloaded !== false) {
4157
+ items.push({ value: "delete", label: "Delete from disk", description: "Typed-phrase confirmation required" });
4158
+ }
4159
+ if (onDemand) {
4160
+ items.push({ value: "ondemand", label: "Why no Load?", description: "on-demand models serve their endpoint directly, with no resident server" });
4161
+ }
4162
+ items.push({ value: "back", label: "Back", description: "" });
4163
+
4164
+ const action = await menu(ctx, `${modelGlyph(m, state)} ${id}`, items);
4165
+ if (action === null || action === "back") return;
4166
+
4167
+ if (action === "view") {
4168
+ await modelDetails(ctx, config, m, state);
4169
+ } else if (action === "ondemand") {
4170
+ ctx.ui.notify(
4171
+ "on-demand models (e.g. built-in upscalers) have no resident model server: " +
4172
+ "/v1/load is not supported. They run through their task endpoint (e.g. /v1/images/upscale) " +
4173
+ "when a tool calls them.",
4174
+ "info"
4175
+ );
4176
+ } else if (action === "canceljob" && job) {
4177
+ const ok = await ctx.ui.confirm(`Cancel the download of ${id}?`, "Partial files stay on disk; a later pull resumes from them.");
4178
+ if (!ok) continue;
4179
+ const [succeeded, msg] = await withStatus(ctx, `Cancelling ${job.id}…`, () =>
4180
+ controlDownload(config, job.id, "cancel").then(() => `Download of ${id} cancelled.`)
4181
+ );
4182
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
4183
+ } else if (action === "load") {
4184
+ const outcome = await busyView(ctx, `Loading ${id}…`, () => loadModel(config, id), {
4185
+ escDetach: true,
4186
+ liveStatus: async () => {
4187
+ const h = await fetchHealth(config);
4188
+ const me = (h?.all_models_loaded ?? []).find((x) => x.model_name === id);
4189
+ if (!me) return "server: preparing load…";
4190
+ return `server: ${me.status ?? "loading"}${me.device ? ` on ${me.device}` : ""}${me.is_busy ? " · busy" : ""}`;
4191
+ },
4192
+ });
4193
+ if (outcome.detached) {
4194
+ ctx.ui.notify(`Load of ${id} continues in the background — the browser will show it live.`, "info");
4195
+ } else if (outcome.error) {
4196
+ ctx.ui.notify(`Load failed: ${outcome.error}`, "error");
4197
+ } else {
4198
+ ctx.ui.notify(`Loaded ${id}.`, "info");
4199
+ }
4200
+ await registerLemonadeProvider(pi, fullConfig);
4201
+ } else if (action === "unload") {
4202
+ const outcome = await busyView(ctx, `Unloading ${id}…`, () => unloadModel(config, id).then(() => "unloaded"));
4203
+ if (outcome.error) ctx.ui.notify(`Unload failed: ${outcome.error}`, "error");
4204
+ else {
4205
+ ctx.ui.notify(`Unloaded ${id}.`, "info");
4206
+ await registerLemonadeProvider(pi, fullConfig);
4207
+ }
4208
+ } else if (action === "pin" && info) {
4209
+ const target = !info.pinned;
4210
+ const outcome = await busyView(ctx, `${target ? "Pinning" : "Unpinning"} ${id}…`, () =>
4211
+ setPinned(config, id, target).then(() => "pinned")
4212
+ );
4213
+ if (outcome.error) ctx.ui.notify(outcome.error, "error");
4214
+ else ctx.ui.notify(`${id} ${target ? "pinned — LRU eviction will skip it" : "unpinned — evictable again"}.`, "info");
4215
+ } else if (action === "pull") {
4216
+ const ok = await ctx.ui.confirm(
4217
+ `Pull ${id}?`,
4218
+ m.size !== undefined ? `Downloads ~${m.size} GB to the server's disk.` : "Downloads the model to the server's disk."
4219
+ );
4220
+ if (!ok) continue;
4221
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, config, id);
4222
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
4223
+ } else if (action === "update") {
4224
+ const ok = await ctx.ui.confirm(
4225
+ `Update ${id}?`,
4226
+ "Downloads the model's newer registry commit (the current copy stays until the pull lands)."
4227
+ );
4228
+ if (!ok) continue;
4229
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, config, id);
4230
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
4231
+ } else if (action === "delete") {
4232
+ const ok = await ctx.ui.confirm(
4233
+ `Delete ${id}?`,
4234
+ "This permanently removes the model files from the server's disk. Load state and cache are also lost."
4235
+ );
4236
+ if (!ok) continue;
4237
+ const phrase = `i want to delete ${id.toLowerCase()}`;
4238
+ let confirmed = false;
4239
+ for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
4240
+ const chancesLeft = 4 - attempt;
4241
+ const typed = await ctx.ui.input(
4242
+ `Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
4243
+ ""
4244
+ );
4245
+ if (typed === undefined) break; // Esc = abort immediately
4246
+ if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) confirmed = true;
4247
+ else if (attempt < 3) ctx.ui.notify("Phrase did not match.", "warning");
4248
+ }
4249
+ if (!confirmed) {
4250
+ ctx.ui.notify(`Deletion of ${id} aborted.`, "warning");
4251
+ continue;
4252
+ }
4253
+ const outcome = await busyView(ctx, `Deleting ${id}…`, () => deleteModel(config, id));
4254
+ if (outcome.error) ctx.ui.notify(`Delete failed: ${outcome.error}`, "error");
4255
+ else {
4256
+ ctx.ui.notify(`Deleted ${id} from disk.`, "info");
4257
+ await registerLemonadeProvider(pi, fullConfig);
4258
+ }
4259
+ return; // the model is gone — leave the action loop
4260
+ } else if (action === "options") {
4261
+ await recipeOptionsEditor(ctx, pi, config, fullConfig, id);
4262
+ }
4263
+ }
4264
+ }
4265
+
4266
+ /** Parse a typed option value the way the API expects: ""/"auto" → null
4267
+ * (tombstone, fall through the priority chain), numbers as numbers, booleans
4268
+ * as booleans, anything else as the raw string. ctx_size keeps -1 (auto). */
4269
+ function parseOptionValue(raw: string, key: string): unknown {
4270
+ const t = raw.trim();
4271
+ if (t === "" || t.toLowerCase() === "auto" || t.toLowerCase() === "null") return null;
4272
+ if (key === "ctx_size" && t === "-1") return -1;
4273
+ if (t === "true" || t === "false") return t === "true";
4274
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
4275
+ return t;
4276
+ }
4277
+
4278
+ /** Per-model recipe options editor: read the saved/effective/defaults layers,
4279
+ * edit one key at a time (saved immediately), reset to defaults, and reload
4280
+ * a loaded model to apply. Works on unloaded models too — options take
4281
+ * effect at the next load. */
4282
+ async function recipeOptionsEditor(
4283
+ ctx: UiCtx,
4284
+ pi: ExtensionAPI,
4285
+ config: LemonadeConfig,
4286
+ fullConfig: LemonadeConfig,
4287
+ id: string
4288
+ ): Promise<void> {
4289
+ for (;;) {
4290
+ let layers: ModelOptionsLayers;
4291
+ try {
4292
+ layers = await fetchModelOptions(config, id);
4293
+ } catch (error) {
4294
+ ctx.ui.notify(`Could not read options for ${id}: ${error instanceof Error ? error.message : String(error)}`, "error");
4295
+ return;
4296
+ }
4297
+ const saved = layers.saved ?? {};
4298
+ const effective = layers.effective ?? {};
4299
+ const defaults = layers.defaults ?? {};
4300
+ const keys = Array.from(new Set([...Object.keys(defaults), ...Object.keys(effective), ...Object.keys(saved)])).sort();
4301
+
4302
+ const items: MenuItem<string>[] = keys.map((k) => ({
4303
+ value: k,
4304
+ label: k,
4305
+ description:
4306
+ `saved: ${k in saved ? JSON.stringify(saved[k]) : "(default)"} · effective: ${JSON.stringify(effective[k] ?? defaults[k] ?? null)}`,
4307
+ }));
4308
+ items.push({ value: "__reset__", label: "Reset ALL saved options", description: "DELETE every saved key — back to recipe defaults" });
4309
+ items.push({ value: "__back__", label: "Back", description: "" });
4310
+
4311
+ const key = await filterMenu(ctx, {
4312
+ title: `Recipe Options — ${id} (recipe: ${layers.recipe ?? "?"}, ${Object.keys(saved).length} saved)`,
4313
+ items,
4314
+ banner: `ctx_size: -1 = auto · ""/"auto" clears a key · edits save immediately`,
4315
+ maxVisible: 12,
4316
+ });
4317
+ if (key === null || key === "__back__") return;
4318
+
4319
+ if (key === "__reset__") {
4320
+ const ok = await ctx.ui.confirm(
4321
+ `Reset every saved option for ${id}?`,
4322
+ "All saved recipe options are deleted; the model falls back to its defaults."
4323
+ );
4324
+ if (!ok) continue;
4325
+ const outcome = await busyView(ctx, `Resetting options for ${id}…`, () => resetModelOptions(config, id));
4326
+ if (outcome.error) ctx.ui.notify(`Reset failed: ${outcome.error}`, "error");
4327
+ else ctx.ui.notify(`Saved options for ${id} reset to defaults.`, "info");
4328
+ continue;
4329
+ }
4330
+
4331
+ // Value entry: backend selectors get a menu of what this system supports;
4332
+ // ctx_size accepts 32k/1m shorthand; everything else is free text.
4333
+ let value: unknown;
4334
+ let cancelled = false;
4335
+ if (/_backend$/.test(key)) {
4336
+ let choices: string[] = [];
4337
+ try {
4338
+ const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
4339
+ const recipe = layers.recipe ?? "";
4340
+ choices = Object.keys(sysinfo?.recipes?.[recipe]?.backends ?? {});
4341
+ } catch {
4342
+ /* fall back to free text */
4343
+ }
4344
+ if (choices.length) {
4345
+ const picked = await menu(ctx, `${id} → ${key}`, [
4346
+ ...choices.map((b) => ({ value: b, label: b, description: `effective: ${JSON.stringify(effective[key] ?? defaults[key] ?? null)}` })),
4347
+ { value: "__clear__", label: "(clear — use the default)", description: "" },
4348
+ ]);
4349
+ if (picked === null) cancelled = true;
4350
+ else value = picked === "__clear__" ? null : picked;
4351
+ }
4352
+ }
4353
+ if (!cancelled && value === undefined) {
4354
+ const current = key in saved ? String(saved[key]) : "";
4355
+ const input = await ctx.ui.input(
4356
+ `${id} → ${key} (empty/auto = clear the saved value${key === "ctx_size" ? "; -1 = auto-size" : ""}):`,
4357
+ key === "ctx_size" ? String(effective.ctx_size ?? "") : current
4358
+ );
4359
+ if (input === undefined) cancelled = true;
4360
+ else if (key === "ctx_size") {
4361
+ const parsed = input.trim() === "" ? null : parseCtxSize(input);
4362
+ if (parsed === null) {
4363
+ ctx.ui.notify("Invalid size — use e.g. 32k, 1m, 262144, -1 (auto), or empty to clear.", "warning");
4364
+ continue;
4365
+ }
4366
+ value = parsed;
4367
+ } else {
4368
+ value = parseOptionValue(input, key);
4369
+ }
4370
+ }
4371
+ if (cancelled) continue;
4372
+
4373
+ const outcome = await busyView(ctx, `Saving ${key} for ${id}…`, () =>
4374
+ saveModelOptions(config, id, { [key]: value })
4375
+ );
4376
+ if (outcome.error) {
4377
+ ctx.ui.notify(`Save failed: ${outcome.error}`, "error");
4378
+ continue;
4379
+ }
4380
+ ctx.ui.notify(
4381
+ `${key} saved for ${id}${value === null ? " (cleared — falls back to defaults)" : `: ${JSON.stringify(value)}`}.`,
4382
+ "info"
4383
+ );
4384
+
4385
+ // A loaded model keeps running with its old options — offer an apply.
4386
+ const h = await fetchHealth(config);
4387
+ if ((h?.all_models_loaded ?? []).some((x) => x.model_name === id)) {
4388
+ const apply = await ctx.ui.confirm(
4389
+ `${id} is loaded — reload it now to apply the new options?`,
4390
+ "The server reloads a model whose load-time options changed (saved options persist either way)."
4391
+ );
4392
+ if (apply) {
4393
+ const loadOutcome = await busyView(ctx, `Reloading ${id}…`, () => loadModel(config, id), { escDetach: true });
4394
+ if (loadOutcome.detached) ctx.ui.notify(`Reload of ${id} continues in the background.`, "info");
4395
+ else if (loadOutcome.error) ctx.ui.notify(`Reload failed: ${loadOutcome.error}`, "error");
4396
+ else ctx.ui.notify(`Reloaded ${id} with the new options.`, "info");
4397
+ await registerLemonadeProvider(pi, fullConfig);
4398
+ }
4399
+ }
4400
+ }
4401
+ }
4402
+
4403
+ /** Server-owned download jobs: live list with per-job actions. Terminal jobs
4404
+ * (errored/cancelled) linger on the server until a client removes them —
4405
+ * this is the view that removes them, and the retry path resumes the
4406
+ * partial files that stayed on disk. */
4407
+ async function downloadsManager(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
4408
+ for (;;) {
4409
+ const picked = await filterMenu<string>(ctx, {
4410
+ title: "Downloads (server-owned jobs)",
4411
+ items: [], // replaced by the first refetch; also handles the empty case
4412
+ refetch: async () => {
4413
+ const jobs = (await listDownloads(config)) ?? [];
4414
+ const items: MenuItem<string>[] = jobs
4415
+ .map((j) => ({
4416
+ value: j.id,
4417
+ label: `${j.running ? "↓" : j.status === "error" ? "✗" : j.status === "paused" ? "⏸" : j.status === "cancelled" ? "∅" : "✓"} ${j.id} — ${j.status}${typeof j.percent === "number" ? ` ${j.percent.toFixed(0)}%` : ""}`,
4418
+ description: [
4419
+ j.model_name,
4420
+ typeof j.total_download_size === "number" ? formatBytes(j.total_download_size) : undefined,
4421
+ j.file ? `file ${j.file_index ?? "?"}/${j.total_files ?? "?"}: ${j.file}` : undefined,
4422
+ j.error ? `error: ${j.error.slice(0, 100)}` : undefined,
4423
+ ]
4424
+ .filter(Boolean)
4425
+ .join(" • "),
4426
+ }))
4427
+ .sort((a, b) => a.label.localeCompare(b.label));
4428
+ items.push({ value: "__clear__", label: "✕ Remove all stopped jobs", description: "Clears errored/cancelled/completed entries from the server's list (files on disk are untouched)" });
4429
+ items.push({ value: "__back__", label: "Back", description: "" });
4430
+ return items;
4431
+ },
4432
+ pollMs: 1500,
4433
+ hint: "live",
4434
+ });
4435
+ if (picked === null || picked === "__back__") return;
4436
+
4437
+ if (picked === "__clear__") {
4438
+ const jobs = (await listDownloads(config)) ?? [];
4439
+ const stopped = jobs.filter((j) => !j.running);
4440
+ if (!stopped.length) {
4441
+ ctx.ui.notify("No stopped jobs to remove.", "info");
4442
+ continue;
4443
+ }
4444
+ const ok = await ctx.ui.confirm(
4445
+ `Remove ${stopped.length} stopped job${stopped.length === 1 ? "" : "s"}?`,
4446
+ "Removes them from the server's job list only — partial downloads stay on disk and resume on the next pull."
4447
+ );
4448
+ if (!ok) continue;
4449
+ const outcome = await busyView(ctx, `Removing ${stopped.length} stopped job(s)…`, async () => {
4450
+ for (const j of stopped) {
4451
+ try {
4452
+ await controlDownload(config, j.id, "remove");
4453
+ } catch {
4454
+ /* already gone — removal is idempotent */
4455
+ }
4456
+ }
4457
+ });
4458
+ if (outcome.error) ctx.ui.notify(outcome.error, "error");
4459
+ else ctx.ui.notify(`Removed ${stopped.length} stopped job(s) from the list.`, "info");
4460
+ continue;
4461
+ }
4462
+
4463
+ // Per-job actions
4464
+ const jobs = (await listDownloads(config)) ?? [];
4465
+ const job = jobs.find((j) => j.id === picked);
4466
+ if (!job) {
4467
+ ctx.ui.notify("That job is no longer listed.", "warning");
4468
+ continue;
4469
+ }
4470
+ const items: MenuItem<string>[] = [];
4471
+ if (job.running) {
4472
+ items.push({ value: "cancel", label: "Cancel", description: "Stop the download — partial files stay, a later pull resumes" });
4473
+ items.push({ value: "pause", label: "Pause", description: "Stop the worker, keep the job listed as paused" });
4474
+ } else {
4475
+ items.push({ value: "remove", label: "Remove from list", description: "Clear the finished/failed entry (files untouched)" });
4476
+ if (job.model_name && (job.status === "error" || job.status === "cancelled")) {
4477
+ items.push({ value: "retry", label: "Retry pull", description: "Re-pulls — resumes from the partial files on disk" });
4478
+ }
4479
+ }
4480
+ items.push({ value: "back", label: "Back", description: "" });
4481
+ const action = await menu(ctx, `${job.id} — ${job.status}`, items);
4482
+ if (action === null || action === "back") continue;
4483
+
4484
+ if (action === "cancel" || action === "pause" || action === "remove") {
4485
+ const outcome = await busyView(ctx, `${action} ${job.id}…`, () => controlDownload(config, job.id, action));
4486
+ if (outcome.error) ctx.ui.notify(outcome.error, "error");
4487
+ else ctx.ui.notify(`${job.id}: ${action} sent.`, "info");
4488
+ } else if (action === "retry" && job.model_name) {
4489
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, config, job.model_name);
4490
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
4491
+ }
4492
+ }
4493
+ }
4494
+
4495
+ /** Backend browser: every recipe × backend on this system with its install
4496
+ * state; install (with live download progress) or uninstall from here. */
4497
+ async function backendsView(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
4498
+ const flatten = (sysinfo: SystemInfo | null): Array<{ recipe: string; backend: string; info: BackendInfo; isDefault: boolean }> => {
4499
+ const out: Array<{ recipe: string; backend: string; info: BackendInfo; isDefault: boolean }> = [];
4500
+ for (const [recipe, r] of Object.entries(sysinfo?.recipes ?? {})) {
4501
+ for (const [backend, info] of Object.entries(r.backends ?? {})) {
4502
+ out.push({ recipe, backend, info, isDefault: r.default_backend === backend });
4503
+ }
4504
+ }
4505
+ const rank = (s: string | undefined): number => (s === "installed" ? 0 : s === "installable" ? 1 : 2);
4506
+ return out.sort(
4507
+ (a, b) => rank(a.info.state) - rank(b.info.state) || `${a.recipe}:${a.backend}`.localeCompare(`${b.recipe}:${b.backend}`)
4508
+ );
4509
+ };
4510
+
4511
+ for (;;) {
4512
+ const picked = await filterMenu<string>(ctx, {
4513
+ title: "Backends (recipe × backend, live from system-info)",
4514
+ items: [],
4515
+ refetch: async () => {
4516
+ const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
4517
+ const flat = flatten(sysinfo);
4518
+ const items: MenuItem<string>[] = flat.map((b) => ({
4519
+ value: `${b.recipe}:${b.backend}`,
4520
+ label: `${b.info.state === "installed" ? "●" : b.info.state === "installable" ? "○" : "⊘"} ${b.recipe}:${b.backend}${b.isDefault ? " (default)" : ""}`,
4521
+ description: [
4522
+ b.info.state,
4523
+ b.info.tier ? `tier: ${b.info.tier}` : undefined,
4524
+ b.info.version ? `v: ${b.info.version}` : undefined,
4525
+ b.info.format,
4526
+ b.info.message || undefined,
4527
+ ]
4528
+ .filter(Boolean)
4529
+ .join(" • "),
4530
+ }));
4531
+ items.push({ value: "__back__", label: "Back", description: "" });
4532
+ return items;
4533
+ },
4534
+ pollMs: 2500,
4535
+ hint: "live",
4536
+ banner: "models whose recipe has only guest-tier backends stay hidden from the catalog until one is installed",
4537
+ });
4538
+ if (picked === null || picked === "__back__") return;
4539
+
4540
+ const [recipe, backend] = picked.split(":", 2);
4541
+ const sysinfo = await fetchJsonOrNull<SystemInfo>(config, "/v1/system-info");
4542
+ const info = sysinfo?.recipes?.[recipe]?.backends?.[backend];
4543
+ if (!info) {
4544
+ ctx.ui.notify("That backend disappeared from system-info — refresh and retry.", "warning");
4545
+ continue;
4546
+ }
4547
+
4548
+ const items: MenuItem<string>[] = [];
4549
+ if (info.state === "installable") {
4550
+ items.push({ value: "install", label: `Install ${recipe}:${backend}`, description: "Downloads + installs — live progress, esc cancels the download" });
4551
+ }
4552
+ if (info.state === "installed" && info.can_uninstall !== false) {
4553
+ items.push({ value: "uninstall", label: `Uninstall ${recipe}:${backend}`, description: "Models needing it can no longer load — the files are removed" });
4554
+ }
4555
+ if (info.action) items.push({ value: "cli", label: "CLI equivalent", description: info.action });
4556
+ if (!items.length) items.push({ value: "noop", label: `Not available on this system (${info.state})`, description: info.message ?? "" });
4557
+ items.push({ value: "back", label: "Back", description: "" });
4558
+ const action = await menu(ctx, `${recipe}:${backend} — ${info.state}${info.tier ? ` (tier: ${info.tier})` : ""}`, items);
4559
+ if (action === null || action === "back" || action === "noop" || action === "cli") continue;
4560
+
4561
+ if (action === "install") {
4562
+ let preview: Record<string, unknown> | undefined;
4563
+ const dry = await busyView(ctx, `Resolving ${recipe}:${backend}…`, () => installBackendDryRun(config, recipe, backend));
4564
+ if (dry.error) {
4565
+ ctx.ui.notify(`Install dry-run failed: ${dry.error}`, "error");
4566
+ continue;
4567
+ }
4568
+ preview = dry.result;
4569
+ const ok = await ctx.ui.confirm(
4570
+ `Install ${recipe}:${backend}?`,
4571
+ `Downloads ${String(preview?.download_filename ?? "the backend asset")} (v ${String(preview?.version ?? "?")}) and installs it on the server.`
4572
+ );
4573
+ if (!ok) continue;
4574
+ let job: DownloadJob;
4575
+ const start = await busyView(ctx, `Starting install of ${recipe}:${backend}…`, () =>
4576
+ installBackendJob(config, recipe, backend)
4577
+ );
4578
+ if (start.error) {
4579
+ ctx.ui.notify(`Install failed to start: ${start.error}`, "error");
4580
+ continue;
4581
+ }
4582
+ job = start.result!;
4583
+ const outcome = await trackDownload(ctx, config, job.id, `${recipe}:${backend}`);
4584
+ ctx.ui.notify(
4585
+ outcome === "complete"
4586
+ ? `Installed ${recipe}:${backend}.`
4587
+ : outcome === "cancelled"
4588
+ ? `Install of ${recipe}:${backend} cancelled.`
4589
+ : `Install of ${recipe}:${backend} failed.`,
4590
+ outcome === "complete" ? "info" : "error"
4591
+ );
4592
+ } else if (action === "uninstall") {
4593
+ const ok = await ctx.ui.confirm(
4594
+ `Uninstall ${recipe}:${backend}?`,
4595
+ "Removes the backend from the server — models that need it can no longer load until it is installed again."
4596
+ );
4597
+ if (!ok) continue;
4598
+ const outcome = await busyView(ctx, `Uninstalling ${recipe}:${backend}…`, () =>
4599
+ uninstallBackend(config, recipe, backend)
4600
+ );
4601
+ if (outcome.error) ctx.ui.notify(`Uninstall failed: ${outcome.error}`, "error");
4602
+ else ctx.ui.notify(`Uninstalled ${recipe}:${backend}.`, "info");
4603
+ }
4604
+ }
4605
+ }
4606
+
4607
+ /** Model-update flow: check what has newer upstream commits, then bulk-sync
4608
+ * (admin key) or update one at a time via plain pulls. */
4609
+ async function updatesFlow(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig, fullConfig: LemonadeConfig): Promise<void> {
4610
+ const check = await busyView(ctx, "Checking every downloaded model for updates…", () => checkModelUpdates(config));
4611
+ if (check.error) {
4612
+ ctx.ui.notify(`Update check failed: ${check.error}`, "error");
4613
+ return;
4614
+ }
4615
+ const result = check.result!;
4616
+ const models = result.models ?? [];
4617
+ if (!models.length) {
4618
+ ctx.ui.notify("All downloaded models are up to date.", "info");
4619
+ return;
4620
+ }
4621
+ const failures = Object.entries(result.failed_models ?? {});
4622
+ const choice = await menu(ctx, `Updates Available — ${models.length} model(s)`, [
4623
+ ...models.map((m) => ({ value: m, label: `⤴ ${m}`, description: "newer upstream commit available" })),
4624
+ { value: "__all__", label: `Update all ${models.length} (bulk sync — needs the admin key)`, description: "One server job checks + downloads every update" },
4625
+ { value: "__back__", label: "Back", description: "" },
4626
+ ]);
4627
+ if (choice === null || choice === "__back__") return;
4628
+
4629
+ if (choice === "__all__") {
4630
+ const outcome = await busyView(ctx, "Syncing model updates…", () => syncModelUpdates(config, { models }), {
4631
+ liveStatus: async () => {
4632
+ const h = await fetchHealth(config);
4633
+ const sync = h?.model_sync;
4634
+ return sync ? `server: sync ${sync.sync_id ?? "?"} — ${sync.status ?? "?"}` : null;
4635
+ },
4636
+ });
4637
+ if (outcome.error) {
4638
+ ctx.ui.notify(outcome.error, "error");
4639
+ return;
4640
+ }
4641
+ const r = outcome.result!;
4642
+ const updated = r.models_updated ?? [];
4643
+ const failed = Object.entries(r.failed_models ?? {});
4644
+ ctx.ui.notify(
4645
+ `Sync ${r.status ?? "done"}: ${updated.length} updated${updated.length ? ` (${updated.join(", ")})` : ""}` +
4646
+ (failed.length ? ` · ${failed.length} failed: ${failed.map(([k, v]) => `${k} (${String(v).slice(0, 60)})`).join(", ")}` : ""),
4647
+ updated.length || !failed.length ? "info" : "warning"
4648
+ );
4649
+ await registerLemonadeProvider(pi, fullConfig);
4650
+ return;
4651
+ }
4652
+
4653
+ // Single model: plain pull (no admin key needed) with live progress.
4654
+ const ok = await ctx.ui.confirm(
4655
+ `Update ${choice}?`,
4656
+ "Downloads the model's newer registry commit — live progress, esc cancels."
4657
+ );
4658
+ if (!ok) return;
4659
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, config, choice);
4660
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
4661
+ if (failures.length) {
4662
+ ctx.ui.notify(`Also failed to check: ${failures.map(([k]) => k).join(", ")}`, "warning");
4663
+ }
4664
+ }
4665
+
4666
+ /** The single model browser: every model on the box, live status inline,
4667
+ * type-to-search, ctrl+t toggles all/downloaded, actions on select. The
4668
+ * list refreshes underneath you every couple of seconds — loads, downloads
4669
+ * and state flips appear without leaving the view. */
4670
+ async function modelBrowser(
4671
+ ctx: UiCtx,
4672
+ pi: ExtensionAPI,
4673
+ config: LemonadeConfig,
4674
+ fullConfig: LemonadeConfig,
4675
+ instanceName: string
4676
+ ): Promise<void> {
4677
+ let downloadedOnly = false;
4678
+ for (;;) {
4679
+ const picked = await filterMenu<string>(ctx, {
4680
+ title: `Model Browser — ${instanceName}`,
4681
+ // Placeholder until the first refetch lands; unreachable boxes keep
4682
+ // the last good list (refetch throws → snapshot preserved).
4683
+ items: [{ value: "__loading__", label: "loading catalog…", description: "" }],
4684
+ refetch: async () => {
4685
+ const state = await fetchBrowserState(config);
4686
+ if (!state.reachable || (!state.catalog.length && !state.loaded.size)) throw new Error("unreachable");
4687
+ return browserItems(state, downloadedOnly);
4688
+ },
4689
+ pollMs: 2000,
4690
+ hint: "ctrl+t all/downloaded · live",
4691
+ banner: "● loaded · ○ downloaded · ○ not on disk · ↓ downloading · ⤴ update · ctrl+t toggles all ⇄ downloaded",
4692
+ onKey: (data, api) => {
4693
+ if (matchesKey(data, Key.ctrl("t"))) {
4694
+ downloadedOnly = !downloadedOnly;
4695
+ api.setBanner(
4696
+ downloadedOnly
4697
+ ? "showing: DOWNLOADED models only — ctrl+t shows every catalog entry again"
4698
+ : "● loaded · ○ downloaded · ○ not on disk · ↓ downloading · ⤴ update · ctrl+t toggles all ⇄ downloaded"
4699
+ );
4700
+ return true;
4701
+ }
4702
+ return false;
4703
+ },
4704
+ });
4705
+ if (picked === null) return; // Esc/back
4706
+ if (picked === "__loading__") continue;
4707
+ await modelActions(ctx, pi, config, fullConfig, picked);
4708
+ }
4709
+ }
4710
+
2940
4711
  function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
2941
4712
  pi.registerCommand("lemonade-setup", {
2942
4713
  description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
@@ -2996,16 +4767,94 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
2996
4767
  const iconfig = viewOrErr;
2997
4768
  const choice = await menu(ctx, "🍋 Lemonade Setup", [
2998
4769
  { value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
2999
- { value: "server", label: "Server settings", description: "Discover, base URL, API key, instances, connection test" },
3000
- { value: "models", label: "Model management", description: `List, load, unload, pull (progress+cancel), delete, change-ctx, HF install, filter, refresh — active instance: ${activeInstance}` },
4770
+ { value: "server", label: "Server settings", description: "Discover, base URL, API/admin keys, instances, connection test" },
4771
+ { value: "models", label: "Model management", description: `Model browser (live status, type-to-search, actions), downloads, HF install, updates — active instance: ${activeInstance}` },
4772
+ { value: "backends", label: "Backends", description: "Recipe × backend inventory — install/uninstall with live progress" },
3001
4773
  { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
3002
4774
  { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
3003
4775
  { value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
4776
+ { value: "refresh", label: "Refresh connection", description: "Force a re-probe of every instance now and re-register (bypasses the outage-resync throttle)" },
4777
+ { value: "cache", label: "Catalog cache", description: `Outage listings (cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}) — cached catalog snapshots: status, toggle, clear` },
3004
4778
  { value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
3005
4779
  { value: "exit", label: "Exit", description: "Close the setup menu" },
3006
4780
  ]);
3007
4781
  if (choice === null || choice === "exit") break;
3008
4782
 
4783
+ // ── Refresh connection (manual outage re-sync) ──────────────────
4784
+ if (choice === "refresh") {
4785
+ const [ok, msg] = await withStatus(ctx, "Probing instances…", async () => {
4786
+ const n = await resyncFromOutage(pi, configRef, undefined, true);
4787
+ if (n < 0) return "A re-sync is already in flight — try again in a moment.";
4788
+ return cachedServing.size
4789
+ ? `Refreshed — ${n} model(s) registered; ${cachedServing.size} instance(s) unreachable, serving cached catalogs.`
4790
+ : `Refreshed — ${n} model(s) registered.`;
4791
+ });
4792
+ ctx.ui.notify(msg, ok ? "info" : "error");
4793
+ continue;
4794
+ }
4795
+
4796
+ // ── Catalog cache (outage listings) ─────────────────────
4797
+ if (choice === "cache") {
4798
+ for (;;) {
4799
+ const config = configRef.current;
4800
+ const cache = loadCatalogCache();
4801
+ const entries = Object.entries(cache.instances).sort(([a], [b]) => a.localeCompare(b));
4802
+ const state = !config.cacheOnOutage
4803
+ ? "disabled (cacheOnOutage: false) — catalogs are neither cached nor served"
4804
+ : entries.length
4805
+ ? entries.map(([name, e]) => `${name}: ${e.catalog.length} model(s) cached ${formatCacheStamp(e.fetchedAt)}`).join(" · ")
4806
+ : "enabled — no snapshots yet (written on every successful probe)";
4807
+ const sub = await menu(ctx, "Catalog Cache — Outage Listings", [
4808
+ { value: "status", label: "Status", description: state },
4809
+ {
4810
+ value: "toggle",
4811
+ label: `Toggle outage cache: ${config.cacheOnOutage ? "on → off" : "off → on"}`,
4812
+ description: `cacheOnOutage (currently ${config.cacheOnOutage ? "on" : "off"}) — when a probe fails, list the instance's last good catalog, provenance-marked "(cached …)"`,
4813
+ },
4814
+ {
4815
+ value: "clear",
4816
+ label: "Clear cached catalogs",
4817
+ description: "Drop the on-disk snapshots — unreachable instances then list nothing",
4818
+ },
4819
+ { value: "back", label: "Back", description: "" },
4820
+ ]);
4821
+ if (sub === null || sub === "back") break;
4822
+
4823
+ if (sub === "toggle") {
4824
+ config.cacheOnOutage = !config.cacheOnOutage;
4825
+ saveConfig(config);
4826
+ ctx.ui.notify(
4827
+ `Outage cache ${config.cacheOnOutage ? "on" : "off"} — takes effect at the next registration (Refresh connection applies it now).`,
4828
+ "info"
4829
+ );
4830
+ continue;
4831
+ }
4832
+ if (sub === "clear") {
4833
+ clearCatalogCache();
4834
+ ctx.ui.notify(
4835
+ "Cached catalogs cleared — unreachable instances will list nothing from the next registration on (Refresh connection applies it now).",
4836
+ "info"
4837
+ );
4838
+ continue;
4839
+ }
4840
+ if (sub === "status") {
4841
+ await textView(ctx, "Catalog Cache — Status", [
4842
+ `cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}`,
4843
+ `Snapshot path: ${catalogCachePath()}`,
4844
+ "",
4845
+ entries.length ? "Cached catalogs on disk:" : "No cached catalogs on disk.",
4846
+ ...entries.map(([name, e]) => ` ${name} — ${e.catalog.length} model(s), fetched ${formatCacheStamp(e.fetchedAt)}${e.unreachableAtLastProbe ? " — LAST PROBE FAILED (instant-served at startup)" : ""}`),
4847
+ "",
4848
+ "Instances currently listed from cache in /model:",
4849
+ ...(cachedServing.size
4850
+ ? [...cachedServing].map(([name, s]) => ` ${name} — serving cache from ${formatCacheStamp(s.fetchedAt)}`)
4851
+ : [" (none — every instance is live or lists nothing)"]),
4852
+ ]);
4853
+ }
4854
+ }
4855
+ continue;
4856
+ }
4857
+
3009
4858
  // ── Spec & version check ───────────────────────────────────────
3010
4859
  if (choice === "spec") {
3011
4860
  for (;;) {
@@ -3221,6 +5070,11 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3221
5070
  { value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
3222
5071
  { value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
3223
5072
  { value: "apikey", label: "Edit shared API key", description: `Fallback for instances without their own — ${config.apiKey ? "••••• (set)" : "(none)"}` },
5073
+ {
5074
+ value: "adminkey",
5075
+ label: "Edit admin API key",
5076
+ description: `${config.adminApiKey ? "••••• (set)" : "(not set — /internal/* uses the API key)"} — LEMONADE_ADMIN_API_KEY for pin, model sync, config`,
5077
+ },
3224
5078
  { value: "test", label: "Test connection", description: "Ping the health endpoint" },
3225
5079
  { value: "back", label: "Back", description: "" },
3226
5080
  ]);
@@ -3459,6 +5313,21 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3459
5313
  saveConfig(config);
3460
5314
  ctx.ui.notify("API key saved.", "info");
3461
5315
  }
5316
+ } else if (sub === "adminkey") {
5317
+ const input = await ctx.ui.input(
5318
+ "Admin API key (LEMONADE_ADMIN_API_KEY — pin, model sync, config; empty = use the API key):",
5319
+ config.adminApiKey
5320
+ );
5321
+ if (input !== undefined) {
5322
+ config.adminApiKey = input.trim();
5323
+ saveConfig(config);
5324
+ ctx.ui.notify(
5325
+ config.adminApiKey
5326
+ ? "Admin key saved — /internal/* endpoints (pin, model sync) will use it."
5327
+ : "Admin key cleared — /internal/* falls back to the API key.",
5328
+ "info"
5329
+ );
5330
+ }
3462
5331
  } else if (sub === "test") {
3463
5332
  const h = await fetchHealth(iconfig);
3464
5333
  ctx.ui.notify(
@@ -3476,13 +5345,10 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3476
5345
  if (choice === "models") {
3477
5346
  for (;;) {
3478
5347
  const sub = await menu(ctx, "Model Management", [
3479
- { value: "list", label: "List catalog", description: "All advertised models + live state" },
3480
- { value: "load", label: "Load a model", description: "Load into memory (may take minutes)" },
3481
- { value: "unload", label: "Unload a model", description: "Free its slot" },
3482
- { value: "pull", label: "Pull (download) a model", description: "Live progress, esc to cancel" },
5348
+ { value: "browser", label: "Model browser", description: `Every model, live status inline — type to search, actions on select (${activeInstance})` },
5349
+ { value: "downloads", label: "Downloads", description: "Server-owned download jobs — live list, cancel/pause/remove/retry" },
3483
5350
  { value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
3484
- { value: "delete", label: "Delete a model", description: "Typed-phrase confirmation required" },
3485
- { value: "changectx", label: "Change context size", description: "Unload → reload with new ctx (saved)" },
5351
+ { value: "updates", label: "Check for model updates", description: "Report — and update — models with newer upstream commits" },
3486
5352
  {
3487
5353
  value: "chatonly",
3488
5354
  label: "Toggle chat-only filter",
@@ -3493,11 +5359,22 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3493
5359
  ]);
3494
5360
  if (sub === null || sub === "back") break;
3495
5361
 
5362
+ if (sub === "browser") {
5363
+ await modelBrowser(ctx, pi, iconfig, config, activeInstance);
5364
+ continue;
5365
+ }
5366
+ if (sub === "downloads") {
5367
+ await downloadsManager(ctx, pi, iconfig);
5368
+ continue;
5369
+ }
3496
5370
  if (sub === "installhf") {
3497
5371
  await installFromHuggingFace(ctx, pi, iconfig);
3498
5372
  continue;
3499
5373
  }
3500
-
5374
+ if (sub === "updates") {
5375
+ await updatesFlow(ctx, pi, iconfig, config);
5376
+ continue;
5377
+ }
3501
5378
  if (sub === "chatonly") {
3502
5379
  config.chatOnly = !config.chatOnly;
3503
5380
  saveConfig(config);
@@ -3514,183 +5391,16 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3514
5391
  ctx.ui.notify(msg, ok ? "info" : "error");
3515
5392
  continue;
3516
5393
  }
3517
-
3518
- // Everything else needs the live catalog. show_all=true includes
3519
- // not-yet-downloaded registry entries (pull candidates).
3520
- let catalog: LemonadeModel[];
3521
- let loadedIds = new Set<string>();
3522
- let loadedCtx = new Map<string, number>();
3523
- try {
3524
- const health = await fetchHealth(iconfig);
3525
- loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
3526
- for (const m of health?.all_models_loaded ?? []) {
3527
- if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
3528
- }
3529
- catalog = await fetchCatalog(iconfig, true);
3530
- } catch (error) {
3531
- ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
3532
- continue;
3533
- }
3534
-
3535
- if (sub === "list") {
3536
- const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
3537
- for (const m of catalog) {
3538
- const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
3539
- lines.push(
3540
- `${state} ${m.id}` +
3541
- (m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
3542
- (m.downloaded === false ? " (not downloaded)" : "") +
3543
- (m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
3544
- (m.size ? ` ${m.size} GB` : "")
3545
- );
3546
- }
3547
- await textView(ctx, "Model Catalog", lines, true);
3548
- continue;
3549
- }
3550
-
3551
- if (sub === "changectx") {
3552
- if (!loadedIds.size) {
3553
- ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
3554
- continue;
3555
- }
3556
- const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
3557
- value: id,
3558
- label: id,
3559
- })));
3560
- if (!picked) continue;
3561
- const currentCtx =
3562
- loadedCtx.get(picked) ??
3563
- catalog.find((m) => m.id === picked)?.max_context_window ??
3564
- 32768;
3565
- const input = await ctx.ui.input(
3566
- `New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
3567
- String(currentCtx)
3568
- );
3569
- const ctxSize = input ? parseCtxSize(input) : null;
3570
- if (!ctxSize) {
3571
- ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
3572
- continue;
3573
- }
3574
- const ok = await ctx.ui.confirm(
3575
- "Unload and reload?",
3576
- `${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
3577
- );
3578
- if (!ok) continue;
3579
- const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
3580
- changeModelContext(iconfig, picked, ctxSize)
3581
- );
3582
- ctx.ui.notify(msg, succeeded ? "info" : "error");
3583
- if (succeeded) await registerLemonadeProvider(pi, config);
3584
- continue;
3585
- }
3586
-
3587
- if (sub === "pull" || sub === "delete") {
3588
- const predicate = sub === "delete"
3589
- ? (m: LemonadeModel) => m.downloaded !== false
3590
- : (m: LemonadeModel) => m.downloaded === false;
3591
- const candidates = catalog.filter(predicate);
3592
-
3593
- let picked: string | null = null;
3594
- if (candidates.length) {
3595
- picked = await menu(
3596
- ctx,
3597
- sub === "pull" ? "Pull (download)" : "Delete from disk",
3598
- candidates.map((m) => ({
3599
- value: m.id,
3600
- label: m.id,
3601
- description: [
3602
- loadedIds.has(m.id) ? "loaded" : undefined,
3603
- m.size ? `${m.size} GB` : undefined,
3604
- m.recipe,
3605
- ].filter(Boolean).join(" • "),
3606
- }))
3607
- );
3608
- } else {
3609
- const input = await ctx.ui.input(
3610
- sub === "pull"
3611
- ? "No undownloaded models in catalog. Enter a model id to pull:"
3612
- : "Enter a model id to delete:",
3613
- ""
3614
- );
3615
- picked = input?.trim() || null;
3616
- }
3617
- if (!picked) continue;
3618
-
3619
- if (sub === "pull") {
3620
- const size = catalog.find((m) => m.id === picked)?.size;
3621
- const ok = await ctx.ui.confirm(
3622
- `Pull ${picked}?`,
3623
- size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
3624
- );
3625
- if (!ok) continue;
3626
- const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
3627
- ctx.ui.notify(msg, succeeded ? "info" : "error");
3628
- continue;
3629
- }
3630
-
3631
- // delete: confirm + typed phrase, 3 attempts
3632
- const ok = await ctx.ui.confirm(
3633
- `Delete ${picked}?`,
3634
- "This permanently removes the model files from the server's disk. Load state and cache are also lost."
3635
- );
3636
- if (!ok) continue;
3637
- const phrase = `i want to delete ${picked.toLowerCase()}`;
3638
- let confirmed = false;
3639
- for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
3640
- const chancesLeft = 4 - attempt;
3641
- const typed = await ctx.ui.input(
3642
- `Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
3643
- ""
3644
- );
3645
- if (typed === undefined) break; // Esc = abort immediately
3646
- if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
3647
- confirmed = true;
3648
- } else if (attempt < 3) {
3649
- ctx.ui.notify("Phrase did not match.", "warning");
3650
- }
3651
- }
3652
- if (!confirmed) {
3653
- ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
3654
- continue;
3655
- }
3656
- const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
3657
- deleteModel(iconfig, picked!)
3658
- );
3659
- ctx.ui.notify(msg, succeeded ? "info" : "error");
3660
- if (succeeded) await registerLemonadeProvider(pi, config);
3661
- continue;
3662
- }
3663
-
3664
- // load / unload
3665
- {
3666
- const candidates =
3667
- sub === "unload"
3668
- ? [...loadedIds]
3669
- : catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
3670
- if (!candidates.length) {
3671
- ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
3672
- continue;
3673
- }
3674
- const picked = await menu(
3675
- ctx,
3676
- sub === "load" ? "Load model" : "Unload model",
3677
- candidates.map((id) => ({
3678
- value: id,
3679
- label: id,
3680
- description: loadedIds.has(id) ? "loaded" : "on-demand",
3681
- }))
3682
- );
3683
- if (!picked) continue;
3684
- const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
3685
- sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
3686
- );
3687
- ctx.ui.notify(msg, succeeded ? "info" : "error");
3688
- if (succeeded) await registerLemonadeProvider(pi, config);
3689
- }
3690
5394
  }
3691
5395
  continue;
3692
5396
  }
3693
5397
 
5398
+ // ── Backends (recipe × backend inventory + install/uninstall) ──────
5399
+ if (choice === "backends") {
5400
+ await backendsView(ctx, pi, iconfig);
5401
+ continue;
5402
+ }
5403
+
3694
5404
  // ── Transcription settings ────────────────────────────────────────
3695
5405
  if (choice === "transcription") {
3696
5406
  for (;;) {
@@ -3787,11 +5497,33 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
3787
5497
  // merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
3788
5498
  // normalized object when the user actually changes something.
3789
5499
 
3790
- await registerLemonadeProvider(pi, configRef.current);
3791
- await registerAgentTools(pi, configRef.current);
5500
+ // One shared parallel probe pass feeds both consumers: an unreachable or
5501
+ // slow box stalls startup once (not once per consumer, not once per
5502
+ // instance) — bounded by a single discoveryTimeoutMs window. With
5503
+ // cacheOnOutage, instances the probe can't reach are listed from their
5504
+ // last good cached catalog, provenance-marked — see withCacheFallback.
5505
+ //
5506
+ // Hybrid SWR: instances whose LAST probe failed (durable flag) are served
5507
+ // from cache instantly — zero probe wait — and reconciled by a background
5508
+ // probe pass; instances last known reachable still probe first, so a
5509
+ // healthy box never flashes stale data. Net: the down-box stall only ever
5510
+ // bites once per outage, at the transition into it.
5511
+ const probes = await instantOrLiveProbes(configRef.current);
5512
+ await registerLemonadeProvider(pi, configRef.current, probes);
5513
+ await registerAgentTools(pi, configRef.current, probes);
5514
+ lastResyncProbeAt = Date.now(); // the activation pass IS a probe pass — throttle the session_start re-sync trigger
5515
+ if (probes.some((p) => p.cachedAt !== undefined)) {
5516
+ // Background reconcile for instant-served instances: probes everything
5517
+ // again (cheap for instances that just answered) and re-registers with
5518
+ // live truth when the box is back. Diff notices are silent here —
5519
+ // activation has no ui ctx, and the session_start cached warning has
5520
+ // already told the user the state; the suffix flips are visible in /model.
5521
+ void resyncFromOutage(pi, configRef, undefined, true);
5522
+ }
3792
5523
  registerLemonadeBar(pi, configRef);
3793
5524
  registerSetupCommand(pi, configRef);
3794
5525
  registerSpecDriftWarnings(pi, configRef);
5526
+ registerOutageResync(pi, configRef);
3795
5527
 
3796
5528
  // Fresh install (or a hand-emptied config): nothing is registered yet —
3797
5529
  // point the user at the wizard instead of leaving them wondering why