loki-mode 6.60.0 → 6.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/app-runner.sh +34 -8
  4. package/autonomy/completion-council.sh +70 -32
  5. package/autonomy/issue-parser.sh +4 -7
  6. package/autonomy/loki +238 -119
  7. package/autonomy/notification-checker.py +49 -23
  8. package/autonomy/run.sh +162 -79
  9. package/autonomy/sandbox.sh +91 -24
  10. package/bin/loki-mode.js +1 -2
  11. package/bin/postinstall.js +10 -4
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/control.py +46 -36
  14. package/dashboard/database.py +21 -4
  15. package/dashboard/server.py +107 -78
  16. package/docs/BUG-AUDIT-v6.61.0.md +957 -0
  17. package/docs/INSTALLATION.md +2 -2
  18. package/events/bus.py +129 -28
  19. package/events/bus.ts +41 -27
  20. package/events/emit.sh +1 -1
  21. package/integrations/openclaw/README.md +139 -0
  22. package/integrations/openclaw/SKILL.md +88 -0
  23. package/integrations/openclaw/bridge/__init__.py +1 -0
  24. package/integrations/openclaw/bridge/__main__.py +88 -0
  25. package/integrations/openclaw/bridge/schema_map.py +180 -0
  26. package/integrations/openclaw/bridge/watcher.py +100 -0
  27. package/integrations/openclaw/scripts/format-progress.sh +80 -0
  28. package/integrations/openclaw/scripts/poll-status.sh +74 -0
  29. package/integrations/vibe-kanban.md +289 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +96 -73
  32. package/memory/consolidation.py +21 -6
  33. package/memory/engine.py +53 -26
  34. package/memory/layers/index_layer.py +16 -3
  35. package/memory/layers/timeline_layer.py +16 -3
  36. package/memory/retrieval.py +4 -1
  37. package/memory/schemas.py +4 -2
  38. package/memory/storage.py +25 -4
  39. package/memory/token_economics.py +9 -2
  40. package/memory/vector_index.py +2 -2
  41. package/package.json +3 -1
  42. package/providers/cline.sh +5 -4
  43. package/providers/codex.sh +27 -5
  44. package/providers/gemini.sh +59 -23
  45. package/providers/loader.sh +3 -2
  46. package/skills/parallel-workflows.md +9 -7
  47. package/state/__init__.py +10 -0
  48. package/state/index.ts +18 -0
  49. package/state/manager.py +1801 -0
  50. package/state/manager.ts +1774 -0
  51. package/state/sqlite_backend.py +188 -0
  52. package/state/test_manager.py +703 -0
  53. package/state/test_manager.ts +366 -0
  54. package/templates/README.md +19 -4
  55. package/templates/dashboard.md +45 -0
  56. package/templates/data-pipeline.md +45 -0
  57. package/templates/game.md +48 -0
  58. package/templates/microservice.md +49 -0
  59. package/templates/npm-library.md +42 -0
  60. package/templates/rest-api.md +170 -33
  61. package/templates/slack-bot.md +48 -0
  62. package/templates/web-scraper.md +45 -0
  63. package/web-app/server.py +360 -191
  64. package/templates/saas-app.md +0 -42
@@ -356,19 +356,24 @@ CREATED_AT=$(date -Iseconds)
356
356
  PARENT_DIR=$PROJECT_DIR
357
357
  EOF
358
358
 
359
- # Save state
359
+ # Save state using python3 for safe JSON encoding (avoids shell injection
360
+ # from paths containing quotes, backslashes, or other special characters)
360
361
  mkdir -p "$(dirname "$WORKTREE_STATE_FILE")"
361
- cat > "$WORKTREE_STATE_FILE" << EOF
362
- {
363
- "sandbox_path": "$sandbox_path",
364
- "sandbox_branch": "$sandbox_branch",
365
- "created_at": "$(date -Iseconds)",
366
- "provider": "$provider",
367
- "prd_path": "$prd_path",
368
- "status": "created",
369
- "isolation_type": "worktree"
362
+ python3 -c "
363
+ import json, sys, datetime
364
+ data = {
365
+ 'sandbox_path': sys.argv[1],
366
+ 'sandbox_branch': sys.argv[2],
367
+ 'created_at': datetime.datetime.now().astimezone().isoformat(),
368
+ 'provider': sys.argv[3],
369
+ 'prd_path': sys.argv[4],
370
+ 'status': 'created',
371
+ 'isolation_type': 'worktree'
370
372
  }
371
- EOF
373
+ with open(sys.argv[5], 'w') as f:
374
+ json.dump(data, f, indent=4)
375
+ f.write('\n')
376
+ " "$sandbox_path" "$sandbox_branch" "$provider" "$prd_path" "$WORKTREE_STATE_FILE"
372
377
 
373
378
  log_success "Worktree sandbox created: $sandbox_path"
374
379
  return 0
@@ -621,18 +626,44 @@ _desktop_install_provider_cli() {
621
626
  }
622
627
 
623
628
  # Build environment variable args for docker sandbox exec
629
+ # Uses printf %q to escape values and prevent shell expansion corruption
630
+ # (API keys can contain $, !, and other special characters)
624
631
  _desktop_build_env_args() {
625
632
  DESKTOP_ENV_ARGS=()
626
- [[ -n "${ANTHROPIC_API_KEY:-}" ]] && DESKTOP_ENV_ARGS+=("-e" "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
627
- [[ -n "${OPENAI_API_KEY:-}" ]] && DESKTOP_ENV_ARGS+=("-e" "OPENAI_API_KEY=$OPENAI_API_KEY")
628
- [[ -n "${GOOGLE_API_KEY:-}" ]] && DESKTOP_ENV_ARGS+=("-e" "GOOGLE_API_KEY=$GOOGLE_API_KEY")
629
- [[ -n "${GITHUB_TOKEN:-}" ]] && DESKTOP_ENV_ARGS+=("-e" "GITHUB_TOKEN=$GITHUB_TOKEN")
630
- [[ -n "${GH_TOKEN:-}" ]] && DESKTOP_ENV_ARGS+=("-e" "GH_TOKEN=$GH_TOKEN")
631
- # Forward all LOKI_* env vars
633
+ local _escaped
634
+ if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then
635
+ printf -v _escaped '%s' "$ANTHROPIC_API_KEY"
636
+ DESKTOP_ENV_ARGS+=("-e" "ANTHROPIC_API_KEY=$_escaped")
637
+ fi
638
+ if [[ -n "${OPENAI_API_KEY:-}" ]]; then
639
+ printf -v _escaped '%s' "$OPENAI_API_KEY"
640
+ DESKTOP_ENV_ARGS+=("-e" "OPENAI_API_KEY=$_escaped")
641
+ fi
642
+ if [[ -n "${GOOGLE_API_KEY:-}" ]]; then
643
+ printf -v _escaped '%s' "$GOOGLE_API_KEY"
644
+ DESKTOP_ENV_ARGS+=("-e" "GOOGLE_API_KEY=$_escaped")
645
+ fi
646
+ if [[ -n "${GITHUB_TOKEN:-}" ]]; then
647
+ printf -v _escaped '%s' "$GITHUB_TOKEN"
648
+ DESKTOP_ENV_ARGS+=("-e" "GITHUB_TOKEN=$_escaped")
649
+ fi
650
+ if [[ -n "${GH_TOKEN:-}" ]]; then
651
+ printf -v _escaped '%s' "$GH_TOKEN"
652
+ DESKTOP_ENV_ARGS+=("-e" "GH_TOKEN=$_escaped")
653
+ fi
654
+ # Forward all LOKI_* env vars via --env-file to avoid shell expansion issues
655
+ local _env_file="${TMPDIR:-/tmp}/loki-sandbox-env-$$"
656
+ local _has_loki_vars=false
632
657
  local var
633
658
  while IFS= read -r var; do
634
- [[ -n "$var" ]] && DESKTOP_ENV_ARGS+=("-e" "$var=${!var}")
659
+ if [[ -n "$var" ]]; then
660
+ printf '%s=%s\n' "$var" "${!var}" >> "$_env_file"
661
+ _has_loki_vars=true
662
+ fi
635
663
  done < <(compgen -v LOKI_ 2>/dev/null || true)
664
+ if [[ "$_has_loki_vars" == "true" ]] && [[ -f "$_env_file" ]]; then
665
+ DESKTOP_ENV_ARGS+=("--env-file" "$_env_file")
666
+ fi
636
667
  }
637
668
 
638
669
  start_docker_desktop_sandbox() {
@@ -669,8 +700,9 @@ start_docker_desktop_sandbox() {
669
700
  # Build environment variable args
670
701
  _desktop_build_env_args
671
702
 
672
- # Build loki command
673
- local loki_cmd="bash ${PROJECT_DIR}/autonomy/run.sh"
703
+ # Build loki command - use SKILL_DIR (the loki-mode install directory) rather than
704
+ # PROJECT_DIR, which is the user's project and may not contain autonomy/run.sh
705
+ local loki_cmd="bash ${SKILL_DIR}/autonomy/run.sh"
674
706
  if [[ -n "$prd_path" ]]; then
675
707
  local abs_prd
676
708
  abs_prd=$(cd "$(dirname "$prd_path")" && pwd)/$(basename "$prd_path")
@@ -1028,8 +1060,14 @@ start_sandbox() {
1028
1060
  # Mount project directory
1029
1061
  if [[ "$SANDBOX_READONLY" == "true" ]]; then
1030
1062
  docker_args+=("--volume" "$PROJECT_DIR:/workspace:ro")
1031
- # Need a writable .loki directory
1032
- docker_args+=("--volume" "loki-sandbox-state:/workspace/.loki:rw")
1063
+ # Need a writable .loki directory - copy existing state to a temp dir so we
1064
+ # do not start with an empty volume (which would lose config/state)
1065
+ local _loki_state_tmp="${TMPDIR:-/tmp}/loki-sandbox-state-$$"
1066
+ mkdir -p "$_loki_state_tmp"
1067
+ if [[ -d "$PROJECT_DIR/.loki" ]]; then
1068
+ cp -a "$PROJECT_DIR/.loki/." "$_loki_state_tmp/" 2>/dev/null || true
1069
+ fi
1070
+ docker_args+=("--volume" "$_loki_state_tmp:/workspace/.loki:rw")
1033
1071
  else
1034
1072
  docker_args+=("--volume" "$PROJECT_DIR:/workspace:rw")
1035
1073
  fi
@@ -1152,8 +1190,11 @@ start_sandbox() {
1152
1190
  local loki_cmd="loki start"
1153
1191
  if [[ -n "$prd_path" ]]; then
1154
1192
  # Convert to container path (handle paths with spaces)
1193
+ # macOS realpath does not support --relative-to, so use Python fallback
1155
1194
  local relative_prd
1156
- relative_prd=$(realpath --relative-to="$PROJECT_DIR" "$prd_path" 2>/dev/null || basename "$prd_path")
1195
+ relative_prd=$(realpath --relative-to="$PROJECT_DIR" "$prd_path" 2>/dev/null \
1196
+ || python3 -c "import os.path; print(os.path.relpath('$prd_path', '$PROJECT_DIR'))" 2>/dev/null \
1197
+ || basename "$prd_path")
1157
1198
  local container_prd="/workspace/${relative_prd}"
1158
1199
  # Quote path to handle spaces
1159
1200
  loki_cmd="$loki_cmd \"$container_prd\""
@@ -1324,6 +1365,17 @@ sandbox_serve() {
1324
1365
 
1325
1366
  log_success "Starting dev server: $serve_cmd"
1326
1367
  log_info ""
1368
+
1369
+ # Warn if the dev server port was not published when the container started
1370
+ local port_published
1371
+ port_published=$(docker port "$CONTAINER_NAME" "$port" 2>/dev/null || true)
1372
+ if [[ -z "$port_published" ]]; then
1373
+ log_warn "Port $port is NOT published to the host."
1374
+ log_warn "The dev server will run inside the container but will not be accessible from localhost."
1375
+ log_warn "To fix, restart the sandbox with: LOKI_EXTRA_PORTS='$port:$port' loki sandbox start"
1376
+ log_info ""
1377
+ fi
1378
+
1327
1379
  log_info "Access the app at:"
1328
1380
  log_info " http://localhost:$port"
1329
1381
  log_info ""
@@ -1658,10 +1710,25 @@ main() {
1658
1710
  done
1659
1711
 
1660
1712
  # Detect sandbox mode for commands that need it
1713
+ # For stop/status/etc, read persisted mode first so we stop the correct sandbox type
1661
1714
  local sandbox_mode=""
1715
+ local _mode_file="${PROJECT_DIR}/.loki/sandbox/mode"
1662
1716
  case "$command" in
1663
- start|stop|status|prompt|shell|logs|run|serve|test|phase)
1717
+ start)
1664
1718
  sandbox_mode=$(detect_sandbox_mode "$mode") || exit 1
1719
+ # Persist the detected mode so stop/status use the same mode
1720
+ mkdir -p "$(dirname "$_mode_file")"
1721
+ echo "$sandbox_mode" > "$_mode_file"
1722
+ ;;
1723
+ stop|status|prompt|shell|logs|run|serve|test|phase)
1724
+ # Read persisted mode if no explicit mode override was given
1725
+ if [[ "$mode" == "auto" ]] && [[ -f "$_mode_file" ]]; then
1726
+ sandbox_mode=$(cat "$_mode_file" 2>/dev/null || true)
1727
+ fi
1728
+ # Fall back to detection if no persisted mode
1729
+ if [[ -z "$sandbox_mode" ]]; then
1730
+ sandbox_mode=$(detect_sandbox_mode "$mode") || exit 1
1731
+ fi
1665
1732
  ;;
1666
1733
  esac
1667
1734
 
package/bin/loki-mode.js CHANGED
@@ -11,8 +11,7 @@ const lokiScript = path.join(__dirname, '..', 'autonomy', 'loki');
11
11
  const args = process.argv.slice(2);
12
12
 
13
13
  const child = spawn(lokiScript, args, {
14
- stdio: 'inherit',
15
- shell: true
14
+ stdio: 'inherit'
16
15
  });
17
16
 
18
17
  child.on('close', (code) => {
@@ -93,11 +93,17 @@ try {
93
93
  const pathDirs = (process.env.PATH || '').split(':');
94
94
  const lokiBinInPath = pathDirs.some(d => d === npmBinDir || d === npmBin);
95
95
 
96
- // On macOS with Homebrew Node, npm global bin path changes on every Node version upgrade.
97
- // Auto-create stable symlinks in /opt/homebrew/bin/ so `loki` always works.
96
+ // On macOS with Homebrew Node, npm global bin path changes on every Node version upgrade
97
+ // (e.g., /opt/homebrew/Cellar/node/22.x/bin -> /opt/homebrew/Cellar/node/23.x/bin).
98
+ // Use the npm prefix bin directory which is stable across upgrades.
98
99
  if (os.platform() === 'darwin') {
99
- const stableDir = '/opt/homebrew/bin';
100
- if (fs.existsSync(stableDir)) {
100
+ let stableDir;
101
+ try {
102
+ stableDir = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() + '/bin';
103
+ } catch {
104
+ stableDir = '/opt/homebrew/bin';
105
+ }
106
+ if (fs.existsSync(stableDir) && stableDir !== npmBinDir) {
101
107
  for (const bin of ['loki', 'loki-mode']) {
102
108
  const src = path.join(npmBinDir, bin);
103
109
  const dest = path.join(stableDir, bin);
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.60.0"
10
+ __version__ = "6.62.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -56,48 +56,58 @@ def atomic_write_json(file_path: Path, data: dict, use_lock: bool = True):
56
56
  """
57
57
  Atomically write JSON data to a file to prevent TOCTOU race conditions.
58
58
  Uses temporary file + os.rename() for atomicity.
59
- Optionally uses fcntl.flock for additional safety.
59
+ Optionally uses fcntl.flock on a dedicated lock file for mutual exclusion.
60
60
  """
61
61
  try:
62
- # Write to temporary file in same directory (for atomic rename)
63
- temp_fd, temp_path = tempfile.mkstemp(
64
- dir=file_path.parent,
65
- prefix=f".{file_path.name}.",
66
- suffix=".tmp"
67
- )
62
+ lock_fd = -1
63
+ lock_path = str(file_path) + ".lock"
64
+
65
+ # Acquire exclusive lock on a dedicated lock file if requested.
66
+ # This ensures all writers to the same target contend on the same
67
+ # lock, unlike the previous approach which locked the temp file
68
+ # (each caller got its own temp file so the lock was a no-op).
69
+ if use_lock:
70
+ try:
71
+ lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
72
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
73
+ except (OSError, AttributeError):
74
+ # flock not available on this platform - continue without lock
75
+ if lock_fd >= 0:
76
+ os.close(lock_fd)
77
+ lock_fd = -1
68
78
 
69
79
  try:
70
- with os.fdopen(temp_fd, 'w') as f:
71
- # Acquire exclusive lock if requested
72
- if use_lock:
73
- try:
74
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
75
- except (OSError, AttributeError):
76
- # flock not available on this platform - continue without lock
77
- pass
78
-
79
- # Write data
80
- json.dump(data, f, indent=2)
81
- f.flush()
82
- os.fsync(f.fileno())
83
-
84
- # Release lock (happens automatically on close, but explicit is clearer)
85
- if use_lock:
86
- try:
87
- fcntl.flock(f.fileno(), fcntl.LOCK_UN)
88
- except (OSError, AttributeError):
89
- pass
90
-
91
- # Atomic rename
92
- os.rename(temp_path, file_path)
80
+ # Write to temporary file in same directory (for atomic rename)
81
+ temp_fd, temp_path = tempfile.mkstemp(
82
+ dir=file_path.parent,
83
+ prefix=f".{file_path.name}.",
84
+ suffix=".tmp"
85
+ )
93
86
 
94
- except Exception:
95
- # Clean up temp file on error
96
87
  try:
97
- os.unlink(temp_path)
98
- except OSError:
99
- pass
100
- raise
88
+ with os.fdopen(temp_fd, 'w') as f:
89
+ json.dump(data, f, indent=2)
90
+ f.flush()
91
+ os.fsync(f.fileno())
92
+
93
+ # Atomic rename
94
+ os.rename(temp_path, file_path)
95
+
96
+ except Exception:
97
+ # Clean up temp file on error
98
+ try:
99
+ os.unlink(temp_path)
100
+ except OSError:
101
+ pass
102
+ raise
103
+
104
+ finally:
105
+ # Release the lock file (close releases flock automatically)
106
+ if lock_fd >= 0:
107
+ try:
108
+ os.close(lock_fd)
109
+ except OSError:
110
+ pass
101
111
 
102
112
  except Exception as e:
103
113
  raise RuntimeError(f"Failed to write {file_path}: {e}")
@@ -4,10 +4,13 @@ Database setup for Loki Mode Dashboard.
4
4
  Uses SQLAlchemy 2.0 with async support and SQLite.
5
5
  """
6
6
 
7
+ import logging
7
8
  import os
8
9
  from contextlib import asynccontextmanager
9
10
  from typing import AsyncGenerator
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
  from sqlalchemy.ext.asyncio import (
12
15
  AsyncSession,
13
16
  async_sessionmaker,
@@ -40,11 +43,14 @@ async_session_factory = async_sessionmaker(
40
43
 
41
44
  async def init_db() -> None:
42
45
  """Initialize the database, creating all tables."""
43
- # Ensure database directory exists
44
46
  os.makedirs(DATABASE_DIR, exist_ok=True)
45
-
46
- async with engine.begin() as conn:
47
- await conn.run_sync(Base.metadata.create_all)
47
+ try:
48
+ async with engine.begin() as conn:
49
+ await conn.run_sync(Base.metadata.create_all)
50
+ logger.info("Database initialized at %s", DATABASE_PATH)
51
+ except Exception as exc:
52
+ logger.error("Database initialization failed: %s", exc, exc_info=True)
53
+ raise
48
54
 
49
55
 
50
56
  async def close_db() -> None:
@@ -52,6 +58,17 @@ async def close_db() -> None:
52
58
  await engine.dispose()
53
59
 
54
60
 
61
+ async def check_db_health() -> bool:
62
+ """Check if the database is accessible."""
63
+ try:
64
+ async with async_session_factory() as session:
65
+ from sqlalchemy import text
66
+ await session.execute(text("SELECT 1"))
67
+ return True
68
+ except Exception:
69
+ return False
70
+
71
+
55
72
  @asynccontextmanager
56
73
  async def get_session() -> AsyncGenerator[AsyncSession, None]:
57
74
  """Get an async database session."""