loki-mode 7.63.1 → 7.64.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, 8 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.63.1
6
+ # Loki Mode v7.64.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.63.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.64.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.63.1
1
+ 7.64.0
@@ -144,6 +144,76 @@ HEALTH_EOF
144
144
  mv "$tmp_file" "$_APP_RUNNER_DIR/health.json"
145
145
  }
146
146
 
147
+ # L5 fix (PID-file reuse race): capture a disambiguating identity token for a
148
+ # live PID, so a later liveness check can tell "our app is still running" apart
149
+ # from "the OS reassigned the crashed app's PID to an unrelated process". A raw
150
+ # `kill -0 $pid` cannot make that distinction: a reused PID reports a foreign
151
+ # process as a healthy app.
152
+ #
153
+ # Token = the process start time (`lstart`) plus its command name (`comm`), one
154
+ # line, read from `ps`. `lstart` is the strong disambiguator: it is constant for
155
+ # a process lifetime and a reused PID is, by definition, a different (later)
156
+ # process with a different start time. `comm` is a weaker secondary signal kept
157
+ # for human readability. We compare the whole line by plain string equality.
158
+ #
159
+ # Portability: the `ps -o lstart=,comm=` field set works on both BSD (macOS) and
160
+ # GNU/Linux. We never parse the format -- we only ever compare two reads taken on
161
+ # the SAME host (capture at start vs read at check), so the BSD-vs-Linux format
162
+ # difference is irrelevant: the two strings either match or they do not. Returns
163
+ # empty on any `ps` miss (dead/foreign pid) so callers can fall back safely.
164
+ _app_runner_pid_token() {
165
+ local pid="$1"
166
+ case "$pid" in
167
+ ''|0|1) return 0 ;;
168
+ esac
169
+ [[ "$pid" =~ ^[0-9]+$ ]] || return 0
170
+ # Collapse internal whitespace runs to single spaces so a benign formatting
171
+ # quirk (e.g. extra padding) never causes a spurious mismatch.
172
+ ps -o lstart=,comm= -p "$pid" 2>/dev/null | tr -s ' ' ' ' | sed 's/^ *//; s/ *$//'
173
+ }
174
+
175
+ # Write the captured identity token for the current app PID. Best-effort: if the
176
+ # token cannot be read (no ps output) we remove any stale token file rather than
177
+ # leave a wrong one, which makes the later check fall back to trusting kill -0
178
+ # (no false-DEAD regression).
179
+ _write_app_token() {
180
+ local pid="$1"
181
+ local tok
182
+ tok=$(_app_runner_pid_token "$pid")
183
+ if [ -n "$tok" ]; then
184
+ local tmp_file="$_APP_RUNNER_DIR/app.token.tmp.$$"
185
+ printf '%s\n' "$tok" > "$tmp_file" 2>/dev/null && \
186
+ mv "$tmp_file" "$_APP_RUNNER_DIR/app.token" 2>/dev/null || \
187
+ rm -f "$tmp_file" 2>/dev/null
188
+ else
189
+ rm -f "$_APP_RUNNER_DIR/app.token" 2>/dev/null
190
+ fi
191
+ }
192
+
193
+ # Decide whether the live PID is still OUR app, using the captured token.
194
+ # Returns 0 (yes, ours) when:
195
+ # - no token file exists (app launched by a pre-fix version, or ps was
196
+ # unavailable at start) -- fall back to trusting kill -0, OR
197
+ # - the live PID's token matches the stored token.
198
+ # Returns 1 (NOT ours) ONLY when a token file exists AND the live token differs
199
+ # (the strong "PID was reused by a foreign process" signal). This asymmetry is
200
+ # deliberate: we never declare DEAD on a missing/unreadable token, so a
201
+ # legitimate still-running app is never falsely killed (no false-DEAD
202
+ # regression). The residual: if `ps` is entirely unavailable on the host, the
203
+ # token file is never written and we always fall back to bare kill -0 -- i.e. no
204
+ # worse than the pre-fix behavior, never worse.
205
+ _app_runner_pid_is_ours() {
206
+ local pid="$1"
207
+ local token_file="$_APP_RUNNER_DIR/app.token"
208
+ [ -f "$token_file" ] || return 0
209
+ local stored live
210
+ stored=$(cat "$token_file" 2>/dev/null)
211
+ [ -n "$stored" ] || return 0
212
+ live=$(_app_runner_pid_token "$pid")
213
+ [ -n "$live" ] || return 0
214
+ [ "$live" = "$stored" ]
215
+ }
216
+
147
217
  # Re-derive a detection.json field (type/command) so we can rewrite it after a
148
218
  # port reconcile without threading those values through globals. Echoes the raw
149
219
  # string value (empty on miss). Mirrors the grep-based read style used by
@@ -1148,6 +1218,10 @@ app_runner_start() {
1148
1218
  # live port even when the app ignored PORT. Mutates globals before the
1149
1219
  # state write below. Bounded; no-op when the app honored the chosen port.
1150
1220
  _app_runner_reconcile_port
1221
+ # L5 fix: capture the identity token now that the child has exec'd into
1222
+ # the real app process, so a later health-check/watchdog can detect a
1223
+ # reused PID (foreign process) instead of trusting it blindly.
1224
+ _write_app_token "$_APP_RUNNER_PID"
1151
1225
  _write_app_state "running"
1152
1226
  log_info "App Runner: application started (PID: $_APP_RUNNER_PID) on port $_APP_RUNNER_PORT"
1153
1227
  return 0
@@ -1271,6 +1345,7 @@ app_runner_stop() {
1271
1345
  fi
1272
1346
 
1273
1347
  rm -f "$_APP_RUNNER_DIR/app.pid"
1348
+ rm -f "$_APP_RUNNER_DIR/app.token"
1274
1349
  _write_app_state "stopped"
1275
1350
  log_info "App Runner: application stopped"
1276
1351
  _APP_RUNNER_PID=""
@@ -1282,7 +1357,20 @@ app_runner_restart() {
1282
1357
  log_step "App Runner: restarting (restart #$_APP_RUNNER_RESTART_COUNT)..."
1283
1358
  app_runner_stop
1284
1359
  sleep 1
1360
+ # L6 fix (non-atomic restart): the old code returned app_runner_start's status
1361
+ # unhandled, so a failed start (e.g. the old port still in TIME_WAIT after the
1362
+ # 1s wait, tripping the port-conflict guard) left the app stopped with only an
1363
+ # internal log_warn -- silent to the caller, which saw the restart "succeed".
1364
+ # Surface the failure loudly via log_error and return the failure status
1365
+ # explicitly so the restart's failure is visible and actionable. The
1366
+ # happy-path behavior (start succeeds -> return 0) is unchanged.
1285
1367
  app_runner_start
1368
+ local _start_rc=$?
1369
+ if [ "$_start_rc" -ne 0 ]; then
1370
+ log_error "App Runner: restart failed -- app stopped but could not be started again (start exit $_start_rc). Check $_APP_RUNNER_DIR/app.log; the previous port may still be held (TIME_WAIT) or the start command may be failing."
1371
+ return "$_start_rc"
1372
+ fi
1373
+ return 0
1286
1374
  }
1287
1375
 
1288
1376
  #===============================================================================
@@ -1374,6 +1462,18 @@ app_runner_health_check() {
1374
1462
  return 1
1375
1463
  fi
1376
1464
 
1465
+ # L5 fix (PID-file reuse race): kill -0 only proves SOME process owns this
1466
+ # PID, not that it is OUR app. The OS may have reassigned the crashed app's
1467
+ # PID to an unrelated process. Verify the captured identity token; on a
1468
+ # mismatch the live PID is a foreign process, so the app is DEAD. (When no
1469
+ # token was captured we fall back to trusting kill -0 -- see
1470
+ # _app_runner_pid_is_ours -- so a still-running legitimate app is never
1471
+ # falsely marked dead.)
1472
+ if ! _app_runner_pid_is_ours "$_APP_RUNNER_PID"; then
1473
+ _write_health "false"
1474
+ return 1
1475
+ fi
1476
+
1377
1477
  # For HTTP apps, try an HTTP health check.
1378
1478
  if [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
1379
1479
  # The health signal is "is the server answering HTTP at all", NOT "does /
@@ -1524,7 +1624,15 @@ app_runner_watchdog() {
1524
1624
  # app_runner_health_check (HTTP-aware for apps that declared a port), and
1525
1625
  # treat an unhealthy-but-alive process as a crash so the same circuit
1526
1626
  # breaker + backoff + restart path handles it.
1527
- if kill -0 "$_APP_RUNNER_PID" 2>/dev/null; then
1627
+ # L5 fix: gate on BOTH liveness AND identity. If kill -0 succeeds but the
1628
+ # PID is a foreign process the OS reassigned our crashed app's PID to
1629
+ # (_app_runner_pid_is_ours false), we must NOT enter this branch: the
1630
+ # alive-but-unhealthy path below calls app_runner_stop, which would send
1631
+ # TERM/KILL to an innocent stranger. Instead let a reused PID fall through to
1632
+ # the dead path, which only increments the crash count and restarts -- it
1633
+ # never signals the PID. (No token captured -> is_ours returns true ->
1634
+ # behaves exactly as before, so no regression for legitimately-running apps.)
1635
+ if kill -0 "$_APP_RUNNER_PID" 2>/dev/null && _app_runner_pid_is_ours "$_APP_RUNNER_PID"; then
1528
1636
  if app_runner_health_check; then
1529
1637
  # BUG 3 fix: a confirmed-healthy observation clears the accumulated
1530
1638
  # crash count so the breaker fires only on 5 CONSECUTIVE failures,
@@ -1557,6 +1665,7 @@ app_runner_watchdog() {
1557
1665
  done
1558
1666
  _write_app_state "crashed"
1559
1667
  rm -f "$_APP_RUNNER_DIR/app.pid"
1668
+ rm -f "$_APP_RUNNER_DIR/app.token"
1560
1669
  _APP_RUNNER_PID=""
1561
1670
  return 1
1562
1671
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.63.1"
10
+ __version__ = "7.64.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -436,23 +436,51 @@ class ConnectionManager:
436
436
  if websocket in self.active_connections:
437
437
  self.active_connections.remove(websocket)
438
438
 
439
+ # Per-client send timeout (seconds). A client that does not stop reading
440
+ # fills its TCP send buffer; without a bound the await blocks indefinitely
441
+ # and one stalled client would freeze the whole fan-out (and the 2s
442
+ # _push_loki_state_loop) for every other client. Drop the slow client
443
+ # instead of blocking everyone.
444
+ SEND_TIMEOUT_SECONDS = 5.0
445
+
439
446
  async def broadcast(self, message: dict[str, Any]) -> None:
440
- """Broadcast a message to all connected clients."""
441
- disconnected = []
442
- for connection in list(self.active_connections):
447
+ """Broadcast a message to all connected clients.
448
+
449
+ Sends run concurrently with a per-client timeout so a single stalled
450
+ or dead client is dropped rather than blocking the fan-out.
451
+ """
452
+ connections = list(self.active_connections)
453
+ if not connections:
454
+ return
455
+
456
+ async def _send(conn: WebSocket) -> bool:
443
457
  try:
444
- await connection.send_json(message)
458
+ await asyncio.wait_for(
459
+ conn.send_json(message), timeout=self.SEND_TIMEOUT_SECONDS
460
+ )
461
+ return True
462
+ except asyncio.TimeoutError:
463
+ logger.debug("WebSocket send timed out, dropping slow client")
464
+ return False
445
465
  except Exception as e:
446
466
  logger.debug(f"WebSocket send failed, client disconnected: {e}")
447
- disconnected.append(connection)
448
- # Clean up disconnected clients
449
- for conn in disconnected:
450
- self.disconnect(conn)
467
+ return False
468
+
469
+ results = await asyncio.gather(*(_send(c) for c in connections))
470
+ # Clean up clients that timed out or errored.
471
+ for conn, ok in zip(connections, results):
472
+ if not ok:
473
+ self.disconnect(conn)
451
474
 
452
475
  async def send_personal(self, websocket: WebSocket, message: dict[str, Any]) -> None:
453
476
  """Send a message to a specific client."""
454
477
  try:
455
- await websocket.send_json(message)
478
+ await asyncio.wait_for(
479
+ websocket.send_json(message), timeout=self.SEND_TIMEOUT_SECONDS
480
+ )
481
+ except asyncio.TimeoutError:
482
+ logger.debug("WebSocket personal send timed out, dropping slow client")
483
+ self.disconnect(websocket)
456
484
  except Exception as e:
457
485
  logger.debug(f"WebSocket personal send failed: {e}")
458
486
  self.disconnect(websocket)
@@ -5257,6 +5285,11 @@ def _compute_cost_snapshot() -> dict:
5257
5285
  for eff_file in sorted(efficiency_dir.glob("*.json")):
5258
5286
  try:
5259
5287
  data = json.loads(eff_file.read_text())
5288
+ # A corrupt/truncated efficiency file can parse to a non-object
5289
+ # (list / null / scalar); data.get(...) would then raise
5290
+ # AttributeError. Skip such files rather than 500 the endpoint.
5291
+ if not isinstance(data, dict):
5292
+ continue
5260
5293
 
5261
5294
  inp = data.get("input_tokens", 0)
5262
5295
  out = data.get("output_tokens", 0)
@@ -5284,7 +5317,7 @@ def _compute_cost_snapshot() -> dict:
5284
5317
  by_model[model]["input_tokens"] += inp
5285
5318
  by_model[model]["output_tokens"] += out
5286
5319
  by_model[model]["cost_usd"] += cost
5287
- except (json.JSONDecodeError, KeyError, TypeError):
5320
+ except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
5288
5321
  pass
5289
5322
 
5290
5323
  # Fallback: read from context tracking if efficiency files have no token data
@@ -5293,7 +5326,13 @@ def _compute_cost_snapshot() -> dict:
5293
5326
  if ctx_file.exists():
5294
5327
  try:
5295
5328
  ctx = json.loads(ctx_file.read_text())
5329
+ # A corrupt/truncated tracking.json can parse to a non-object;
5330
+ # ctx.get(...) would then raise AttributeError. Skip it.
5331
+ if not isinstance(ctx, dict):
5332
+ ctx = {}
5296
5333
  totals = ctx.get("totals", {})
5334
+ if not isinstance(totals, dict):
5335
+ totals = {}
5297
5336
  total_input = totals.get("total_input", 0)
5298
5337
  total_output = totals.get("total_output", 0)
5299
5338
  if total_input > 0 or total_output > 0:
@@ -5309,7 +5348,7 @@ def _compute_cost_snapshot() -> dict:
5309
5348
  by_model[model]["input_tokens"] += inp
5310
5349
  by_model[model]["output_tokens"] += out
5311
5350
  by_model[model]["cost_usd"] += cost
5312
- except (json.JSONDecodeError, KeyError):
5351
+ except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
5313
5352
  pass
5314
5353
 
5315
5354
  # Read budget configuration
@@ -5386,13 +5425,26 @@ async def get_budget():
5386
5425
  except (json.JSONDecodeError, KeyError):
5387
5426
  pass
5388
5427
 
5428
+ # Coerce defensively: a budget.json with a non-numeric budget_used/limit
5429
+ # (e.g. "n/a", null, a list) parses as valid JSON but would crash float()
5430
+ # with ValueError/TypeError. Treat non-numeric values as 0.0 / None so the
5431
+ # endpoint returns a clean payload instead of a 500.
5432
+ def _to_float(value, default=0.0):
5433
+ try:
5434
+ return float(value)
5435
+ except (ValueError, TypeError):
5436
+ return default
5437
+
5438
+ budget_limit_f = _to_float(budget_limit, None) if budget_limit is not None else None
5439
+ budget_used_f = _to_float(budget_used, 0.0)
5440
+
5389
5441
  remaining = None
5390
- if budget_limit is not None:
5391
- remaining = max(0.0, float(budget_limit) - float(budget_used))
5442
+ if budget_limit_f is not None:
5443
+ remaining = max(0.0, budget_limit_f - budget_used_f)
5392
5444
 
5393
5445
  return {
5394
- "budget_limit": float(budget_limit) if budget_limit is not None else None,
5395
- "current_cost": round(float(budget_used), 4),
5446
+ "budget_limit": budget_limit_f,
5447
+ "current_cost": round(budget_used_f, 4),
5396
5448
  "exceeded": exceeded,
5397
5449
  "exceeded_at": exceeded_at,
5398
5450
  "remaining": round(remaining, 4) if remaining is not None else None,
@@ -6590,10 +6642,17 @@ async def resume_agent(agent_id: str):
6590
6642
 
6591
6643
  @app.get("/api/logs")
6592
6644
  async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optional[dict] = Depends(auth.get_current_token)):
6593
- """Get recent log entries from session log files."""
6645
+ """Get recent log entries from session log files (redacted)."""
6594
6646
  log_dir = _get_loki_dir() / "logs"
6595
6647
  entries = []
6596
6648
 
6649
+ # Session logs (.loki/logs/*.log) are written raw by run.sh and can contain
6650
+ # secrets an agent/tool echoed to stdout (sk-ant-, ghp_, Bearer, AWS keys).
6651
+ # Redact every returned message exactly like the /api/app-runner/logs and
6652
+ # /errors endpoints do. The response shape is unchanged; only the message
6653
+ # content passes through the redactor.
6654
+ _redact = _get_log_redactor()
6655
+
6597
6656
  # Regex for full timestamp: [2026-02-07T01:32:00] [INFO] msg or 2026-02-07 01:32:00 INFO msg
6598
6657
  _LOG_TS_FULL = re.compile(
6599
6658
  r'^\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})\]?\s*\[?(\w+)\]?\s*(.*)'
@@ -6661,7 +6720,7 @@ async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optio
6661
6720
  timestamp = file_mtime
6662
6721
 
6663
6722
  entries.append({
6664
- "message": message,
6723
+ "message": _redact(message),
6665
6724
  "level": level,
6666
6725
  "timestamp": timestamp,
6667
6726
  })
@@ -7171,6 +7230,10 @@ def _build_metrics_text() -> str:
7171
7230
  for eff_file in efficiency_dir.glob("*.json"):
7172
7231
  try:
7173
7232
  data = json.loads(eff_file.read_text())
7233
+ # Skip non-object (corrupt/truncated) efficiency files so a
7234
+ # bad file does not 500 the Prometheus scrape.
7235
+ if not isinstance(data, dict):
7236
+ continue
7174
7237
  cost = data.get("cost_usd")
7175
7238
  if cost is not None:
7176
7239
  estimated_cost += float(cost)
@@ -7180,7 +7243,7 @@ def _build_metrics_text() -> str:
7180
7243
  estimated_cost += _calculate_model_cost(
7181
7244
  data.get("model", "sonnet").lower(), inp, out
7182
7245
  )
7183
- except (json.JSONDecodeError, KeyError, TypeError):
7246
+ except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
7184
7247
  pass
7185
7248
  except OSError:
7186
7249
  pass
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function F$(){if(Q$!==null)return Q$;let $="7.63.1";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=XQ(KQ(import.meta.url)),Z=n$(Q);Q$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var s$=P(()=>{C()});var b1={};b(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>JQ,commandExists:()=>f,ShellError:()=>r$});async function k($,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[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new r$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function JQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var r$;var d=P(()=>{r$=class r$ 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 s($){return WQ?"":$}var WQ,T,S,_,wZ,I,R,h,V;var c=P(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),wZ=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as wQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var Y$;var V$=P(()=>{d()});var e1={};b(e1,{runStatus:()=>uQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*R$/Q);if(X>R$)X=R$;let q=R$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function F$(){if(Q$!==null)return Q$;let $="7.64.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=XQ(KQ(import.meta.url)),Z=n$(Q);Q$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var s$=P(()=>{C()});var b1={};b(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>JQ,commandExists:()=>f,ShellError:()=>r$});async function k($,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[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new r$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function JQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var r$;var d=P(()=>{r$=class r$ 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 s($){return WQ?"":$}var WQ,T,S,_,wZ,I,R,h,V;var c=P(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),wZ=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as wQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var Y$;var V$=P(()=>{d()});var e1={};b(e1,{runStatus:()=>uQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*R$/Q);if(X>R$)X=R$;let q=R$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
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)
@@ -791,4 +791,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
791
791
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
792
792
  `),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
793
793
 
794
- //# debugId=57FA9507F46D167A64756E2164756E21
794
+ //# debugId=377AD6A602B0171564756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.63.1'
60
+ __version__ = '7.64.0'
package/mcp/server.py CHANGED
@@ -15,6 +15,7 @@ Usage:
15
15
  python -m mcp.server --transport http # HTTP mode
16
16
  """
17
17
 
18
+ import asyncio
18
19
  import sys
19
20
  import os
20
21
  import json
@@ -811,7 +812,10 @@ async def loki_task_queue_add(
811
812
  "created_at": datetime.now(timezone.utc).isoformat()
812
813
  }
813
814
 
814
- queue["tasks"].append(task)
815
+ # Use setdefault so an existing-but-malformed queue dict that lacks a
816
+ # "tasks" key does not raise KeyError, consistent with the safe
817
+ # queue.get("tasks", ...) read above.
818
+ queue.setdefault("tasks", []).append(task)
815
819
 
816
820
  # Save using StateManager if available
817
821
  if manager and STATE_MANAGER_AVAILABLE:
@@ -1710,7 +1714,10 @@ async def loki_code_search(
1710
1714
  'type_filter': type_filter})
1711
1715
 
1712
1716
  # Warn-if-stale (default) or opt-in auto-reindex before querying.
1713
- _maybe_autoreindex_code()
1717
+ # _maybe_autoreindex_code runs a synchronous subprocess (timeout up to
1718
+ # 300s) when LOKI_CODE_INDEX_AUTOREINDEX=1; offload it so it cannot
1719
+ # freeze the MCP event loop while the indexer runs.
1720
+ await asyncio.to_thread(_maybe_autoreindex_code)
1714
1721
  _staleness = _code_index_staleness()
1715
1722
 
1716
1723
  collection = _get_chroma_collection()
@@ -1743,7 +1750,10 @@ async def loki_code_search(
1743
1750
  where = {"$and": where_clauses}
1744
1751
 
1745
1752
  try:
1746
- results = collection.query(
1753
+ # collection.query is a blocking HTTP call to ChromaDB; offload it so
1754
+ # the MCP event loop is not frozen for the duration of the request.
1755
+ results = await asyncio.to_thread(
1756
+ collection.query,
1747
1757
  query_texts=[query],
1748
1758
  n_results=n_results,
1749
1759
  where=where,
@@ -2209,6 +2219,13 @@ async def loki_get_co_changes(
2209
2219
  continue
2210
2220
  if not isinstance(pair_files, (list, tuple)) or len(pair_files) != 2:
2211
2221
  continue
2222
+ # Coerce the count to int so a mixed-type producer (one row's
2223
+ # count a string, another's an int) does not raise TypeError when
2224
+ # results are sorted below. Skip rows whose count is not numeric.
2225
+ try:
2226
+ count = int(count)
2227
+ except (ValueError, TypeError):
2228
+ continue
2212
2229
  a, b = pair_files[0], pair_files[1]
2213
2230
  if a == b:
2214
2231
  continue
@@ -2323,6 +2340,7 @@ async def loki_findings(iteration: int = -1) -> str:
2323
2340
  return json.dumps(data)
2324
2341
  reviews_dir = safe_path_join('.loki', 'quality', 'reviews')
2325
2342
  if not os.path.exists(reviews_dir):
2343
+ _emit_tool_event_async('loki_findings', 'complete', result_status='success')
2326
2344
  return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
2327
2345
  candidates = sorted([d for d in os.listdir(reviews_dir)
2328
2346
  if d.startswith('review-')
@@ -2330,6 +2348,7 @@ async def loki_findings(iteration: int = -1) -> str:
2330
2348
  if iteration >= 0:
2331
2349
  candidates = [c for c in candidates if c.endswith(f'-{iteration}')]
2332
2350
  if not candidates:
2351
+ _emit_tool_event_async('loki_findings', 'complete', result_status='success')
2333
2352
  return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
2334
2353
  latest = candidates[-1]
2335
2354
  review_path = safe_path_join('.loki', 'quality', 'reviews', latest)
@@ -2425,9 +2444,14 @@ async def loki_counter_evidence_template(iteration: int) -> str:
2425
2444
  try:
2426
2445
  findings_data = json.loads(await loki_findings(iteration=iteration))
2427
2446
  if 'error' in findings_data:
2447
+ _emit_tool_event_async('loki_counter_evidence_template', 'complete',
2448
+ result_status='error',
2449
+ error=findings_data.get('error'))
2428
2450
  return json.dumps(findings_data)
2429
2451
  findings = findings_data.get('findings', [])
2430
2452
  if not findings:
2453
+ _emit_tool_event_async('loki_counter_evidence_template', 'complete',
2454
+ result_status='success')
2431
2455
  return json.dumps({"iteration": iteration, "template": None,
2432
2456
  "message": f"No findings for iteration {iteration}."})
2433
2457
  evidence = []
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.63.1",
4
+ "version": "7.64.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 8 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.63.1",
5
+ "version": "7.64.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 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",