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:
parent
8e0a5f82cb
commit
c85bade62f
5 changed files with 159 additions and 35 deletions
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue