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.
Files changed (92) hide show
  1. package/SKILL.md +3 -3
  2. package/VERSION +1 -1
  3. package/autonomy/lib/claude-flags.sh +69 -5
  4. package/autonomy/lib/contract-scaffold/scaffold.sh +1 -1
  5. package/autonomy/loki +324 -74
  6. package/autonomy/run.sh +176 -40
  7. package/autonomy/verify.sh +16 -3
  8. package/bin/loki +25 -1
  9. package/dashboard/__init__.py +1 -1
  10. package/dashboard/control.py +88 -4
  11. package/dashboard/server.py +50 -48
  12. package/docs/INSTALLATION.md +2 -2
  13. package/docs/RARV-C-100X-PLAN.md +9 -1
  14. package/docs/enterprise/performance.md +51 -9
  15. package/docs/exit-codes.md +4 -0
  16. package/loki-ts/dist/cockpit.js +1 -1
  17. package/loki-ts/dist/loki.js +634 -549
  18. package/loki-ts/package.json +2 -2
  19. package/mcp/__init__.py +1 -1
  20. package/package.json +6 -5
  21. package/plugins/loki-mode/.claude-plugin/plugin.json +2 -3
  22. package/providers/loader.sh +2 -2
  23. package/providers/model_catalog.json +26 -8
  24. package/providers/models.sh +1 -1
  25. package/providers/opencode.sh +1 -1
  26. package/skills/providers.md +42 -14
  27. package/tools/normalize-package-permissions.mjs +34 -0
  28. package/web-app/dist/assets/{AdminPage-DLhWX6si.js → AdminPage-PyyVXg4b.js} +1 -1
  29. package/web-app/dist/assets/{Avatar-CqyDcUxl.js → Avatar-CTUlr_Xf.js} +1 -1
  30. package/web-app/dist/assets/{Badge-BKGSt1n2.js → Badge-BQeXvmg8.js} +1 -1
  31. package/web-app/dist/assets/{Button-DuuKHXre.js → Button-Dc6-P6pz.js} +1 -1
  32. package/web-app/dist/assets/{CockpitPage-DdClYERC.js → CockpitPage-BspOJ3Fv.js} +1 -1
  33. package/web-app/dist/assets/{ComparePage-CUWlqBxv.js → ComparePage-CFXIM--Z.js} +1 -1
  34. package/web-app/dist/assets/{ErrorBoundary-RwfGKyRJ.js → ErrorBoundary-C7vQYDW9.js} +1 -1
  35. package/web-app/dist/assets/{EvidenceReceiptPanel-CzIgk9sg.js → EvidenceReceiptPanel-CqjsjTOs.js} +1 -1
  36. package/web-app/dist/assets/{GitHubIssuesPanel-CsXVKuyJ.js → GitHubIssuesPanel-DyuNDMgD.js} +1 -1
  37. package/web-app/dist/assets/{GitHubPRsPanel-CYRp1eKS.js → GitHubPRsPanel-BtAq20xF.js} +1 -1
  38. package/web-app/dist/assets/{HomePage-CVJcI5pQ.js → HomePage-BuVFOrO8.js} +1 -1
  39. package/web-app/dist/assets/{LoginPage-BBJNV9Qb.js → LoginPage-CuhhrxGb.js} +1 -1
  40. package/web-app/dist/assets/{MagicPage-CIi9P8jK.js → MagicPage-D2KZPX3F.js} +1 -1
  41. package/web-app/dist/assets/{MetricsPage-Bm34ipiy.js → MetricsPage-DFEYJl9z.js} +1 -1
  42. package/web-app/dist/assets/{NotFoundPage-DpTbcC2P.js → NotFoundPage-CTnnaWlL.js} +1 -1
  43. package/web-app/dist/assets/{ProjectPage-CY2SMGx9.js → ProjectPage-uJFaoM8-.js} +55 -55
  44. package/web-app/dist/assets/{ProjectsPage-DSrXUUKJ.js → ProjectsPage-8lKAOBIW.js} +1 -1
  45. package/web-app/dist/assets/{SettingsPage-jvDyOqYi.js → SettingsPage-D1BBd5qy.js} +1 -1
  46. package/web-app/dist/assets/{ShowcasePage-C8tFWKau.js → ShowcasePage-CmQ0n8iO.js} +1 -1
  47. package/web-app/dist/assets/{SystemSettingsPage-DH4FTH3K.js → SystemSettingsPage-CSHaVyuD.js} +1 -1
  48. package/web-app/dist/assets/{TeamsPage-C1fQYauF.js → TeamsPage-BS0TtVUa.js} +1 -1
  49. package/web-app/dist/assets/{TemplatesPage-CSTFt-4P.js → TemplatesPage-C2zPnnf2.js} +1 -1
  50. package/web-app/dist/assets/{TerminalOutput-Cu4NsyN6.js → TerminalOutput-Dle1klqq.js} +1 -1
  51. package/web-app/dist/assets/{activity-CtjsDzmh.js → activity-DQp5U9f9.js} +1 -1
  52. package/web-app/dist/assets/{bell-C5es1xAs.js → bell-BqkTmgL7.js} +1 -1
  53. package/web-app/dist/assets/{bot-DE04U5Lc.js → bot-DILhaIGH.js} +1 -1
  54. package/web-app/dist/assets/{check-DSKKA9EU.js → check-BC9x7oPm.js} +1 -1
  55. package/web-app/dist/assets/{chevron-left-DJ13W5AR.js → chevron-left--yqvAfjx.js} +1 -1
  56. package/web-app/dist/assets/{circle-alert-CS0BfqAd.js → circle-alert-DOH6PcgZ.js} +1 -1
  57. package/web-app/dist/assets/{clock-D1Y5nruk.js → clock-DKSDFsE6.js} +1 -1
  58. package/web-app/dist/assets/{cloud-nU6LBoZL.js → cloud-DRBU3JYs.js} +1 -1
  59. package/web-app/dist/assets/{code-xml-BZfvIdFo.js → code-xml-D2LqszR9.js} +1 -1
  60. package/web-app/dist/assets/{copy-CK_2O1Xv.js → copy-BKW4U4Qr.js} +1 -1
  61. package/web-app/dist/assets/{database-DJiwznAm.js → database-D7ceedlh.js} +1 -1
  62. package/web-app/dist/assets/{dollar-sign-pO20A3QY.js → dollar-sign-D0A47egY.js} +1 -1
  63. package/web-app/dist/assets/{file-code-corner-Besy6vek.js → file-code-corner-CH_Qbtjo.js} +1 -1
  64. package/web-app/dist/assets/{file-plus-DeaswxYY.js → file-plus-CUNMjHk9.js} +1 -1
  65. package/web-app/dist/assets/{folder-open-cr1UvQLI.js → folder-open-CQ_SVq94.js} +1 -1
  66. package/web-app/dist/assets/{git-commit-horizontal-BLxnfbSn.js → git-commit-horizontal-DQUBe0oD.js} +1 -1
  67. package/web-app/dist/assets/{globe-DrhG1NfX.js → globe-gZnaGg8N.js} +1 -1
  68. package/web-app/dist/assets/{hammer-CeZtPBfE.js → hammer-BhJC5DMK.js} +1 -1
  69. package/web-app/dist/assets/{index-CLvjO22Z.js → index-BMmYYws0.js} +2 -2
  70. package/web-app/dist/assets/{layers-fldADzyE.js → layers-glXeoUbJ.js} +1 -1
  71. package/web-app/dist/assets/{lightbulb-CznOfsPb.js → lightbulb-gXn8H0Lf.js} +1 -1
  72. package/web-app/dist/assets/{loader-circle---lPB1Fs.js → loader-circle-Do2ke8xm.js} +1 -1
  73. package/web-app/dist/assets/{lock-D5jVF-tO.js → lock-Bgplb1_n.js} +1 -1
  74. package/web-app/dist/assets/{mail-D7HkLnlW.js → mail-j4Lo8hfB.js} +1 -1
  75. package/web-app/dist/assets/{minus-C-dT19Ct.js → minus-dhrzKSK5.js} +1 -1
  76. package/web-app/dist/assets/{package-Cm9B-cKE.js → package-CInWQQ97.js} +1 -1
  77. package/web-app/dist/assets/{plus-CQwOffLD.js → plus-BpYvoLlZ.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-CvWJZLbN.js → refresh-cw-F6koqEgF.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-C2Q1LYgM.js → rotate-ccw-CQ2W5S8f.js} +1 -1
  80. package/web-app/dist/assets/{save-BP0MvpoQ.js → save-DMCYGm06.js} +1 -1
  81. package/web-app/dist/assets/{server-4i4VZXix.js → server-Bj8EX4Z1.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-D2_bgb7j.js → shield-alert-CiYuulCU.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-vACbzeXj.js → thumbs-up-BqEh1puP.js} +1 -1
  84. package/web-app/dist/assets/{trash-2-EYjdESLs.js → trash-2-CCwouZJJ.js} +1 -1
  85. package/web-app/dist/assets/{trending-down-BWBdcvE3.js → trending-down-Cx1vhE4o.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-Bix6V_-Y.js → trending-up-BUhVYJ2T.js} +1 -1
  87. package/web-app/dist/assets/{upload-B7hh6ERF.js → upload-D5IsZeFM.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-b_e-k-IN.js → usePolling-CJbfhFcH.js} +1 -1
  89. package/web-app/dist/assets/{user-pmjZKWEg.js → user-BRLhezsD.js} +1 -1
  90. package/web-app/dist/index.html +1 -1
  91. package/web-app/requirements.txt +1 -0
  92. 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 plausibly OUR dashboard, or a recycled number now naming something
1523
- # else? Fails OPEN (returns 0) whenever ps cannot tell us, so the only behavior
1524
- # change is refusing to kill a process that is positively identified as NOT a
1525
- # dashboard. See the call site for the measured stale-file evidence.
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 0 # ps unavailable: behave as before
1532
- case "$_cmd" in
1533
- *dashboard*|*uvicorn*|*loki*) return 0 ;;
1534
- esac
1535
- return 1
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
- # Mirrors _app_runner_pid_is_ours (app-runner.sh:242) and fails OPEN
1595
- # the same way -- if `ps` says nothing we signal as before, so a
1596
- # legitimate dashboard is never left running by this check.
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
- # (d) Defense-in-depth: reclaim the dashboard port only in the CLEAR
1605
- # case, so we never kill a shared dashboard another project owns.
1606
- # BUT never kill a HEALTHY dashboard already serving on the port: that is
1607
- # almost always the user's own live dashboard (open in their browser), and
1608
- # killing it mid-use drops their session (ERR_CONNECTION_REFUSED, WS fail).
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
- || exit 125
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
- ') || exit 125
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 if it's a Python/uvicorn dashboard process
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 [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then
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
- sleep 2
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
@@ -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 < <(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null)
379
- [ "$_zt_real" -eq 0 ] && return 0
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
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.22.12"
10
+ __version__ = "9.24.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -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
- _cors_origins = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default).split(",")
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=[o.strip() for o in _cors_origins if o.strip()],
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."""