loki-mode 7.42.0 → 7.43.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 CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 11 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.42.0
6
+ # Loki Mode v7.43.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -398,4 +398,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
398
398
 
399
399
  ---
400
400
 
401
- **v7.42.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
401
+ **v7.43.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.42.0
1
+ 7.43.0
@@ -156,6 +156,112 @@ _rewrite_detection_port() {
156
156
  _write_detection "$d_type" "$d_command"
157
157
  }
158
158
 
159
+ # Collect the transitive descendant tree of a PID (children, grandchildren, ...).
160
+ #
161
+ # Echoes one PID per line, deepest-LAST is NOT guaranteed; order is breadth-first
162
+ # from the root. The root PID itself is NOT included. Used by the non-setsid stop
163
+ # fallback (BUG 1): the app is started as `( ... ) &` WITHOUT setsid, so on stock
164
+ # macOS the whole tree (subshell -> bash -lc -> npm -> sh -> node -> workers)
165
+ # inherits the ORCHESTRATOR's process group. A `kill -- -PGID` would therefore
166
+ # signal run.sh and the Claude agent driving it (self-termination), so we MUST
167
+ # walk parent->child links from OUR pid only. This guarantees we never signal a
168
+ # process outside our own subtree: every returned pid has our root as an ancestor.
169
+ #
170
+ # Snapshot semantics: the caller MUST collect the full tree BEFORE sending any
171
+ # signal. If we TERM top-down while walking, grandchildren reparent to init and
172
+ # `pgrep -P <dead-parent>` returns nothing, re-creating the orphaned-worker bug
173
+ # this fix exists to close.
174
+ _app_runner_collect_descendants() {
175
+ local root="$1"
176
+ # Guard against empty / init / kernel pids: walking from 0/1 would sweep
177
+ # unrelated processes. A valid app pid is always > 1.
178
+ case "$root" in
179
+ ''|0|1) return 0 ;;
180
+ esac
181
+ if ! [[ "$root" =~ ^[0-9]+$ ]]; then
182
+ return 0
183
+ fi
184
+
185
+ local -a frontier=("$root")
186
+ local -a found=()
187
+ local pid child
188
+ local -a kids
189
+ # Bound iterations defensively against a pathological/looping tree.
190
+ local guard=0
191
+ while [ "${#frontier[@]}" -gt 0 ] && [ "$guard" -lt 10000 ]; do
192
+ guard=$(( guard + 1 ))
193
+ pid="${frontier[0]}"
194
+ frontier=("${frontier[@]:1}")
195
+ # Direct children of pid.
196
+ kids=()
197
+ while IFS= read -r child; do
198
+ [ -n "$child" ] && kids+=("$child")
199
+ done < <(pgrep -P "$pid" 2>/dev/null)
200
+ local k
201
+ for k in "${kids[@]:-}"; do
202
+ [ -n "$k" ] || continue
203
+ found+=("$k")
204
+ frontier+=("$k")
205
+ done
206
+ done
207
+
208
+ local f
209
+ for f in "${found[@]:-}"; do
210
+ [ -n "$f" ] && printf '%s\n' "$f"
211
+ done
212
+ }
213
+
214
+ # Signal an EXPLICIT, pre-captured set of PIDs with a given signal.
215
+ #
216
+ # Usage: _app_runner_signal_pids <SIGNAL> <pid> [pid ...]
217
+ #
218
+ # Why an explicit list and not "(re-)walk from root": a worker that traps
219
+ # SIGTERM (a Node server doing graceful shutdown is the textbook case) survives
220
+ # the TERM phase while its intermediate ancestors (npm, sh) die. Once the
221
+ # ancestors die, the surviving worker reparents to init, so re-deriving the tree
222
+ # from the now-dead root via `pgrep -P` would return NOTHING -- the KILL phase
223
+ # would be skipped and the orphaned, port-holding worker would live on. That is
224
+ # exactly the orphaned-worker bug (BUG 1) resurfacing at the force-kill phase.
225
+ # The fix: the caller snapshots root + all descendants ONCE before any signal,
226
+ # and every phase (TERM, aliveness, KILL) operates over that frozen list.
227
+ #
228
+ # Safety: the caller builds the list from _app_runner_collect_descendants, which
229
+ # only ever follows parent->child links from OUR pid, so the list can never
230
+ # contain a process outside our own subtree. We signal pids individually (never
231
+ # a process group) because in the non-setsid path the app inherits the
232
+ # orchestrator's process group; a group signal would kill run.sh and the agent.
233
+ # Pids are signaled in REVERSE capture order so descendants (captured after the
234
+ # root) are signaled before the root.
235
+ _app_runner_signal_pids() {
236
+ local sig="$1"; shift
237
+ local -a pids=("$@")
238
+ local i p
239
+ for (( i=${#pids[@]}-1; i>=0; i-- )); do
240
+ p="${pids[$i]}"
241
+ case "$p" in
242
+ ''|0|1) continue ;;
243
+ esac
244
+ kill "-${sig}" "$p" 2>/dev/null || true
245
+ done
246
+ }
247
+
248
+ # True (0) if ANY pid in the EXPLICIT pre-captured list is still alive.
249
+ # Used by the non-setsid stop grace-wait so a deep worker that outlived the main
250
+ # subshell does not let us fall through to "stopped" prematurely. Operates over
251
+ # the frozen snapshot for the same reason _app_runner_signal_pids does.
252
+ _app_runner_any_alive() {
253
+ local p
254
+ for p in "$@"; do
255
+ case "$p" in
256
+ ''|0|1) continue ;;
257
+ esac
258
+ if kill -0 "$p" 2>/dev/null; then
259
+ return 0
260
+ fi
261
+ done
262
+ return 1
263
+ }
264
+
159
265
  # Fix #2 (finding #597): reconcile the recorded port with the port the app
160
266
  # ACTUALLY bound, using the listen line in app.log as the source of truth. This
161
267
  # corrects the dashboard Live Preview even when the app ignores PORT and picks
@@ -887,32 +993,82 @@ app_runner_stop() {
887
993
  fi
888
994
  fi
889
995
 
996
+ # BUG 1 fix: on the non-setsid fallback (the DEFAULT path on stock macOS,
997
+ # which has no setsid) capture the FULL process subtree -- root + every
998
+ # transitive descendant -- ONCE, BEFORE sending any signal. The old
999
+ # `pkill -TERM -P <pid>` reached only ONE level of children, so deep workers
1000
+ # (npm -> sh -> node -> workers) holding the listening socket survived as
1001
+ # orphans and kept the port bound, blocking the next start.
1002
+ #
1003
+ # Capturing once is load-bearing: a worker that traps SIGTERM survives the
1004
+ # TERM phase while its intermediate ancestors die, then reparents to init.
1005
+ # Re-deriving the tree from the now-dead root would return nothing and skip
1006
+ # the KILL phase, leaving the port-holder alive. Every phase below (TERM,
1007
+ # grace-wait, KILL) operates over this one frozen snapshot instead.
1008
+ local -a _stop_snapshot=()
1009
+ if [ "$_APP_RUNNER_HAS_SETSID" != true ]; then
1010
+ _stop_snapshot=("$_APP_RUNNER_PID")
1011
+ local _snap_d
1012
+ while IFS= read -r _snap_d; do
1013
+ [ -n "$_snap_d" ] && _stop_snapshot+=("$_snap_d")
1014
+ done < <(_app_runner_collect_descendants "$_APP_RUNNER_PID")
1015
+ fi
1016
+
890
1017
  # Send SIGTERM to process and children
891
1018
  if [ "$_APP_RUNNER_HAS_SETSID" = true ]; then
1019
+ # setsid path: the app is its own process group leader, so a group
1020
+ # signal reaches the whole tree safely. Unchanged.
892
1021
  kill -TERM "-$_APP_RUNNER_PID" 2>/dev/null || kill -TERM "$_APP_RUNNER_PID" 2>/dev/null || true
893
1022
  else
894
- pkill -TERM -P "$_APP_RUNNER_PID" 2>/dev/null || true
895
- kill -TERM "$_APP_RUNNER_PID" 2>/dev/null || true
1023
+ # Group-kill is NOT used here: in this path the app inherits the
1024
+ # orchestrator's process group, so a group signal would kill run.sh and
1025
+ # the agent driving it. Signal the frozen snapshot, descendants first.
1026
+ _app_runner_signal_pids TERM "${_stop_snapshot[@]}"
896
1027
  fi
897
1028
 
898
- # Wait up to 5 seconds for graceful shutdown
1029
+ # Wait up to 5 seconds for graceful shutdown. Key the wait on the WHOLE
1030
+ # snapshot being alive (not just the main pid): a deep worker can outlive the
1031
+ # main subshell, and treating the main pid's exit as "done" is exactly what
1032
+ # let workers leak before. setsid path keeps the simpler main-pid check.
899
1033
  local waited=0
900
1034
  while [ "$waited" -lt 5 ]; do
901
- if ! kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
902
- break
1035
+ if [ "$_APP_RUNNER_HAS_SETSID" = true ]; then
1036
+ kill -0 "$_APP_RUNNER_PID" 2>/dev/null || break
1037
+ else
1038
+ _app_runner_any_alive "${_stop_snapshot[@]}" || break
903
1039
  fi
904
1040
  sleep 1
905
1041
  waited=$(( waited + 1 ))
906
1042
  done
907
1043
 
908
1044
  # Force kill if still running
909
- if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1045
+ local _still_alive=false
1046
+ if [ "$_APP_RUNNER_HAS_SETSID" = true ]; then
1047
+ kill -0 "$_APP_RUNNER_PID" 2>/dev/null && _still_alive=true
1048
+ else
1049
+ _app_runner_any_alive "${_stop_snapshot[@]}" && _still_alive=true
1050
+ fi
1051
+ if [ "$_still_alive" = true ]; then
910
1052
  log_warn "App Runner: process did not stop gracefully, sending SIGKILL"
911
1053
  if [ "$_APP_RUNNER_HAS_SETSID" = true ]; then
912
1054
  kill -KILL "-$_APP_RUNNER_PID" 2>/dev/null || kill -KILL "$_APP_RUNNER_PID" 2>/dev/null || true
913
1055
  else
914
- pkill -KILL -P "$_APP_RUNNER_PID" 2>/dev/null || true
915
- kill -KILL "$_APP_RUNNER_PID" 2>/dev/null || true
1056
+ # BUG 1 fix (KILL phase): SIGKILL the SAME frozen snapshot (root +
1057
+ # all descendants captured pre-signal), so a TERM-trapping worker
1058
+ # that reparented to init is still force-killed. SIGKILL cannot be
1059
+ # trapped, so this is the terminal guarantee that no port-holder
1060
+ # survives. The snapshot does the real work. The fresh walk below only
1061
+ # adds anything while the root is still alive (a worker spawned during
1062
+ # shutdown); once the root is dead it is empty and the snapshot covers.
1063
+ _app_runner_signal_pids KILL "${_stop_snapshot[@]}"
1064
+ local -a _kill_fresh=()
1065
+ local _kf
1066
+ while IFS= read -r _kf; do
1067
+ [ -n "$_kf" ] && _kill_fresh+=("$_kf")
1068
+ done < <(_app_runner_collect_descendants "$_APP_RUNNER_PID")
1069
+ if [ "${#_kill_fresh[@]}" -gt 0 ]; then
1070
+ _app_runner_signal_pids KILL "${_kill_fresh[@]}"
1071
+ fi
916
1072
  fi
917
1073
  fi
918
1074
 
@@ -1094,6 +1250,11 @@ app_runner_watchdog() {
1094
1250
  # it restarts the stack under the same crash-count circuit breaker.
1095
1251
  if [ "$_APP_RUNNER_IS_DOCKER" = true ] && echo "$_APP_RUNNER_METHOD" | grep -q "docker compose"; then
1096
1252
  if app_runner_health_check; then
1253
+ # BUG 3 fix: the breaker is meant to fire on 5 CONSECUTIVE failures.
1254
+ # A confirmed-healthy observation clears any accumulated count so a
1255
+ # long-lived stack that recovered from a few transient blips is not
1256
+ # tripped permanently on cumulative (non-consecutive) crashes.
1257
+ _APP_RUNNER_CRASH_COUNT=0
1097
1258
  return 0
1098
1259
  fi
1099
1260
  _APP_RUNNER_CRASH_COUNT=$(( _APP_RUNNER_CRASH_COUNT + 1 ))
@@ -1125,6 +1286,11 @@ app_runner_watchdog() {
1125
1286
 
1126
1287
  # Process alive, nothing to do
1127
1288
  if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1289
+ # BUG 3 fix: a confirmed-alive observation clears the accumulated crash
1290
+ # count so the breaker fires only on 5 CONSECUTIVE deaths, not on 5
1291
+ # cumulative crashes that were each successfully recovered over a long
1292
+ # session (which would trip the breaker on a HEALTHY app).
1293
+ _APP_RUNNER_CRASH_COUNT=0
1128
1294
  return 0
1129
1295
  fi
1130
1296
 
@@ -1519,8 +1519,17 @@ council_evidence_gate() {
1519
1519
  if committed_files=$(git diff --name-only "$base_sha" HEAD 2>/dev/null); then
1520
1520
  :
1521
1521
  else
1522
- # Base present but unreachable (e.g. shallow clone): fall back to
1523
- # working-tree diff vs HEAD (mirrors proof-generator.py fallback).
1522
+ # Base present but UNREACHABLE (e.g. shallow clone, history rewrite,
1523
+ # or `git reset --hard` -- a documented live hazard). The diff vs the
1524
+ # run-start SHA cannot be computed, so we can no longer prove that the
1525
+ # committed-union diff is empty. Treat this as INCONCLUSIVE, not as
1526
+ # positive empty-diff fabrication evidence: an agent that committed
1527
+ # all its work leaves a clean working tree, and `git diff HEAD` would
1528
+ # read empty -> a false BLOCK. We still fall back to the working-tree
1529
+ # diff vs HEAD to capture any uncommitted work, but the empty-diff
1530
+ # block is suppressed below via the diff_inconclusive guard.
1531
+ diff_inconclusive="true"
1532
+ diff_inconclusive_reason="base_unreachable"
1524
1533
  committed_files=$(git diff --name-only HEAD 2>/dev/null || echo "")
1525
1534
  fi
1526
1535
  unstaged_files=$(git diff --name-only HEAD 2>/dev/null || echo "")
@@ -1543,7 +1552,11 @@ council_evidence_gate() {
1543
1552
  else
1544
1553
  diff_files=0
1545
1554
  fi
1546
- if [ "$diff_files" -eq 0 ]; then
1555
+ # Only treat an empty union as positive fabrication evidence when the
1556
+ # baseline was CONCLUSIVE. If the base SHA was unreachable (history
1557
+ # rewrite / reset --hard), a clean committed tree yields an empty
1558
+ # working-tree diff that must NOT read as empty-diff fabrication.
1559
+ if [ "$diff_files" -eq 0 ] && [ "$diff_inconclusive" != "true" ]; then
1547
1560
  diff_fails="true"
1548
1561
  fi
1549
1562
  fi
package/autonomy/loki CHANGED
@@ -1057,6 +1057,7 @@ cmd_start() {
1057
1057
  echo "Options:"
1058
1058
  echo " --provider NAME AI provider: claude (default), codex, cline, aider"
1059
1059
  echo " --parallel Enable parallel mode with git worktrees"
1060
+ echo " --allow-haiku Enable Haiku model for the fast tier (default: disabled)"
1060
1061
  echo " --bg, --background Run in background mode"
1061
1062
  echo " --simple Force simple complexity tier (3 phases)"
1062
1063
  echo " --complex Force complex complexity tier (8 phases)"
@@ -1180,6 +1181,17 @@ cmd_start() {
1180
1181
  args+=("--parallel")
1181
1182
  shift
1182
1183
  ;;
1184
+ --allow-haiku)
1185
+ # Enable Haiku for the fast tier. Mirrors the LOKI_ALLOW_HAIKU=true
1186
+ # env var (consumed by providers/claude.sh and run.sh). Documented in
1187
+ # loki --help and run.sh; previously only the env var worked here, so
1188
+ # `loki start ./prd.md --allow-haiku` aborted with "Unknown option".
1189
+ # Export reaches the runner; also forward as an arg so the run.sh
1190
+ # parser (run.sh:15015) sees it on every route.
1191
+ export LOKI_ALLOW_HAIKU=true
1192
+ args+=("--allow-haiku")
1193
+ shift
1194
+ ;;
1183
1195
  --regen-prd|--regenerate-prd|--regen|--fresh-prd)
1184
1196
  # v7.8.1: force a fresh generated PRD on a no-PRD run, overriding
1185
1197
  # the staleness-aware reuse (decide_generated_prd_action in
package/autonomy/run.sh CHANGED
@@ -7041,6 +7041,48 @@ enforce_test_coverage() {
7041
7041
  local output
7042
7042
  output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx mocha 2>&1) || test_passed=false
7043
7043
  details="mocha: $(echo "$output" | tail -3 | tr '\n' ' ')"
7044
+ else
7045
+ # v7.41.x (test-coverage fail-open fix): a real "scripts.test" was
7046
+ # previously missed entirely. A greenfield project whose package.json
7047
+ # has {"scripts":{"test":"node --test"}} (or any non-placeholder test
7048
+ # script) actually runs a working suite via `npm test`, yet the gate
7049
+ # reported runner:none + pass:true -- so a project whose tests FAIL
7050
+ # green-lit identically. Detect a real test script (excluding the npm
7051
+ # placeholder "no test specified") with a JSON parser, not grep (grep
7052
+ # would false-positive on devDeps / unrelated keys), then run the
7053
+ # configured command. This MUST sit before the monorepo/python/go/rust
7054
+ # checks, all of which gate on test_runner=="none".
7055
+ local _pkg_test_script
7056
+ _pkg_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
7057
+ import json, os, sys
7058
+ try:
7059
+ with open(os.environ['_LOKI_PKG']) as f:
7060
+ d = json.load(f)
7061
+ except Exception:
7062
+ sys.exit(0)
7063
+ t = (d.get('scripts') or {}).get('test') or ''
7064
+ # npm's default placeholder; treat as 'no test'.
7065
+ if 'no test specified' in t.lower():
7066
+ sys.exit(0)
7067
+ sys.stdout.write(t.strip())
7068
+ " 2>/dev/null || echo "")
7069
+ if [ -n "$_pkg_test_script" ]; then
7070
+ # LOKI_TEST_COMMAND lets an operator override the invocation; the
7071
+ # default is the project's own `npm test`.
7072
+ local _test_cmd="${LOKI_TEST_COMMAND:-npm test}"
7073
+ # Label the runner by what the script invokes so evidence is
7074
+ # honest (node --test, vitest, jest, etc. all surface here).
7075
+ case "$_pkg_test_script" in
7076
+ *"node --test"*|*"node:test"*) test_runner="node-test" ;;
7077
+ *vitest*) test_runner="vitest" ;;
7078
+ *jest*) test_runner="jest" ;;
7079
+ *mocha*) test_runner="mocha" ;;
7080
+ *) test_runner="npm-test" ;;
7081
+ esac
7082
+ local output
7083
+ output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" sh -c "$_test_cmd" 2>&1) || test_passed=false
7084
+ details="$test_runner ($_test_cmd): $(echo "$output" | tail -5 | tr '\n' ' ')"
7085
+ fi
7044
7086
  fi
7045
7087
  fi
7046
7088
 
@@ -7165,10 +7207,23 @@ enforce_test_coverage() {
7165
7207
  fi
7166
7208
 
7167
7209
  if [ "$test_runner" = "none" ]; then
7168
- log_info "Test coverage: no test runner detected, skipping"
7210
+ log_info "Test coverage: no test runner detected, recording inconclusive (not pass)"
7211
+ # v7.41.x fail-open fix: previously this wrote pass:true, so a project
7212
+ # whose tests truly do not run was indistinguishable from one whose tests
7213
+ # passed. Record pass:"inconclusive" instead. The completion-council
7214
+ # evidence gate already treats runner=="none" as pass-through regardless
7215
+ # of the pass value (completion-council.sh: runner=='none' short-circuits
7216
+ # BEFORE the `passed is False` block), so genuinely-no-tests stays
7217
+ # non-blocking (no infinite hang), while the JSON record is now honest:
7218
+ # "no tests" never reads as "tests passed". A DETECTED runner that fails
7219
+ # still writes pass:false below and BLOCKS.
7220
+ #
7221
+ # unit-tests.pass is only read for the status-line display (run.sh ~2183,
7222
+ # PASS vs PENDING); keeping the touch preserves the historical
7223
+ # non-blocking behavior for legitimate no-test projects.
7169
7224
  touch "$quality_dir/unit-tests.pass"
7170
7225
  cat > "$quality_dir/test-results.json" << TREOF
7171
- {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":true,"summary":"No test runner detected"}
7226
+ {"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"No test runner detected"}
7172
7227
  TREOF
7173
7228
  # Finding #598: stamp the per-iteration freshness marker so a later
7174
7229
  # completion-route capture (ensure_completion_test_evidence) reuses this
@@ -14161,6 +14216,22 @@ if __name__ == "__main__":
14161
14216
  log_warn " Review details under .loki/quality/reviews/ ; gate_failures=${gate_failures}"
14162
14217
  _gate_block_for_completion=""
14163
14218
  # Fall through; the gate-failed loop continues normally
14219
+ # HIGH (trust-gate): the checklist hard gate must also guard the
14220
+ # DEFAULT completion-promise / loki_complete_task route, not only the
14221
+ # interval-gated council path (council_evaluate) and the dashboard
14222
+ # force-review path -- both of which already call this gate. Without
14223
+ # it, an agent that leaves a `priority: critical` checklist item
14224
+ # `failing` and claims done on a non-council-interval iteration would
14225
+ # ship, bypassing the checklist gate entirely. council_reverify_checklist
14226
+ # ran above (when a claim is present) so statuses are fresh here.
14227
+ # Mirrors the evidence/held-out gate arms below. No-op safe:
14228
+ # council_checklist_gate returns 0 (pass) when there is no checklist
14229
+ # results file or when no critical items are failing, so this branch
14230
+ # never fires on those projects. Gate output is written by the gate.
14231
+ elif [ "$_completion_claimed" = 1 ] && type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
14232
+ log_warn "Completion claim rejected: critical checklist item(s) failing (hard gate)."
14233
+ log_warn " Details under .loki/council/gate-block.json"
14234
+ # Fall through; keep iterating until critical checklist items pass.
14164
14235
  # v7.19.1: the verified-completion evidence gate must also guard the
14165
14236
  # DEFAULT completion route (a completion claim via loki_complete_task
14166
14237
  # / the completion-promise text), not only the interval-gated council
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.42.0"
10
+ __version__ = "7.43.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3910,7 +3910,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
3910
3910
  `:e.steps!==void 0?`
3911
3911
  <div class="detail-panel">
3912
3912
  <div class="detail-header">
3913
- <h3>Skill: ${e.name}</h3>
3913
+ <h3>Skill: ${this._escapeHtml(e.name)}</h3>
3914
3914
  <button class="close-btn" id="close-detail">&times;</button>
3915
3915
  </div>
3916
3916
  <div class="detail-body">
@@ -5518,7 +5518,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5518
5518
  ${this._renderTabContent()}
5519
5519
  </div>
5520
5520
 
5521
- ${this._error?`<div class="error-banner">${this._error}</div>`:""}
5521
+ ${this._error?`<div class="error-banner">${this._escapeHtml(this._error)}</div>`:""}
5522
5522
  </div>
5523
5523
  `,this._attachEventListeners())}_attachEventListeners(){let e=this.shadowRoot;if(!e)return;let t=e.getElementById("force-review-btn");t&&t.addEventListener("click",()=>this._forceReview()),e.querySelectorAll(".tab[data-tab]").forEach(i=>{i.addEventListener("click",()=>this._setTab(i.dataset.tab))})}_renderTabContent(){switch(this._activeTab){case"overview":return this._renderOverview();case"decisions":return this._renderDecisions();case"convergence":return this._renderConvergence();case"agents":return this._renderAgents();default:return""}}_renderOverview(){let e=this._councilState||{},t=e.consecutive_no_change||0,i=e.done_signals||0,a=e.total_votes||0,s=e.approve_votes||0,r=this._verdicts.length>0?this._verdicts[this._verdicts.length-1]:null,o=this._agents.filter(n=>n.alive).length;return`
5524
5524
  <div class="overview-grid">
@@ -5631,27 +5631,27 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5631
5631
  <div class="agent-card ${this._selectedAgent?.id===t.id?"agent-selected":""}"
5632
5632
  data-agent-index="${i}">
5633
5633
  <div class="agent-header">
5634
- <span class="agent-name">${t.name||t.id||"Unknown"}</span>
5634
+ <span class="agent-name">${this._escapeHtml(t.name||t.id||"Unknown")}</span>
5635
5635
  <span class="agent-status ${t.alive?"status-alive":"status-dead"}">
5636
5636
  ${t.alive?"Running":"Stopped"}
5637
5637
  </span>
5638
5638
  </div>
5639
5639
  <div class="agent-meta">
5640
- ${t.type?`<span class="agent-type">${t.type}</span>`:""}
5640
+ ${t.type?`<span class="agent-type">${this._escapeHtml(t.type)}</span>`:""}
5641
5641
  ${t.pid?`<span class="agent-pid">PID: ${t.pid}</span>`:""}
5642
- ${t.task?`<span class="agent-task">Task: ${t.task}</span>`:""}
5642
+ ${t.task?`<span class="agent-task">Task: ${this._escapeHtml(t.task)}</span>`:""}
5643
5643
  </div>
5644
5644
  ${this._selectedAgent?.id===t.id?`
5645
5645
  <div class="agent-actions">
5646
5646
  ${t.alive?`
5647
- <button class="btn btn-sm btn-warn" data-action="pause" data-agent-id="${t.id||t.name}">
5647
+ <button class="btn btn-sm btn-warn" data-action="pause" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5648
5648
  Pause
5649
5649
  </button>
5650
- <button class="btn btn-sm btn-danger" data-action="kill" data-agent-id="${t.id||t.name}">
5650
+ <button class="btn btn-sm btn-danger" data-action="kill" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5651
5651
  Kill
5652
5652
  </button>
5653
5653
  `:`
5654
- <button class="btn btn-sm btn-primary" data-action="resume" data-agent-id="${t.id||t.name}">
5654
+ <button class="btn btn-sm btn-primary" data-action="resume" data-agent-id="${this._escapeHtml(t.id||t.name)}">
5655
5655
  Resume
5656
5656
  </button>
5657
5657
  `}
@@ -5660,7 +5660,7 @@ var LokiDashboard=(()=>{var Ee=Object.defineProperty;var rt=Object.getOwnPropert
5660
5660
  </div>
5661
5661
  `).join("")}
5662
5662
  </div>
5663
- `;return this._pendingRaf=requestAnimationFrame(()=>{this._pendingRaf=null;let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(i=>{let a=parseInt(i.dataset.agentIndex,10),s=this._agents[a];s&&(i.addEventListener("click",()=>this._selectAgent(s)),i.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation();let n=r.dataset.action,l=r.dataset.agentId;n==="pause"?this._pauseAgent(l):n==="kill"?this._killAgent(l):n==="resume"&&this._resumeAgent(l)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_getStyles(){return`
5663
+ `;return this._pendingRaf=requestAnimationFrame(()=>{this._pendingRaf=null;let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(i=>{let a=parseInt(i.dataset.agentIndex,10),s=this._agents[a];s&&(i.addEventListener("click",()=>this._selectAgent(s)),i.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",o=>{o.stopPropagation();let n=r.dataset.action,l=r.dataset.agentId;n==="pause"?this._pauseAgent(l):n==="kill"?this._killAgent(l):n==="resume"&&this._resumeAgent(l)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_escapeHtml(e){return e?String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}_getStyles(){return`
5664
5664
  :host {
5665
5665
  display: block;
5666
5666
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.42.0
5
+ **Version:** v7.43.0
6
6
 
7
7
  ---
8
8
 
package/events/bus.py CHANGED
@@ -328,17 +328,20 @@ class EventBus:
328
328
  Events as they arrive
329
329
  """
330
330
  start_time = time.time()
331
- last_check = datetime.now(timezone.utc).isoformat()
332
331
 
333
332
  while True:
334
333
  if timeout and (time.time() - start_time) > timeout:
335
334
  break
336
335
 
337
- # Set last_check BEFORE fetching to avoid missing events that
338
- # arrive between fetch and timestamp update
339
- next_check = datetime.now(timezone.utc).isoformat()
340
- events = self.get_pending_events(types=types, since=last_check)
341
- last_check = next_check
336
+ # Dedup is driven solely by _processed_ids (maintained via
337
+ # mark_processed), NOT by a wall-clock `since` window. A local
338
+ # `since=now` filter silently drops any event whose timestamp is
339
+ # at or behind the subscriber's clock: cross-process clock skew
340
+ # (an emitter a few ms/s behind) or second-granularity timestamps
341
+ # (emit.sh's .000Z fallback) would lose events forever. This
342
+ # mirrors start_background_processing() and bus.ts, which both
343
+ # call get_pending_events with no `since` argument.
344
+ events = self.get_pending_events(types=types)
342
345
 
343
346
  for event in events:
344
347
  yield event
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.42.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
2
+ var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.43.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a$;var d=L(()=>{a$=class a$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -789,4 +789,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
789
789
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
790
790
  `),process.stderr.write(o6),2}}p1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var ZZ=await QZ(Bun.argv.slice(2));process.exit(ZZ);
791
791
 
792
- //# debugId=D7F92E946CD3E45564756E2164756E21
792
+ //# debugId=D2E334AA5C606B8064756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.42.0'
60
+ __version__ = '7.43.0'
package/mcp/server.py CHANGED
@@ -1257,6 +1257,12 @@ async def get_continuity() -> str:
1257
1257
  return "# CONTINUITY.md not found"
1258
1258
  except PathTraversalError:
1259
1259
  return "# Access denied"
1260
+ except Exception as e:
1261
+ # Match the tool-handler error-envelope pattern so a corrupt or
1262
+ # unreadable state file (e.g. IsADirectoryError, OSError) returns an
1263
+ # honest error string instead of raising uncaught into the MCP runtime.
1264
+ logger.error(f"get_continuity failed: {e}")
1265
+ return f"# Error reading CONTINUITY.md: {e}"
1260
1266
 
1261
1267
 
1262
1268
  @mcp.resource("loki://memory/index")
@@ -1271,14 +1277,26 @@ async def get_memory_index() -> str:
1271
1277
  return json.dumps(index_data)
1272
1278
  return json.dumps({"topics": [], "message": "Index not initialized"})
1273
1279
 
1274
- # Fallback to direct file read
1280
+ # Fallback to direct file read. Parse-and-reserialize so a corrupt
1281
+ # index.json yields a clean error envelope instead of serving corrupt
1282
+ # bytes as a successful response (or raising on a downstream consumer).
1275
1283
  index_path = safe_path_join('.loki', 'memory', 'index.json')
1276
1284
  if os.path.exists(index_path):
1277
1285
  with safe_open(index_path, 'r') as f:
1278
- return f.read()
1286
+ raw = f.read()
1287
+ try:
1288
+ return json.dumps(json.loads(raw))
1289
+ except (json.JSONDecodeError, ValueError) as e:
1290
+ logger.error(f"get_memory_index: corrupt index.json: {e}")
1291
+ return json.dumps({"error": f"corrupt index.json: {e}", "topics": []})
1279
1292
  return json.dumps({"topics": [], "message": "Index not initialized"})
1280
1293
  except PathTraversalError:
1281
1294
  return json.dumps({"error": "Access denied", "topics": []})
1295
+ except Exception as e:
1296
+ # Generic envelope so any other state-file failure (OSError,
1297
+ # IsADirectoryError) returns honestly rather than raising uncaught.
1298
+ logger.error(f"get_memory_index failed: {e}")
1299
+ return json.dumps({"error": str(e), "topics": []})
1282
1300
 
1283
1301
 
1284
1302
  @mcp.resource("loki://queue/pending")
@@ -1304,6 +1322,12 @@ async def get_pending_tasks() -> str:
1304
1322
  return json.dumps({"pending_tasks": [], "count": 0})
1305
1323
  except PathTraversalError:
1306
1324
  return json.dumps({"error": "Access denied", "pending_tasks": [], "count": 0})
1325
+ except Exception as e:
1326
+ # Generic envelope: a degraded install (STATE_MANAGER_AVAILABLE=False)
1327
+ # does a bare json.load on .loki/state/task-queue.json; a corrupt file
1328
+ # raises JSONDecodeError that must return an error, not crash the runtime.
1329
+ logger.error(f"get_pending_tasks failed: {e}")
1330
+ return json.dumps({"error": str(e), "pending_tasks": [], "count": 0})
1307
1331
 
1308
1332
 
1309
1333
  # ============================================================
@@ -281,7 +281,12 @@ class VectorIndex:
281
281
  import tempfile
282
282
  npz_path = f"{path}.npz"
283
283
  npz_dir = os.path.dirname(npz_path) or "."
284
- tmp_fd, tmp_path = tempfile.mkstemp(dir=npz_dir, suffix=".npz.tmp")
284
+ # The temp file MUST end in ".npz". np.savez appends ".npz" to any
285
+ # target whose name does not already end in ".npz", so a ".npz.tmp"
286
+ # suffix would make numpy write the real archive to <tmp>.npz and leave
287
+ # the original temp file 0 bytes. os.replace would then move the empty
288
+ # file into place and orphan the real data (corrupting every index).
289
+ tmp_fd, tmp_path = tempfile.mkstemp(dir=npz_dir, suffix=".npz")
285
290
  os.close(tmp_fd)
286
291
  try:
287
292
  np.savez(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.42.0",
4
+ "version": "7.43.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 11 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.42.0",
5
+ "version": "7.43.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 11 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",
@@ -116,10 +116,17 @@ provider_version() {
116
116
  # Invocation function
117
117
  # Note: Codex uses positional prompt, not -p flag
118
118
  # Note: Reasoning effort is configured via environment or config, not CLI flag
119
+ # v7.x: pin the resolved model explicitly via -m/--model. Without it, codex
120
+ # falls back to the installed CLI's built-in default (e.g. gpt-5.5 on codex
121
+ # 0.132.0), which silently ignores _codex_validate_model and makes the run.sh
122
+ # cost table (priced for gpt-5.3-codex) wrong. --model is the documented model
123
+ # selector and is readable in process listings.
119
124
  provider_invoke() {
120
125
  local prompt="$1"
121
126
  shift
122
- codex exec --full-auto --skip-git-repo-check "$prompt" "$@"
127
+ codex exec --full-auto --skip-git-repo-check \
128
+ --model "$PROVIDER_MODEL_DEVELOPMENT" \
129
+ "$prompt" "$@"
123
130
  }
124
131
 
125
132
  # Model tier to effort level parameter (Codex uses effort, not separate models)
@@ -197,6 +204,18 @@ provider_invoke_with_tier() {
197
204
  local effort
198
205
  effort=$(resolve_model_for_tier "$tier")
199
206
 
207
+ # Resolve the model name by tier. These three vars can diverge via the
208
+ # generic LOKI_MODEL_* env (each validated by _codex_validate_model), so
209
+ # honor the tier rather than hardcoding development. Capability aliases
210
+ # (best/balanced/cheap) mirror resolve_model_for_tier's mapping.
211
+ local model
212
+ case "$tier" in
213
+ planning|best) model="$PROVIDER_MODEL_PLANNING" ;;
214
+ development|balanced) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
215
+ fast|cheap) model="$PROVIDER_MODEL_FAST" ;;
216
+ *) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
217
+ esac
218
+
200
219
  local extra_flags=()
201
220
  if [ "${LOKI_CODEX_WEB_SEARCH:-false}" = "true" ]; then
202
221
  extra_flags+=(--search)
@@ -211,6 +230,7 @@ provider_invoke_with_tier() {
211
230
  --ask-for-approval never \
212
231
  --sandbox danger-full-access \
213
232
  --skip-git-repo-check \
233
+ --model "$model" \
214
234
  "${extra_flags[@]}" \
215
235
  "$prompt" "$@"
216
236
  }