loki-mode 9.17.0 → 9.18.2

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/autonomy/run.sh CHANGED
@@ -1519,6 +1519,22 @@ PYREG
1519
1519
  # if any other project is still running (KEEP) it is left up. NEVER uses a
1520
1520
  # blanket pkill and NEVER touches another folder's pids. Best-effort and
1521
1521
  # 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.
1526
+ _loki_pid_looks_like_dashboard() {
1527
+ local _p="$1" _cmd
1528
+ case "$_p" in ''|*[!0-9]*) return 1 ;; esac
1529
+ [ "$_p" -gt 1 ] 2>/dev/null || return 1 # never signal pid 0/1
1530
+ _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
1536
+ }
1537
+
1522
1538
  loki_mark_project_stopped_and_maybe_kill_shared_dashboard() {
1523
1539
  local _skill="${LOKI_SKILL_DIR:-${PROJECT_DIR:-$SCRIPT_DIR/..}}"
1524
1540
  local _shared_pidfile="${HOME}/.loki/dashboard/dashboard.pid"
@@ -1566,7 +1582,19 @@ PYCHECK
1566
1582
  if [ -f "$_shared_pidfile" ]; then
1567
1583
  local _shared_pid
1568
1584
  _shared_pid=$(cat "$_shared_pidfile" 2>/dev/null)
1569
- if [ -n "$_shared_pid" ]; then
1585
+ # Identity check before signalling. dashboard.pid is removed only on
1586
+ # the explicit stop paths (:16608, :16658, :17399) -- nothing covers
1587
+ # a crash, so the file outlives its dashboard. Measured in this very
1588
+ # checkout: .loki/dashboard/dashboard.pid held a DEAD 87992 with an
1589
+ # 8-day-old mtime. PIDs recycle, so a stale number eventually names
1590
+ # an unrelated LIVE process and this `kill -9` hits it.
1591
+ #
1592
+ # kill -0 is NOT sufficient: a recycled pid is alive by definition,
1593
+ # 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.
1597
+ if [ -n "$_shared_pid" ] && _loki_pid_looks_like_dashboard "$_shared_pid"; then
1570
1598
  kill "$_shared_pid" 2>/dev/null || true
1571
1599
  sleep 0.5
1572
1600
  kill -9 "$_shared_pid" 2>/dev/null || true
@@ -2049,40 +2077,7 @@ except Exception:
2049
2077
  pass
2050
2078
  " 2>/dev/null || true)"
2051
2079
  fi
2052
- if [ -n "$_cost" ]; then
2053
- _fields="${_fields} | \$${_cost}"
2054
- # PROJECTED spend at the iteration cap, shown beside the actual.
2055
- #
2056
- # WHY. MAX_ITERATIONS (default 25) is the ONLY backstop on a run's cost:
2057
- # LOKI_BUDGET_LIMIT defaults to "" and LOKI_MAX_DURATION to 0, both
2058
- # documented at run.sh:1118-1121, and the runtime says so out loud at
2059
- # :21333. That is a defensible default -- a run killed mid-flight at a
2060
- # dollar threshold the user never chose is worse than one that finishes.
2061
- # But it left the user unable to SEE where the run was heading: $4.20 at
2062
- # iteration 3 of 25 reads as cheap right up to the moment it is not.
2063
- #
2064
- # Linear extrapolation, and labelled "proj" rather than presented as a
2065
- # forecast: later iterations are usually cheaper than early ones (more
2066
- # cache hits, smaller diffs), so this is an upper bound, not a promise.
2067
- # Shown only when it would actually tell the user something -- from
2068
- # iteration 2 (one data point cannot extrapolate) and only when the
2069
- # projection is meaningfully above what has already been spent.
2070
- if [ "${_iter:-0}" -ge 2 ] && [ "${_max:-0}" -gt 0 ]; then
2071
- local _proj
2072
- _proj="$(LOKI_C="$_cost" LOKI_I="$_iter" LOKI_M="$_max" python3 -c "
2073
- import os
2074
- try:
2075
- c = float(os.environ['LOKI_C']); i = int(os.environ['LOKI_I']); m = int(os.environ['LOKI_M'])
2076
- if i > 0 and m > i:
2077
- p = c / i * m
2078
- if p >= c * 1.5:
2079
- print('%.2f' % p)
2080
- except Exception:
2081
- pass
2082
- " 2>/dev/null || true)"
2083
- [ -n "$_proj" ] && _fields="${_fields} (proj \$${_proj} at ${_max})"
2084
- fi
2085
- fi
2080
+ [ -n "$_cost" ] && _fields="${_fields} | \$${_cost}"
2086
2081
 
2087
2082
  # Files changed (+ins/-del and file count) vs the run start SHA. Reuse the
2088
2083
  # build_completion_summary diff approach incl. the .loki/.git exclude pathspec.
@@ -3349,34 +3344,10 @@ detect_complexity() {
3349
3344
  file_count="${file_count:-0}"
3350
3345
  file_count="${file_count//[^0-9]/}"
3351
3346
 
3352
- # Check for external integrations.
3353
- #
3354
- # THE EXCLUDES ARE LOAD-BEARING. This grep used to prune nothing while the
3355
- # find eleven lines above it prunes node_modules/.git/vendor/dist/build --
3356
- # same function, same intent, inconsistent implementation. With --include
3357
- # "*.json" that meant ANY transitive dependency whose package.json mentions
3358
- # azure, stripe or aws-sdk set has_external=true.
3359
- #
3360
- # And has_external does not merely block "simple": in the classifier below it
3361
- # jumps straight to "complex", skipping "standard". So a one-liner in any
3362
- # repo that has ever run npm install landed on the MOST expensive tier, which
3363
- # then runs the architecture doc suite (up to 300s of silence per attempt)
3364
- # and holds the council's forced minimum-iteration floor at 3 instead of 1.
3365
- #
3366
- # Reproduced from scratch before fixing: a project with ONE dependency naming
3367
- # @azure/core classified complex; adding --exclude-dir=node_modules made the
3368
- # identical project classify simple. It also fired TRUE on this repo.
3369
- #
3370
- # A prior incident matches exactly -- a coffee landing page took 1h34m over
3371
- # 11 iterations because the simple fast-path never engaged. The fast path was
3372
- # correctly built and correctly wired the whole time; this one missing prune
3373
- # was what made it unreachable.
3347
+ # Check for external integrations
3374
3348
  local has_external=false
3375
3349
  if grep -rq "oauth\|SAML\|OIDC\|stripe\|twilio\|aws-sdk\|@google-cloud\|azure" \
3376
- "$target_dir" --include="*.json" --include="*.ts" --include="*.js" \
3377
- --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=vendor \
3378
- --exclude-dir=dist --exclude-dir=build --exclude-dir=__pycache__ \
3379
- --exclude-dir=.venv --exclude-dir=venv 2>/dev/null; then
3350
+ "$target_dir" --include="*.json" --include="*.ts" --include="*.js" 2>/dev/null; then
3380
3351
  has_external=true
3381
3352
  fi
3382
3353
 
@@ -11399,7 +11370,58 @@ enforce_test_coverage() {
11399
11370
  if [ -f "${TARGET_DIR:-.}/package.json" ]; then
11400
11371
  # BUG-EC-014: Wrap test runners with timeout to prevent hanging indefinitely
11401
11372
  local gate_timeout="${LOKI_GATE_TIMEOUT:-300}" # 5 minutes default
11402
- if grep -q '"vitest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
11373
+ # A DECLARED scripts.test wins over an installed package.
11374
+ #
11375
+ # The v7.41.x fix below already established that grep false-positives on
11376
+ # devDependencies -- read its comment -- but it only guarded the `else`
11377
+ # branch, so the three grep branches AHEAD of it still shadowed it. This
11378
+ # repo is the proof: jest is a devDependency with no jest config while
11379
+ # scripts.test runs `bash -n` + `node --test`, and the grep branch
11380
+ # hijacked the runner, ran jest over 895 non-jest files and failed every
11381
+ # one of them.
11382
+ #
11383
+ # _has_declared_test_script is true only when the project DECLARES a real
11384
+ # test script, which is the project's own statement of its runner. When
11385
+ # it declares nothing, the grep branches still apply exactly as before --
11386
+ # that is the legitimate case they were written for (a package that ships
11387
+ # a runner but no npm script).
11388
+ local _declared_test_script=""
11389
+ _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
11390
+ import json, os, sys
11391
+ try:
11392
+ with open(os.environ['_LOKI_PKG']) as f:
11393
+ d = json.load(f)
11394
+ except Exception:
11395
+ sys.exit(0)
11396
+ if not isinstance(d, dict):
11397
+ sys.exit(0)
11398
+ t = (d.get('scripts') or {}).get('test') or ''
11399
+ if 'no test specified' in t.lower():
11400
+ sys.exit(0)
11401
+ sys.stdout.write(t.strip())
11402
+ " 2>/dev/null || echo "")
11403
+ if [ -n "$_declared_test_script" ]; then
11404
+ # Run the DECLARED script. This body used to live in the trailing
11405
+ # `else`; a no-op `:` here would have terminated the if/elif chain
11406
+ # and left test_runner=none, turning a false BLOCK into a silently
11407
+ # unmeasured gate -- strictly worse than the bug being fixed.
11408
+ #
11409
+ # LOKI_TEST_COMMAND lets an operator override the invocation; the
11410
+ # default is the project's own `npm test`.
11411
+ local _test_cmd="${LOKI_TEST_COMMAND:-npm test}"
11412
+ # Label the runner by what the script invokes so evidence is honest
11413
+ # (node --test, vitest, jest, etc. all surface here).
11414
+ case "$_declared_test_script" in
11415
+ *"node --test"*|*"node:test"*) test_runner="node-test" ;;
11416
+ *vitest*) test_runner="vitest" ;;
11417
+ *jest*) test_runner="jest" ;;
11418
+ *mocha*) test_runner="mocha" ;;
11419
+ *) test_runner="npm-test" ;;
11420
+ esac
11421
+ local output
11422
+ output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" sh -c "$_test_cmd" 2>&1) || test_passed=false
11423
+ details="$test_runner ($_test_cmd): $(echo "$output" | tail -5 | tr '\n' ' ')"
11424
+ elif grep -q '"vitest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
11403
11425
  test_runner="vitest"
11404
11426
  local output
11405
11427
  output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx vitest run --reporter=json 2>&1) || test_passed=false
@@ -11425,37 +11447,13 @@ enforce_test_coverage() {
11425
11447
  # would false-positive on devDeps / unrelated keys), then run the
11426
11448
  # configured command. This MUST sit before the monorepo/python/go/rust
11427
11449
  # checks, all of which gate on test_runner=="none".
11428
- local _pkg_test_script
11429
- _pkg_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
11430
- import json, os, sys
11431
- try:
11432
- with open(os.environ['_LOKI_PKG']) as f:
11433
- d = json.load(f)
11434
- except Exception:
11435
- sys.exit(0)
11436
- t = (d.get('scripts') or {}).get('test') or ''
11437
- # npm's default placeholder; treat as 'no test'.
11438
- if 'no test specified' in t.lower():
11439
- sys.exit(0)
11440
- sys.stdout.write(t.strip())
11441
- " 2>/dev/null || echo "")
11442
- if [ -n "$_pkg_test_script" ]; then
11443
- # LOKI_TEST_COMMAND lets an operator override the invocation; the
11444
- # default is the project's own `npm test`.
11445
- local _test_cmd="${LOKI_TEST_COMMAND:-npm test}"
11446
- # Label the runner by what the script invokes so evidence is
11447
- # honest (node --test, vitest, jest, etc. all surface here).
11448
- case "$_pkg_test_script" in
11449
- *"node --test"*|*"node:test"*) test_runner="node-test" ;;
11450
- *vitest*) test_runner="vitest" ;;
11451
- *jest*) test_runner="jest" ;;
11452
- *mocha*) test_runner="mocha" ;;
11453
- *) test_runner="npm-test" ;;
11454
- esac
11455
- local output
11456
- output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" sh -c "$_test_cmd" 2>&1) || test_passed=false
11457
- details="$test_runner ($_test_cmd): $(echo "$output" | tail -5 | tr '\n' ' ')"
11458
- fi
11450
+ #
11451
+ # That handler now runs in the FIRST branch above, because a declared
11452
+ # script must win over an installed devDependency. Nothing is left to
11453
+ # do here: reaching this point means the project declares no test
11454
+ # script AND ships no recognised runner, so test_runner stays "none"
11455
+ # and the monorepo/python/go/rust detection below takes over.
11456
+ :
11459
11457
  fi
11460
11458
  fi
11461
11459
 
@@ -11497,15 +11495,61 @@ sys.stdout.write(t.strip())
11497
11495
  "${TARGET_DIR:-.}"/apps/*/package.json \
11498
11496
  "${TARGET_DIR:-.}"/services/*/package.json; do
11499
11497
  [ -f "$pkg_json" ] || continue
11500
- if grep -q '"vitest"' "$pkg_json" 2>/dev/null; then
11501
- workspace_runner="vitest"
11502
- break
11503
- elif grep -q '"jest"' "$pkg_json" 2>/dev/null; then
11504
- workspace_runner="jest"
11505
- break
11506
- fi
11498
+ # Read the workspace's DECLARED test script, same rule as the
11499
+ # single-package path above. A bare grep for '"jest"' matches
11500
+ # a devDependency, so a workspace that merely depends on jest
11501
+ # got labelled monorepo-jest.
11502
+ #
11503
+ # LOWER SEVERITY than the single-package case, and worth being
11504
+ # precise about why: all three branches below dispatch the
11505
+ # project's own script (turbo test / pnpm test --recursive /
11506
+ # npm test), so the grep only ever picked the LABEL, never
11507
+ # what ran. This corrects the evidence, not the execution.
11508
+ local _ws_script
11509
+ _ws_script=$(_LOKI_PKG="$pkg_json" python3 -c "
11510
+ import json, os, sys
11511
+ try:
11512
+ with open(os.environ['_LOKI_PKG']) as f:
11513
+ d = json.load(f)
11514
+ except Exception:
11515
+ sys.exit(0)
11516
+ if not isinstance(d, dict):
11517
+ sys.exit(0)
11518
+ t = (d.get('scripts') or {}).get('test') or ''
11519
+ if 'no test specified' in t.lower():
11520
+ sys.exit(0)
11521
+ sys.stdout.write(t.strip())
11522
+ " 2>/dev/null || echo "")
11523
+ case "$_ws_script" in
11524
+ *vitest*) workspace_runner="vitest"; break ;;
11525
+ *jest*) workspace_runner="jest"; break ;;
11526
+ *mocha*) workspace_runner="mocha"; break ;;
11527
+ "") : ;;
11528
+ *) workspace_runner="npm-test"; break ;;
11529
+ esac
11507
11530
  done
11508
11531
 
11532
+ # FAIL-SAFE. If no workspace DECLARES a test script but one has a
11533
+ # runner installed, still run the monorepo suite -- labelled
11534
+ # "installed" so the evidence does not claim a declaration that
11535
+ # does not exist.
11536
+ #
11537
+ # Without this the change would trade a wrong label for a SKIPPED
11538
+ # GATE, which is the dangerous direction: the old grep at least
11539
+ # dispatched `npm test`. Reporting a runner inaccurately is a
11540
+ # documentation bug; silently not testing a monorepo is not.
11541
+ if [ -z "$workspace_runner" ]; then
11542
+ for pkg_json in "${TARGET_DIR:-.}"/packages/*/package.json \
11543
+ "${TARGET_DIR:-.}"/apps/*/package.json \
11544
+ "${TARGET_DIR:-.}"/services/*/package.json; do
11545
+ [ -f "$pkg_json" ] || continue
11546
+ if grep -qE '"(vitest|jest|mocha)"' "$pkg_json" 2>/dev/null; then
11547
+ workspace_runner="installed"
11548
+ break
11549
+ fi
11550
+ done
11551
+ fi
11552
+
11509
11553
  if [ -n "$workspace_runner" ]; then
11510
11554
  test_runner="monorepo-$workspace_runner"
11511
11555
  local output
@@ -12355,14 +12399,6 @@ auto_generate_docs_if_needed() {
12355
12399
  elif command -v timeout >/dev/null 2>&1; then
12356
12400
  _doc_cmd=(timeout "${_doc_to}s")
12357
12401
  fi
12358
- # SAY WHAT IS HAPPENING BEFORE GOING QUIET. This call discards child output
12359
- # and can run for the full timeout (default 300s), so without this line the
12360
- # user sees a single "Auto-documentation" header and then minutes of nothing.
12361
- # A silent gap reads as a hang: the observed incident was a build stuck ~55
12362
- # min here with the work committed but never pushed, and nothing on screen
12363
- # said which step owned the time. Naming the step and its cap turns an
12364
- # apparent freeze into a bounded wait the user can reason about.
12365
- log_info "Auto-documentation: generating architecture suite (no output until it finishes; up to ${_doc_to}s)"
12366
12402
  if "${_doc_cmd[@]}" "$loki_bin" docs generate "$project_dir" >/dev/null 2>&1; then
12367
12403
  :
12368
12404
  else
@@ -12421,10 +12457,6 @@ run_magic_debate_gate() {
12421
12457
  # verdict on genuinely thin input, not a spurious process block.
12422
12458
  log_info "Magic Modules: running debate on '$latest_name'"
12423
12459
  local debate_out debate_rc
12424
- # Captured to a variable, so nothing reaches the screen for up to 300s. Same
12425
- # reasoning as the doc suite above: name the step and its cap so a bounded
12426
- # wait does not read as a hang.
12427
- log_info "Magic debate: reviewing $latest_name (2 rounds, output shown when it finishes; up to 300s)"
12428
12460
  debate_out=$(cd "$TARGET_DIR" && PYTHONPATH="$PROJECT_DIR" LOKI_PROVIDER="${PROVIDER_NAME:-claude}" \
12429
12461
  timeout 300 "$PROJECT_DIR/autonomy/loki" magic debate "$latest_name" --rounds 2 2>&1) \
12430
12462
  && debate_rc=0 || debate_rc=$?
@@ -20089,11 +20121,8 @@ except Exception:
20089
20121
  if [ -n "$gate_escalation_context" ]; then
20090
20122
  _legacy_priority="${_legacy_priority}${_legacy_priority:+ }${gate_escalation_context}"
20091
20123
  fi
20092
- # Same cap, same reason, same default as the degraded path above --
20093
- # a pasted spec has to be bounded, but the bound must not silently
20094
- # eat requirements. 4000 bytes dropped anything past ~600 words.
20095
20124
  if [ -n "$prd" ] && [ -f "$prd" ]; then
20096
- _legacy_prd_content=$(head -c "${LOKI_DEGRADED_PRD_CAP:-24000}" "$prd")
20125
+ _legacy_prd_content=$(head -c 4000 "$prd")
20097
20126
  fi
20098
20127
  if [ $retry -eq 0 ]; then
20099
20128
  if [ -n "$prd" ]; then
@@ -20144,35 +20173,9 @@ except Exception:
20144
20173
 
20145
20174
  if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then
20146
20175
  # Degraded providers: simpler wording, but still static-first.
20147
- #
20148
- # THE CAP IS NOW ANNOUNCED, NOT SILENT. This path PASTES the spec text
20149
- # (a degraded provider cannot be told "read the file at this path" the
20150
- # way claude/cline/opencode are at :20196), so it has to be bounded. It
20151
- # was bounded at 4000 bytes with no notice: a requirement past ~600 words
20152
- # was dropped mid-sentence and the model never knew a spec existed beyond
20153
- # what it saw. Demonstrated on a 4229-byte spec -- the requirement on the
20154
- # last line was simply absent from what the model received.
20155
- #
20156
- # That is the same class of defect spec-expand.sh:5-7 already names for
20157
- # OpenAPI ("a 40-operation file loses 21 of 40 ops") and fixed for
20158
- # contracts only. Markdown specs still had it, and only for the two
20159
- # degraded providers -- so codex and aider users silently got a worse
20160
- # build than claude users from the identical spec.
20161
- #
20162
- # Raised to 24000 (a large PRD fits whole) and, when the spec still
20163
- # exceeds it, the model is TOLD so and given the path to read the rest.
20164
- # An unannounced truncation makes the model confidently build the wrong
20165
- # thing; an announced one makes it go look.
20166
- local _prd_cap="${LOKI_DEGRADED_PRD_CAP:-24000}"
20167
20176
  local prd_content=""
20168
- local _prd_truncated=0
20169
20177
  if [ -n "$prd" ] && [ -f "$prd" ]; then
20170
- prd_content=$(head -c "$_prd_cap" "$prd")
20171
- local _prd_bytes
20172
- _prd_bytes=$(wc -c < "$prd" 2>/dev/null | tr -d ' ')
20173
- if [ -n "$_prd_bytes" ] && [ "$_prd_bytes" -gt "$_prd_cap" ] 2>/dev/null; then
20174
- _prd_truncated=1
20175
- fi
20178
+ prd_content=$(head -c 4000 "$prd")
20176
20179
  fi
20177
20180
 
20178
20181
  local degraded_prd_anchor="Loki Mode"
@@ -20201,38 +20204,6 @@ except Exception:
20201
20204
  [ -n "$queue_tasks" ] && printf 'Tasks: %s\n' "$queue_tasks"
20202
20205
  if [ -n "$prd" ]; then
20203
20206
  printf 'PRD contents: %s\n' "$prd_content"
20204
- # Announce the cut. Silence here is what made the old 4000-byte cap
20205
- # dangerous: the model treated a partial spec as the whole spec and
20206
- # built confidently against requirements it had never seen. Naming
20207
- # the file lets it read the remainder itself.
20208
- if [ "${_prd_truncated:-0}" = "1" ]; then
20209
- printf 'NOTE: the spec above is TRUNCATED at %s bytes. The full spec is at %s -- read it before deciding the work is complete.\n' \
20210
- "$_prd_cap" "$prd"
20211
- fi
20212
- fi
20213
-
20214
- # FIRST-PASS EXCELLENCE FOR DEGRADED PROVIDERS.
20215
- #
20216
- # This directive existed only for Claude. providers/claude.sh:322 injects
20217
- # it via --append-system-prompt, a flag codex/aider do not have, so the
20218
- # one mechanism built specifically to make a WEAKER model land complete
20219
- # on iteration 1 reached only the strongest one. Measured before writing
20220
- # this: grep for FIRST_PASS_EXCELLENCE returns 0 in codex.sh, aider.sh,
20221
- # cline.sh and opencode.sh.
20222
- #
20223
- # It matters most exactly where it was missing. The premise (recorded
20224
- # when the Claude version was built) is that iteration count is a proxy
20225
- # for how much the first pass missed, and that for a weak model context
20226
- # quality beats iteration count. Codex is also the free on-ramp, so the
20227
- # users least able to absorb a bad build were the ones getting no help.
20228
- #
20229
- # Condensed rather than byte-mirrored: the Claude text is ~4.3KB of
20230
- # system prompt, and these providers take it inline in the user turn
20231
- # where budget is tighter. The four load-bearing instructions are kept --
20232
- # build fully, wire the backend, verify by RUNNING, commit to one design.
20233
- # Same iteration-1 gate and same env var, so one switch controls both.
20234
- if [ "${LOKI_FIRST_PASS_EXCELLENCE:-1}" != "0" ] && [ "${iteration:-1}" -le 1 ] 2>/dev/null; then
20235
- printf '%s\n' '[FIRST-PASS EXCELLENCE] Treat THIS pass as your one shot to ship a complete, working solution. The loop is a safety net, not a plan. 1) BUILD IT FULLY: no stubs, no TODOs, no placeholder or mock data where real logic belongs. If the spec implies a backend (auth, persistence, a form that submits), WIRE IT so it actually persists -- a UI whose buttons do nothing is the most common failure. 2) VERIFY BY RUNNING each acceptance path, not by reading the code. 3) DECIDE the architecture now rather than refactoring later. 4) Commit to ONE specific design; avoid the generic purple-gradient default look.'
20236
20207
  fi
20237
20208
  printf '</dynamic_context>\n'
20238
20209
  return 0
@@ -24725,6 +24696,58 @@ kill_provider_child() {
24725
24696
  # ~run.sh:15034); in interactive foreground we share the user's shell group,
24726
24697
  # leave the pgid absent, and skip this reap entirely -- so Ctrl+C semantics
24727
24698
  # and the user's shell are untouched.
24699
+ # --- pgid stamp helpers (P0: stale-pgid session killer) ---------------------
24700
+ # The pgid file is written as `pgid=<n> boot=<id> started=<epoch>` so a reader
24701
+ # can tell a live record from a week-old orphan. See reap_own_process_group.
24702
+
24703
+ # Extract one field from a stamp. A bare number (legacy file) yields the pgid
24704
+ # and nothing else, so the reader's fail-closed branch rejects it.
24705
+ _loki_pgid_field() {
24706
+ local _s="$1" _k="$2"
24707
+ case "$_s" in
24708
+ *=*) ;;
24709
+ *) [ "$_k" = "pgid" ] && printf '%s' "$(printf '%s' "$_s" | tr -d ' ')"; return 0 ;;
24710
+ esac
24711
+ # Prefix/suffix trimming, not `for _tok in $_s`. Two reasons:
24712
+ # - `while read` in a pipeline runs in a SUBSHELL and drops a final field
24713
+ # with no trailing newline, silently returning an empty `started`.
24714
+ # - unquoted `$_s` in a `for` relies on word splitting, which bash does
24715
+ # and ZSH DOES NOT. run.sh is bash, but this file gets sourced and
24716
+ # probed from other shells, and a helper whose correctness depends on
24717
+ # the caller's shell is a trap. This form needs no word splitting.
24718
+ local _rest="$_s" _tok
24719
+ while [ -n "$_rest" ]; do
24720
+ _tok="${_rest%% *}" # first space-delimited token
24721
+ case "$_tok" in
24722
+ "$_k"=*) printf '%s' "${_tok#*=}"; return 0 ;;
24723
+ esac
24724
+ case "$_rest" in
24725
+ *" "*) _rest="${_rest#* }" ;; # advance past this token
24726
+ *) _rest="" ;; # last token: stop
24727
+ esac
24728
+ done
24729
+ }
24730
+
24731
+ # A value that changes on every reboot, so a pgid recorded before a reboot can
24732
+ # never validate afterwards. Empty when unavailable -- callers fail closed.
24733
+ _loki_boot_id() {
24734
+ if [ -r /proc/stat ]; then
24735
+ awk '/^btime /{print $2; exit}' /proc/stat 2>/dev/null && return 0
24736
+ fi
24737
+ # macOS: `kern.boottime` prints `{ sec = 1234567890, usec = 0 } ...`
24738
+ sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec *= *\([0-9][0-9]*\).*/\1/p'
24739
+ }
24740
+
24741
+ # Start time of a pid as a unix epoch, or empty when it cannot be determined.
24742
+ _loki_proc_start_epoch() {
24743
+ local _p="${1:-$$}" _lstart
24744
+ _lstart="$(ps -o lstart= -p "$_p" 2>/dev/null)"
24745
+ [ -n "$_lstart" ] || return 0
24746
+ date -j -f '%a %b %e %T %Y' "$_lstart" '+%s' 2>/dev/null \
24747
+ || date -d "$_lstart" '+%s' 2>/dev/null \
24748
+ || true
24749
+ }
24750
+
24728
24751
  reap_own_process_group() {
24729
24752
  local loki_dir="${TARGET_DIR:-.}/.loki"
24730
24753
  # Resolve the pgid file the same way main() recorded it (global or per-session).
@@ -24735,8 +24758,13 @@ reap_own_process_group() {
24735
24758
  _reap_pgid_file="${_reap_pgid_file%.pid}.pgid"
24736
24759
  [ -f "$_reap_pgid_file" ] || return 0 # interactive / no own session: skip
24737
24760
 
24738
- local _pgid
24739
- _pgid=$(cat "$_reap_pgid_file" 2>/dev/null | tr -d ' ')
24761
+ # Parse the stamped format `pgid=<n> boot=<id> started=<epoch>`, falling back
24762
+ # to a bare number for a file written by an older version.
24763
+ local _stamp _pgid _boot _started
24764
+ _stamp=$(cat "$_reap_pgid_file" 2>/dev/null | tr -d '\n')
24765
+ _pgid=$(_loki_pgid_field "$_stamp" pgid)
24766
+ _boot=$(_loki_pgid_field "$_stamp" boot)
24767
+ _started=$(_loki_pgid_field "$_stamp" started)
24740
24768
  case "$_pgid" in ''|*[!0-9]*) return 0 ;; esac
24741
24769
  [ "$_pgid" -gt 1 ] 2>/dev/null || return 0 # never touch pgid 0/1
24742
24770
 
@@ -24747,6 +24775,40 @@ reap_own_process_group() {
24747
24775
  _my_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
24748
24776
  [ -n "$_my_pgid" ] && [ "$_pgid" = "$_my_pgid" ] || return 0
24749
24777
 
24778
+ # BOOT + AGE GUARD (the fix for the session-killer).
24779
+ #
24780
+ # The self-check above is NOT sufficient on its own. loki.pgid was removed
24781
+ # only on the normal exit path, so a Ctrl+C'd or crashed run left the file
24782
+ # behind indefinitely -- measured on a real machine: two orphans aged 155h
24783
+ # and 202h. PIDs recycle (macOS wraps near 99999; max observed 99762), so a
24784
+ # week-old pgid eventually matches a LIVE, unrelated shell group. The
24785
+ # self-check then passes and every sibling in the user's terminal -- their
24786
+ # editor, another agent session -- is TERM'd then KILL'd.
24787
+ #
24788
+ # A recycled pgid cannot forge both of these:
24789
+ # - boot id: a file from a previous boot can never match this boot.
24790
+ # - started: the recording run must not predate THIS process.
24791
+ #
24792
+ # Fails CLOSED. A missing or malformed stamp means we do nothing: an
24793
+ # orphaned agent (the v7.41.x problem this reap was added for) is strictly
24794
+ # less harmful than killing a user's editor.
24795
+ local _now_boot
24796
+ _now_boot="$(_loki_boot_id)"
24797
+ if [ -z "$_boot" ] || [ -z "$_started" ]; then
24798
+ return 0 # unstamped legacy file: never trust it
24799
+ fi
24800
+ if [ -z "$_now_boot" ] || [ "$_boot" != "$_now_boot" ]; then
24801
+ return 0 # different boot (or boot id unavailable): refuse
24802
+ fi
24803
+ case "$_started" in ''|*[!0-9]*) return 0 ;; esac
24804
+ local _self_started
24805
+ _self_started="$(_loki_proc_start_epoch $$)"
24806
+ # The stamp must not predate this process by more than a small clock skew.
24807
+ # A stale file is always OLDER than the process now reading it.
24808
+ if [ -n "$_self_started" ] && [ "$_started" -lt "$(( _self_started - 5 ))" ]; then
24809
+ return 0
24810
+ fi
24811
+
24750
24812
  # Collect protected pids (dashboard, app-runner, registered children) so the
24751
24813
  # reap never takes down the shared dashboard if it happens to share our
24752
24814
  # group. Mirrors the `loki stop` / dashboard reaper protection set.
@@ -24889,17 +24951,7 @@ check_human_intervention() {
24889
24951
  # Security: Check it's a regular file (not symlink) to prevent symlink attacks
24890
24952
  if [ -f "$loki_dir/HUMAN_INPUT.md" ] && [ ! -L "$loki_dir/HUMAN_INPUT.md" ]; then
24891
24953
  # Security: Prompt injection disabled by default for enterprise security
24892
- # Accepts "1" AND "true". It used to accept only "true", while
24893
- # `loki steer` prints "Enable it: export LOKI_PROMPT_INJECTION=1" and
24894
- # then "The next iteration will read and apply it." A user who followed
24895
- # that instruction exactly had their steering note moved to
24896
- # logs/human-input-REJECTED-*.md and never read -- the CLI reported
24897
- # success, the runner silently discarded it, and the only warning went
24898
- # to the runner log rather than to the person who typed the command.
24899
- #
24900
- # Both forms is the convention here (LOKI_GATE_SEMANTIC_TESTS_BLOCK and
24901
- # LOKI_DURABLE_STATE each test for both); this flag was the outlier.
24902
- if [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ] && [ "${LOKI_PROMPT_INJECTION:-false}" != "1" ]; then
24954
+ if [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ]; then
24903
24955
  log_warn "HUMAN_INPUT.md detected but prompt injection is DISABLED"
24904
24956
  log_warn "To enable, set LOKI_PROMPT_INJECTION=true (only in trusted environments)"
24905
24957
  # Move to rejected instead of processed
@@ -25158,6 +25210,20 @@ _loki_remove_temp_self_copy() {
25158
25210
  fi
25159
25211
  }
25160
25212
 
25213
+ # P0 (stale-pgid session killer): the pgid file must never outlive its run.
25214
+ # Recorded by the writer (see _LOKI_PGID_FILE below) rather than re-derived, so
25215
+ # the removal hits the exact path written -- global OR per-session -- and can
25216
+ # never delete a concurrent run's file.
25217
+ _loki_remove_pgid_file() {
25218
+ [ -n "${_LOKI_PGID_FILE:-}" ] || return 0
25219
+ # A `trap ... EXIT` fires in SUBSHELLS too, and a subshell exiting must not
25220
+ # delete a LIVE parent's file (repo scar: a bare trap deleted a parent's
25221
+ # lock). $$ does NOT change in a subshell -- BASHPID does, so it is the only
25222
+ # real discriminator here.
25223
+ [ "${BASHPID:-$$}" = "${_LOKI_PGID_OWNER:-$$}" ] || return 0
25224
+ rm -f "$_LOKI_PGID_FILE" 2>/dev/null || true
25225
+ }
25226
+
25161
25227
  _loki_session_exit_cleanup() {
25162
25228
  local exit_code=$?
25163
25229
  _loki_terminal_record 2>/dev/null || true
@@ -25165,6 +25231,12 @@ _loki_session_exit_cleanup() {
25165
25231
  && type safe_release_lock >/dev/null 2>&1; then
25166
25232
  safe_release_lock "$_LOKI_SESSION_LOCK_FILE" 2>/dev/null || true
25167
25233
  fi
25234
+ # Covers EXIT plus INT/TERM: every cleanup() path that actually terminates
25235
+ # does so via `exit`, which fires this trap. Deliberately NOT called from
25236
+ # cleanup() itself -- its perpetual/pause branches RETURN and the run
25237
+ # continues, so removing the file there would silently disable the
25238
+ # completion-path reap for the rest of the run.
25239
+ _loki_remove_pgid_file
25168
25240
  _loki_remove_temp_self_copy
25169
25241
  return "$exit_code"
25170
25242
  }
@@ -25833,6 +25905,10 @@ main() {
25833
25905
  fi
25834
25906
  else
25835
25907
  # Lock helper not loaded (lib/lock.sh missing). PID-only fallback.
25908
+ # This branch still falls through to the pgid writer below, so it needs
25909
+ # the same EXIT cleanup or the pgid file leaks on every interrupt here.
25910
+ # An empty lock file is safe: the release is [ -n ]-guarded.
25911
+ _loki_arm_session_exit_cleanup ""
25836
25912
  if [ -f "$pid_file" ]; then
25837
25913
  local existing_pid
25838
25914
  existing_pid=$(cat "$pid_file" 2>/dev/null)
@@ -25863,7 +25939,22 @@ main() {
25863
25939
  if [ "${LOKI_OWN_SESSION:-}" = "1" ]; then
25864
25940
  _loki_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
25865
25941
  if [ -n "$_loki_pgid" ]; then
25866
- echo "$_loki_pgid" > "${pid_file%.pid}.pgid" 2>/dev/null || true
25942
+ # STAMPED, not a bare number. The reader requires boot id + start
25943
+ # epoch to match before it will signal anything, because this file
25944
+ # outlived its run (measured: 155h and 202h orphans) and PIDs
25945
+ # recycle -- a stale bare pgid eventually matches a live shell group
25946
+ # and the reap killed the user's other terminal sessions.
25947
+ _loki_pgid_started="$(_loki_proc_start_epoch $$)"
25948
+ [ -n "$_loki_pgid_started" ] || _loki_pgid_started="$(date +%s 2>/dev/null)"
25949
+ # Record the EXACT path written (global or per-session) plus the
25950
+ # owning shell, so the EXIT trap removes THIS file and only from
25951
+ # this process. Without removal on the interrupt/crash paths the
25952
+ # file outlived its run -- measured orphans aged 155h and 202h.
25953
+ _LOKI_PGID_FILE="${pid_file%.pid}.pgid"
25954
+ _LOKI_PGID_OWNER="${BASHPID:-$$}"
25955
+ printf 'pgid=%s boot=%s started=%s\n' \
25956
+ "$_loki_pgid" "$(_loki_boot_id)" "$_loki_pgid_started" \
25957
+ > "$_LOKI_PGID_FILE" 2>/dev/null || true
25867
25958
  fi
25868
25959
  fi
25869
25960
  # Store session ID in state for dashboard/status visibility