loki-mode 9.22.12 → 9.24.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 +3 -3
- package/VERSION +1 -1
- package/autonomy/lib/claude-flags.sh +69 -5
- package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
- package/autonomy/loki +324 -74
- package/autonomy/run.sh +176 -40
- package/autonomy/verify.sh +16 -3
- package/bin/loki +25 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +88 -4
- package/dashboard/server.py +50 -48
- package/docs/INSTALLATION.md +2 -2
- package/docs/RARV-C-100X-PLAN.md +9 -1
- package/docs/enterprise/performance.md +51 -9
- package/docs/exit-codes.md +4 -0
- package/loki-ts/dist/cockpit.js +1 -1
- package/loki-ts/dist/loki.js +634 -549
- package/loki-ts/package.json +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +6 -5
- package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
- package/providers/loader.sh +2 -2
- package/providers/model_catalog.json +26 -8
- package/providers/models.sh +1 -1
- package/providers/opencode.sh +1 -1
- package/skills/providers.md +42 -14
- package/tools/normalize-package-permissions.mjs +34 -0
- package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
- package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
- package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
- package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
- package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
- package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
- package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
- package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
- package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
- package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
- package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
- package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
- package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
- package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
- package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
- package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
- package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
- package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
- package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
- package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
- package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
- package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
- package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
- package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
- package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
- package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
- package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
- package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
- package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
- package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
- package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
- package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
- package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
- package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
- package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
- package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
- package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
- package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
- package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
- package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/web-app/requirements.txt +1 -0
- package/web-app/server.py +160 -31
package/autonomy/run.sh
CHANGED
|
@@ -967,6 +967,36 @@ _loki_provider_pipeline_exit_code() {
|
|
|
967
967
|
fi
|
|
968
968
|
}
|
|
969
969
|
|
|
970
|
+
# Invoke through a provider's argv-builder seam while preserving the main
|
|
971
|
+
# loop's deadline, fan-out logging, and provider-stage exit status.
|
|
972
|
+
_loki_invoke_argv_provider() {
|
|
973
|
+
local tier="${1:-development}"
|
|
974
|
+
local prompt="${2:-}"
|
|
975
|
+
local log_file="${3:-}"
|
|
976
|
+
local agent_log="${4:-}"
|
|
977
|
+
local iter_output="${5:-}"
|
|
978
|
+
local -a _loki_argv_pipe_status=()
|
|
979
|
+
|
|
980
|
+
if ! type provider_invoke_argv >/dev/null 2>&1; then
|
|
981
|
+
log_error "Provider ${PROVIDER_NAME:-unknown} does not implement provider_invoke_argv"
|
|
982
|
+
return 125
|
|
983
|
+
fi
|
|
984
|
+
if ! provider_invoke_argv "$tier" "$prompt" \
|
|
985
|
+
|| [ "${#_LOKI_INVOKE_ARGV[@]}" -eq 0 ]; then
|
|
986
|
+
log_error "Provider ${PROVIDER_NAME:-unknown} failed to build invocation argv"
|
|
987
|
+
return 125
|
|
988
|
+
fi
|
|
989
|
+
|
|
990
|
+
LOKI_DEADLINE_IDLE_TIMEOUT="${LOKI_PROVIDER_IDLE_TIMEOUT:-0}" \
|
|
991
|
+
_loki_with_deadline "${LOKI_PROVIDER_CALL_TIMEOUT:-0}" \
|
|
992
|
+
"${_LOKI_INVOKE_ARGV[@]}" 2>&1 \
|
|
993
|
+
| tee -a "$log_file" "$agent_log" "$iter_output"
|
|
994
|
+
_loki_argv_pipe_status=("${PIPESTATUS[@]}")
|
|
995
|
+
return "$(_loki_provider_pipeline_exit_code \
|
|
996
|
+
"${_loki_argv_pipe_status[0]:-125}" \
|
|
997
|
+
"${_loki_argv_pipe_status[1]:-125}" 0)"
|
|
998
|
+
}
|
|
999
|
+
|
|
970
1000
|
_LOKI_DEPENDENCY_SETUP_LIB="${SCRIPT_DIR}/lib/dependency-setup.sh"
|
|
971
1001
|
if [ -r "$_LOKI_DEPENDENCY_SETUP_LIB" ]; then
|
|
972
1002
|
# shellcheck source=lib/dependency-setup.sh
|
|
@@ -1519,20 +1549,20 @@ PYREG
|
|
|
1519
1549
|
# if any other project is still running (KEEP) it is left up. NEVER uses a
|
|
1520
1550
|
# blanket pkill and NEVER touches another folder's pids. Best-effort and
|
|
1521
1551
|
# failure-swallowed: teardown bookkeeping must never block a clean exit.
|
|
1522
|
-
# Is this pid
|
|
1523
|
-
#
|
|
1524
|
-
#
|
|
1525
|
-
#
|
|
1552
|
+
# Is this pid positively identified as our dashboard, rather than a recycled
|
|
1553
|
+
# number now naming something else? Ownership-sensitive cleanup must fail closed:
|
|
1554
|
+
# an absent `ps` result is not evidence, and a generic python/loki substring is
|
|
1555
|
+
# not specific enough to authorize a signal.
|
|
1526
1556
|
_loki_pid_looks_like_dashboard() {
|
|
1527
1557
|
local _p="$1" _cmd
|
|
1528
1558
|
case "$_p" in ''|*[!0-9]*) return 1 ;; esac
|
|
1529
1559
|
[ "$_p" -gt 1 ] 2>/dev/null || return 1 # never signal pid 0/1
|
|
1530
1560
|
_cmd="$(ps -o command= -p "$_p" 2>/dev/null)"
|
|
1531
|
-
[ -n "$_cmd" ] || return
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1561
|
+
[ -n "$_cmd" ] || return 1
|
|
1562
|
+
# run.sh and `loki dashboard start` both launch this exact module form.
|
|
1563
|
+
# Anchor the interpreter and module tokens so an unrelated command merely
|
|
1564
|
+
# mentioning "dashboard.server" in an argument cannot forge identity.
|
|
1565
|
+
[[ "$_cmd" =~ ^[[:space:]]*([^[:space:]]*/)?[Pp]ython([0-9]+([.][0-9]+)*)?[[:space:]]+-m[[:space:]]+dashboard[.]server([[:space:]]|$) ]]
|
|
1536
1566
|
}
|
|
1537
1567
|
|
|
1538
1568
|
loki_mark_project_stopped_and_maybe_kill_shared_dashboard() {
|
|
@@ -1591,9 +1621,10 @@ PYCHECK
|
|
|
1591
1621
|
#
|
|
1592
1622
|
# kill -0 is NOT sufficient: a recycled pid is alive by definition,
|
|
1593
1623
|
# which is exactly the insufficiency the loki.pgid self-check had.
|
|
1594
|
-
#
|
|
1595
|
-
#
|
|
1596
|
-
#
|
|
1624
|
+
# This guard fails CLOSED: if `ps` says nothing, or says something
|
|
1625
|
+
# that is not the dashboard's own command, we do not signal. The
|
|
1626
|
+
# cost of failing closed is a leaked dashboard the next explicit
|
|
1627
|
+
# stop reaps; the cost of failing open was signalling a stranger.
|
|
1597
1628
|
if [ -n "$_shared_pid" ] && _loki_pid_looks_like_dashboard "$_shared_pid"; then
|
|
1598
1629
|
kill "$_shared_pid" 2>/dev/null || true
|
|
1599
1630
|
sleep 0.5
|
|
@@ -1601,27 +1632,11 @@ PYCHECK
|
|
|
1601
1632
|
fi
|
|
1602
1633
|
rm -f "$_shared_pidfile" 2>/dev/null || true
|
|
1603
1634
|
fi
|
|
1604
|
-
#
|
|
1605
|
-
#
|
|
1606
|
-
#
|
|
1607
|
-
#
|
|
1608
|
-
#
|
|
1609
|
-
# A healthy server is reusable by every project, so probe /api/status first
|
|
1610
|
-
# and only reclaim the port when nothing is answering (a genuinely stale
|
|
1611
|
-
# listener). Opt out of the probe with LOKI_DASHBOARD_FORCE_RECLAIM=1.
|
|
1612
|
-
if command -v lsof >/dev/null 2>&1; then
|
|
1613
|
-
local _dash_port="${DASHBOARD_PORT:-57374}"
|
|
1614
|
-
local _dash_alive=""
|
|
1615
|
-
if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
|
|
1616
|
-
_dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
|
|
1617
|
-
"http://127.0.0.1:${_dash_port}/api/status" 2>/dev/null || true)
|
|
1618
|
-
fi
|
|
1619
|
-
if [ "$_dash_alive" = "200" ]; then
|
|
1620
|
-
log_info "Reusing the healthy dashboard already serving on port ${_dash_port} (not reclaiming)."
|
|
1621
|
-
else
|
|
1622
|
-
lsof -ti:"${_dash_port}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
|
1623
|
-
fi
|
|
1624
|
-
fi
|
|
1635
|
+
# A port number and an HTTP response are not ownership evidence. In
|
|
1636
|
+
# particular, a 404 can be an unrelated healthy service. The shared PID
|
|
1637
|
+
# file plus positive command identity above is the only authority to
|
|
1638
|
+
# signal a process; absent or forged ownership leaves every port holder
|
|
1639
|
+
# untouched.
|
|
1625
1640
|
fi
|
|
1626
1641
|
}
|
|
1627
1642
|
# Register as running now. We deliberately do NOT install an EXIT trap to
|
|
@@ -3207,7 +3222,7 @@ validate_api_keys() {
|
|
|
3207
3222
|
# "loggedin" or "unknown" -> proceed (fail open on uncertainty).
|
|
3208
3223
|
fi
|
|
3209
3224
|
|
|
3210
|
-
# CLI tools (claude, codex, cline, aider) use their own login sessions.
|
|
3225
|
+
# CLI tools (claude, codex, cline, aider, opencode) use their own login sessions.
|
|
3211
3226
|
# Only require API keys inside Docker/K8s where CLI login isn't available.
|
|
3212
3227
|
if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
|
|
3213
3228
|
return 0
|
|
@@ -13425,6 +13440,43 @@ _loki_requirements_contract_emit() {
|
|
|
13425
13440
|
"${LOKI_REVIEW_REQUIREMENTS_IDENTITY:-}"
|
|
13426
13441
|
}
|
|
13427
13442
|
|
|
13443
|
+
# A reviewer dispatch can die BEFORE the provider is ever invoked -- the
|
|
13444
|
+
# prompt rematerialization or the shard-identity lookup below both exit 125
|
|
13445
|
+
# while LOKI_REVIEW_STDERR_FILE is still unset and no timing sidecar exists
|
|
13446
|
+
# yet. That left a lost shard structurally invisible: the logical aggregation
|
|
13447
|
+
# (REQUIREMENTS_LOGICAL_TIMING) substituted an anonymous placeholder and the
|
|
13448
|
+
# only symptom was a bare call count that could not name WHICH shard vanished.
|
|
13449
|
+
# Emit the same sidecar the completed path emits, plus one bounded stderr
|
|
13450
|
+
# line, so the existing aggregation carries a shard-identifying cause.
|
|
13451
|
+
# ponytail: fixed-format single line, no truncation helper needed.
|
|
13452
|
+
_dispatch_reviewer_prestub_failure() {
|
|
13453
|
+
local review_output="$1" stderr_output="$2" stage="$3" shard_index="${4:-0}"
|
|
13454
|
+
printf 'loki-review-prestub-failure stage=%s shard_index=%s reviewer=%s\n' \
|
|
13455
|
+
"$stage" "$shard_index" "${5:-unknown}" >> "$stderr_output" 2>/dev/null || true
|
|
13456
|
+
chmod 600 "$stderr_output" 2>/dev/null || true
|
|
13457
|
+
_LOKI_RDT_PATH="${review_output%.txt}-timing.json" \
|
|
13458
|
+
_LOKI_RDT_STAGE="$stage" _LOKI_RDT_SHARD="$shard_index" \
|
|
13459
|
+
_LOKI_RDT_STDERR="$stderr_output" \
|
|
13460
|
+
_LOKI_RDT_BUDGET="${LOKI_REVIEW_CALL_TIMEOUT:-0}" python3 - <<'REVIEW_PRESTUB_TIMING' 2>/dev/null || true
|
|
13461
|
+
import json
|
|
13462
|
+
import os
|
|
13463
|
+
|
|
13464
|
+
record = {
|
|
13465
|
+
"schema": "loki-review-dispatch/v1",
|
|
13466
|
+
"budget_seconds": int(os.environ.get("_LOKI_RDT_BUDGET", "0") or 0),
|
|
13467
|
+
"elapsed_ms": 0,
|
|
13468
|
+
"exit_code": 125,
|
|
13469
|
+
"outcome": "prestub_error",
|
|
13470
|
+
"deadline_scope": "pre_dispatch",
|
|
13471
|
+
"stage": os.environ.get("_LOKI_RDT_STAGE", "unknown"),
|
|
13472
|
+
"shard_index": int(os.environ.get("_LOKI_RDT_SHARD", "0") or 0),
|
|
13473
|
+
"stderr_bytes": os.path.getsize(os.environ["_LOKI_RDT_STDERR"]),
|
|
13474
|
+
}
|
|
13475
|
+
with open(os.environ["_LOKI_RDT_PATH"], "w", encoding="utf-8") as handle:
|
|
13476
|
+
json.dump(record, handle, sort_keys=True)
|
|
13477
|
+
REVIEW_PRESTUB_TIMING
|
|
13478
|
+
}
|
|
13479
|
+
|
|
13428
13480
|
# Persist one compact record per provider review. The sidecar is written by the
|
|
13429
13481
|
# same process that owns the dispatch, so parallel reviewer timings do not get
|
|
13430
13482
|
# distorted by the parent's ordered wait loop.
|
|
@@ -15373,7 +15425,13 @@ BUILD_PROMPT
|
|
|
15373
15425
|
prompt_text=$(python3 "$requirements_helper" read-bound \
|
|
15374
15426
|
"$review_prompt_file" "$prompt_sha" \
|
|
15375
15427
|
"$_review_max_prompt_bytes" "$prompt_identity" 2>/dev/null) \
|
|
15376
|
-
||
|
|
15428
|
+
|| {
|
|
15429
|
+
_dispatch_reviewer_prestub_failure \
|
|
15430
|
+
"$review_stage_file" \
|
|
15431
|
+
"$review_dir/$review_id/${reviewer_name}-stderr.log" \
|
|
15432
|
+
prompt_read_bound "$reviewer_shard_index" "$reviewer_name"
|
|
15433
|
+
exit 125
|
|
15434
|
+
}
|
|
15377
15435
|
if [ "$reviewer_logical_name" = "requirements-verifier" ]; then
|
|
15378
15436
|
local shard_label shard_identity
|
|
15379
15437
|
printf -v shard_label '%03d' "$reviewer_shard_index"
|
|
@@ -15381,7 +15439,13 @@ BUILD_PROMPT
|
|
|
15381
15439
|
_LOKI_REVIEW_SHARD_INDEX="$reviewer_shard_index" python3 -c '
|
|
15382
15440
|
import json, os
|
|
15383
15441
|
print(json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
|
|
15384
|
-
') ||
|
|
15442
|
+
') || {
|
|
15443
|
+
_dispatch_reviewer_prestub_failure \
|
|
15444
|
+
"$review_stage_file" \
|
|
15445
|
+
"$review_dir/$review_id/${reviewer_name}-stderr.log" \
|
|
15446
|
+
shard_identity "$reviewer_shard_index" "$reviewer_name"
|
|
15447
|
+
exit 125
|
|
15448
|
+
}
|
|
15385
15449
|
LOKI_REVIEW_REQUIREMENTS_HELPER="$requirements_helper" \
|
|
15386
15450
|
LOKI_REVIEW_REQUIREMENTS_SOURCE="$requirements_source" \
|
|
15387
15451
|
LOKI_REVIEW_REQUIREMENTS_SNAPSHOT="$requirements_file" \
|
|
@@ -16557,9 +16621,16 @@ start_dashboard() {
|
|
|
16557
16621
|
# Check if it's our own dashboard
|
|
16558
16622
|
local existing_pid=$(lsof -ti :$DASHBOARD_PORT 2>/dev/null | head -1)
|
|
16559
16623
|
if [ -n "$existing_pid" ]; then
|
|
16560
|
-
# Only kill
|
|
16624
|
+
# Only kill a process positively identified as OUR dashboard.
|
|
16625
|
+
# `-o comm=` yields just the executable name ("python3"), so the old
|
|
16626
|
+
# *python*/*uvicorn* test matched ANY python process that happened to
|
|
16627
|
+
# hold this port -- an unrelated http.server answering 404 was killed
|
|
16628
|
+
# as a "stuck dashboard". Reuse the same full-command-line guard the
|
|
16629
|
+
# teardown path uses: it fails closed on an unreadable or foreign
|
|
16630
|
+
# command, so a stranger on this port is stepped over (port++) rather
|
|
16631
|
+
# than signalled.
|
|
16561
16632
|
local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true)
|
|
16562
|
-
if
|
|
16633
|
+
if _loki_pid_looks_like_dashboard "$existing_pid"; then
|
|
16563
16634
|
# Never kill a HEALTHY dashboard already serving here: it is almost
|
|
16564
16635
|
# always the user's own live dashboard (open in their browser), and
|
|
16565
16636
|
# killing it on `loki start` drops their session mid-use. A healthy
|
|
@@ -16731,7 +16802,32 @@ start_dashboard() {
|
|
|
16731
16802
|
return 1
|
|
16732
16803
|
fi
|
|
16733
16804
|
|
|
16734
|
-
|
|
16805
|
+
# Wait for the dashboard to come up, but only as long as it actually takes.
|
|
16806
|
+
# This was a flat `sleep 2` on the critical path of every build, before the
|
|
16807
|
+
# first iteration, spent entirely on a process that is typically serving in
|
|
16808
|
+
# a fraction of that. Poll the endpoint the reuse path above already trusts
|
|
16809
|
+
# (/api/status), so this returns the moment the server really serves rather
|
|
16810
|
+
# than on a fixed guess.
|
|
16811
|
+
#
|
|
16812
|
+
# The floor is deliberate and NOT an optimization target. A process that
|
|
16813
|
+
# starts and then dies at t=1.5s would pass an early `kill -0` where the old
|
|
16814
|
+
# flat sleep would have caught it, so polling alone would trade a real
|
|
16815
|
+
# liveness check for a second of wall clock. We keep polling until the
|
|
16816
|
+
# endpoint answers AND require the process to still be alive at the end,
|
|
16817
|
+
# which is strictly stronger than the old single check at t=2s.
|
|
16818
|
+
_dash_ready=0
|
|
16819
|
+
for _ in $(seq 1 40); do
|
|
16820
|
+
kill -0 "$DASHBOARD_PID" 2>/dev/null || break
|
|
16821
|
+
if curl -fsS -m 1 "http://127.0.0.1:${DASHBOARD_PORT}/api/status" >/dev/null 2>&1; then
|
|
16822
|
+
_dash_ready=1
|
|
16823
|
+
break
|
|
16824
|
+
fi
|
|
16825
|
+
sleep 0.05
|
|
16826
|
+
done
|
|
16827
|
+
# A server that never answered still gets the original grace period: some
|
|
16828
|
+
# environments have no curl, and the endpoint is not the only thing that
|
|
16829
|
+
# makes a dashboard useful. Falling back keeps behavior identical there.
|
|
16830
|
+
[ "$_dash_ready" = "1" ] || sleep 2
|
|
16735
16831
|
|
|
16736
16832
|
if kill -0 "$DASHBOARD_PID" 2>/dev/null; then
|
|
16737
16833
|
DASHBOARD_LAST_ALIVE=$(date +%s)
|
|
@@ -23266,6 +23362,16 @@ if __name__ == "__main__":
|
|
|
23266
23362
|
} && exit_code=0 || exit_code=$?
|
|
23267
23363
|
;;
|
|
23268
23364
|
|
|
23365
|
+
opencode)
|
|
23366
|
+
# Keep argv construction provider-owned: its prompt is
|
|
23367
|
+
# positional, --auto is mandatory, and the model is provider
|
|
23368
|
+
# scoped. The wrapper preserves the main-loop deadline/tee
|
|
23369
|
+
# contract and returns the provider stage's nonzero status.
|
|
23370
|
+
_loki_invoke_argv_provider "$CURRENT_TIER" "$prompt" \
|
|
23371
|
+
"$log_file" "$agent_log" "$iter_output"
|
|
23372
|
+
exit_code=$?
|
|
23373
|
+
;;
|
|
23374
|
+
|
|
23269
23375
|
*)
|
|
23270
23376
|
log_error "Unknown provider: ${PROVIDER_NAME:-unknown}"
|
|
23271
23377
|
local exit_code=1
|
|
@@ -25745,7 +25851,7 @@ main() {
|
|
|
25745
25851
|
fi
|
|
25746
25852
|
shift 2
|
|
25747
25853
|
else
|
|
25748
|
-
log_error "--provider requires a value (claude, codex, cline, aider)"
|
|
25854
|
+
log_error "--provider requires a value (claude, codex, cline, aider, opencode)"
|
|
25749
25855
|
exit 1
|
|
25750
25856
|
fi
|
|
25751
25857
|
;;
|
|
@@ -25992,6 +26098,18 @@ main() {
|
|
|
25992
26098
|
exit 1
|
|
25993
26099
|
fi
|
|
25994
26100
|
|
|
26101
|
+
# BOOT WINDOW (v9.24.0). Everything from here to setup_agent_branch is
|
|
26102
|
+
# pre-loop setup: prerequisites, provider detection, complexity detection,
|
|
26103
|
+
# dashboard start, branch setup. None of it was timed, and none of the nine
|
|
26104
|
+
# existing emit_stage_complete sites lies outside the iteration body -- so
|
|
26105
|
+
# on the one profiled build, stage_total_s summed to 723s against a 960s
|
|
26106
|
+
# wall clock and 237s (25%) was attributed by GUESS, not measurement
|
|
26107
|
+
# (benchmarks/results/gate-profile.json). The guess named "rsync of the
|
|
26108
|
+
# engine copy", which measurement later falsified: the only rsync in the
|
|
26109
|
+
# repo is in the benchmark harness, costs 1.09s, and runs before the timer
|
|
26110
|
+
# starts. Bracket the window instead of arguing about it.
|
|
26111
|
+
_boot_t0=$(date +%s 2>/dev/null)
|
|
26112
|
+
|
|
25995
26113
|
# Check prerequisites (unless skipped)
|
|
25996
26114
|
if [ "$SKIP_PREREQS" != "true" ]; then
|
|
25997
26115
|
if ! check_prerequisites; then
|
|
@@ -26207,6 +26325,11 @@ main() {
|
|
|
26207
26325
|
# Setup agent branch protection (isolates agent changes to a feature branch)
|
|
26208
26326
|
setup_agent_branch
|
|
26209
26327
|
|
|
26328
|
+
# Close the boot window. Purely additive: emit_stage_complete appends one
|
|
26329
|
+
# event line and swallows every error, so it cannot alter a gate verdict or
|
|
26330
|
+
# control flow (see its contract at run.sh:2523-2531).
|
|
26331
|
+
emit_stage_complete "boot" "pass" "$_boot_t0"
|
|
26332
|
+
|
|
26210
26333
|
# Log session start for audit
|
|
26211
26334
|
audit_log "SESSION_START" "prd=$PRD_PATH,dashboard=$ENABLE_DASHBOARD,staged_autonomy=$STAGED_AUTONOMY,parallel=$PARALLEL_MODE"
|
|
26212
26335
|
audit_agent_action "session_start" "Session started" "prd=$PRD_PATH,provider=${PROVIDER_NAME:-claude}"
|
|
@@ -26303,6 +26426,11 @@ main() {
|
|
|
26303
26426
|
# a stuck "Planning" state.
|
|
26304
26427
|
_advance_current_phase "BUILDING"
|
|
26305
26428
|
run_autonomous "$PRD_PATH" || result=$?
|
|
26429
|
+
# TEARDOWN WINDOW (v9.24.0): the other half of the unmeasured 237s.
|
|
26430
|
+
# Everything after the loop -- pre-edit snapshot, commit, handoff and
|
|
26431
|
+
# learnings writers, proof generation, metrics aggregation -- runs here
|
|
26432
|
+
# and was never timed. Opened immediately so nothing below is missed.
|
|
26433
|
+
_teardown_t0=$(date +%s 2>/dev/null)
|
|
26306
26434
|
# PRE-EDIT SNAPSHOT: freeze the agent's raw diff HERE, the first
|
|
26307
26435
|
# instruction after the loop returns, because everything below this line
|
|
26308
26436
|
# can change the tree -- commit_session_changes commits the work (after
|
|
@@ -26497,6 +26625,14 @@ except Exception:
|
|
|
26497
26625
|
generate_proof_of_run "$result" || true
|
|
26498
26626
|
fi
|
|
26499
26627
|
|
|
26628
|
+
# Close the teardown window here rather than after cleanup: everything below
|
|
26629
|
+
# is process reaping and file removal, while everything above is the work a
|
|
26630
|
+
# user waits on (commit, PR, summary, proof). Emitting before cleanup also
|
|
26631
|
+
# guarantees the event is written even if a later reap kills this shell.
|
|
26632
|
+
if [ -n "${_teardown_t0:-}" ]; then
|
|
26633
|
+
emit_stage_complete "teardown" "pass" "$_teardown_t0"
|
|
26634
|
+
fi
|
|
26635
|
+
|
|
26500
26636
|
# Cleanup
|
|
26501
26637
|
if type app_runner_cleanup &>/dev/null; then
|
|
26502
26638
|
app_runner_cleanup
|
package/autonomy/verify.sh
CHANGED
|
@@ -368,15 +368,28 @@ _verify_zero_tests_executed() {
|
|
|
368
368
|
[ -n "$_zt_f" ] || continue
|
|
369
369
|
_zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
|
|
370
370
|
done
|
|
371
|
-
local _zt_real=0 _zt_line _zt_label
|
|
371
|
+
local _zt_real=0 _zt_line _zt_label _zt_matches
|
|
372
|
+
_zt_matches=$(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null || true)
|
|
372
373
|
while IFS= read -r _zt_line; do
|
|
374
|
+
[ -n "$_zt_line" ] || continue
|
|
373
375
|
_zt_label="${_zt_line#* - }"
|
|
374
376
|
case "$_zt_bases" in
|
|
375
377
|
*$'\n'"$_zt_label"$'\n'*) continue ;;
|
|
376
378
|
esac
|
|
377
379
|
_zt_real=$((_zt_real + 1))
|
|
378
|
-
done
|
|
379
|
-
|
|
380
|
+
done << ZT_MATCHES_EOF
|
|
381
|
+
$_zt_matches
|
|
382
|
+
ZT_MATCHES_EOF
|
|
383
|
+
[ "$_zt_real" -gt 0 ] && return 1
|
|
384
|
+
|
|
385
|
+
# Node 26 defaults to the human-readable spec reporter under npm
|
|
386
|
+
# scripts. It has no TAP ok-lines, so an absent match is not proof
|
|
387
|
+
# that zero tests ran. Only classify zero tests when the output is
|
|
388
|
+
# positively identifiable as TAP; unknown/custom reporter shapes
|
|
389
|
+
# remain a passing run instead of becoming a false CONCERN.
|
|
390
|
+
case "$_zt_out" in
|
|
391
|
+
*"TAP version"*|*$'\n# tests '*|"# tests "*) return 0 ;;
|
|
392
|
+
esac
|
|
380
393
|
return 1
|
|
381
394
|
;;
|
|
382
395
|
jest)
|
package/bin/loki
CHANGED
|
@@ -188,7 +188,7 @@ fi
|
|
|
188
188
|
# error regardless of which subcommand they invoke.
|
|
189
189
|
if [ "${LOKI_PROVIDER:-}" = "gemini" ]; then
|
|
190
190
|
echo "Error: Provider 'gemini' is deprecated as of v7.5.18 and has been removed." >&2
|
|
191
|
-
echo "Active providers: claude, codex, cline, aider" >&2
|
|
191
|
+
echo "Active providers: claude, codex, cline, aider, opencode" >&2
|
|
192
192
|
echo "Unset LOKI_PROVIDER or use: LOKI_PROVIDER=claude" >&2
|
|
193
193
|
exit 1
|
|
194
194
|
fi
|
|
@@ -273,8 +273,19 @@ _loki_start_needs_bash() {
|
|
|
273
273
|
# $@ = the start args (already know $1 = start). Return 0 (divert to bash) if
|
|
274
274
|
# any bash-only orchestration flag or an issue-ref spec is present.
|
|
275
275
|
local a
|
|
276
|
+
local explicit_provider=""
|
|
277
|
+
local provider_value_next=0
|
|
276
278
|
for a in "$@"; do
|
|
279
|
+
if [ "$provider_value_next" = "1" ]; then
|
|
280
|
+
explicit_provider="$a"
|
|
281
|
+
provider_value_next=0
|
|
282
|
+
continue
|
|
283
|
+
fi
|
|
277
284
|
case "$a" in
|
|
285
|
+
--provider)
|
|
286
|
+
provider_value_next=1 ;;
|
|
287
|
+
--provider=*)
|
|
288
|
+
explicit_provider="${a#--provider=}" ;;
|
|
278
289
|
--parallel|--bg|--background|--github|--api|--sandbox \
|
|
279
290
|
|--fast|--repo-fast|--full|--repo-full \
|
|
280
291
|
|--bmad-project|--openspec \
|
|
@@ -296,6 +307,19 @@ _loki_start_needs_bash() {
|
|
|
296
307
|
esac
|
|
297
308
|
done
|
|
298
309
|
unset _loki_iso_next
|
|
310
|
+
|
|
311
|
+
# The Bun RARV loop has not ported the opencode invoker yet. Keep the
|
|
312
|
+
# released, autonomous shell adapter reachable when SDK mode is enabled
|
|
313
|
+
# instead of accepting the selection and then failing after intake. CLI
|
|
314
|
+
# selection wins over env, which wins over the project-level saved choice.
|
|
315
|
+
local effective_provider="$explicit_provider"
|
|
316
|
+
if [ -z "$effective_provider" ] && [ -n "${LOKI_PROVIDER:-}" ]; then
|
|
317
|
+
effective_provider="$LOKI_PROVIDER"
|
|
318
|
+
fi
|
|
319
|
+
if [ -z "$effective_provider" ] && [ -f "${PWD}/.loki/state/provider" ]; then
|
|
320
|
+
effective_provider="$(sed -n '1p' "${PWD}/.loki/state/provider" 2>/dev/null || true)"
|
|
321
|
+
fi
|
|
322
|
+
[ "$effective_provider" = "opencode" ] && return 0
|
|
299
323
|
return 1
|
|
300
324
|
}
|
|
301
325
|
if [ "${1:-}" = "start" ] && { [ "${LOKI_SDK_LOOP:-}" = "1" ] || [ "${LOKI_SDK_LOOP:-}" = "true" ]; }; then
|
package/dashboard/__init__.py
CHANGED
package/dashboard/control.py
CHANGED
|
@@ -20,10 +20,11 @@ import tempfile
|
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Optional
|
|
23
|
+
from urllib.parse import urlsplit
|
|
23
24
|
|
|
24
|
-
from fastapi import Depends, FastAPI, HTTPException
|
|
25
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
25
26
|
from fastapi.middleware.cors import CORSMiddleware
|
|
26
|
-
from fastapi.responses import StreamingResponse
|
|
27
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
27
28
|
from pydantic import BaseModel
|
|
28
29
|
|
|
29
30
|
# Auth gating for the standalone control app.
|
|
@@ -56,6 +57,63 @@ except Exception: # pragma: no cover - defensive fallback for non-package runs
|
|
|
56
57
|
|
|
57
58
|
_CONTROL_DEP = _require_control_scope()
|
|
58
59
|
|
|
60
|
+
|
|
61
|
+
def _normalized_http_origin(value: str) -> tuple[str, str, int] | None:
|
|
62
|
+
"""Return a comparable browser origin, or ``None`` when malformed.
|
|
63
|
+
|
|
64
|
+
Origin is scheme + host + effective port. Paths, credentials, queries and
|
|
65
|
+
fragments are never valid in an Origin header. Normalising default ports
|
|
66
|
+
makes ``https://host`` and ``https://host:443`` equivalent without doing
|
|
67
|
+
loose prefix/suffix matching on attacker-controlled text.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
parsed = urlsplit(value.strip())
|
|
71
|
+
if (
|
|
72
|
+
parsed.scheme.lower() not in ("http", "https")
|
|
73
|
+
or not parsed.hostname
|
|
74
|
+
or parsed.username is not None
|
|
75
|
+
or parsed.password is not None
|
|
76
|
+
or parsed.path not in ("", "/")
|
|
77
|
+
or parsed.query
|
|
78
|
+
or parsed.fragment
|
|
79
|
+
):
|
|
80
|
+
return None
|
|
81
|
+
port = parsed.port
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
if port is None:
|
|
85
|
+
port = 443 if parsed.scheme.lower() == "https" else 80
|
|
86
|
+
return parsed.scheme.lower(), parsed.hostname.lower(), port
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def browser_mutation_origin_allowed(
|
|
90
|
+
request: Request, allowed_origins: list[str]
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""Allow non-browser clients, same-origin pages and explicit CORS origins.
|
|
93
|
+
|
|
94
|
+
CORS controls whether a browser may *read* a response; it does not stop a
|
|
95
|
+
simple cross-origin POST from being sent. State-changing routes therefore
|
|
96
|
+
need their own check. Browsers always attach Origin to cross-origin fetches,
|
|
97
|
+
while CLI/SDK/local health clients commonly omit it, so absence preserves
|
|
98
|
+
the existing local-first API. A present malformed or unrelated origin is
|
|
99
|
+
refused before route code can touch disk or processes.
|
|
100
|
+
"""
|
|
101
|
+
supplied = request.headers.get("origin")
|
|
102
|
+
if supplied is None:
|
|
103
|
+
return True
|
|
104
|
+
if "*" in allowed_origins:
|
|
105
|
+
return True
|
|
106
|
+
supplied_origin = _normalized_http_origin(supplied)
|
|
107
|
+
if supplied_origin is None:
|
|
108
|
+
return False
|
|
109
|
+
request_origin = _normalized_http_origin(str(request.base_url))
|
|
110
|
+
if supplied_origin == request_origin:
|
|
111
|
+
return True
|
|
112
|
+
return any(
|
|
113
|
+
supplied_origin == _normalized_http_origin(candidate)
|
|
114
|
+
for candidate in allowed_origins
|
|
115
|
+
)
|
|
116
|
+
|
|
59
117
|
# Configuration
|
|
60
118
|
LOKI_DIR = Path(os.environ.get("LOKI_DIR", ".loki"))
|
|
61
119
|
STATE_DIR = LOKI_DIR / "state"
|
|
@@ -202,16 +260,42 @@ app = FastAPI(
|
|
|
202
260
|
# CORS middleware for dashboard frontend - restricted to localhost by default.
|
|
203
261
|
# Set LOKI_DASHBOARD_CORS to override (comma-separated origins).
|
|
204
262
|
_cors_default = "http://localhost:57374,http://127.0.0.1:57374"
|
|
205
|
-
|
|
263
|
+
_cors_raw = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default)
|
|
264
|
+
_cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()]
|
|
265
|
+
if "*" in _cors_origins or _cors_raw.strip() == "*":
|
|
266
|
+
if os.environ.get("LOKI_ENV") == "production":
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
"Wildcard CORS ('*') is not allowed in production. "
|
|
269
|
+
"Set LOKI_DASHBOARD_CORS to a specific origin list, "
|
|
270
|
+
"or set LOKI_ENV != production."
|
|
271
|
+
)
|
|
272
|
+
import logging
|
|
273
|
+
|
|
274
|
+
logging.getLogger(__name__).warning(
|
|
275
|
+
"LOKI_DASHBOARD_CORS is set to '*' -- all origins are allowed. "
|
|
276
|
+
"This is insecure for production deployments."
|
|
277
|
+
)
|
|
206
278
|
app.add_middleware(
|
|
207
279
|
CORSMiddleware,
|
|
208
|
-
allow_origins=
|
|
280
|
+
allow_origins=_cors_origins,
|
|
209
281
|
allow_credentials=True,
|
|
210
282
|
allow_methods=["*"],
|
|
211
283
|
allow_headers=["*"],
|
|
212
284
|
)
|
|
213
285
|
|
|
214
286
|
|
|
287
|
+
@app.middleware("http")
|
|
288
|
+
async def browser_mutation_boundary(request: Request, call_next):
|
|
289
|
+
"""Refuse cross-origin browser mutations before control side effects."""
|
|
290
|
+
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
|
291
|
+
if not browser_mutation_origin_allowed(request, _cors_origins):
|
|
292
|
+
return JSONResponse(
|
|
293
|
+
status_code=403,
|
|
294
|
+
content={"detail": "cross-origin mutation refused"},
|
|
295
|
+
)
|
|
296
|
+
return await call_next(request)
|
|
297
|
+
|
|
298
|
+
|
|
215
299
|
# Request/Response models
|
|
216
300
|
class StartRequest(BaseModel):
|
|
217
301
|
"""Request body for starting a Loki Mode session."""
|