eagle-mem 4.12.1 → 4.13.1

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 (40) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +4 -0
  3. package/db/migrate.sh +11 -1
  4. package/docs/agent-compatibility/claude-code.md +27 -0
  5. package/docs/agent-compatibility/codex.md +1 -0
  6. package/docs/reviews/2026-06-10-full-spectrum-hardening.md +90 -0
  7. package/hooks/post-tool-use.sh +73 -22
  8. package/hooks/session-end.sh +10 -0
  9. package/hooks/session-start.sh +24 -1
  10. package/hooks/stop.sh +7 -2
  11. package/integrations/google_antigravity_hook.py +61 -26
  12. package/lib/codex-hooks.sh +5 -1
  13. package/lib/common.sh +104 -4
  14. package/lib/db-core.sh +28 -0
  15. package/lib/db-events.sh +13 -0
  16. package/lib/db-observations.sh +10 -3
  17. package/lib/db-sessions.sh +10 -1
  18. package/lib/db-summaries.sh +4 -1
  19. package/lib/hooks-sessionstart.sh +32 -13
  20. package/lib/hooks.sh +37 -0
  21. package/lib/provider.sh +10 -2
  22. package/lib/updater.sh +16 -2
  23. package/package.json +1 -1
  24. package/scripts/enrich-summary.sh +4 -1
  25. package/scripts/install.sh +3 -41
  26. package/scripts/logs.sh +44 -12
  27. package/scripts/orchestrate.sh +34 -4
  28. package/scripts/session.sh +5 -0
  29. package/scripts/statusline-em.sh +5 -1
  30. package/scripts/tasks.sh +6 -3
  31. package/scripts/test.sh +31 -3
  32. package/scripts/update.sh +3 -17
  33. package/tests/test_compaction_survival_matrix.sh +13 -1
  34. package/tests/test_context_budget.sh +117 -0
  35. package/tests/test_data_integrity_hardening.sh +115 -0
  36. package/tests/test_mod_tracker_concurrency.sh +142 -0
  37. package/tests/test_redaction_coverage.sh +183 -0
  38. package/tests/test_reliability_retention.sh +75 -0
  39. package/tests/test_rust_migration_plan.sh +8 -1
  40. package/tests/test_test_runner_no_abort.sh +86 -0
package/lib/common.sh CHANGED
@@ -456,7 +456,9 @@ eagle_get_session_project_light() {
456
456
 
457
457
  local sid_sql project
458
458
  sid_sql=$(eagle_sql_escape "$session_id")
459
- project=$("$sqlite_bin" "$EAGLE_MEM_DB" "SELECT project FROM sessions WHERE id = '$sid_sql' AND project != '' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
459
+ # busy_timeout so a momentary SQLITE_BUSY waits for the lock instead of
460
+ # exiting non-zero and being misread as "session has no project" (fail-open).
461
+ project=$("$sqlite_bin" "$EAGLE_MEM_DB" "PRAGMA busy_timeout=10000; SELECT project FROM sessions WHERE id = '$sid_sql' AND project != '' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
460
462
  [ -n "$project" ] || return 1
461
463
  printf '%s\n' "$project"
462
464
  }
@@ -465,6 +467,13 @@ eagle_project_has_table_row() {
465
467
  local table="${1:-}"
466
468
  local project="${2:-}"
467
469
  [ -n "$table" ] && [ -n "$project" ] || return 1
470
+ # The table name is interpolated raw (SQLite cannot bind identifiers), so
471
+ # allowlist the only callers' tables. Reject anything else rather than risk
472
+ # identifier injection if a future caller passes a non-constant.
473
+ case "$table" in
474
+ agent_memories|summaries|observations|agent_tasks|agent_plans|sessions) ;;
475
+ *) return 1 ;;
476
+ esac
468
477
  local sqlite_bin
469
478
  sqlite_bin=$(eagle_sqlite_path)
470
479
  [ -n "$sqlite_bin" ] || return 1
@@ -472,7 +481,9 @@ eagle_project_has_table_row() {
472
481
 
473
482
  local project_sql found
474
483
  project_sql=$(eagle_sql_escape "$project")
475
- found=$("$sqlite_bin" "$EAGLE_MEM_DB" "SELECT 1 FROM $table WHERE project = '$project_sql' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
484
+ # busy_timeout so a momentary SQLITE_BUSY waits for the lock instead of
485
+ # exiting non-zero and being misread as "row doesn't exist" (fail-open).
486
+ found=$("$sqlite_bin" "$EAGLE_MEM_DB" "PRAGMA busy_timeout=10000; SELECT 1 FROM $table WHERE project = '$project_sql' LIMIT 1;" 2>/dev/null | awk 'NF { print; exit }')
476
487
  [ "$found" = "1" ]
477
488
  }
478
489
 
@@ -1103,6 +1114,62 @@ eagle_read_guard_block_threshold() {
1103
1114
  printf '%s\n' "$threshold"
1104
1115
  }
1105
1116
 
1117
+ # Generous ceiling (chars) on the recall body SessionStart injects. Typical
1118
+ # recall is a few KB; this only clips pathological projects (huge/many
1119
+ # summaries, lanes, etc.). It is NOT meant to shrink normal output. Default
1120
+ # 24000 chars (~6K tokens).
1121
+ eagle_sessionstart_inject_budget() {
1122
+ local budget
1123
+ if declare -F eagle_config_get >/dev/null 2>&1; then
1124
+ budget=$(eagle_config_get "context_budget" "sessionstart_chars" "24000")
1125
+ else
1126
+ budget=$(eagle_config_get_light "context_budget" "sessionstart_chars" "24000")
1127
+ fi
1128
+ case "$budget" in *[!0-9]*|"") budget=24000 ;; esac
1129
+ # Refuse a self-defeating tiny budget; floor keeps the top sections intact.
1130
+ [ "$budget" -lt 4000 ] 2>/dev/null && budget=4000
1131
+ printf '%s\n' "$budget"
1132
+ }
1133
+
1134
+ # Trim a SessionStart recall body to a char budget by dropping whole
1135
+ # "=== Eagle Mem: ..." sections from the END (lowest priority appended last)
1136
+ # until the body fits. The body is passed on stdin; trimmed body is printed on
1137
+ # stdout; the count of dropped sections is printed on fd 3 if open, else to
1138
+ # the EAGLE_INJECT_TRIM_COUNT file when set. Sections are never split — a
1139
+ # section is kept whole or dropped whole, so no surface is half-emitted.
1140
+ eagle_trim_inject_body() {
1141
+ local budget="$1"
1142
+ local body; body=$(cat)
1143
+ local dropped=0
1144
+
1145
+ if [ "${#body}" -le "$budget" ] 2>/dev/null; then
1146
+ printf '%s' "$body"
1147
+ [ -n "${EAGLE_INJECT_TRIM_COUNT:-}" ] && printf '0' > "$EAGLE_INJECT_TRIM_COUNT" 2>/dev/null
1148
+ return 0
1149
+ fi
1150
+
1151
+ # Iteratively remove the last "=== Eagle Mem:" section (and everything
1152
+ # after it) until we fit or only the first section remains.
1153
+ while [ "${#body}" -gt "$budget" ] 2>/dev/null; do
1154
+ # Byte offset of the last section header.
1155
+ local last_idx="${body##*=== Eagle Mem:}"
1156
+ # No (more) section headers, or only one section left → stop trimming.
1157
+ if [ "$last_idx" = "$body" ]; then
1158
+ break
1159
+ fi
1160
+ local head="${body%=== Eagle Mem:*}"
1161
+ # If trimming would leave nothing, keep at least the first section.
1162
+ case "$head" in
1163
+ *"=== Eagle Mem:"*) body="$head"; dropped=$((dropped + 1)) ;;
1164
+ *) break ;;
1165
+ esac
1166
+ done
1167
+
1168
+ printf '%s' "$body"
1169
+ [ -n "${EAGLE_INJECT_TRIM_COUNT:-}" ] && printf '%s' "$dropped" > "$EAGLE_INJECT_TRIM_COUNT" 2>/dev/null
1170
+ return 0
1171
+ }
1172
+
1106
1173
  eagle_raw_output_command_needs_guard() {
1107
1174
  local cmd="$1"
1108
1175
  local first
@@ -1378,8 +1445,32 @@ eagle_read_stdin() {
1378
1445
  # Redact secrets from text before storage.
1379
1446
  # Covers: Bearer tokens, API keys, passwords, secrets, tokens,
1380
1447
  # Stripe/AWS/GitHub/Anthropic/OpenAI key patterns, named env vars.
1448
+ # Read configured [redaction] extra_patterns (TOML array) into a list of regexes,
1449
+ # one per line. Parses `extra_patterns = ["A.*", "B-[0-9]+"]` from config.toml.
1450
+ # Lines beginning with '#' (the commented default) are ignored by the config reader.
1451
+ eagle_redaction_extra_patterns() {
1452
+ local raw
1453
+ if declare -F eagle_config_get >/dev/null 2>&1; then
1454
+ raw=$(eagle_config_get "redaction" "extra_patterns" "" 2>/dev/null)
1455
+ else
1456
+ raw=$(eagle_config_get_light "redaction" "extra_patterns" "" 2>/dev/null)
1457
+ fi
1458
+ [ -n "$raw" ] || return 0
1459
+ # Strip surrounding [ ] brackets, then extract each double/single-quoted
1460
+ # pattern. Using awk avoids splitting on commas that appear inside a regex
1461
+ # character class (e.g. [A-Z,0-9]).
1462
+ printf '%s' "$raw" \
1463
+ | sed -E 's/^[[:space:]]*\[//; s/\][[:space:]]*$//' \
1464
+ | grep -oE '"[^"]*"|'"'"'[^'"'"']*'"'"'' \
1465
+ | sed -E 's/^["'"'"']//; s/["'"'"']$//' \
1466
+ | while IFS= read -r pat; do
1467
+ [ -n "$pat" ] && printf '%s\n' "$pat"
1468
+ done
1469
+ }
1470
+
1381
1471
  eagle_redact() {
1382
- sed -E \
1472
+ local _redacted
1473
+ _redacted=$(sed -E \
1383
1474
  -e 's/(Bearer )[^[:space:],;"'"'"']*/\1[REDACTED]/gi' \
1384
1475
  -e 's/(api[_-]?key[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/gi' \
1385
1476
  -e 's/(password[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/gi' \
@@ -1407,7 +1498,16 @@ eagle_redact() {
1407
1498
  -e 's/(OPENAI_API_KEY[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/g' \
1408
1499
  -e 's/(GOOGLE_API_KEY[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/g' \
1409
1500
  -e 's/(SLACK_TOKEN[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/g' \
1410
- -e 's/(DATABASE_URL[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/g'
1501
+ -e 's/(DATABASE_URL[= :])[^[:space:],;"'"'"']*/\1[REDACTED]/g')
1502
+
1503
+ # Apply user-configured extra patterns (if any) on top of the built-in set.
1504
+ local pat
1505
+ while IFS= read -r pat; do
1506
+ [ -n "$pat" ] || continue
1507
+ _redacted=$(printf '%s' "$_redacted" | sed -E "s/${pat}/[REDACTED]/g" 2>/dev/null) || true
1508
+ done < <(eagle_redaction_extra_patterns)
1509
+
1510
+ printf '%s\n' "$_redacted"
1411
1511
  }
1412
1512
 
1413
1513
  # Collect project files into a destination file.
package/lib/db-core.sh CHANGED
@@ -14,6 +14,13 @@ PRAGMA foreign_keys=ON;
14
14
  PRAGMA trusted_schema=ON;
15
15
  .output stdout"
16
16
 
17
+ # eagle_db — CONTRACT: continue-on-error (NO `.bail on`). When passed
18
+ # multi-statement SQL, sqlite3 runs every statement even if an earlier one
19
+ # errors. Many callers depend on this best-effort behavior (e.g. probing for a
20
+ # table then querying it, or fire-and-forget multi-table writes). The return
21
+ # code reflects sqlite3's exit status (non-zero if the LAST statement failed),
22
+ # and any stderr is mirrored to the log. If you need fail-fast atomicity across
23
+ # statements, use eagle_db_strict (or eagle_db_pipe, which also sets `.bail on`).
17
24
  eagle_db() {
18
25
  local _eagle_sqlite_bin
19
26
  _eagle_sqlite_bin=$(eagle_sqlite_path)
@@ -31,6 +38,27 @@ eagle_db() {
31
38
  return $_eagle_db_rc
32
39
  }
33
40
 
41
+ # eagle_db_strict — same as eagle_db but with `.bail on`, so a multi-statement
42
+ # script aborts on the FIRST error instead of continuing. Use for fail-fast
43
+ # transactions (BEGIN/.../COMMIT) where a mid-script error must not commit a
44
+ # partially-applied result. Single-statement callers can use either function.
45
+ eagle_db_strict() {
46
+ local _eagle_sqlite_bin
47
+ _eagle_sqlite_bin=$(eagle_sqlite_path)
48
+ [ -n "$_eagle_sqlite_bin" ] || return 1
49
+ local _eagle_db_err
50
+ _eagle_db_err=$(mktemp 2>/dev/null || echo "/tmp/_eagle_db_strict_err.$$")
51
+ local _eagle_db_out
52
+ _eagle_db_out=$({ echo "$EAGLE_DB_SETUP"; echo ".bail on"; echo "$*"; } | "$_eagle_sqlite_bin" "$EAGLE_MEM_DB" 2>"$_eagle_db_err")
53
+ local _eagle_db_rc=$?
54
+ if [ -s "$_eagle_db_err" ]; then
55
+ cat "$_eagle_db_err" >> "$EAGLE_MEM_LOG" 2>/dev/null
56
+ fi
57
+ rm -f "$_eagle_db_err" 2>/dev/null
58
+ [ -n "$_eagle_db_out" ] && printf '%s\n' "$_eagle_db_out"
59
+ return $_eagle_db_rc
60
+ }
61
+
34
62
  eagle_db_pipe() {
35
63
  local _eagle_sqlite_bin
36
64
  _eagle_sqlite_bin=$(eagle_sqlite_path)
package/lib/db-events.sh CHANGED
@@ -41,6 +41,19 @@ eagle_insert_event() {
41
41
  );" >/dev/null 2>&1 || true
42
42
  }
43
43
 
44
+ # eagle_events is hook-observability telemetry written on every hook fire, so it
45
+ # grows much faster than any user-data table and was previously never pruned.
46
+ # Bound it by age at SessionEnd (mirrors eagle_prune_observations).
47
+ eagle_prune_events() {
48
+ local days; days=$(eagle_sql_int "${1:-30}")
49
+ local project_filter=""
50
+ if [ -n "${2:-}" ]; then
51
+ local proj; proj=$(eagle_sql_escape "$2")
52
+ project_filter="AND project = '$proj'"
53
+ fi
54
+ eagle_db "DELETE FROM eagle_events WHERE created_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-$days days') $project_filter;" >/dev/null 2>&1 || true
55
+ }
56
+
44
57
  eagle_hook_observability_begin() {
45
58
  local input="$1"
46
59
  local default_hook="$2"
@@ -24,7 +24,13 @@ eagle_insert_observation() {
24
24
  extra_vals=", $(eagle_sql_int "$output_bytes"), $(eagle_sql_int "$output_lines"), '$command_category'"
25
25
  fi
26
26
 
27
- eagle_db "INSERT INTO observations (session_id, project, agent, tool_name, tool_input_summary, files_read, files_modified${extra_cols})
27
+ # BEGIN IMMEDIATE serializes the check-then-insert under the write lock so
28
+ # two concurrent hooks can't both pass NOT EXISTS and double-insert (the
29
+ # second blocks via busy_timeout, then sees the first row). Without it the
30
+ # 5-second dedup window is racy. eagle_db_strict (.bail on) ensures a failed
31
+ # INSERT aborts before COMMIT so the transaction rolls back cleanly.
32
+ eagle_db_strict "BEGIN IMMEDIATE;
33
+ INSERT INTO observations (session_id, project, agent, tool_name, tool_input_summary, files_read, files_modified${extra_cols})
28
34
  SELECT '$session_id', '$project', '$agent', '$tool_name', '$tool_input_summary', '$files_read', '$files_modified'${extra_vals}
29
35
  WHERE NOT EXISTS (
30
36
  SELECT 1 FROM observations
@@ -32,7 +38,8 @@ eagle_insert_observation() {
32
38
  AND tool_name = '$tool_name'
33
39
  AND tool_input_summary = '$tool_input_summary'
34
40
  AND created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-5 seconds')
35
- );"
41
+ );
42
+ COMMIT;"
36
43
  }
37
44
 
38
45
  eagle_insert_recall_event() {
@@ -40,7 +47,7 @@ eagle_insert_recall_event() {
40
47
  local project; project=$(eagle_sql_escape "${2:-}")
41
48
  local cwd; cwd=$(eagle_sql_escape "${3:-}")
42
49
  local agent; agent=$(eagle_sql_escape "${4:-$(eagle_agent_source)}")
43
- local prompt_snippet; prompt_snippet=$(eagle_sql_escape "$(eagle_trim_text "${5:-}" 240)")
50
+ local prompt_snippet; prompt_snippet=$(eagle_sql_escape "$(eagle_trim_text "${5:-}" 240 | eagle_redact)")
44
51
  local fts_query; fts_query=$(eagle_sql_escape "${6:-}")
45
52
  local summary_matches; summary_matches=$(eagle_sql_int "${7:-0}")
46
53
  local memory_matches; memory_matches=$(eagle_sql_int "${8:-0}")
@@ -53,7 +53,12 @@ eagle_upsert_session() {
53
53
  fi
54
54
 
55
55
  if [ "$needs_project_repair" = "1" ]; then
56
- eagle_db_pipe <<SQL >/dev/null 2>&1
56
+ # Capture rc instead of >/dev/null 2>&1 swallowing it: a failed repair
57
+ # must be distinguishable from success. eagle_db_pipe (.bail on) already
58
+ # mirrors sqlite stderr to EAGLE_MEM_LOG; we additionally log a clear
59
+ # marker so a half-applied repair is greppable. Happy path is unchanged.
60
+ local _repair_rc
61
+ eagle_db_pipe >/dev/null <<SQL
57
62
  BEGIN;
58
63
  UPDATE summaries SET project = '$project' WHERE session_id = '$session_id' AND project != '$project';
59
64
  UPDATE observations SET project = '$project' WHERE session_id = '$session_id' AND project != '$project';
@@ -146,6 +151,10 @@ UPDATE pending_feature_verifications SET project = '$project' WHERE source_sessi
146
151
  UPDATE sessions SET project = '$project' WHERE id = '$session_id' AND project != '$project';
147
152
  COMMIT;
148
153
  SQL
154
+ _repair_rc=$?
155
+ if [ "$_repair_rc" -ne 0 ]; then
156
+ eagle_log "ERROR" "session project repair failed (rc=$_repair_rc) session=$session_id_raw project=$project_raw — child rows may remain under a stale project key; see prior sqlite errors in this log"
157
+ fi
149
158
  fi
150
159
  }
151
160
 
@@ -42,7 +42,10 @@ VALUES (
42
42
  '$capture_source'
43
43
  )
44
44
  ON CONFLICT(session_id) DO UPDATE SET
45
- project = excluded.project,
45
+ project = CASE
46
+ WHEN summaries.capture_source = 'agent' THEN summaries.project
47
+ ELSE excluded.project
48
+ END,
46
49
  agent = COALESCE(NULLIF(excluded.agent, ''), summaries.agent),
47
50
  request = COALESCE(NULLIF(excluded.request, ''), summaries.request),
48
51
  investigated = COALESCE(NULLIF(excluded.investigated, ''), summaries.investigated),
@@ -31,6 +31,17 @@ _eagle_state_touch() {
31
31
  touch "$state_file"
32
32
  }
33
33
 
34
+ # In-flight markers debounce concurrent SessionStart spawns WITHOUT doubling as
35
+ # the freshness marker. The freshness marker ("$key") is touched ONLY by the
36
+ # background job on genuine success, so a job that crashes, is killed, or exits 0
37
+ # without producing output never blocks retry for a full day — it leaves at most
38
+ # a short-lived in-flight marker that ages out in minutes. Checked in minutes.
39
+ _eagle_state_inflight_fresh() {
40
+ local key="$1" project="$2" max_age_min="${3:-15}"
41
+ local state_file; state_file=$(_eagle_state_file "${key}-inflight" "$project")
42
+ [ -f "$state_file" ] && [ -z "$(find "$state_file" -mmin +"${max_age_min}" 2>/dev/null)" ]
43
+ }
44
+
34
45
  eagle_sessionstart_auto_provision() {
35
46
  local project="$1" cwd="$2" scripts_dir="$3"
36
47
  local needs_scan=false needs_index=false
@@ -38,7 +49,7 @@ eagle_sessionstart_auto_provision() {
38
49
  # Auto-scan: no overview exists
39
50
  local overview
40
51
  overview=$(eagle_get_overview "$project")
41
- if [ -z "$overview" ] && ! _eagle_state_fresh "scan" "$project" 1; then
52
+ if [ -z "$overview" ] && ! _eagle_state_fresh "scan" "$project" 1 && ! _eagle_state_inflight_fresh "scan" "$project" 15; then
42
53
  needs_scan=true
43
54
  fi
44
55
 
@@ -46,22 +57,25 @@ eagle_sessionstart_auto_provision() {
46
57
  local chunk_count
47
58
  chunk_count=$(eagle_db "SELECT COUNT(*) FROM code_chunks WHERE project = '$(eagle_sql_escape "$project")';" 2>/dev/null)
48
59
  chunk_count=${chunk_count:-0}
49
- if [ "$chunk_count" -eq 0 ] && ! _eagle_state_fresh "index" "$project" 1; then
60
+ if [ "$chunk_count" -eq 0 ] && ! _eagle_state_fresh "index" "$project" 1 && ! _eagle_state_inflight_fresh "index" "$project" 15; then
50
61
  needs_index=true
51
- elif [ "$chunk_count" -gt 0 ] && ! _eagle_state_fresh "index" "$project" 7; then
62
+ elif [ "$chunk_count" -gt 0 ] && ! _eagle_state_fresh "index" "$project" 7 && ! _eagle_state_inflight_fresh "index" "$project" 15; then
52
63
  needs_index=true
53
64
  fi
54
65
 
55
66
  if [ "$needs_scan" = true ] && [ "$needs_index" = true ]; then
56
67
  eagle_log "INFO" "SessionStart: first-session provision — scan then index"
57
- _eagle_state_touch "scan" "$project"
58
- _eagle_state_touch "index" "$project"
68
+ _eagle_state_touch "scan-inflight" "$project"
69
+ _eagle_state_touch "index-inflight" "$project"
59
70
  scan_state=$(_eagle_state_file "scan" "$project")
71
+ scan_inflight=$(_eagle_state_file "scan-inflight" "$project")
60
72
  index_state=$(_eagle_state_file "index" "$project")
73
+ index_inflight=$(_eagle_state_file "index-inflight" "$project")
61
74
  nohup bash -c '
62
- scripts_dir="$1"; cwd="$2"; log="$3"; scan_state="$4"; index_state="$5"
75
+ scripts_dir="$1"; cwd="$2"; log="$3"; scan_state="$4"; scan_inflight="$5"; index_state="$6"; index_inflight="$7"
63
76
  bash "$scripts_dir/scan.sh" "$cwd" >> "$log" 2>&1
64
77
  scan_rc=$?
78
+ rm -f "$scan_inflight" 2>/dev/null || true
65
79
  if [ "$scan_rc" -eq 0 ]; then
66
80
  touch "$scan_state" 2>/dev/null || true
67
81
  else
@@ -71,6 +85,7 @@ eagle_sessionstart_auto_provision() {
71
85
 
72
86
  bash "$scripts_dir/index.sh" "$cwd" >> "$log" 2>&1
73
87
  index_rc=$?
88
+ rm -f "$index_inflight" 2>/dev/null || true
74
89
  if [ "$index_rc" -eq 0 ]; then
75
90
  touch "$index_state" 2>/dev/null || true
76
91
  else
@@ -80,15 +95,17 @@ eagle_sessionstart_auto_provision() {
80
95
 
81
96
  [ "$scan_rc" -eq 0 ] && exit "$index_rc"
82
97
  exit "$scan_rc"
83
- ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$scan_state" "$index_state" &
98
+ ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$scan_state" "$scan_inflight" "$index_state" "$index_inflight" &
84
99
  elif [ "$needs_scan" = true ]; then
85
100
  eagle_log "INFO" "SessionStart: auto-scan triggered"
86
- _eagle_state_touch "scan" "$project"
101
+ _eagle_state_touch "scan-inflight" "$project"
87
102
  scan_state=$(_eagle_state_file "scan" "$project")
103
+ scan_inflight=$(_eagle_state_file "scan-inflight" "$project")
88
104
  nohup bash -c '
89
- scripts_dir="$1"; cwd="$2"; log="$3"; scan_state="$4"
105
+ scripts_dir="$1"; cwd="$2"; log="$3"; scan_state="$4"; scan_inflight="$5"
90
106
  bash "$scripts_dir/scan.sh" "$cwd" >> "$log" 2>&1
91
107
  rc=$?
108
+ rm -f "$scan_inflight" 2>/dev/null || true
92
109
  if [ "$rc" -eq 0 ]; then
93
110
  touch "$scan_state" 2>/dev/null || true
94
111
  else
@@ -96,15 +113,17 @@ eagle_sessionstart_auto_provision() {
96
113
  printf "[%s] [ERROR] SessionStart: auto-scan failed rc=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$rc" >> "$log" 2>/dev/null || true
97
114
  fi
98
115
  exit "$rc"
99
- ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$scan_state" &
116
+ ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$scan_state" "$scan_inflight" &
100
117
  elif [ "$needs_index" = true ]; then
101
118
  eagle_log "INFO" "SessionStart: auto-index triggered"
102
- _eagle_state_touch "index" "$project"
119
+ _eagle_state_touch "index-inflight" "$project"
103
120
  index_state=$(_eagle_state_file "index" "$project")
121
+ index_inflight=$(_eagle_state_file "index-inflight" "$project")
104
122
  nohup bash -c '
105
- scripts_dir="$1"; cwd="$2"; log="$3"; index_state="$4"
123
+ scripts_dir="$1"; cwd="$2"; log="$3"; index_state="$4"; index_inflight="$5"
106
124
  bash "$scripts_dir/index.sh" "$cwd" >> "$log" 2>&1
107
125
  rc=$?
126
+ rm -f "$index_inflight" 2>/dev/null || true
108
127
  if [ "$rc" -eq 0 ]; then
109
128
  touch "$index_state" 2>/dev/null || true
110
129
  else
@@ -112,7 +131,7 @@ eagle_sessionstart_auto_provision() {
112
131
  printf "[%s] [ERROR] SessionStart: auto-index failed rc=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$rc" >> "$log" 2>/dev/null || true
113
132
  fi
114
133
  exit "$rc"
115
- ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$index_state" &
134
+ ' eagle-auto "$scripts_dir" "$cwd" "$EAGLE_MEM_LOG" "$index_state" "$index_inflight" &
116
135
  fi
117
136
  }
118
137
 
package/lib/hooks.sh CHANGED
@@ -100,3 +100,40 @@ eagle_patch_hook() {
100
100
  [ -n "$description" ] && eagle_ok "$description"
101
101
  return 0
102
102
  }
103
+
104
+ # Register (idempotently) the full Claude Code hook set into a settings.json.
105
+ # Single source of truth for the event→matcher→script mapping so install.sh and
106
+ # update.sh can never drift (the historical bug class). Requires $EAGLE_MEM_DIR.
107
+ # $1 = settings.json path
108
+ # $2 = "verbose" to print a "✓ <Event> hook" line per registration (installer);
109
+ # omitted/anything else = quiet (updater prints its own summary line).
110
+ eagle_register_claude_hooks() {
111
+ local settings="$1"
112
+ local V=""
113
+ [ "${2:-}" = "verbose" ] && V=1
114
+
115
+ # Clean old registrations before re-registering (handles matcher changes across versions).
116
+ eagle_clean_hook_entries "$settings" "Stop" "$EAGLE_MEM_DIR/hooks/stop.sh"
117
+ eagle_clean_hook_entries "$settings" "PostToolUse" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh"
118
+ eagle_clean_hook_entries "$settings" "PreToolUse" "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh"
119
+
120
+ # ${V:+label} expands to the label only in verbose mode; otherwise to "",
121
+ # which makes eagle_patch_hook silent (it only prints when given a description).
122
+ eagle_patch_hook "$settings" "SessionStart" "" "$EAGLE_MEM_DIR/hooks/session-start.sh" "${V:+SessionStart hook}"
123
+ eagle_patch_hook "$settings" "Stop" "" "bash \"$EAGLE_MEM_DIR/hooks/stop.sh\"" "${V:+Stop hook}"
124
+ eagle_patch_hook "$settings" "PostToolUse" "Read|Write|Edit|Bash|TaskUpdate" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" "${V:+PostToolUse hook}"
125
+ eagle_patch_hook "$settings" "TaskCreated" "" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" "${V:+TaskCreated hook}"
126
+ eagle_patch_hook "$settings" "TaskCompleted" "" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" "${V:+TaskCompleted hook}"
127
+ eagle_patch_hook "$settings" "SessionEnd" "" "$EAGLE_MEM_DIR/hooks/session-end.sh" "${V:+SessionEnd hook}"
128
+ eagle_patch_hook "$settings" "UserPromptSubmit" "" "$EAGLE_MEM_DIR/hooks/user-prompt-submit.sh" "${V:+UserPromptSubmit hook}"
129
+ eagle_patch_hook "$settings" "PreToolUse" "Bash|Read|Edit|Write" "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh" "${V:+PreToolUse hook}"
130
+
131
+ # Allow agent-issued session capture to run without a permission prompt.
132
+ if eagle_patch_permission_allow "$settings" "Bash(eagle-mem session save:*)"; then
133
+ [ -n "$V" ] && eagle_ok "Capture permission ${DIM}(eagle-mem session save)${RESET}"
134
+ fi
135
+ # Explicit success: the trailing conditional above evaluates false in quiet
136
+ # mode (V empty), which would otherwise make this function return 1 and abort
137
+ # a `set -e` caller (update.sh) right after a fully successful registration.
138
+ return 0
139
+ }
package/lib/provider.sh CHANGED
@@ -173,6 +173,12 @@ claude_model = ""
173
173
  route = "opposite"
174
174
  auto_worktree = "true"
175
175
  worktree_root = ""
176
+ # worker_autonomy: "safe" (default) runs spawned workers with a sandbox and an
177
+ # approval/permission gate. "danger" runs them with full filesystem access and
178
+ # no approvals (codex --sandbox danger-full-access, claude --permission-mode
179
+ # dontAsk). Only enable "danger" when you trust the lane descriptions, since
180
+ # worker prompts are assembled from DB-stored lane text.
181
+ worker_autonomy = "safe"
176
182
  codex_worker_model = "gpt-5.5"
177
183
  codex_worker_effort = "xhigh"
178
184
  claude_worker_model = "claude-opus-4-7"
@@ -561,6 +567,8 @@ _eagle_call_claude_cli() {
561
567
 
562
568
  local _had_errexit=0
563
569
  case "$-" in *e*) _had_errexit=1; set +e ;; esac
570
+ # Pass the prompt via stdin (not argv) so secrets in the prompt are not
571
+ # visible in `ps`, matching the Codex provider path above.
564
572
  if [ -n "$model" ]; then
565
573
  EAGLE_MEM_DISABLE_HOOKS=1 CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 claude -p \
566
574
  --no-session-persistence \
@@ -569,7 +577,7 @@ _eagle_call_claude_cli() {
569
577
  --tools "" \
570
578
  --output-format text \
571
579
  --model "$model" \
572
- "$(cat "$prompt_file")" > "$out_file" 2>> "$EAGLE_MEM_LOG"
580
+ < "$prompt_file" > "$out_file" 2>> "$EAGLE_MEM_LOG"
573
581
  rc=$?
574
582
  else
575
583
  EAGLE_MEM_DISABLE_HOOKS=1 CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1 claude -p \
@@ -578,7 +586,7 @@ _eagle_call_claude_cli() {
578
586
  --permission-mode dontAsk \
579
587
  --tools "" \
580
588
  --output-format text \
581
- "$(cat "$prompt_file")" > "$out_file" 2>> "$EAGLE_MEM_LOG"
589
+ < "$prompt_file" > "$out_file" 2>> "$EAGLE_MEM_LOG"
582
590
  rc=$?
583
591
  fi
584
592
  [ "$_had_errexit" -eq 1 ] && set -e
package/lib/updater.sh CHANGED
@@ -195,9 +195,16 @@ eagle_update_backup_runtime() {
195
195
  local sqlite_bin
196
196
  sqlite_bin=$(eagle_sqlite_path)
197
197
  if [ -n "$sqlite_bin" ]; then
198
- "$sqlite_bin" "$EAGLE_MEM_DB" ".backup '$backup_dir/memory.db'" >/dev/null 2>&1 || cp "$EAGLE_MEM_DB" "$backup_dir/memory.db" 2>/dev/null || true
198
+ # busy_timeout so a concurrent hook holding the lock doesn't make
199
+ # .backup fail immediately and fall through to a raw cp. The raw cp
200
+ # last-resort copies the -wal/-shm too so the snapshot stays
201
+ # internally consistent (a bare cp of a WAL DB drops uncommitted WAL).
202
+ "$sqlite_bin" "$EAGLE_MEM_DB" "PRAGMA busy_timeout=10000; .backup '$backup_dir/memory.db'" >/dev/null 2>&1 \
203
+ || { cp "$EAGLE_MEM_DB" "$backup_dir/memory.db" 2>/dev/null \
204
+ && cp "$EAGLE_MEM_DB-wal" "$backup_dir/memory.db-wal" 2>/dev/null; cp "$EAGLE_MEM_DB-shm" "$backup_dir/memory.db-shm" 2>/dev/null; true; }
199
205
  else
200
- cp "$EAGLE_MEM_DB" "$backup_dir/memory.db" 2>/dev/null || true
206
+ cp "$EAGLE_MEM_DB" "$backup_dir/memory.db" 2>/dev/null \
207
+ && { cp "$EAGLE_MEM_DB-wal" "$backup_dir/memory.db-wal" 2>/dev/null; cp "$EAGLE_MEM_DB-shm" "$backup_dir/memory.db-shm" 2>/dev/null; true; }
201
208
  fi
202
209
  fi
203
210
  }
@@ -218,7 +225,14 @@ eagle_update_restore_runtime() {
218
225
  done
219
226
 
220
227
  if [ -f "$backup_dir/memory.db" ]; then
228
+ # Drop any live sidecars first so a restored main DB is never paired with
229
+ # a stale -wal/-shm. Then restore the main DB and (if the raw-cp fallback
230
+ # captured them) its sidecars, keeping the snapshot internally consistent.
231
+ rm -f "$EAGLE_MEM_DB-wal" "$EAGLE_MEM_DB-shm" 2>/dev/null || true
221
232
  cp "$backup_dir/memory.db" "$EAGLE_MEM_DB" 2>/dev/null || true
233
+ [ -f "$backup_dir/memory.db-wal" ] && cp "$backup_dir/memory.db-wal" "$EAGLE_MEM_DB-wal" 2>/dev/null
234
+ [ -f "$backup_dir/memory.db-shm" ] && cp "$backup_dir/memory.db-shm" "$EAGLE_MEM_DB-shm" 2>/dev/null
235
+ true
222
236
  fi
223
237
  }
224
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eagle-mem",
3
- "version": "4.12.1",
3
+ "version": "4.13.1",
4
4
  "description": "Shared memory, release guardrails, RTK token protection, and worker lanes for Claude Code, Codex, OpenCode, Grok, and Google Antigravity",
5
5
  "bin": {
6
6
  "eagle-mem": "bin/eagle-mem"
@@ -33,7 +33,10 @@ if [ "$provider" = "none" ]; then
33
33
  exit 0
34
34
  fi
35
35
 
36
- excerpt=$(printf '%s' "$text_content" | tail -c 3000)
36
+ # Redact secrets BEFORE the transcript tail is sent to an LLM provider. The job
37
+ # file is already redacted by Stop, but redact here too in case this script is
38
+ # invoked directly or against a legacy job file.
39
+ excerpt=$(printf '%s' "$text_content" | tail -c 3000 | eagle_redact)
37
40
 
38
41
  enrich_prompt="Extract facts from this AI coding session. Only include items with clear evidence in the session text. Do NOT invent or repeat example content.
39
42
 
@@ -283,47 +283,9 @@ if [ "$claude_found" = true ]; then
283
283
  echo '{}' > "$SETTINGS"
284
284
  fi
285
285
 
286
- eagle_patch_hook "$SETTINGS" "SessionStart" "" \
287
- "$EAGLE_MEM_DIR/hooks/session-start.sh" \
288
- "SessionStart hook"
289
-
290
- # Clean old registrations before re-registering (handles matcher changes across versions)
291
- eagle_clean_hook_entries "$SETTINGS" "Stop" "$EAGLE_MEM_DIR/hooks/stop.sh"
292
- eagle_clean_hook_entries "$SETTINGS" "PostToolUse" "$EAGLE_MEM_DIR/hooks/post-tool-use.sh"
293
- eagle_clean_hook_entries "$SETTINGS" "PreToolUse" "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh"
294
-
295
- eagle_patch_hook "$SETTINGS" "Stop" "" \
296
- "bash \"$EAGLE_MEM_DIR/hooks/stop.sh\"" \
297
- "Stop hook"
298
-
299
- eagle_patch_hook "$SETTINGS" "PostToolUse" "Read|Write|Edit|Bash|TaskUpdate" \
300
- "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" \
301
- "PostToolUse hook"
302
-
303
- eagle_patch_hook "$SETTINGS" "TaskCreated" "" \
304
- "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" \
305
- "TaskCreated hook"
306
-
307
- eagle_patch_hook "$SETTINGS" "TaskCompleted" "" \
308
- "$EAGLE_MEM_DIR/hooks/post-tool-use.sh" \
309
- "TaskCompleted hook"
310
-
311
- eagle_patch_hook "$SETTINGS" "SessionEnd" "" \
312
- "$EAGLE_MEM_DIR/hooks/session-end.sh" \
313
- "SessionEnd hook"
314
-
315
- eagle_patch_hook "$SETTINGS" "UserPromptSubmit" "" \
316
- "$EAGLE_MEM_DIR/hooks/user-prompt-submit.sh" \
317
- "UserPromptSubmit hook"
318
-
319
- eagle_patch_hook "$SETTINGS" "PreToolUse" "Bash|Read|Edit|Write" \
320
- "$EAGLE_MEM_DIR/hooks/pre-tool-use.sh" \
321
- "PreToolUse hook"
322
-
323
- # Allow agent-issued session capture to run without a permission prompt
324
- if eagle_patch_permission_allow "$SETTINGS" "Bash(eagle-mem session save:*)"; then
325
- eagle_ok "Capture permission ${DIM}(eagle-mem session save)${RESET}"
326
- fi
286
+ # Single source of truth for the Claude hook set (see lib/hooks.sh);
287
+ # verbose mode prints a line per registration.
288
+ eagle_register_claude_hooks "$SETTINGS" verbose
327
289
  fi
328
290
  else
329
291
  eagle_info "Claude hooks skipped ${DIM}(Claude Code not detected)${RESET}"