loki-mode 7.81.0 → 7.82.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/autonomy/run.sh CHANGED
@@ -3979,9 +3979,18 @@ EOF
3979
3979
  if [ -n "$BUDGET_LIMIT" ]; then
3980
3980
  # Validate budget limit is numeric before writing JSON
3981
3981
  if ! echo "$BUDGET_LIMIT" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
3982
- log_warn "Invalid BUDGET_LIMIT '$BUDGET_LIMIT', defaulting to 0"
3983
- BUDGET_LIMIT=0
3982
+ log_warn "Invalid BUDGET_LIMIT '$BUDGET_LIMIT', ignoring (no cap set)"
3983
+ BUDGET_LIMIT=""
3984
3984
  fi
3985
+ # Mirror the CLI guard: a non-positive cap (0/0.00) would make
3986
+ # check_budget_limit pause before any work runs. Treat it as "no cap"
3987
+ # rather than a silent pre-work pause.
3988
+ if [ -n "$BUDGET_LIMIT" ] && ! awk -v b="$BUDGET_LIMIT" 'BEGIN{exit !(b+0 > 0)}'; then
3989
+ log_warn "BUDGET_LIMIT '$BUDGET_LIMIT' is not greater than 0, ignoring (no cap set)"
3990
+ BUDGET_LIMIT=""
3991
+ fi
3992
+ fi
3993
+ if [ -n "$BUDGET_LIMIT" ]; then
3985
3994
  cat > ".loki/metrics/budget.json" << BUDGET_EOF
3986
3995
  {
3987
3996
  "limit": $BUDGET_LIMIT,
@@ -10386,20 +10395,32 @@ check_primary_recovery() {
10386
10395
  # Returns: 0 if rate limit detected, 1 otherwise
10387
10396
  is_rate_limited() {
10388
10397
  local log_file="$1"
10389
-
10390
- # Generic patterns that work across all providers
10391
- # - HTTP 429 status code
10392
- # - "rate limit" / "rate-limit" / "ratelimit" text
10393
- # - "too many requests" text
10394
- # - "quota exceeded" text
10395
- # - "request limit" text
10396
- # - "retry after" / "retry-after" headers
10397
- if grep -qiE '(429|rate.?limit|too many requests|quota exceeded|request limit|retry.?after)' "$log_file" 2>/dev/null; then
10398
+ [ -f "$log_file" ] || return 1
10399
+
10400
+ # Only consider the TAIL of the log: a real provider rate-limit appears at the
10401
+ # END of the iteration (the call that failed), not buried in mid-run prose.
10402
+ # Scanning the whole file false-positived on the agent's OWN output (a build
10403
+ # that printed or generated rate-limiting code -- "rate limit", "429",
10404
+ # "retry-after" as source/text -- wrongly triggered a multi-minute wait).
10405
+ local tail_txt
10406
+ tail_txt=$(tail -n 40 "$log_file" 2>/dev/null) || return 1
10407
+
10408
+ # Require an ERROR CONTEXT, not a bare keyword: a rate-limit token must
10409
+ # co-occur (same line) with a genuine provider-error frame -- an explicit
10410
+ # error word ("Error"/"failed"/"exceeded"), an HTTP/status frame, or the
10411
+ # canonical "429 Too Many Requests" phrasing. This distinguishes a real
10412
+ # failing API line from the words "rate limit"/"retry-after"/"429" appearing
10413
+ # in the model's own generated source or prose. Note: a bare "429" or a bare
10414
+ # "retry-after" is NOT sufficient on its own (both occur in generated code).
10415
+ local _err='(error|errored|failed|exceeded|http[ /]?[0-9]|status[: ]+[0-9]|too many requests)'
10416
+ local _rl='(rate.?limit|too many requests|quota exceeded|request limit|429[ )"]*too many|retry.?after)'
10417
+ if printf '%s\n' "$tail_txt" | grep -qiE "(${_rl}).*(${_err})|(${_err}).*(${_rl})" 2>/dev/null; then
10398
10418
  return 0
10399
10419
  fi
10400
10420
 
10401
- # Claude-specific: "resets Xam/pm" format
10402
- if grep -qE 'resets [0-9]+[ap]m' "$log_file" 2>/dev/null; then
10421
+ # Claude-specific: the explicit "resets Xam/pm" reset-time line is itself an
10422
+ # unambiguous provider rate-limit signal (the CLI only prints it on a limit).
10423
+ if printf '%s\n' "$tail_txt" | grep -qE 'resets [0-9]+[ap]m' 2>/dev/null; then
10403
10424
  return 0
10404
10425
  fi
10405
10426
 
package/completions/_loki CHANGED
@@ -67,8 +67,12 @@ function _loki {
67
67
  _arguments \
68
68
  '--follow[Follow logs in real-time]' \
69
69
  '-f[Follow logs in real-time]' \
70
- '--lines[Number of lines]:number:' \
71
- '-n[Number of lines]:number:'
70
+ '--tail[Number of lines to show]:number:' \
71
+ '-n[Number of lines to show]:number:' \
72
+ '--all[Show all lines]' \
73
+ '-a[Show all lines]' \
74
+ '--help[Show help]' \
75
+ '-h[Show help]'
72
76
  ;;
73
77
  issue)
74
78
  _loki_issue
@@ -150,7 +154,63 @@ function _loki_commands {
150
154
  'share:Share session report as GitHub Gist'
151
155
  'proof:Inspect/share proof-of-run artifacts'
152
156
  'context:Context window management'
157
+ 'ctx:Context window management (alias)'
153
158
  'code:Codebase intelligence'
159
+ 'web:Start the web dashboard'
160
+ 'plan:Preview the build plan for a spec'
161
+ 'report:Reporting commands (cost, kpis, share)'
162
+ 'cost:Show cost of recent runs'
163
+ 'kpis:Key performance indicators'
164
+ 'stats:Session statistics'
165
+ 'preview:Open the running app preview'
166
+ 'deploy:Advisory deploy command (print-only)'
167
+ 'docker:Run Loki in a Docker container'
168
+ 'mcp:MCP server commands'
169
+ 'magic:Spec-driven component generation'
170
+ 'assets:Export/import shareable team assets'
171
+ 'spec:Living spec drift detection'
172
+ 'verify:Deterministic PR verification'
173
+ 'grill:Interrogate a spec before building'
174
+ 'trust:Visible trust trajectory'
175
+ 'trust-metrics:Trust metrics report'
176
+ 'why:Explain the last build outcome'
177
+ 'heal:Legacy system healing'
178
+ 'modernize:Modernize a legacy system'
179
+ 'migrate:Migration commands'
180
+ 'analyze:Analyze a codebase'
181
+ 'compliance:Compliance reporting'
182
+ 'crash:Crash report management'
183
+ 'otel:OpenTelemetry management'
184
+ 'wiki:Wiki commands'
185
+ 'explain:Explain Loki concepts'
186
+ 'optimize:Optimization commands'
187
+ 'review:Code review commands'
188
+ 'export:Export session data'
189
+ 'test:Run tests'
190
+ 'ci:CI helpers'
191
+ 'watch:Watch mode'
192
+ 'audit:Audit commands'
193
+ 'syslog:System log commands'
194
+ 'cluster:Cluster commands'
195
+ 'failover:Failover commands'
196
+ 'remote:Remote execution commands'
197
+ 'worktree:Git worktree commands'
198
+ 'wt:Git worktree commands (alias)'
199
+ 'trigger:Trigger commands'
200
+ 'cleanup:Clean up Loki state'
201
+ 'rollback:Roll back a change'
202
+ 'update:Update Loki Mode'
203
+ 'self-update:Update Loki Mode'
204
+ 'setup-skill:Set up the Claude Code skill'
205
+ 'quickstart:Quickstart guide'
206
+ 'sentrux:Sentrux integration'
207
+ 'state:Inspect run state'
208
+ 'template:PRD template commands'
209
+ 'ultracode:Ultra code review'
210
+ 'voice:Voice commands'
211
+ 'bench:Run benchmarks'
212
+ 'open:Open a project resource'
213
+ 'rc:Release candidate commands'
154
214
  'version:Show version'
155
215
  'completions:Output shell completions'
156
216
  'help:Show help'
@@ -160,7 +220,7 @@ function _loki_commands {
160
220
 
161
221
  function _loki_start {
162
222
  _arguments \
163
- '--provider[AI provider]:provider name:(claude codex gemini)' \
223
+ '--provider[AI provider]:provider name:(claude codex cline aider)' \
164
224
  '--parallel[Parallel mode]' \
165
225
  '--background[Background mode]' \
166
226
  '--bg[Background mode]' \
@@ -5,7 +5,7 @@ _loki_completion() {
5
5
  _init_completion || return
6
6
 
7
7
  # Main subcommands (must match autonomy/loki main case statement)
8
- local main_commands="start quick monitor demo init stop pause resume status dashboard logs serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets doctor watchdog audit metrics syslog onboard share proof explain plan report test ci watch telemetry agent context code run export review optimize heal migrate cluster worktree trigger failover remote version completions help"
8
+ local main_commands="start quick monitor demo init stop pause resume status dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets doctor watchdog audit metrics syslog onboard share proof explain plan report cost kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
9
9
 
10
10
  # 1. If we are on the first argument (subcommand)
11
11
  if [[ $cword -eq 1 ]]; then
@@ -18,7 +18,10 @@ _loki_completion() {
18
18
  start)
19
19
  # If the previous word was --provider, show provider names
20
20
  if [[ "$prev" == "--provider" ]]; then
21
- COMPREPLY=( $(compgen -W "claude codex gemini" -- "$cur") )
21
+ # Active providers (loki rejects gemini since v7.5.18; cline/aider
22
+ # are supported). Keep in sync with providers/loader.sh
23
+ # SUPPORTED_PROVIDERS.
24
+ COMPREPLY=( $(compgen -W "claude codex cline aider" -- "$cur") )
22
25
  return 0
23
26
  fi
24
27
 
@@ -119,7 +122,7 @@ _loki_completion() {
119
122
 
120
123
  logs)
121
124
  if [[ "$cur" == -* ]]; then
122
- COMPREPLY=( $(compgen -W "--follow -f --lines -n --help" -- "$cur") )
125
+ COMPREPLY=( $(compgen -W "--tail -n --all -a --follow -f --help -h" -- "$cur") )
123
126
  return 0
124
127
  fi
125
128
  ;;
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.81.0"
10
+ __version__ = "7.82.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -21,6 +21,7 @@ import logging.handlers
21
21
  import os
22
22
  import socket
23
23
  import sys
24
+ import threading
24
25
  from datetime import datetime, timezone
25
26
  from pathlib import Path
26
27
  from typing import Any, Optional
@@ -47,6 +48,16 @@ _SYSLOG_PROTO = os.environ.get("LOKI_AUDIT_SYSLOG_PROTO", "udp").lower().strip()
47
48
  INTEGRITY_ENABLED = os.environ.get("LOKI_AUDIT_NO_INTEGRITY", "").lower() not in ("true", "1", "yes")
48
49
  _last_hash: str = "0" * 64 # Genesis hash
49
50
 
51
+ # Serializes the chain read-modify-write + file append in log_event(). Without
52
+ # it, concurrent callers (the dashboard fans audit writes out across async
53
+ # handlers / asyncio.to_thread workers) interleave the unsynchronized
54
+ # _last_hash RMW with the file append: lines get written in a different order
55
+ # than the hashes were chained, which breaks the tamper-evident chain so
56
+ # verify_all_logs() reports valid:False even though no entry was tampered with.
57
+ # Holding this lock makes "compute hash, update _last_hash, append the line" a
58
+ # single atomic step so on-disk line order always matches chain order.
59
+ _hash_lock = threading.Lock()
60
+
50
61
 
51
62
  def _recover_last_hash() -> str:
52
63
  """Recover the last integrity hash from the most recent audit log file.
@@ -254,20 +265,35 @@ def log_event(
254
265
  "details": details or {},
255
266
  }
256
267
 
257
- # Tamper-evident chain hash
268
+ # Tamper-evident chain hash + file append, serialized as one atomic step.
269
+ #
270
+ # The chain hash is a read-modify-write of the module-global _last_hash, and
271
+ # the line must land in the file in the same order the hashes were chained.
272
+ # _hash_lock guards the whole "compute hash -> update _last_hash -> append"
273
+ # critical section so concurrent callers cannot interleave and break the
274
+ # chain (see _hash_lock definition above).
275
+ #
276
+ # NOTE for async callers: log_event() does blocking file I/O while holding
277
+ # this lock. When called from an asyncio handler it SHOULD be offloaded with
278
+ # `await asyncio.to_thread(audit.log_event, ...)` so a slow disk does not
279
+ # stall the dashboard event loop. The thread-safety here is what makes that
280
+ # offload safe; rewriting every call site to async is a separate, larger
281
+ # change and is intentionally not done here.
258
282
  global _last_hash
259
- if INTEGRITY_ENABLED:
260
- entry_json = json.dumps(entry, sort_keys=True, default=str)
261
- entry["_integrity_hash"] = _compute_chain_hash(entry_json, _last_hash)
262
- _last_hash = entry["_integrity_hash"]
283
+ with _hash_lock:
284
+ if INTEGRITY_ENABLED:
285
+ entry_json = json.dumps(entry, sort_keys=True, default=str)
286
+ entry["_integrity_hash"] = _compute_chain_hash(entry_json, _last_hash)
287
+ _last_hash = entry["_integrity_hash"]
263
288
 
264
- log_file = _get_current_log_file()
265
- _rotate_logs_if_needed(log_file)
289
+ log_file = _get_current_log_file()
290
+ _rotate_logs_if_needed(log_file)
266
291
 
267
- with open(log_file, "a") as f:
268
- f.write(json.dumps(entry) + "\n")
292
+ with open(log_file, "a") as f:
293
+ f.write(json.dumps(entry) + "\n")
269
294
 
270
- # Forward to syslog if configured
295
+ # Forward to syslog if configured (outside the lock: fire-and-forget and
296
+ # must never extend the critical section / block other writers).
271
297
  _forward_to_syslog(entry)
272
298
 
273
299
  return entry
@@ -140,6 +140,9 @@ def register_project(
140
140
  path: str,
141
141
  name: Optional[str] = None,
142
142
  alias: Optional[str] = None,
143
+ pid: Optional[int] = None,
144
+ port: Optional[int] = None,
145
+ status: Optional[str] = None,
143
146
  ) -> dict:
144
147
  """
145
148
  Register a project in the registry.
@@ -148,6 +151,18 @@ def register_project(
148
151
  path: Absolute path to the project directory
149
152
  name: Display name (defaults to directory name)
150
153
  alias: Short alias for CLI usage
154
+ pid: Live runtime pid for the project's current build (optional)
155
+ port: Live runtime port for the project's current build (optional)
156
+ status: Runtime status override, e.g. "active" / "running" (optional)
157
+
158
+ The pid / port / status kwargs let a caller register a project AND set its
159
+ runtime fields in one atomic, locked load->mutate->save. Before this, the
160
+ runner had to call register_project() and then do a SECOND, unlocked
161
+ load-mutate-save to stamp pid/port/status, which lost-updates a concurrent
162
+ writer (two writers each load the registry, set their own runtime fields,
163
+ and save -- the last save wins and silently drops the other's entry). Set
164
+ these here so the whole change happens inside _registry_lock(). When all
165
+ three are omitted the behavior is unchanged (backward compatible).
151
166
 
152
167
  Returns:
153
168
  The registered project entry
@@ -188,6 +203,16 @@ def register_project(
188
203
  }
189
204
  registry["projects"][project_id] = project
190
205
 
206
+ # Atomically stamp runtime fields inside the same lock (both the create
207
+ # and update branches). None means "leave as-is" so omitting these
208
+ # keeps the legacy behavior.
209
+ if pid is not None:
210
+ project["pid"] = pid
211
+ if port is not None:
212
+ project["port"] = port
213
+ if status is not None:
214
+ project["status"] = status
215
+
191
216
  _save_registry(registry)
192
217
  return project
193
218
 
@@ -438,7 +463,6 @@ def sync_registry_with_discovery() -> dict:
438
463
  Returns:
439
464
  Summary of sync results
440
465
  """
441
- registry = _load_registry()
442
466
  discovered = discover_projects()
443
467
 
444
468
  # Track results
@@ -446,31 +470,50 @@ def sync_registry_with_discovery() -> dict:
446
470
  updated = []
447
471
  missing = []
448
472
 
449
- # Add/update discovered projects
450
- discovered_paths = set()
451
- for project_info in discovered:
452
- path = project_info["path"]
453
- discovered_paths.add(path)
454
- project_id = _generate_project_id(path)
455
-
456
- if project_id not in registry["projects"]:
457
- # New project
458
- project = register_project(path)
459
- added.append(project)
460
- else:
461
- # Update existing
462
- project = registry["projects"][project_id]
463
- project["has_loki_dir"] = True
464
- project["updated_at"] = datetime.now(timezone.utc).isoformat()
465
- updated.append(project)
473
+ # Mutate a single registry copy under the lock and save it ONCE at the end.
474
+ # Previously this loaded the registry, then called register_project() in the
475
+ # loop (which loads+mutates+saves its OWN copy), and finally saved the stale
476
+ # original over the top -- silently dropping every newly added project (the
477
+ # summary said "added: N" while the on-disk registry kept zero of them). All
478
+ # mutations now happen on the same in-memory dict that gets persisted.
479
+ with _registry_lock():
480
+ registry = _load_registry()
466
481
 
467
- # Check for missing projects
468
- for project_id, project in registry["projects"].items():
469
- if not os.path.isdir(project["path"]):
470
- project["status"] = "missing"
471
- missing.append(project)
482
+ # Add/update discovered projects
483
+ for project_info in discovered:
484
+ path = project_info["path"]
485
+ project_id = _generate_project_id(path)
486
+
487
+ if project_id not in registry["projects"]:
488
+ # New project
489
+ now = datetime.now(timezone.utc).isoformat()
490
+ project = {
491
+ "id": project_id,
492
+ "path": path,
493
+ "name": os.path.basename(path),
494
+ "alias": None,
495
+ "registered_at": now,
496
+ "updated_at": now,
497
+ "last_accessed": None,
498
+ "has_loki_dir": os.path.isdir(os.path.join(path, ".loki")),
499
+ "status": "active",
500
+ }
501
+ registry["projects"][project_id] = project
502
+ added.append(project)
503
+ else:
504
+ # Update existing
505
+ project = registry["projects"][project_id]
506
+ project["has_loki_dir"] = True
507
+ project["updated_at"] = datetime.now(timezone.utc).isoformat()
508
+ updated.append(project)
472
509
 
473
- _save_registry(registry)
510
+ # Check for missing projects
511
+ for project_id, project in registry["projects"].items():
512
+ if not os.path.isdir(project["path"]):
513
+ project["status"] = "missing"
514
+ missing.append(project)
515
+
516
+ _save_registry(registry)
474
517
 
475
518
  return {
476
519
  "added": len(added),