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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/deadline.py +38 -4
- package/autonomy/loki +194 -3
- package/autonomy/run.sh +170 -29
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +207 -19
- package/dashboard/static/index.html +10 -9
- package/docs/environment-variables.md +36 -4
- package/loki-ts/dist/loki.js +387 -382
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/aider.sh +35 -3
- package/providers/claude.sh +12 -1
- package/providers/cline.sh +26 -3
- package/providers/model_catalog.json +17 -6
- package/providers/models.sh +58 -3
- package/providers/opencode.sh +8 -2
- package/references/multi-provider.md +9 -0
- package/skills/providers.md +35 -1
package/dashboard/server.py
CHANGED
|
@@ -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
|
|
2816
|
+
"""Normalize a start-time model / advisor alias.
|
|
2808
2817
|
|
|
2809
|
-
|
|
2810
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
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 (
|
|
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
|
-
${
|
|
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
|
-
${
|
|
3786
|
-
<div class="model-disclosure"
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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 `
|
|
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
|
|