diff --git a/hooks-handlers/post-tool-use.py b/hooks-handlers/post-tool-use.py index ad48c04..4fa77b9 100755 --- a/hooks-handlers/post-tool-use.py +++ b/hooks-handlers/post-tool-use.py @@ -24,6 +24,30 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" +# Each CC session gets its own state file keyed by process group ID. +# All hooks within one session share the same PGRP, giving stable per-session state. +SESSION_KEY = str(os.getpgrp()) +SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json" + + +def get_session_state() -> dict: + """Read the current session's state file, falling back to global active.json.""" + session = load_json(SESSION_FILE) + if not session: + # No session file yet — inherit from global default + global_active = load_json(BUDDYMON_DIR / "active.json") + session = { + "buddymon_id": global_active.get("buddymon_id"), + "challenge": global_active.get("challenge"), + "session_xp": 0, + } + return session + + +def save_session_state(state: dict) -> None: + SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) + save_json(SESSION_FILE, state) + KNOWN_EXTENSIONS = { ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", ".jsx": "JavaScript/React", ".tsx": "TypeScript/React", @@ -62,15 +86,13 @@ def get_state(): def add_session_xp(amount: int): - active_file = BUDDYMON_DIR / "active.json" - roster_file = BUDDYMON_DIR / "roster.json" - - active = load_json(active_file) - active["session_xp"] = active.get("session_xp", 0) + amount - buddy_id = active.get("buddymon_id") - save_json(active_file, active) + session = get_session_state() + session["session_xp"] = session.get("session_xp", 0) + amount + buddy_id = session.get("buddymon_id") + save_session_state(session) if buddy_id: + roster_file = BUDDYMON_DIR / "roster.json" roster = load_json(roster_file) if buddy_id in roster.get("owned", {}): roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount @@ -153,8 +175,7 @@ def is_starter_chosen(): def get_active_buddy_id(): - active = load_json(BUDDYMON_DIR / "active.json") - return active.get("buddymon_id") + return get_session_state().get("buddymon_id") def get_active_encounter(): diff --git a/hooks-handlers/session-start.sh b/hooks-handlers/session-start.sh index 5ecf3fc..fa02855 100755 --- a/hooks-handlers/session-start.sh +++ b/hooks-handlers/session-start.sh @@ -7,8 +7,31 @@ source "${PLUGIN_ROOT}/lib/state.sh" buddymon_init -ACTIVE_ID=$(buddymon_get_active) -SESSION_XP=$(buddymon_get_session_xp) +# Per-session state — keyed by process group ID so parallel sessions are isolated. +SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())") +SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json" +mkdir -p "${BUDDYMON_DIR}/sessions" + +# Create session file if missing, inheriting buddy from global active.json +if [[ ! -f "${SESSION_FILE}" ]]; then + python3 << PYEOF +import json, os +active = {} +try: + active = json.load(open('${BUDDYMON_DIR}/active.json')) +except Exception: + pass +session_state = { + "buddymon_id": active.get("buddymon_id"), + "challenge": active.get("challenge"), + "session_xp": 0, +} +json.dump(session_state, open('${SESSION_FILE}', 'w'), indent=2) +PYEOF +fi + +ACTIVE_ID=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('buddymon_id',''))" 2>/dev/null) +SESSION_XP=$(python3 -c "import json; d=json.load(open('${SESSION_FILE}')); print(d.get('session_xp',0))" 2>/dev/null) # Load catalog for buddy display info CATALOG="${PLUGIN_ROOT}/lib/catalog.json" diff --git a/hooks-handlers/session-stop.sh b/hooks-handlers/session-stop.sh index 47a3cae..0aecef8 100755 --- a/hooks-handlers/session-stop.sh +++ b/hooks-handlers/session-stop.sh @@ -6,10 +6,29 @@ source "${PLUGIN_ROOT}/lib/state.sh" buddymon_init -ACTIVE_ID=$(buddymon_get_active) -SESSION_XP=$(buddymon_get_session_xp) +SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())") +SESSION_FILE="${BUDDYMON_DIR}/sessions/${SESSION_KEY}.json" + +ACTIVE_ID=$(python3 -c " +import json, sys +try: + d = json.load(open('${SESSION_FILE}')) + print(d.get('buddymon_id', '')) +except Exception: + print('') +" 2>/dev/null) + +SESSION_XP=$(python3 -c " +import json, sys +try: + d = json.load(open('${SESSION_FILE}')) + print(d.get('session_xp', 0)) +except Exception: + print(0) +" 2>/dev/null) if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then + [[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}" exit 0 fi @@ -20,17 +39,20 @@ SUMMARY=$(python3 << PYEOF import json, os catalog_file = '${CATALOG}' -active_file = '${BUDDYMON_DIR}/active.json' +session_state_file = '${SESSION_FILE}' roster_file = '${BUDDYMON_DIR}/roster.json' session_file = '${BUDDYMON_DIR}/session.json' -encounters_file = '${BUDDYMON_DIR}/encounters.json' catalog = json.load(open(catalog_file)) -active = json.load(open(active_file)) +session_state = json.load(open(session_state_file)) roster = json.load(open(roster_file)) -session = json.load(open(session_file)) +session = {} +try: + session = json.load(open(session_file)) +except Exception: + pass -buddy_id = active.get('buddymon_id') +buddy_id = session_state.get('buddymon_id') if not buddy_id: print('') exit() @@ -39,7 +61,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id) or catalog.get('evolutions', {}).get(buddy_id) or {}) display = b.get('display', buddy_id) -xp_earned = active.get('session_xp', 0) +xp_earned = session_state.get('session_xp', 0) level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1) total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0) xp_needed = level * 100 @@ -61,7 +83,7 @@ commits = session.get('commits_this_session', 0) tools = session.get('tools_used', 0) langs = session.get('languages_seen', []) challenge_completed = session.get('challenge_completed', False) -challenge = active.get('challenge') +challenge = session_state.get('challenge') lines = [f"\n## 🐾 Session complete — {display}"] lines.append(f"**+{xp_earned} XP earned** this session") @@ -104,17 +126,10 @@ print('\n'.join(lines)) PYEOF ) -# Reset session XP + clear challenge so next session assigns a fresh one -python3 << PYEOF -import json -active_file = '${BUDDYMON_DIR}/active.json' -active = json.load(open(active_file)) -active['session_xp'] = 0 -active['challenge'] = None -json.dump(active, open(active_file, 'w'), indent=2) -PYEOF +# Clean up this session's state file — each session is ephemeral +rm -f "${SESSION_FILE}" -# Reset session file +# Reset shared session.json for legacy compatibility buddymon_session_reset SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null) diff --git a/hooks-handlers/user-prompt-submit.py b/hooks-handlers/user-prompt-submit.py index 3620128..843c4e1 100644 --- a/hooks-handlers/user-prompt-submit.py +++ b/hooks-handlers/user-prompt-submit.py @@ -16,6 +16,27 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" +SESSION_KEY = str(os.getpgrp()) +SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json" + + +def get_session_state() -> dict: + try: + with open(SESSION_FILE) as f: + return json.load(f) + except Exception: + global_active = {} + try: + with open(BUDDYMON_DIR / "active.json") as f: + global_active = json.load(f) + except Exception: + pass + return { + "buddymon_id": global_active.get("buddymon_id"), + "challenge": global_active.get("challenge"), + "session_xp": 0, + } + def load_json(path): try: @@ -55,9 +76,8 @@ def main(): enc_data["active_encounter"] = enc save_json(enc_file, enc_data) - # Resolve buddy display name - active = load_json(BUDDYMON_DIR / "active.json") - buddy_id = active.get("buddymon_id") + # Resolve buddy display name from session-specific state + buddy_id = get_session_state().get("buddymon_id") buddy_display = "your buddy" if buddy_id: catalog = load_json(CATALOG_FILE) diff --git a/skills/buddymon/SKILL.md b/skills/buddymon/SKILL.md index 2b56da9..4fdc6b1 100644 --- a/skills/buddymon/SKILL.md +++ b/skills/buddymon/SKILL.md @@ -118,12 +118,57 @@ Greet them and explain the encounter system. ## `assign ` — Assign Buddy +Assignment is **per-session** — each Claude Code window can have its own buddy. +It writes to the session state file only, not the global default. + Fuzzy-match `` against owned Buddymon (case-insensitive, partial). If ambiguous, list matches and ask which. If no name given, list roster and ask. -On match, update `active.json` (buddy_id, reset session_xp, set challenge). -Show challenge proposal with Accept / Decline / Reroll. +On match, show challenge proposal with Accept / Decline / Reroll, then write: + +```python +import json, os, random +from pathlib import Path + +BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" +PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "") +catalog = json.load(open(f"{PLUGIN_ROOT}/lib/catalog.json")) + +SESSION_KEY = str(os.getpgrp()) +SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json" +SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) + +roster = json.load(open(BUDDYMON_DIR / "roster.json")) + +buddy_id = "Debuglin" # replace with matched buddy id +buddy_catalog = (catalog.get("buddymon", {}).get(buddy_id) + or catalog.get("evolutions", {}).get(buddy_id) or {}) +challenges = buddy_catalog.get("challenges", []) + +# Load or init session state +try: + session_state = json.load(open(SESSION_FILE)) +except Exception: + session_state = {} + +session_state["buddymon_id"] = buddy_id +session_state["session_xp"] = 0 +session_state["challenge"] = random.choice(challenges) if challenges else None + +json.dump(session_state, open(SESSION_FILE, "w"), indent=2) + +# Also update global default so new sessions inherit this assignment +active = {} +try: + active = json.load(open(BUDDYMON_DIR / "active.json")) +except Exception: + pass +active["buddymon_id"] = buddy_id +json.dump(active, open(BUDDYMON_DIR / "active.json", "w"), indent=2) +``` + +Show challenge proposal with Accept / Decline / Reroll (updating `session_state["challenge"]` accordingly). ---