loki-mode 8.6.1 → 8.8.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.
@@ -2802,16 +2802,153 @@ _SESSION_MODEL_ALLOWLIST = ("haiku", "sonnet", "opus", "fable")
2802
2802
  # allowlist is unchanged) because it is an explicit live-run control.
2803
2803
  _START_MODEL_ALLOWLIST = ("haiku", "sonnet", "opus")
2804
2804
 
2805
+ # Provider-agnostic capability tiers. These are the vocabulary the picker offers
2806
+ # on a non-Claude provider, and they resolve per-provider through
2807
+ # providers/models.sh (loki_tier_alias): small -> fast, medium -> development,
2808
+ # high -> planning. Accepting them here is what makes the start-time picker work
2809
+ # on codex at all -- the Claude aliases above are meaningless there, so before
2810
+ # this every value a codex user could pick normalized to "" and was silently
2811
+ # dropped, and the run started on the provider default with no feedback.
2812
+ _START_MODEL_GENERIC_TIERS = ("small", "medium", "high")
2813
+
2805
2814
 
2806
2815
  def _normalize_start_model(raw: str | None) -> str:
2807
- """Normalize a start-time model / advisor alias (haiku|sonnet|opus, no fable).
2816
+ """Normalize a start-time model / advisor alias.
2808
2817
 
2809
- Same trim + lowercase + exact-match rule as _normalize_session_model, but on
2810
- the narrower _START_MODEL_ALLOWLIST. Returns "" for absent/invalid/fable so
2818
+ Accepts the Claude aliases (haiku|sonnet|opus, no fable) and the generic
2819
+ capability tiers (small|medium|high). Returns "" for absent/invalid/fable so
2811
2820
  callers can treat empty as "no selection" (engine uses its own default).
2821
+
2822
+ fable stays excluded: it is advisory-only and the runner collapses it to
2823
+ opus, so offering it as a start-time execution model would be a cost
2824
+ surprise. That reasoning is unchanged by adding the generic tiers.
2812
2825
  """
2813
2826
  val = (raw or "").strip().lower()
2814
- return val if val in _START_MODEL_ALLOWLIST else ""
2827
+ if val in _START_MODEL_ALLOWLIST or val in _START_MODEL_GENERIC_TIERS:
2828
+ return val
2829
+ return ""
2830
+
2831
+
2832
+ # =============================================================================
2833
+ # Provider-aware model offer set
2834
+ # =============================================================================
2835
+ # The two allowlists above are WIRE values: what run.sh will actually honor in
2836
+ # .loki/state/model-override. They are Claude aliases because run.sh:20996 gates
2837
+ # the whole override block on PROVIDER_NAME=claude and feeds the file straight
2838
+ # into `claude --model`. They must not change.
2839
+ #
2840
+ # What the dashboard OFFERS is a separate question, and it was the bug: the
2841
+ # picker rendered those four Claude aliases on every run, so a codex session was
2842
+ # offered Haiku/Sonnet/Opus/Fable, none of which codex can dispatch. The offer
2843
+ # set below is derived from the RUNNING session's provider plus the canonical
2844
+ # providers/model_catalog.json, so the picker never names a model the active
2845
+ # provider cannot run.
2846
+
2847
+ # Generic tier -> catalog key. These three tier names are provider-independent
2848
+ # (every catalog entry carries latest_fast/development/planning), which is what
2849
+ # makes the picker portable across providers.
2850
+ _TIER_LABELS = (
2851
+ ("small", "fast"),
2852
+ ("medium", "development"),
2853
+ ("high", "planning"),
2854
+ )
2855
+
2856
+
2857
+ def _active_provider() -> str:
2858
+ """The provider the CURRENT run is executing on.
2859
+
2860
+ Resolution order mirrors the CLI (autonomy/loki:5142): the per-project state
2861
+ file run.sh writes at launch (run.sh:1458), then the environment, then the
2862
+ stock default. The state file wins because it is the only source that
2863
+ reflects the live run rather than the dashboard process's own environment.
2864
+ """
2865
+ try:
2866
+ p = _get_loki_dir() / "state" / "provider"
2867
+ if p.is_file():
2868
+ val = p.read_text().strip().lower()
2869
+ if val:
2870
+ return val
2871
+ except OSError:
2872
+ pass
2873
+ return (os.environ.get("LOKI_PROVIDER") or "claude").strip().lower() or "claude"
2874
+
2875
+
2876
+ def _load_model_catalog() -> dict:
2877
+ """Read providers/model_catalog.json, the single source of truth for model ids.
2878
+
2879
+ Same candidate paths as GET /api/providers/models. Returns {} when the
2880
+ catalog is unreadable; every caller degrades to "no model ids to show"
2881
+ rather than inventing one.
2882
+ """
2883
+ for path in (
2884
+ _Path(__file__).resolve().parent.parent / "providers" / "model_catalog.json",
2885
+ _Path("providers/model_catalog.json"),
2886
+ ):
2887
+ try:
2888
+ if path.exists():
2889
+ with path.open("r", encoding="utf-8") as fh:
2890
+ return json.load(fh)
2891
+ except (json.JSONDecodeError, OSError):
2892
+ continue
2893
+ return {}
2894
+
2895
+
2896
+ def _resolve_catalog_model(provider: str, catalog_tier: str) -> str:
2897
+ """The model id `provider` dispatches for `catalog_tier` (fast/development/planning).
2898
+
2899
+ Python mirror of loki_latest_model (providers/models.sh:22), including its
2900
+ env-override chain and its "generic" registry fallback for a provider the
2901
+ catalog does not name. Kept in Python rather than shelling out to models.sh:
2902
+ the dashboard answers this per request and a subprocess per tier per poll is
2903
+ not worth it. Model IDS still come only from the catalog, never from here.
2904
+ """
2905
+ provider_env = re.sub(r"[^A-Z0-9_]", "_", provider.upper())
2906
+ for var in (
2907
+ f"LOKI_{provider_env}_MODEL_{catalog_tier.upper()}",
2908
+ f"LOKI_{provider_env}_MODEL",
2909
+ ):
2910
+ val = (os.environ.get(var) or "").strip()
2911
+ if val:
2912
+ return val
2913
+ providers = _load_model_catalog().get("providers", {})
2914
+ entry = providers.get(provider) or providers.get("generic") or {}
2915
+ return str(entry.get(f"latest_{catalog_tier}") or "")
2916
+
2917
+
2918
+ def _provider_model_offers(provider: str) -> list[dict]:
2919
+ """The model choices to OFFER for `provider`, each with what it resolves to.
2920
+
2921
+ Claude keeps its established alias picker byte-for-byte: those aliases are
2922
+ the values run.sh honors in the override file, so changing them would break
2923
+ the one provider where mid-run switching actually works.
2924
+
2925
+ Every other provider is offered the generic tiers (small/medium/high), which
2926
+ are provider-independent, each annotated with the concrete model id the
2927
+ catalog says that provider dispatches. That is what makes the picker read
2928
+ "medium -> gpt-5.3-codex" on codex and "medium -> claude-sonnet-5" on claude
2929
+ without the frontend knowing a single model id.
2930
+ """
2931
+ if provider == "claude":
2932
+ aliases = _load_model_catalog().get("providers", {}).get("claude", {}).get("cli_aliases", {})
2933
+ return [
2934
+ {"value": alias, "tier": None, "model": aliases.get(alias, "")}
2935
+ for alias in _SESSION_MODEL_ALLOWLIST
2936
+ ]
2937
+ return [
2938
+ {"value": tier, "tier": tier, "model": _resolve_catalog_model(provider, catalog_tier)}
2939
+ for tier, catalog_tier in _TIER_LABELS
2940
+ ]
2941
+
2942
+
2943
+ def _provider_supports_model_switch(provider: str) -> bool:
2944
+ """Whether a live run on `provider` honors .loki/state/model-override.
2945
+
2946
+ Only claude does: run.sh:20996 gates the entire override-read block on
2947
+ PROVIDER_NAME=claude. On any other provider the file is written and never
2948
+ read, so the POST path rejects rather than reporting a switch that will not
2949
+ happen.
2950
+ """
2951
+ return provider == "claude"
2815
2952
 
2816
2953
 
2817
2954
  class SessionModelRequest(BaseModel):
@@ -2890,17 +3027,28 @@ def _normalize_session_model(raw: str | None) -> str:
2890
3027
  # tier names ARE valid pins.
2891
3028
  _SESSION_PIN_ALLOWLIST = _SESSION_MODEL_ALLOWLIST + ("planning", "development", "fast")
2892
3029
 
3030
+ # Generic capability vocabulary. A user picks a CLASS of model (small/medium/
3031
+ # high) and each provider supplies its own latest model in that class, so nobody
3032
+ # has to know a vendor's model names. These are translated onto the canonical
3033
+ # tier names rather than added to the allowlist, keeping this mirror in step
3034
+ # with run.sh's entry-point case and the `loki plan` estimator without widening
3035
+ # what any of the three actually route on. Mirrors loki_tier_alias() in
3036
+ # providers/models.sh.
3037
+ _GENERIC_TIERS = {"small": "fast", "medium": "development", "high": "planning"}
3038
+
2893
3039
 
2894
3040
  def _normalize_session_pin(raw: str | None) -> str:
2895
3041
  """Normalize a LOKI_SESSION_MODEL pin value (aliases + raw tier names).
2896
3042
 
2897
3043
  Mirrors run.sh's session-pin case: trim + lowercase, accept the four model
2898
- aliases and the three tier names. Interior whitespace is preserved (so
3044
+ aliases and the three tier names, and translate the generic small/medium/
3045
+ high vocabulary onto those tier names. Interior whitespace is preserved (so
2899
3046
  "fab le" stays junk and falls through to the default tier, exactly like the
2900
3047
  runner's "*" arm). Use this for the session-pin (no-override) derivation;
2901
3048
  use _normalize_session_model for the override-file / POST path.
2902
3049
  """
2903
3050
  val = (raw or "").strip().lower()
3051
+ val = _GENERIC_TIERS.get(val, val)
2904
3052
  return val if val in _SESSION_PIN_ALLOWLIST else ""
2905
3053
 
2906
3054
 
@@ -3126,11 +3274,24 @@ async def get_session_model():
3126
3274
  # the reported effective model agrees with dispatch on BOTH routes (v7.39.1).
3127
3275
  if effective == "fable":
3128
3276
  effective = "opus"
3277
+ provider = _active_provider()
3278
+ offers = _provider_model_offers(provider)
3279
+ if provider != "claude":
3280
+ # Non-claude: the claude-alias default/effective computed above describe a
3281
+ # dispatch that is not happening on this run. Report what the provider
3282
+ # actually runs, from the catalog, and drop the stale override (run.sh
3283
+ # never reads the file on this provider, so it cannot be in effect).
3284
+ override = None
3285
+ default = "medium"
3286
+ effective = _resolve_catalog_model(provider, "development")
3129
3287
  return {
3130
3288
  "override": override,
3131
3289
  "default": default,
3132
3290
  "effective": effective,
3133
- "allowed": list(_SESSION_MODEL_ALLOWLIST),
3291
+ "provider": provider,
3292
+ "switchable": _provider_supports_model_switch(provider),
3293
+ "offers": offers,
3294
+ "allowed": [o["value"] for o in offers],
3134
3295
  }
3135
3296
 
3136
3297
 
@@ -3156,6 +3317,22 @@ async def set_session_model(request: SessionModelRequest):
3156
3317
  """
3157
3318
  requested_raw = (request.model or "").strip().lower()
3158
3319
  override_path = _model_override_path()
3320
+ # Mid-run switching is a claude-only runtime capability: run.sh:20996 gates the
3321
+ # override-read block on PROVIDER_NAME=claude, so on any other provider this
3322
+ # file would be written and never read. Reject instead of writing a file that
3323
+ # does nothing and reporting success (a false affordance is worse than no
3324
+ # control). Clearing is still allowed everywhere: removing a stale file is
3325
+ # always safe and never claims a switch.
3326
+ provider = _active_provider()
3327
+ if requested_raw != "" and not _provider_supports_model_switch(provider):
3328
+ raise HTTPException(
3329
+ status_code=409,
3330
+ detail=(
3331
+ f"Mid-run model switching is not supported on provider '{provider}'. "
3332
+ f"The run dispatches {_resolve_catalog_model(provider, 'development') or 'its configured model'}; "
3333
+ "restart the run with a different model to change it."
3334
+ ),
3335
+ )
3159
3336
  if requested_raw == "":
3160
3337
  # Clear the override; revert to tier mapping.
3161
3338
  try:
@@ -3965,19 +4142,30 @@ async def start_build(request: Request, body: StartBuildRequest):
3965
4142
  popen_env["LOKI_TARGET_DIR"] = str(workspace_dir)
3966
4143
  popen_env["LOKI_DIR"] = str(loki_dir)
3967
4144
  if start_model:
3968
- # EXACT-model pin (not the session-pin tier route): set all three tier
3969
- # models to the chosen alias so resolve_model_for_tier returns the alias
3970
- # for every tier and every iteration dispatches exactly the picked model.
3971
- # This is the honest start-time equivalent of the mid-flight override
3972
- # file, which run.sh clears at iteration 0. LOKI_SESSION_MODEL is set too
3973
- # for internal coherence (the run's own tier accounting/logging), but the
3974
- # env triple is the load-bearing dispatch-honesty mechanism: on the
3975
- # v7.104.0 stock config the session pin alone would remap opus->planning->
3976
- # sonnet and haiku->fast->sonnet, dispatching sonnet for both.
3977
- popen_env["LOKI_CLAUDE_MODEL_PLANNING"] = start_model
3978
- popen_env["LOKI_CLAUDE_MODEL_DEVELOPMENT"] = start_model
3979
- popen_env["LOKI_CLAUDE_MODEL_FAST"] = start_model
3980
- popen_env["LOKI_SESSION_MODEL"] = start_model
4145
+ if start_model in _START_MODEL_GENERIC_TIERS:
4146
+ # A generic capability tier is provider-agnostic BY CONSTRUCTION --
4147
+ # it names a capability class, not a model, and each provider
4148
+ # resolves its own latest model for that class via
4149
+ # providers/models.sh. Pinning the LOKI_CLAUDE_MODEL_* triple here
4150
+ # would be actively wrong: those variables are inert on codex and
4151
+ # every other non-Claude provider, so the pin would silently do
4152
+ # nothing. LOKI_SESSION_MODEL is the correct and only lever.
4153
+ popen_env["LOKI_SESSION_MODEL"] = start_model
4154
+ else:
4155
+ # EXACT-model pin (not the session-pin tier route): set all three tier
4156
+ # models to the chosen alias so resolve_model_for_tier returns the alias
4157
+ # for every tier and every iteration dispatches exactly the picked model.
4158
+ # This is the honest start-time equivalent of the mid-flight override
4159
+ # file, which run.sh clears at iteration 0. LOKI_SESSION_MODEL is set too
4160
+ # for internal coherence (the run's own tier accounting/logging), but the
4161
+ # env triple is the load-bearing dispatch-honesty mechanism: on the
4162
+ # v7.104.0 stock config the session pin alone would remap
4163
+ # opus->planning->sonnet and haiku->fast->sonnet, dispatching sonnet
4164
+ # for both.
4165
+ popen_env["LOKI_CLAUDE_MODEL_PLANNING"] = start_model
4166
+ popen_env["LOKI_CLAUDE_MODEL_DEVELOPMENT"] = start_model
4167
+ popen_env["LOKI_CLAUDE_MODEL_FAST"] = start_model
4168
+ popen_env["LOKI_SESSION_MODEL"] = start_model
3981
4169
  if advisor_model:
3982
4170
  # Opt-in Opus (or other) judge for code review; execution model unchanged.
3983
4171
  popen_env["LOKI_ADVISOR_MODEL"] = advisor_model
@@ -3774,19 +3774,19 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3774
3774
  </div>
3775
3775
  `}).join("")}
3776
3776
  </div>
3777
- `}}_attachEventListeners(){let e=this.shadowRoot.getElementById("refresh-btn");e&&e.addEventListener("click",()=>this._loadTasks());let t=this.shadowRoot.getElementById("bulk-toggle-btn");t&&t.addEventListener("click",()=>this._toggleBulkMode()),this.shadowRoot.querySelectorAll(".filter-pill").forEach(r=>{r.addEventListener("click",()=>this._setFilter(r.dataset.filter))});let i=this.shadowRoot.getElementById("task-search");i&&i.addEventListener("input",r=>this._setSearch(r.target.value)),this.shadowRoot.querySelectorAll(".show-more-btn").forEach(r=>{r.addEventListener("click",()=>this._showMore(r.dataset.showMore))}),this.shadowRoot.querySelectorAll(".bulk-btn").forEach(r=>{r.addEventListener("click",()=>{let o=r.dataset.bulkAction;o==="delete"?this._bulkDelete():this._bulkMove(o)})}),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(r=>{r.addEventListener("click",()=>{this._openAddTaskModal(r.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-checkbox").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleTaskSelection(r.dataset.checkId,o)})}),this.shadowRoot.querySelectorAll(".expand-toggle").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleCardExpand(r.dataset.expandId)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(r=>{let o=r.dataset.taskId,n=this._tasks.find(l=>l.id.toString()===o);n&&(r.addEventListener("click",l=>{if(this._bulkMode){this._toggleTaskSelection(o,l);return}this._openTaskDetail(n)}),r.addEventListener("keydown",l=>{l.key==="Enter"||l.key===" "?(l.preventDefault(),this._bulkMode?this._toggleTaskSelection(o,l):this._openTaskDetail(n)):(l.key==="ArrowDown"||l.key==="ArrowUp")&&(l.preventDefault(),this._navigateTaskCards(r,l.key==="ArrowDown"?"next":"prev"))}),r.classList.contains("draggable")&&(r.addEventListener("dragstart",l=>this._handleDragStart(l,n)),r.addEventListener("dragend",l=>this._handleDragEnd(l))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(r=>{r.addEventListener("dragover",o=>this._handleDragOver(o)),r.addEventListener("dragenter",o=>this._handleDragEnter(o)),r.addEventListener("dragleave",o=>this._handleDragLeave(o)),r.addEventListener("drop",o=>this._handleDrop(o,r.dataset.status))});let a=this.shadowRoot.getElementById("modal-close-btn");a&&a.addEventListener("click",()=>this._closeTaskDetail());let s=this.shadowRoot.getElementById("task-detail-overlay");s&&s.addEventListener("click",r=>{r.target===s&&this._closeTaskDetail()})}_escapeHtml(e){let t=document.createElement("div");return t.textContent=e,t.innerHTML}_navigateTaskCards(e,t){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(e);if(a===-1)return;let s=t==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",Q);var X=class extends v{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._model={override:null,default:"sonnet",effective:"sonnet",notice:""},this._modelBusy=!1,this._startBusy=!1,this._startNotice="",this._specText="",this._startModel="",this._advisorModel="",this._api=null,this._state=U(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._loadModel(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._teardownApiListeners()}_teardownApiListeners(){this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(f.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(f.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(e,t,i){t!==i&&(e==="api-url"&&this._api&&(this._teardownApiListeners(),this._setupApi(),this._loadStatus(),this._loadModel()),e==="theme"&&this._applyTheme(),e==="compact"&&this.render())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=h({baseUrl:e}),this._statusUpdateHandler=t=>this._updateFromStatus(t.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(f.CONNECTED,this._connectedHandler),this._api.addEventListener(f.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){let e=this._api;try{let t=await e.getStatus();if(e!==this._api)return;this._updateFromStatus(t)}catch{if(e!==this._api)return;this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(e){e&&(this._status={...this._status,connected:!0,mode:e.status||"running",version:e.version,uptime:e.uptime_seconds||0,activeAgents:e.running_agents||0,pendingTasks:e.pending_tasks||0,phase:e.phase,iteration:e.iteration,complexity:e.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._poll=b({loadFn:()=>this._loadStatus(),intervalMs:3e3,sectionId:null,immediate:!1})}_stopPolling(){this._poll&&(this._poll.stop(),this._poll=null)}_formatUptime(e){if(!e||e<0)return"--";let t=Math.floor(e/3600),i=Math.floor(e%3600/60),a=Math.floor(e%60);return t>0?`${t}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(e){let t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}async _triggerStart(){if(this._startBusy)return;let e=(this._specText||"").trim();if(!e){this._startNotice="Enter a spec or one-line brief to start a build.",this.render();return}if(!this._api||typeof this._api.startSession!="function"){this._startNotice="Start is not available on this server.",this.render();return}this._startBusy=!0,this._startNotice="Starting build...",this.render();try{let t=await this._api.startSession(e,{provider:this._status.provider||"claude",model:this._startModel||"",advisorModel:this._advisorModel||""});if(t&&t.error)throw new Error(t.error);this._startBusy=!1,this._startNotice="",this._specText="",this._status.mode="running",this._status.connected=!0,this.render(),this._loadStatus(),this.dispatchEvent(new CustomEvent("session-start",{detail:{...this._status,pid:t&&t.pid,spec:t&&t.spec}}))}catch(t){console.error("Failed to start build:",t),this._startBusy=!1,this._startNotice=t&&t.message?`Could not start: ${t.message}`:"Could not start the build. Try again.",this.render()}}_onSpecInput(e){this._specText=e}async _triggerPause(){try{let e=await this._api.pauseSession();if(e&&e.error)throw new Error(e.error);this._status.mode="paused",this.render(),this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}catch(e){console.error("Failed to pause session:",e),this.render()}}async _triggerResume(){try{let e=await this._api.resumeSession();if(e&&e.error)throw new Error(e.error);this._status.mode="running",this.render(),this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}catch(e){console.error("Failed to resume session:",e),this.render()}}async _triggerStop(){try{let e=await this._api.stopSession();if(e&&e.error)throw new Error(e.error);this._status.mode="stopped",this.render(),this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}catch(e){console.error("Failed to stop session:",e),this.render()}}async _loadModel(){if(!this._api||typeof this._api.getSessionModel!="function")return;let e=this._api;try{let t=await e.getSessionModel();if(e!==this._api)return;t&&!t.error&&(this._model={...this._model,override:t.override??null,default:t.default||"sonnet",effective:t.effective||t.default||"sonnet"},this.render())}catch{}}async _onModelChange(e){if(this._modelBusy)return;this._modelBusy=!0;let t=e===""?null:e;try{let i=await this._api.setSessionModel(t);if(i&&i.error)throw new Error(i.error);this._model.override=t,this._model.notice=t?`Switching to ${t}. Applies from the next iteration, for the current run only.`:"Override cleared. Reverts to the tier mapping from the next iteration.",this._modelBusy=!1,await this._loadModel()}catch(i){console.error("Failed to set session model:",i),this._model.notice="Could not change the model. Try again.",this._modelBusy=!1,this.render()}}_renderModelControl(){let e=this._model.override||"",i=[{value:"",label:`Default (tier: ${this._escapeHtml(this._model.default)})`},{value:"haiku",label:"Haiku (fastest, cheapest)"},{value:"sonnet",label:"Sonnet 5 (balanced)"},{value:"opus",label:"Opus (top coding)"},{value:"fable",label:"Fable 5 (2x Opus cost: $10/$50 per MTok)"}].map(s=>{let r=s.value===e?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join(""),a=this._model.effective==="fable";return`
3777
+ `}}_attachEventListeners(){let e=this.shadowRoot.getElementById("refresh-btn");e&&e.addEventListener("click",()=>this._loadTasks());let t=this.shadowRoot.getElementById("bulk-toggle-btn");t&&t.addEventListener("click",()=>this._toggleBulkMode()),this.shadowRoot.querySelectorAll(".filter-pill").forEach(r=>{r.addEventListener("click",()=>this._setFilter(r.dataset.filter))});let i=this.shadowRoot.getElementById("task-search");i&&i.addEventListener("input",r=>this._setSearch(r.target.value)),this.shadowRoot.querySelectorAll(".show-more-btn").forEach(r=>{r.addEventListener("click",()=>this._showMore(r.dataset.showMore))}),this.shadowRoot.querySelectorAll(".bulk-btn").forEach(r=>{r.addEventListener("click",()=>{let o=r.dataset.bulkAction;o==="delete"?this._bulkDelete():this._bulkMove(o)})}),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(r=>{r.addEventListener("click",()=>{this._openAddTaskModal(r.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-checkbox").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleTaskSelection(r.dataset.checkId,o)})}),this.shadowRoot.querySelectorAll(".expand-toggle").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation(),this._toggleCardExpand(r.dataset.expandId)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(r=>{let o=r.dataset.taskId,n=this._tasks.find(l=>l.id.toString()===o);n&&(r.addEventListener("click",l=>{if(this._bulkMode){this._toggleTaskSelection(o,l);return}this._openTaskDetail(n)}),r.addEventListener("keydown",l=>{l.key==="Enter"||l.key===" "?(l.preventDefault(),this._bulkMode?this._toggleTaskSelection(o,l):this._openTaskDetail(n)):(l.key==="ArrowDown"||l.key==="ArrowUp")&&(l.preventDefault(),this._navigateTaskCards(r,l.key==="ArrowDown"?"next":"prev"))}),r.classList.contains("draggable")&&(r.addEventListener("dragstart",l=>this._handleDragStart(l,n)),r.addEventListener("dragend",l=>this._handleDragEnd(l))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(r=>{r.addEventListener("dragover",o=>this._handleDragOver(o)),r.addEventListener("dragenter",o=>this._handleDragEnter(o)),r.addEventListener("dragleave",o=>this._handleDragLeave(o)),r.addEventListener("drop",o=>this._handleDrop(o,r.dataset.status))});let a=this.shadowRoot.getElementById("modal-close-btn");a&&a.addEventListener("click",()=>this._closeTaskDetail());let s=this.shadowRoot.getElementById("task-detail-overlay");s&&s.addEventListener("click",r=>{r.target===s&&this._closeTaskDetail()})}_escapeHtml(e){let t=document.createElement("div");return t.textContent=e,t.innerHTML}_navigateTaskCards(e,t){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(e);if(a===-1)return;let s=t==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",Q);var X=class d extends v{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._model={override:null,default:"sonnet",effective:"sonnet",notice:"",provider:"claude",switchable:!0,offers:[]},this._modelBusy=!1,this._startBusy=!1,this._startNotice="",this._specText="",this._startModel="",this._advisorModel="",this._api=null,this._state=U(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._loadModel(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._teardownApiListeners()}_teardownApiListeners(){this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(f.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(f.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(e,t,i){t!==i&&(e==="api-url"&&this._api&&(this._teardownApiListeners(),this._setupApi(),this._loadStatus(),this._loadModel()),e==="theme"&&this._applyTheme(),e==="compact"&&this.render())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=h({baseUrl:e}),this._statusUpdateHandler=t=>this._updateFromStatus(t.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(f.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(f.CONNECTED,this._connectedHandler),this._api.addEventListener(f.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){let e=this._api;try{let t=await e.getStatus();if(e!==this._api)return;this._updateFromStatus(t)}catch{if(e!==this._api)return;this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(e){e&&(this._status={...this._status,connected:!0,mode:e.status||"running",version:e.version,uptime:e.uptime_seconds||0,activeAgents:e.running_agents||0,pendingTasks:e.pending_tasks||0,phase:e.phase,iteration:e.iteration,complexity:e.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._poll=b({loadFn:()=>this._loadStatus(),intervalMs:3e3,sectionId:null,immediate:!1})}_stopPolling(){this._poll&&(this._poll.stop(),this._poll=null)}_formatUptime(e){if(!e||e<0)return"--";let t=Math.floor(e/3600),i=Math.floor(e%3600/60),a=Math.floor(e%60);return t>0?`${t}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(e){let t=document.createElement("div");return t.textContent=String(e??""),t.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}async _triggerStart(){if(this._startBusy)return;let e=(this._specText||"").trim();if(!e){this._startNotice="Enter a spec or one-line brief to start a build.",this.render();return}if(!this._api||typeof this._api.startSession!="function"){this._startNotice="Start is not available on this server.",this.render();return}this._startBusy=!0,this._startNotice="Starting build...",this.render();try{let t=await this._api.startSession(e,{provider:this._status.provider||"claude",model:this._startModel||"",advisorModel:this._advisorModel||""});if(t&&t.error)throw new Error(t.error);this._startBusy=!1,this._startNotice="",this._specText="",this._status.mode="running",this._status.connected=!0,this.render(),this._loadStatus(),this.dispatchEvent(new CustomEvent("session-start",{detail:{...this._status,pid:t&&t.pid,spec:t&&t.spec}}))}catch(t){console.error("Failed to start build:",t),this._startBusy=!1,this._startNotice=t&&t.message?`Could not start: ${t.message}`:"Could not start the build. Try again.",this.render()}}_onSpecInput(e){this._specText=e}async _triggerPause(){try{let e=await this._api.pauseSession();if(e&&e.error)throw new Error(e.error);this._status.mode="paused",this.render(),this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}catch(e){console.error("Failed to pause session:",e),this.render()}}async _triggerResume(){try{let e=await this._api.resumeSession();if(e&&e.error)throw new Error(e.error);this._status.mode="running",this.render(),this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}catch(e){console.error("Failed to resume session:",e),this.render()}}async _triggerStop(){try{let e=await this._api.stopSession();if(e&&e.error)throw new Error(e.error);this._status.mode="stopped",this.render(),this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}catch(e){console.error("Failed to stop session:",e),this.render()}}async _loadModel(){if(!this._api||typeof this._api.getSessionModel!="function")return;let e=this._api;try{let t=await e.getSessionModel();if(e!==this._api)return;t&&!t.error&&(this._model={...this._model,override:t.override??null,default:t.default||"sonnet",effective:t.effective||t.default||"sonnet",provider:t.provider||"claude",switchable:t.switchable!==!1,offers:Array.isArray(t.offers)?t.offers:[]},this.render())}catch{}}async _onModelChange(e){if(this._modelBusy)return;this._modelBusy=!0;let t=e===""?null:e;try{let i=await this._api.setSessionModel(t);if(i&&i.error)throw new Error(i.error);this._model.override=t,this._model.notice=t?`Switching to ${t}. Applies from the next iteration, for the current run only.`:"Override cleared. Reverts to the tier mapping from the next iteration.",this._modelBusy=!1,await this._loadModel()}catch(i){console.error("Failed to set session model:",i),this._model.notice="Could not change the model. Try again.",this._modelBusy=!1,this.render()}}static get MODEL_HINTS(){return{haiku:"fastest, cheapest",sonnet:"balanced",opus:"top coding",fable:"2x Opus cost: $10/$50 per MTok"}}_renderModelControl(){let e=this._model.override||"",t=this._model.offers,i=d.MODEL_HINTS,s=[{value:"",label:`Default (${this._model.default})`},...t.map(l=>{let c=i[l.value],p=c?`${l.value} (${c})`:l.value;return{value:l.value,label:l.model?`${p} -> ${l.model}`:p}})].map(l=>{let c=l.value===e?" selected":"";return`<option value="${this._escapeHtml(l.value)}"${c}>${this._escapeHtml(l.label)}</option>`}).join(""),r=this._model.effective==="fable",o=this._model.switchable===!1;return`
3778
3778
  <div class="model-control">
3779
3779
  <div class="model-row">
3780
3780
  <label for="model-select">Model</label>
3781
- <select class="model-select" id="model-select" aria-label="Run model"${this._modelBusy?" disabled":""}>
3782
- ${i}
3781
+ <select class="model-select" id="model-select" aria-label="Run model"${this._modelBusy||o?" disabled":""}>
3782
+ ${s}
3783
3783
  </select>
3784
3784
  </div>
3785
- ${a?'<div class="model-cost-note">Fable 5 costs 2x Opus per token ($10/$50 per MTok).</div>':""}
3786
- <div class="model-disclosure">Model changes apply from the next iteration, for the current run only.</div>
3785
+ ${r?'<div class="model-cost-note">Fable 5 costs 2x Opus per token ($10/$50 per MTok).</div>':""}
3786
+ <div class="model-disclosure">${o?`Provider ${this._escapeHtml(this._model.provider)} runs ${this._escapeHtml(this._model.effective)} and does not support switching models mid-run. Restart the run to change it.`:"Model changes apply from the next iteration, for the current run only."}</div>
3787
3787
  ${this._model.notice?`<div class="model-notice">${this._escapeHtml(this._model.notice)}</div>`:""}
3788
3788
  </div>
3789
- `}_renderStartControl(){let t=[{value:"",label:"Sonnet 5 (default)"},{value:"haiku",label:"Haiku (fastest, cheapest)"},{value:"sonnet",label:"Sonnet 5 (balanced)"},{value:"opus",label:"Opus (top coding, priciest)"}].map(s=>{let r=s.value===this._startModel?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join(""),a=[{value:"",label:"Account default"},{value:"opus",label:"Opus (stronger judge)"}].map(s=>{let r=s.value===this._advisorModel?" selected":"";return`<option value="${this._escapeHtml(s.value)}"${r}>${this._escapeHtml(s.label)}</option>`}).join("");return`
3789
+ `}_renderStartControl(){let e=d.MODEL_HINTS,i=[{value:"",label:`Default (${this._model.default})`},...this._model.offers.filter(r=>r.value!=="fable").map(r=>{let o=e[r.value],n=o?`${r.value} (${o})`:r.value;return{value:r.value,label:r.model?`${n} -> ${r.model}`:n}})].map(r=>{let o=r.value===this._startModel?" selected":"";return`<option value="${this._escapeHtml(r.value)}"${o}>${this._escapeHtml(r.label)}</option>`}).join(""),a=this._model.provider==="claude"?[{value:"",label:"Account default"},{value:"opus",label:"Opus (stronger judge)"}]:[],s=a.map(r=>{let o=r.value===this._advisorModel?" selected":"";return`<option value="${this._escapeHtml(r.value)}"${o}>${this._escapeHtml(r.label)}</option>`}).join("");return`
3790
3790
  <div class="start-control">
3791
3791
  <label class="start-label" for="spec-input">Start a build from a spec</label>
3792
3792
  <textarea
@@ -3804,10 +3804,11 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3804
3804
  id="start-model-select"
3805
3805
  aria-label="Execution model for this build"
3806
3806
  ${this._startBusy?"disabled":""}>
3807
- ${t}
3807
+ ${i}
3808
3808
  </select>
3809
3809
  </div>
3810
3810
 
3811
+ ${a.length?`
3811
3812
  <div class="start-field">
3812
3813
  <label for="advisor-select">Advisor</label>
3813
3814
  <select
@@ -3815,10 +3816,10 @@ var LokiDashboard=(()=>{var Re=Object.defineProperty;var _t=Object.getOwnPropert
3815
3816
  id="advisor-select"
3816
3817
  aria-label="Advisor (code-review judge) model"
3817
3818
  ${this._startBusy?"disabled":""}>
3818
- ${a}
3819
+ ${s}
3819
3820
  </select>
3820
3821
  </div>
3821
- <div class="start-hint" id="advisor-hint">Advisor judges the code-review gate; execution stays on the model above.</div>
3822
+ <div class="start-hint" id="advisor-hint">Advisor judges the code-review gate; execution stays on the model above.</div>`:""}
3822
3823
 
3823
3824
  <button class="control-btn start" id="start-btn" aria-label="Start build" ${this._startBusy?"disabled":""}>
3824
3825
  <svg viewBox="0 0 24 24" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>
@@ -37,15 +37,47 @@ interact and why hitting one is a failure rather than a success.
37
37
 
38
38
  | Variable | Default | Effect |
39
39
  |---|---|---|
40
- | `LOKI_PROVIDER` | `claude` | `claude`, `cline`, `codex`, or `aider`. |
40
+ | `LOKI_PROVIDER` | `claude` | `claude`, `cline`, `codex`, `aider`, or `opencode`. |
41
+ | `LOKI_SESSION_MODEL` | `medium` | The capability tier for the run: `small`, `medium`, or `high`. See below. |
41
42
  | `LOKI_MAX_TIER` | unlimited | Caps model tier, so a run cannot escalate past what you are willing to pay for. |
42
- | `LOKI_TIER` | per-phase default | Forces a specific tier for the run. |
43
- | `LOKI_SESSION_MODEL` | provider default | Pins the session model explicitly. |
44
43
  | `LOKI_MODEL_OVERRIDE` | unset | Overrides the resolved model outright. |
44
+ | `LOKI_TIER` | `oss` | **Not a model setting.** The open-core licensing seam. Leave it unset. |
45
+
46
+ ### Picking a model without naming one
47
+
48
+ You do not need to know any vendor's model names. Ask for a capability class
49
+ and each provider supplies its own latest model in that class:
50
+
51
+ | Tier | Means | Claude | Codex | Cline / Aider / OpenCode |
52
+ |---|---|---|---|---|
53
+ | `small` | cheap and fast | `claude-haiku-4-5` | account default | `deepseek-chat` |
54
+ | `medium` | the workhorse (**default**) | `claude-sonnet-5` | account default | `deepseek-v3.2` |
55
+ | `high` | the most capable | `claude-opus-4-8` | account default | `deepseek-v3.2` |
56
+
57
+ ```bash
58
+ LOKI_SESSION_MODEL=small loki start ./prd.md # or: loki start --session-model small ./prd.md
59
+ ```
60
+
61
+ `medium` is the default and resolves to the same model today's builds already
62
+ use, so setting it explicitly changes nothing. The older spellings
63
+ (`fast`/`development`/`planning`, and the Claude aliases `haiku`/`sonnet`/
64
+ `opus`) still work and are unchanged.
65
+
66
+ **To see what a tier actually resolves to on your machine, run `loki provider
67
+ models`.** It prints the dispatched model per tier per provider along with
68
+ which environment variable set it, so you can verify what you will really get
69
+ rather than trusting the table above. Codex deliberately shows a provider
70
+ default: it sends no `--model` flag and lets Codex pick a model appropriate to
71
+ your account, because a hardcoded name breaks ChatGPT-account users.
72
+
73
+ Override a single tier for one provider with `LOKI_<PROVIDER>_MODEL_<TIER>`
74
+ (for example `LOKI_CLAUDE_MODEL_FAST=claude-haiku-4-5`), or every tier at once
75
+ with `LOKI_<PROVIDER>_MODEL`.
45
76
 
46
77
  `LOKI_MAX_TIER` is the cost control worth knowing: it bounds escalation, while
47
78
  `LOKI_BUDGET_LIMIT` bounds total spend. They answer different questions and are
48
- usefully set together.
79
+ usefully set together. Note that `LOKI_MAX_TIER` is a **ceiling** and
80
+ `LOKI_SESSION_MODEL` is a **choice** -- the ceiling still clamps the choice.
49
81
 
50
82
  ## Output volume
51
83