Skip to content

Commit d6b5ad4

Browse files
authored
fix: harden fork cleanup detection (#15)
Document the python3 runtime dependency and tighten fork cleanup so the wrapper can safely discover and delete only new maintenance forks without risking the active session in degraded environments.
1 parent 26f3d7b commit d6b5ad4

3 files changed

Lines changed: 236 additions & 55 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ Claude Code writes memory → OpenCode reads it. OpenCode writes memory → Clau
2929

3030
## 🚀 Quick Start
3131

32+
### Prerequisites
33+
34+
- `opencode`
35+
- `python3` available in `PATH`
36+
37+
`python3` is a runtime dependency for the wrapper's scoped session detection and fork cleanup logic.
38+
If it is missing or not executable, post-session maintenance becomes less reliable: session targeting can fall back to less precise heuristics, and fork cleanup is skipped for safety.
39+
40+
Common install commands:
41+
42+
```bash
43+
# macOS (Homebrew)
44+
brew install python
45+
46+
# Ubuntu / Debian
47+
sudo apt-get update && sudo apt-get install -y python3
48+
49+
# Fedora
50+
sudo dnf install -y python3
51+
52+
# Arch Linux
53+
sudo pacman -S python
54+
```
55+
3256
### 1. Install
3357

3458
```bash
@@ -41,6 +65,8 @@ This installs:
4165
- The `opencode-memory` **CLI** — wraps opencode with automatic memory extraction + auto-dream consolidation
4266
- A **shell hook** — defines an `opencode()` function in your `.zshrc`/`.bashrc` that delegates to `opencode-memory`
4367

68+
If `python3` is not installed yet, install it first using the commands above before enabling the shell hook.
69+
4470
### 2. Configure
4571

4672
```jsonc
@@ -118,6 +144,18 @@ The shell hook defines an `opencode()` function that delegates to `opencode-memo
118144
8. Maintenance runs **in the background** unless `OPENCODE_MEMORY_FOREGROUND=1`
119145
9. Terminal maintenance logs are shown in foreground mode by default, or can be forced on/off with `OPENCODE_MEMORY_TERMINAL_LOG=1|0`
120146

147+
### Runtime dependencies
148+
149+
The wrapper expects `python3` to be available at runtime.
150+
151+
It is used for:
152+
153+
- scoped session selection from `opencode session list`
154+
- parsing `opencode export` output to resolve session directories
155+
- safely identifying and cleaning up forked extraction / auto-dream sessions
156+
157+
Without `python3`, the plugin tools still load, but wrapper maintenance is degraded and fork cleanup is intentionally skipped to avoid deleting the wrong session.
158+
121159
### Compatibility details
122160

123161
The implementation ports core logic from Claude Code for path hashing, git-root/worktree handling, memory format, and memory prompting behavior, so both tools can operate on the same files safely.

bin/opencode-memory

Lines changed: 55 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -478,17 +478,18 @@ get_session_target_id() {
478478
local started_at_ms="$2"
479479
local workdir="$3"
480480
local project_dir="$4"
481+
local allow_existing_fallback="${5:-1}"
481482
local after_json
482483

483484
after_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT") || return 1
484485

485486
if command -v python3 >/dev/null 2>&1; then
486-
python3 - "$before_json" "$after_json" "$started_at_ms" "$workdir" "$project_dir" <<'PY'
487+
python3 - "$before_json" "$after_json" "$started_at_ms" "$workdir" "$project_dir" "$allow_existing_fallback" <<'PY'
487488
import json
488489
import os
489490
import sys
490491
491-
before_raw, after_raw, started_at_ms_raw, workdir, project_dir = sys.argv[1:6]
492+
before_raw, after_raw, started_at_ms_raw, workdir, project_dir, allow_existing_fallback_raw = sys.argv[1:7]
492493
493494
def parse(raw):
494495
try:
@@ -522,6 +523,7 @@ def normalize(path):
522523
before = parse(before_raw)
523524
after = parse(after_raw)
524525
started_at_ms = int(started_at_ms_raw or "0")
526+
allow_existing_fallback = allow_existing_fallback_raw == "1"
525527
before_ids = {item.get("id") for item in before if item.get("id")}
526528
workdir = normalize(workdir)
527529
project_dir = normalize(project_dir)
@@ -544,11 +546,15 @@ def choose(candidates):
544546
new_sessions = [item for item in after if item.get("id") not in before_ids]
545547
updated_sessions = [item for item in after if timestamp(item) > started_at_ms]
546548
547-
for pool in (
549+
candidate_pools = [
548550
[item for item in new_sessions if in_scope(item)],
549551
[item for item in updated_sessions if in_scope(item)],
550-
[item for item in after if in_scope(item)],
551-
):
552+
]
553+
554+
if allow_existing_fallback:
555+
candidate_pools.append([item for item in after if in_scope(item)])
556+
557+
for pool in candidate_pools:
552558
if choose(pool):
553559
break
554560
PY
@@ -795,17 +801,19 @@ main_prompt_requests_ignore_memory() {
795801
printf '%s\n' "$joined" | grep -Eq "(ignore|don't use|do not use|without|skip)[[:space:]]+(the[[:space:]]+)?memory|memory[[:space:]]+((should|must)[[:space:]]+be[[:space:]]+)?ignored"
796802
}
797803

798-
wait_for_session_target_id() {
804+
wait_for_scoped_session_id_since() {
799805
local before_json="$1"
800806
local started_at_ms="$2"
801-
local wait_seconds="${3:-5}"
807+
local timestamp_file="$3"
808+
local wait_seconds="${4:-5}"
809+
local allow_existing_fallback="${5:-1}"
802810
local attempt=0
803811
local session_id=""
804812

805813
while [ "$attempt" -lt "$wait_seconds" ]; do
806-
session_id=$(get_session_target_id "$before_json" "$started_at_ms" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
814+
session_id=$(get_session_target_id "$before_json" "$started_at_ms" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" "$allow_existing_fallback" || true)
807815
if [ -z "$session_id" ]; then
808-
session_id=$(get_scoped_artifact_session_id_since "$TIMESTAMP_FILE" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
816+
session_id=$(get_scoped_artifact_session_id_since "$timestamp_file" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" || true)
809817
fi
810818
if [ -n "$session_id" ]; then
811819
printf '%s\n' "$session_id"
@@ -818,6 +826,14 @@ wait_for_session_target_id() {
818826
return 1
819827
}
820828

829+
wait_for_session_target_id() {
830+
local before_json="$1"
831+
local started_at_ms="$2"
832+
local wait_seconds="${3:-5}"
833+
834+
wait_for_scoped_session_id_since "$before_json" "$started_at_ms" "$TIMESTAMP_FILE" "$wait_seconds"
835+
}
836+
821837
file_mtime_secs() {
822838
local file="$1"
823839
if [ ! -f "$file" ]; then
@@ -958,58 +974,32 @@ rollback_consolidation_lock() {
958974

959975
cleanup_forked_sessions() {
960976
local before_json="$1"
977+
local started_at_ms="$2"
978+
local timestamp_file="$3"
961979

962-
if ! command -v python3 >/dev/null 2>&1; then
980+
if [ -z "$before_json" ] || [ -z "$started_at_ms" ] || [ -z "$timestamp_file" ]; then
963981
return 0
964982
fi
965983

966-
local after_json
967-
after_json=$(get_session_list_json 10 2>/dev/null || true)
968-
969-
if [ -z "$before_json" ] || [ -z "$after_json" ]; then
984+
if ! command -v python3 >/dev/null 2>&1; then
970985
return 0
971986
fi
972987

973-
local fork_ids
974-
fork_ids=$(python3 - "$before_json" "$after_json" "$WORKING_DIR" "$PROJECT_SCOPE_DIR" <<'PY'
975-
import json
976-
import os
977-
import sys
978-
979-
def parse(raw):
980-
try:
981-
data = json.loads(raw)
982-
return data if isinstance(data, list) else []
983-
except Exception:
984-
return []
985-
986-
before_raw, after_raw, workdir, project_dir = sys.argv[1:5]
987-
before = parse(before_raw)
988-
after = parse(after_raw)
988+
if ! python3 - <<'PY' >/dev/null 2>&1
989+
pass
990+
PY
991+
then
992+
return 0
993+
fi
989994

990-
before_ids = {item.get("id") for item in before if item.get("id")}
991-
workdir = os.path.realpath(workdir)
992-
project_dir = os.path.realpath(project_dir)
995+
local fork_id
996+
fork_id=$(wait_for_scoped_session_id_since "$before_json" "$started_at_ms" "$timestamp_file" "$SESSION_WAIT_SECONDS" 0 || true)
993997

994-
for item in after:
995-
sid = item.get("id", "")
996-
if not sid or sid in before_ids:
997-
continue
998-
directory = item.get("directory", "")
999-
if not directory:
1000-
continue
1001-
d = os.path.realpath(directory)
1002-
if d in (workdir, project_dir):
1003-
print(sid)
1004-
PY
1005-
) || return 0
998+
[ -n "$fork_id" ] || return 0
1006999

1007-
while IFS= read -r fork_id; do
1008-
[ -n "$fork_id" ] || continue
1009-
if "$REAL_OPENCODE" session delete "$fork_id" >/dev/null 2>&1; then
1010-
log "Cleaned up forked session $fork_id"
1011-
fi
1012-
done <<< "$fork_ids"
1000+
if "$REAL_OPENCODE" session delete "$fork_id" >/dev/null 2>&1; then
1001+
log "Cleaned up forked session $fork_id"
1002+
fi
10131003
}
10141004

10151005
session_has_conversation() {
@@ -1060,7 +1050,11 @@ run_extraction_if_needed() {
10601050
cmd+=("$EXTRACT_PROMPT")
10611051

10621052
local pre_fork_json
1063-
pre_fork_json=$(get_session_list_json 5 2>/dev/null || true)
1053+
pre_fork_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true)
1054+
local fork_timestamp_file
1055+
fork_timestamp_file=$(mktemp)
1056+
local fork_started_at_ms
1057+
fork_started_at_ms=$(( $(date +%s) * 1000 ))
10641058

10651059
if "${cmd[@]}" >> "$EXTRACT_LOG_FILE" 2>&1; then
10661060
log "Memory extraction completed successfully"
@@ -1069,7 +1063,8 @@ run_extraction_if_needed() {
10691063
log "Memory extraction failed (exit code $code). Check $EXTRACT_LOG_FILE for details"
10701064
fi
10711065

1072-
cleanup_forked_sessions "$pre_fork_json"
1066+
cleanup_forked_sessions "$pre_fork_json" "$fork_started_at_ms" "$fork_timestamp_file"
1067+
rm -f "$fork_timestamp_file"
10731068
release_simple_lock "$EXTRACT_LOCK_FILE"
10741069
}
10751070

@@ -1122,7 +1117,11 @@ run_autodream_if_needed() {
11221117
cmd+=("$AUTODREAM_PROMPT")
11231118

11241119
local pre_fork_json
1125-
pre_fork_json=$(get_session_list_json 5 2>/dev/null || true)
1120+
pre_fork_json=$(get_session_list_json "$AUTODREAM_SCAN_LIMIT" 2>/dev/null || true)
1121+
local fork_timestamp_file
1122+
fork_timestamp_file=$(mktemp)
1123+
local fork_started_at_ms
1124+
fork_started_at_ms=$(( $(date +%s) * 1000 ))
11261125

11271126
if "${cmd[@]}" >> "$AUTODREAM_LOG_FILE" 2>&1; then
11281127
log "Auto-dream consolidation completed successfully"
@@ -1133,7 +1132,8 @@ run_autodream_if_needed() {
11331132
rollback_consolidation_lock "$CONSOLIDATION_PRIOR_MTIME"
11341133
fi
11351134

1136-
cleanup_forked_sessions "$pre_fork_json"
1135+
cleanup_forked_sessions "$pre_fork_json" "$fork_started_at_ms" "$fork_timestamp_file"
1136+
rm -f "$fork_timestamp_file"
11371137
}
11381138

11391139
run_post_session_tasks() {

0 commit comments

Comments
 (0)