feat: per-session buddy isolation via PGRP-keyed state files

Each Claude Code session now gets its own state file at:
  ~/.claude/buddymon/sessions/<pgrp>.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.
This commit is contained in:
pyr0ball 2026-04-02 23:11:19 -07:00
parent 8e0a5f82cb
commit c85bade62f
5 changed files with 159 additions and 35 deletions

View file

@ -24,6 +24,30 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" 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 = { KNOWN_EXTENSIONS = {
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
".jsx": "JavaScript/React", ".tsx": "TypeScript/React", ".jsx": "JavaScript/React", ".tsx": "TypeScript/React",
@ -62,15 +86,13 @@ def get_state():
def add_session_xp(amount: int): def add_session_xp(amount: int):
active_file = BUDDYMON_DIR / "active.json" session = get_session_state()
roster_file = BUDDYMON_DIR / "roster.json" session["session_xp"] = session.get("session_xp", 0) + amount
buddy_id = session.get("buddymon_id")
active = load_json(active_file) save_session_state(session)
active["session_xp"] = active.get("session_xp", 0) + amount
buddy_id = active.get("buddymon_id")
save_json(active_file, active)
if buddy_id: if buddy_id:
roster_file = BUDDYMON_DIR / "roster.json"
roster = load_json(roster_file) roster = load_json(roster_file)
if buddy_id in roster.get("owned", {}): if buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount 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(): def get_active_buddy_id():
active = load_json(BUDDYMON_DIR / "active.json") return get_session_state().get("buddymon_id")
return active.get("buddymon_id")
def get_active_encounter(): def get_active_encounter():

View file

@ -7,8 +7,31 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init buddymon_init
ACTIVE_ID=$(buddymon_get_active) # Per-session state — keyed by process group ID so parallel sessions are isolated.
SESSION_XP=$(buddymon_get_session_xp) 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 # Load catalog for buddy display info
CATALOG="${PLUGIN_ROOT}/lib/catalog.json" CATALOG="${PLUGIN_ROOT}/lib/catalog.json"

View file

@ -6,10 +6,29 @@ source "${PLUGIN_ROOT}/lib/state.sh"
buddymon_init buddymon_init
ACTIVE_ID=$(buddymon_get_active) SESSION_KEY=$(python3 -c "import os; print(os.getpgrp())")
SESSION_XP=$(buddymon_get_session_xp) 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 if [[ -z "${ACTIVE_ID}" ]] || [[ "${SESSION_XP}" -eq 0 ]]; then
[[ -f "${SESSION_FILE}" ]] && rm -f "${SESSION_FILE}"
exit 0 exit 0
fi fi
@ -20,17 +39,20 @@ SUMMARY=$(python3 << PYEOF
import json, os import json, os
catalog_file = '${CATALOG}' catalog_file = '${CATALOG}'
active_file = '${BUDDYMON_DIR}/active.json' session_state_file = '${SESSION_FILE}'
roster_file = '${BUDDYMON_DIR}/roster.json' roster_file = '${BUDDYMON_DIR}/roster.json'
session_file = '${BUDDYMON_DIR}/session.json' session_file = '${BUDDYMON_DIR}/session.json'
encounters_file = '${BUDDYMON_DIR}/encounters.json'
catalog = json.load(open(catalog_file)) 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)) 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: if not buddy_id:
print('') print('')
exit() exit()
@ -39,7 +61,7 @@ b = (catalog.get('buddymon', {}).get(buddy_id)
or catalog.get('evolutions', {}).get(buddy_id) or {}) or catalog.get('evolutions', {}).get(buddy_id) or {})
display = b.get('display', buddy_id) 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) level = roster.get('owned', {}).get(buddy_id, {}).get('level', 1)
total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0) total_xp = roster.get('owned', {}).get(buddy_id, {}).get('xp', 0)
xp_needed = level * 100 xp_needed = level * 100
@ -61,7 +83,7 @@ commits = session.get('commits_this_session', 0)
tools = session.get('tools_used', 0) tools = session.get('tools_used', 0)
langs = session.get('languages_seen', []) langs = session.get('languages_seen', [])
challenge_completed = session.get('challenge_completed', False) challenge_completed = session.get('challenge_completed', False)
challenge = active.get('challenge') challenge = session_state.get('challenge')
lines = [f"\n## 🐾 Session complete — {display}"] lines = [f"\n## 🐾 Session complete — {display}"]
lines.append(f"**+{xp_earned} XP earned** this session") lines.append(f"**+{xp_earned} XP earned** this session")
@ -104,17 +126,10 @@ print('\n'.join(lines))
PYEOF PYEOF
) )
# Reset session XP + clear challenge so next session assigns a fresh one # Clean up this session's state file — each session is ephemeral
python3 << PYEOF rm -f "${SESSION_FILE}"
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
# Reset session file # Reset shared session.json for legacy compatibility
buddymon_session_reset buddymon_session_reset
SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null) SUMMARY_JSON=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "${SUMMARY}" 2>/dev/null)

View file

@ -16,6 +16,27 @@ PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.par
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon" BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json" 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): def load_json(path):
try: try:
@ -55,9 +76,8 @@ def main():
enc_data["active_encounter"] = enc enc_data["active_encounter"] = enc
save_json(enc_file, enc_data) save_json(enc_file, enc_data)
# Resolve buddy display name # Resolve buddy display name from session-specific state
active = load_json(BUDDYMON_DIR / "active.json") buddy_id = get_session_state().get("buddymon_id")
buddy_id = active.get("buddymon_id")
buddy_display = "your buddy" buddy_display = "your buddy"
if buddy_id: if buddy_id:
catalog = load_json(CATALOG_FILE) catalog = load_json(CATALOG_FILE)

View file

@ -118,12 +118,57 @@ Greet them and explain the encounter system.
## `assign <name>` — Assign Buddy ## `assign <name>` — 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 `<name>` against owned Buddymon (case-insensitive, partial). Fuzzy-match `<name>` against owned Buddymon (case-insensitive, partial).
If ambiguous, list matches and ask which. If ambiguous, list matches and ask which.
If no name given, list roster and ask. If no name given, list roster and ask.
On match, update `active.json` (buddy_id, reset session_xp, set challenge). On match, show challenge proposal with Accept / Decline / Reroll, then write:
Show challenge proposal with Accept / Decline / Reroll.
```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).
--- ---