pi-lemonade-link 1.1.0 → 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;
@@ -131,6 +155,7 @@ export interface ServerEntry {
131
155
  name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
132
156
  baseUrl: string;
133
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
134
159
  description?: string; // free-form context for the user's own reminder's sake
135
160
  }
136
161
 
@@ -159,6 +184,14 @@ const DEFAULT_CONFIG: LemonadeConfig = {
159
184
  audioGenerationPath: "/v1/audio/generations",
160
185
  mesh3dPath: "/v1/3d/generations",
161
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",
162
195
  beaconPort: 13305,
163
196
  chatOnly: true,
164
197
  cacheOnOutage: false,
@@ -315,7 +348,12 @@ function listInstances(config: LemonadeConfig): InstanceView[] {
315
348
  name: s.name,
316
349
  description: s.description ?? "",
317
350
  isDefault: i === 0,
318
- 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
+ },
319
357
  }));
320
358
  }
321
359
 
@@ -329,7 +367,12 @@ function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | s
329
367
  if (!target) {
330
368
  return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
331
369
  }
332
- 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
+ };
333
376
  }
334
377
 
335
378
  /** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
@@ -349,6 +392,22 @@ interface LemonadeModel {
349
392
  size?: number;
350
393
  downloaded?: boolean;
351
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 }>;
352
411
  }
353
412
 
354
413
  interface LoadedModelInfo {
@@ -369,6 +428,8 @@ interface LoadedModelInfo {
369
428
  slot_pool?: string;
370
429
  residency_class?: string;
371
430
  watchdog_reset?: boolean;
431
+ watchdog_reset_reason?: string;
432
+ launch_command?: Array<string | number>;
372
433
  }
373
434
 
374
435
  interface LemonadeHealth {
@@ -381,6 +442,14 @@ interface LemonadeHealth {
381
442
  pinned_models?: Record<string, number>;
382
443
  pinned_helper_models?: Record<string, number>;
383
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;
384
453
  }
385
454
 
386
455
  interface SystemStats {
@@ -389,6 +458,18 @@ interface SystemStats {
389
458
  gpu_percent?: number | null;
390
459
  vram_gb?: number | null;
391
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;
392
473
  }
393
474
 
394
475
  interface PerfStats {
@@ -397,6 +478,10 @@ interface PerfStats {
397
478
  input_tokens?: number;
398
479
  output_tokens?: number;
399
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 };
400
485
  cache_tokens?: number | null;
401
486
  request_count_total?: number;
402
487
  input_tokens_total?: number;
@@ -423,13 +508,19 @@ interface SystemInfo {
423
508
  Processor?: string;
424
509
  "Physical Memory"?: string;
425
510
  "Windows Power Setting"?: string;
426
- 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 };
427
512
  devices?: {
428
513
  cpu?: { name?: string; cores?: number; threads?: number; available?: boolean };
429
514
  amd_gpu?: GpuInfo[];
430
515
  nvidia_gpu?: GpuInfo[];
431
516
  amd_npu?: { name?: string; family?: string; power_mode?: string; tops_max_int?: number; utilization?: number; available?: boolean };
432
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>> };
433
524
  }
434
525
 
435
526
  interface DiscoveredServer {
@@ -443,6 +534,7 @@ interface DownloadJob {
443
534
  model_name?: string;
444
535
  status: string; // downloading | paused | cancelled | completed | error
445
536
  running?: boolean;
537
+ type?: string; // currently "model" for server-owned jobs
446
538
  file?: string;
447
539
  file_index?: number;
448
540
  total_files?: number;
@@ -450,7 +542,10 @@ interface DownloadJob {
450
542
  bytes_total?: number;
451
543
  percent?: number;
452
544
  cumulative_bytes_downloaded?: number;
545
+ overall_bytes_downloaded?: number;
453
546
  total_download_size?: number;
547
+ bytes_previously_downloaded?: number;
548
+ completed_files_bytes?: number;
454
549
  complete?: boolean;
455
550
  error?: string;
456
551
  }
@@ -462,6 +557,10 @@ interface RegistryResult {
462
557
  downloads?: number;
463
558
  likes?: number;
464
559
  tags?: string[];
560
+ has_gguf?: boolean;
561
+ task?: string;
562
+ source?: string;
563
+ repository_type?: string;
465
564
  }
466
565
 
467
566
  interface PullVariants {
@@ -470,6 +569,9 @@ interface PullVariants {
470
569
  suggested_name?: string;
471
570
  suggested_labels?: string[];
472
571
  mmproj_files?: string[];
572
+ draft_files?: string[];
573
+ repo_kind?: string;
574
+ source?: string;
473
575
  variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
474
576
  }
475
577
 
@@ -589,10 +691,200 @@ async function deleteModel(config: LemonadeConfig, id: string): Promise<string>
589
691
  return `Deleted ${id} from disk.`;
590
692
  }
591
693
 
592
- /** Change ctx for a loaded model: unload → reload with new ctx_size + save_options. */
593
- async function changeModelContext(config: LemonadeConfig, id: string, ctxSize: number): Promise<string> {
594
- await unloadModel(config, id);
595
- 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)}`);
596
888
  }
597
889
 
598
890
  /** Parse "32k", "1m", "262144" → number of tokens. */
@@ -711,7 +1003,17 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
711
1003
  * network failure — a hygiene check must never cry wolf on offline
712
1004
  * startups: unknown ≠ out of date. */
713
1005
 
714
- 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
+ ];
715
1017
  const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
716
1018
  const DOCS_GH_CONTENTS = (file: string) =>
717
1019
  `https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
@@ -927,7 +1229,7 @@ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
927
1229
  * config: it is a property of the build — updated with each extension
928
1230
  * release after re-verification — never a user preference to move. The
929
1231
  * server's own version is probed live from /v1/health; this is the peg. */
930
- const EXTENSION_CONFIRMED_SERVER_VERSION = "11.7.0";
1232
+ const EXTENSION_CONFIRMED_SERVER_VERSION = "2026.46.2";
931
1233
 
932
1234
  /** This extension's own version, from package.json (kept beside the
933
1235
  * provider.apiVerifiedAgainst record — see the README versioning
@@ -944,11 +1246,20 @@ const PKG_VERSION = (() => {
944
1246
  }
945
1247
  })();
946
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
+
947
1257
  /** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
948
- * 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. */
949
1260
  function versionCompare(a: string, b: string): number | null {
950
- const pa = a.split(".").map(Number);
951
- const pb = b.split(".").map(Number);
1261
+ const pa = canonicalVersion(a).split(".").map(Number);
1262
+ const pb = canonicalVersion(b).split(".").map(Number);
952
1263
  if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
953
1264
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
954
1265
  const d = (pa[i] ?? 0) - (pb[i] ?? 0);
@@ -987,7 +1298,11 @@ const serverVersionsSeen = new Map<string, string>();
987
1298
  function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
988
1299
  const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
989
1300
  const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
990
- 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;
991
1306
  if (version) serverVersionsSeen.set(instance, version);
992
1307
  else serverVersionsSeen.delete(instance);
993
1308
  if (mismatched && version) {
@@ -1332,14 +1647,22 @@ function isChatModel(m: LemonadeModel): boolean {
1332
1647
 
1333
1648
  function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade", cachedStamp?: string) {
1334
1649
  const labels = m.labels ?? [];
1335
- const contextWindow = m.max_context_window ?? 128000;
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(" ")}` : "";
1336
1659
  return {
1337
1660
  id: m.id,
1338
1661
  name: cachedStamp
1339
- ? `${m.id} (${providerId}, cached ${cachedStamp})`
1662
+ ? `${m.id}${badgeText} (${providerId}, cached ${cachedStamp})`
1340
1663
  : loaded.has(m.id)
1341
- ? `${m.id} (${providerId}, loaded)`
1342
- : `${m.id} (${providerId}, on-demand)`,
1664
+ ? `${m.id}${badgeText} (${providerId}, loaded)`
1665
+ : `${m.id}${badgeText} (${providerId}, on-demand)`,
1343
1666
  reasoning: labels.includes("reasoning"),
1344
1667
  input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
1345
1668
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1771,7 +2094,9 @@ async function registerAgentTools(
1771
2094
  if (!probe.catalog) {
1772
2095
  continue; // instance unreachable at registration time — hint stays generic for it
1773
2096
  }
1774
- for (const m of probe.catalog.filter((m) => m.recipe === "onnxruntime")) {
2097
+ for (const m of probe.catalog.filter(
2098
+ (m) => m.recipe === "onnxruntime" || (m.labels ?? []).includes("classification")
2099
+ )) {
1775
2100
  ids.add(m.id);
1776
2101
  if (m.downloaded !== false) pulled.add(m.id);
1777
2102
  }
@@ -1983,13 +2308,39 @@ async function registerAgentTools(
1983
2308
  label: "Text to Speech",
1984
2309
  description:
1985
2310
  "Convert text to spoken audio using a TTS model on the lemonade server " +
1986
- "(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.",
1987
2314
  promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
1988
2315
  parameters: Type.Object({
1989
2316
  server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1990
2317
  text: Type.String({ description: "The text to speak." }),
1991
2318
  model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
1992
- 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
+ ),
1993
2344
  speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
1994
2345
  response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
1995
2346
  }),
@@ -2005,6 +2356,22 @@ async function registerAgentTools(
2005
2356
  const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
2006
2357
  if (params.voice) body.voice = params.voice;
2007
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
+ }
2008
2375
  return postJsonSaveBinary(config, config.speechPath, body, "speech", fmt === "pcm" ? "pcm" : fmt, signal, `model: ${model}`);
2009
2376
  },
2010
2377
  });
@@ -2023,6 +2390,8 @@ async function registerAgentTools(
2023
2390
  prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
2024
2391
  model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
2025
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)." })),
2026
2395
  lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
2027
2396
  vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
2028
2397
  seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
@@ -2031,12 +2400,14 @@ async function registerAgentTools(
2031
2400
  const instCfg = instanceView(gconfig, params.server);
2032
2401
  if (typeof instCfg === "string") return toolError(instCfg);
2033
2402
  const config = instCfg; // instance view — every reference below targets the selected box
2034
- const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
2403
+ const auto = await pickModelByLabels(config, ["audio-generation", "music", "sfx", "audio", "tts-audio"]);
2035
2404
  if (auto === null) return toolError(unreachableMessage(config));
2036
2405
  const model = params.model || auto;
2037
2406
  if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
2038
2407
  const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
2039
2408
  if (params.duration) body.duration = params.duration;
2409
+ if (params.steps) body.steps = params.steps;
2410
+ if (params.cfg) body.cfg = params.cfg;
2040
2411
  if (params.lyrics) body.lyrics = params.lyrics;
2041
2412
  if (params.vocal_language) body.vocal_language = params.vocal_language;
2042
2413
  if (params.seed !== undefined) body.seed = params.seed;
@@ -2086,12 +2457,15 @@ async function registerAgentTools(
2086
2457
  name: "classify_text",
2087
2458
  label: "Classify Text",
2088
2459
  description:
2089
- "Classify input TEXT with an encoder-classifier model on the lemonade server " +
2090
- "(recipe: onnxruntime). Each classifier answers exactly one question, decided by " +
2091
- "its baked-in label set, and the response's labels reveal that universe — if they " +
2092
- "don't fit the question, retry with a different model. Text-only by architecture: " +
2093
- "image questions go to vision-capable chat models (read the image), audio must be " +
2094
- "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). " +
2095
2469
  (classifierHint ||
2096
2470
  "Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
2097
2471
  " Returns all labels with confidence scores, ranked highest to lowest.",
@@ -2101,7 +2475,19 @@ async function registerAgentTools(
2101
2475
  server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
2102
2476
  input: Type.String({ description: "The text to classify." }),
2103
2477
  model: Type.Optional(
2104
- 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
+ })
2105
2491
  ),
2106
2492
  top_k: Type.Optional(
2107
2493
  Type.Number({ description: "Only return the highest-scoring k labels." })
@@ -2111,16 +2497,23 @@ async function registerAgentTools(
2111
2497
  const instCfg = instanceView(gconfig, params.server);
2112
2498
  if (typeof instCfg === "string") return toolError(instCfg);
2113
2499
  const config = instCfg; // instance view — every reference below targets the selected box
2114
- 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");
2115
2506
  if (auto === null) return toolError(unreachableMessage(config));
2116
2507
  const model = params.model || config.defaultClassifierModel || auto;
2117
2508
  if (!model) {
2118
2509
  return toolError(
2119
- "No classifier model (onnxruntime recipe) found on the lemonade server. " +
2120
- "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."
2121
2513
  );
2122
2514
  }
2123
2515
  const body: Record<string, unknown> = { model, input: params.input };
2516
+ if (params.labels?.length) body.labels = params.labels;
2124
2517
  if (params.top_k) body.top_k = params.top_k;
2125
2518
  const res = await toolFetch(config, config.classifyPath, {
2126
2519
  method: "POST",
@@ -2177,6 +2570,11 @@ interface BarMetrics {
2177
2570
  npuPct: number | null;
2178
2571
  npuSupported: boolean | null; // null = unknown yet; false = box has no NPU (segment dropped)
2179
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;
2180
2578
  }
2181
2579
 
2182
2580
  /**
@@ -2190,7 +2588,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
2190
2588
  const KEY = "lemonade-status-bar";
2191
2589
  type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
2192
2590
 
2193
- 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 };
2194
2592
  let activeInstance: string | null = null;
2195
2593
  let timer: ReturnType<typeof setInterval> | undefined;
2196
2594
  let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
@@ -2213,6 +2611,8 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
2213
2611
  metrics.npuPct = null;
2214
2612
  metrics.npuSupported = null;
2215
2613
  metrics.vramGb = null;
2614
+ metrics.activeReqs = null;
2615
+ metrics.serverToksPerSec = null;
2216
2616
  msgStartAt = null; // a stream started under another/removed row must not leak into the next
2217
2617
  };
2218
2618
 
@@ -2228,10 +2628,11 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
2228
2628
  tuiRef?.requestRender();
2229
2629
  return;
2230
2630
  }
2231
- const [health, sys, metricsBody] = await Promise.all([
2631
+ const [health, sys, metricsBody, perf] = await Promise.all([
2232
2632
  fetchHealth(view),
2233
2633
  fetchJsonOrNull<SystemStats>(view, "/v1/system-stats"),
2234
2634
  fetchTextOrNull(view, "/metrics"),
2635
+ fetchJsonOrNull<PerfStats>(view, "/v1/stats"),
2235
2636
  ]);
2236
2637
  if (activeInstance !== instance) return; // row switched while fetching: discard
2237
2638
  if (!health) {
@@ -2255,6 +2656,19 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
2255
2656
  metrics.npuSupported = false; // box reported system-stats with no NPU
2256
2657
  }
2257
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
+ }
2258
2672
  // Free liveness detector: this poll already proves the box answers —
2259
2673
  // while its /model listing is served from the outage cache, trigger
2260
2674
  // the throttled re-sync (throttle/in-flight guards live inside
@@ -2306,6 +2720,15 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
2306
2720
  if (metrics.busy === null) parts.push(dim("…"));
2307
2721
  else if (metrics.busy === "idle") parts.push(dim("idle"));
2308
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
+ }
2309
2732
  parts.push(dim(`CPU ${metrics.cpuPct !== null ? `${metrics.cpuPct.toFixed(0)}%` : "…"}`));
2310
2733
  parts.push(dim(`GPU ${metrics.gpuPct !== null ? `${metrics.gpuPct.toFixed(0)}%` : "…"}`));
2311
2734
  // Boxes without an NPU report npu_percent: null — the segment is
@@ -2399,6 +2822,8 @@ interface MenuItem<T extends string> {
2399
2822
 
2400
2823
  /** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
2401
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 });
2402
2827
  return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
2403
2828
  const container = new Container();
2404
2829
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
@@ -2423,6 +2848,273 @@ function menu<T extends string>(ctx: UiCtx, title: string, items: MenuItem<T>[])
2423
2848
  });
2424
2849
  }
2425
2850
 
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. */
2859
+ pollMs?: number;
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;
2869
+ }
2870
+
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
+
2426
3118
  /** Live-refresh wiring for the scrollable textView (status panel). */
2427
3119
  interface TextViewLive {
2428
3120
  /** Poll interval in ms; failed/empty refetches keep the last good snapshot. */
@@ -2673,6 +3365,15 @@ async function trackDownload(
2673
3365
  const finish = (v: "complete" | "cancelled" | "error") => {
2674
3366
  if (finished) return;
2675
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
+ }
2676
3377
  done(v);
2677
3378
  };
2678
3379
 
@@ -2968,6 +3669,14 @@ function formatStatus(
2968
3669
  if (typeof perf.output_tokens_total === "number") lifetime.push(`out ${fmtTokens(perf.output_tokens_total)}`);
2969
3670
  if (typeof perf.prompt_tokens_total === "number") lifetime.push(`prompt ${fmtTokens(perf.prompt_tokens_total)}`);
2970
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
+ }
2971
3680
  if (lifetime.length) lines.push(`Lifetime: ${lifetime.join(" · ")}`);
2972
3681
  if (typeof perf.routing_decisions_total === "number" || typeof perf.routing_switches_total === "number") {
2973
3682
  lines.push(
@@ -3254,6 +3963,751 @@ async function addInstanceFlow(
3254
3963
  return nameInput;
3255
3964
  }
3256
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
+
3257
4711
  function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
3258
4712
  pi.registerCommand("lemonade-setup", {
3259
4713
  description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
@@ -3313,8 +4767,9 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3313
4767
  const iconfig = viewOrErr;
3314
4768
  const choice = await menu(ctx, "🍋 Lemonade Setup", [
3315
4769
  { value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
3316
- { value: "server", label: "Server settings", description: "Discover, base URL, API key, instances, connection test" },
3317
- { 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" },
3318
4773
  { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
3319
4774
  { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
3320
4775
  { value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
@@ -3615,6 +5070,11 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3615
5070
  { value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
3616
5071
  { value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
3617
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
+ },
3618
5078
  { value: "test", label: "Test connection", description: "Ping the health endpoint" },
3619
5079
  { value: "back", label: "Back", description: "" },
3620
5080
  ]);
@@ -3853,6 +5313,21 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3853
5313
  saveConfig(config);
3854
5314
  ctx.ui.notify("API key saved.", "info");
3855
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
+ }
3856
5331
  } else if (sub === "test") {
3857
5332
  const h = await fetchHealth(iconfig);
3858
5333
  ctx.ui.notify(
@@ -3870,13 +5345,10 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3870
5345
  if (choice === "models") {
3871
5346
  for (;;) {
3872
5347
  const sub = await menu(ctx, "Model Management", [
3873
- { value: "list", label: "List catalog", description: "All advertised models + live state" },
3874
- { value: "load", label: "Load a model", description: "Load into memory (may take minutes)" },
3875
- { value: "unload", label: "Unload a model", description: "Free its slot" },
3876
- { 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" },
3877
5350
  { value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
3878
- { value: "delete", label: "Delete a model", description: "Typed-phrase confirmation required" },
3879
- { 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" },
3880
5352
  {
3881
5353
  value: "chatonly",
3882
5354
  label: "Toggle chat-only filter",
@@ -3887,11 +5359,22 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3887
5359
  ]);
3888
5360
  if (sub === null || sub === "back") break;
3889
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
+ }
3890
5370
  if (sub === "installhf") {
3891
5371
  await installFromHuggingFace(ctx, pi, iconfig);
3892
5372
  continue;
3893
5373
  }
3894
-
5374
+ if (sub === "updates") {
5375
+ await updatesFlow(ctx, pi, iconfig, config);
5376
+ continue;
5377
+ }
3895
5378
  if (sub === "chatonly") {
3896
5379
  config.chatOnly = !config.chatOnly;
3897
5380
  saveConfig(config);
@@ -3908,183 +5391,16 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3908
5391
  ctx.ui.notify(msg, ok ? "info" : "error");
3909
5392
  continue;
3910
5393
  }
3911
-
3912
- // Everything else needs the live catalog. show_all=true includes
3913
- // not-yet-downloaded registry entries (pull candidates).
3914
- let catalog: LemonadeModel[];
3915
- let loadedIds = new Set<string>();
3916
- let loadedCtx = new Map<string, number>();
3917
- try {
3918
- const health = await fetchHealth(iconfig);
3919
- loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
3920
- for (const m of health?.all_models_loaded ?? []) {
3921
- if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
3922
- }
3923
- catalog = await fetchCatalog(iconfig, true);
3924
- } catch (error) {
3925
- ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
3926
- continue;
3927
- }
3928
-
3929
- if (sub === "list") {
3930
- const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
3931
- for (const m of catalog) {
3932
- const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
3933
- lines.push(
3934
- `${state} ${m.id}` +
3935
- (m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
3936
- (m.downloaded === false ? " (not downloaded)" : "") +
3937
- (m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
3938
- (m.size ? ` ${m.size} GB` : "")
3939
- );
3940
- }
3941
- await textView(ctx, "Model Catalog", lines, true);
3942
- continue;
3943
- }
3944
-
3945
- if (sub === "changectx") {
3946
- if (!loadedIds.size) {
3947
- ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
3948
- continue;
3949
- }
3950
- const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
3951
- value: id,
3952
- label: id,
3953
- })));
3954
- if (!picked) continue;
3955
- const currentCtx =
3956
- loadedCtx.get(picked) ??
3957
- catalog.find((m) => m.id === picked)?.max_context_window ??
3958
- 32768;
3959
- const input = await ctx.ui.input(
3960
- `New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
3961
- String(currentCtx)
3962
- );
3963
- const ctxSize = input ? parseCtxSize(input) : null;
3964
- if (!ctxSize) {
3965
- ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
3966
- continue;
3967
- }
3968
- const ok = await ctx.ui.confirm(
3969
- "Unload and reload?",
3970
- `${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
3971
- );
3972
- if (!ok) continue;
3973
- const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
3974
- changeModelContext(iconfig, picked, ctxSize)
3975
- );
3976
- ctx.ui.notify(msg, succeeded ? "info" : "error");
3977
- if (succeeded) await registerLemonadeProvider(pi, config);
3978
- continue;
3979
- }
3980
-
3981
- if (sub === "pull" || sub === "delete") {
3982
- const predicate = sub === "delete"
3983
- ? (m: LemonadeModel) => m.downloaded !== false
3984
- : (m: LemonadeModel) => m.downloaded === false;
3985
- const candidates = catalog.filter(predicate);
3986
-
3987
- let picked: string | null = null;
3988
- if (candidates.length) {
3989
- picked = await menu(
3990
- ctx,
3991
- sub === "pull" ? "Pull (download)" : "Delete from disk",
3992
- candidates.map((m) => ({
3993
- value: m.id,
3994
- label: m.id,
3995
- description: [
3996
- loadedIds.has(m.id) ? "loaded" : undefined,
3997
- m.size ? `${m.size} GB` : undefined,
3998
- m.recipe,
3999
- ].filter(Boolean).join(" • "),
4000
- }))
4001
- );
4002
- } else {
4003
- const input = await ctx.ui.input(
4004
- sub === "pull"
4005
- ? "No undownloaded models in catalog. Enter a model id to pull:"
4006
- : "Enter a model id to delete:",
4007
- ""
4008
- );
4009
- picked = input?.trim() || null;
4010
- }
4011
- if (!picked) continue;
4012
-
4013
- if (sub === "pull") {
4014
- const size = catalog.find((m) => m.id === picked)?.size;
4015
- const ok = await ctx.ui.confirm(
4016
- `Pull ${picked}?`,
4017
- size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
4018
- );
4019
- if (!ok) continue;
4020
- const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
4021
- ctx.ui.notify(msg, succeeded ? "info" : "error");
4022
- continue;
4023
- }
4024
-
4025
- // delete: confirm + typed phrase, 3 attempts
4026
- const ok = await ctx.ui.confirm(
4027
- `Delete ${picked}?`,
4028
- "This permanently removes the model files from the server's disk. Load state and cache are also lost."
4029
- );
4030
- if (!ok) continue;
4031
- const phrase = `i want to delete ${picked.toLowerCase()}`;
4032
- let confirmed = false;
4033
- for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
4034
- const chancesLeft = 4 - attempt;
4035
- const typed = await ctx.ui.input(
4036
- `Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
4037
- ""
4038
- );
4039
- if (typed === undefined) break; // Esc = abort immediately
4040
- if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
4041
- confirmed = true;
4042
- } else if (attempt < 3) {
4043
- ctx.ui.notify("Phrase did not match.", "warning");
4044
- }
4045
- }
4046
- if (!confirmed) {
4047
- ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
4048
- continue;
4049
- }
4050
- const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
4051
- deleteModel(iconfig, picked!)
4052
- );
4053
- ctx.ui.notify(msg, succeeded ? "info" : "error");
4054
- if (succeeded) await registerLemonadeProvider(pi, config);
4055
- continue;
4056
- }
4057
-
4058
- // load / unload
4059
- {
4060
- const candidates =
4061
- sub === "unload"
4062
- ? [...loadedIds]
4063
- : catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
4064
- if (!candidates.length) {
4065
- ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
4066
- continue;
4067
- }
4068
- const picked = await menu(
4069
- ctx,
4070
- sub === "load" ? "Load model" : "Unload model",
4071
- candidates.map((id) => ({
4072
- value: id,
4073
- label: id,
4074
- description: loadedIds.has(id) ? "loaded" : "on-demand",
4075
- }))
4076
- );
4077
- if (!picked) continue;
4078
- const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
4079
- sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
4080
- );
4081
- ctx.ui.notify(msg, succeeded ? "info" : "error");
4082
- if (succeeded) await registerLemonadeProvider(pi, config);
4083
- }
4084
5394
  }
4085
5395
  continue;
4086
5396
  }
4087
5397
 
5398
+ // ── Backends (recipe × backend inventory + install/uninstall) ──────
5399
+ if (choice === "backends") {
5400
+ await backendsView(ctx, pi, iconfig);
5401
+ continue;
5402
+ }
5403
+
4088
5404
  // ── Transcription settings ────────────────────────────────────────
4089
5405
  if (choice === "transcription") {
4090
5406
  for (;;) {