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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +34 -8
- package/autonomy/completion-council.sh +70 -32
- package/autonomy/issue-parser.sh +4 -7
- package/autonomy/loki +238 -119
- package/autonomy/notification-checker.py +49 -23
- package/autonomy/run.sh +162 -79
- package/autonomy/sandbox.sh +91 -24
- package/bin/loki-mode.js +1 -2
- package/bin/postinstall.js +10 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +46 -36
- package/dashboard/database.py +21 -4
- package/dashboard/server.py +107 -78
- package/docs/BUG-AUDIT-v6.61.0.md +957 -0
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +129 -28
- package/events/bus.ts +41 -27
- package/events/emit.sh +1 -1
- package/integrations/openclaw/README.md +139 -0
- package/integrations/openclaw/SKILL.md +88 -0
- package/integrations/openclaw/bridge/__init__.py +1 -0
- package/integrations/openclaw/bridge/__main__.py +88 -0
- package/integrations/openclaw/bridge/schema_map.py +180 -0
- package/integrations/openclaw/bridge/watcher.py +100 -0
- package/integrations/openclaw/scripts/format-progress.sh +80 -0
- package/integrations/openclaw/scripts/poll-status.sh +74 -0
- package/integrations/vibe-kanban.md +289 -0
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +96 -73
- package/memory/consolidation.py +21 -6
- package/memory/engine.py +53 -26
- package/memory/layers/index_layer.py +16 -3
- package/memory/layers/timeline_layer.py +16 -3
- package/memory/retrieval.py +4 -1
- package/memory/schemas.py +4 -2
- package/memory/storage.py +25 -4
- package/memory/token_economics.py +9 -2
- package/memory/vector_index.py +2 -2
- package/package.json +3 -1
- package/providers/cline.sh +5 -4
- package/providers/codex.sh +27 -5
- package/providers/gemini.sh +59 -23
- package/providers/loader.sh +3 -2
- package/skills/parallel-workflows.md +9 -7
- package/state/__init__.py +10 -0
- package/state/index.ts +18 -0
- package/state/manager.py +1801 -0
- package/state/manager.ts +1774 -0
- package/state/sqlite_backend.py +188 -0
- package/state/test_manager.py +703 -0
- package/state/test_manager.ts +366 -0
- package/templates/README.md +19 -4
- package/templates/dashboard.md +45 -0
- package/templates/data-pipeline.md +45 -0
- package/templates/game.md +48 -0
- package/templates/microservice.md +49 -0
- package/templates/npm-library.md +42 -0
- package/templates/rest-api.md +170 -33
- package/templates/slack-bot.md +48 -0
- package/templates/web-scraper.md +45 -0
- package/web-app/server.py +360 -191
- package/templates/saas-app.md +0 -42
package/autonomy/sandbox.sh
CHANGED
|
@@ -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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
627
|
-
[[ -n "${
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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" ]]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
package/bin/postinstall.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
100
|
-
|
|
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);
|
package/dashboard/__init__.py
CHANGED
package/dashboard/control.py
CHANGED
|
@@ -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
|
|
59
|
+
Optionally uses fcntl.flock on a dedicated lock file for mutual exclusion.
|
|
60
60
|
"""
|
|
61
61
|
try:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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}")
|
package/dashboard/database.py
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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."""
|