From c85bade62f9ddf55289654d93e11de3e41af5d32 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Thu, 2 Apr 2026 23:11:19 -0700 Subject: [PATCH] feat: per-session buddy isolation via PGRP-keyed state files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Claude Code session now gets its own state file at: ~/.claude/buddymon/sessions/.json Contains: buddymon_id, session_xp, challenge — all session-local. Global active.json keeps the default buddymon_id for new sessions. /buddymon assign writes to the session file only, so assigning in one terminal window doesn't affect other open sessions. Each window can have its own buddy assigned independently. SessionStart creates the session file (inheriting global default). SessionStop reads XP from it, writes to roster, then removes it. --- hooks-handlers/post-tool-use.py | 39 +++++++++++++++----- hooks-handlers/session-start.sh | 27 ++++++++++++-- hooks-handlers/session-stop.sh | 53 ++++++++++++++++++---------- hooks-handlers/user-prompt-submit.py | 26 ++++++++++++-- skills/buddymon/SKILL.md | 49 +++++++++++++++++++++++-- 5 files changed, 159 insertions(+), 35 deletions(-) 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). ---