diff --git a/.opencode/plugins/learning-agent.js b/.opencode/plugins/learning-agent.js index 534dc12..64b422a 100644 --- a/.opencode/plugins/learning-agent.js +++ b/.opencode/plugins/learning-agent.js @@ -51,6 +51,11 @@ export const LearningAgentPlugin = async ({ client, directory }) => { config.skills.paths.push(skillsDir); } + // Auto-allow webfetch so the learning agent can research + // without permission prompts on every fetch. + config.permission = config.permission || {}; + config.permission.webfetch = config.permission.webfetch || "allow"; + // Register the learning agent from the markdown file. if (fs.existsSync(agentFile)) { const raw = fs.readFileSync(agentFile, "utf8"); diff --git a/claude/skills/learn/SKILL.md b/claude/skills/learn/SKILL.md index 80be664..f5ac55a 100644 --- a/claude/skills/learn/SKILL.md +++ b/claude/skills/learn/SKILL.md @@ -6,6 +6,18 @@ context: fork agent: learning --- +## Session Lifecycle + +**At the very start of every learning session**, before doing anything else: +1. Run `mkdir -p ~/.claude/learning && touch ~/.claude/learning/.session-active` + +This marker file signals to the plugin's hooks that a learning session is active, enabling auto-approval of research tools (WebSearch, WebFetch, Read, etc.) so the session runs smoothly without constant permission prompts. + +**When the session ends** (user says stop, goodbye, or switches topic): +1. Run `rm -f ~/.claude/learning/.session-active` + +--- + Start a learning session on: $ARGUMENTS If no topic was provided, ask the user what they want to learn about. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..af10add --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,97 @@ +{ + "description": "Auto-allow learning agent writes and session-gated research tools", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "WebSearch", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "WebFetch", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Grep", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Glob", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + }, + { + "matcher": "Agent", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/learning_allow_hook.py", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/hooks/learning_allow_hook.py b/hooks/learning_allow_hook.py new file mode 100644 index 0000000..2cf02c4 --- /dev/null +++ b/hooks/learning_allow_hook.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +PreToolUse hook for the learning-agent plugin. + +Auto-allows: +- Write/Edit to ~/.claude/learning/ (always, by path check) +- Bash commands for session lifecycle (mkdir/touch/rm on the marker file) +- Research tools (WebSearch, WebFetch, Read, Grep, Glob, Agent) ONLY when + a learning session is active (marker file exists) + +The marker file ~/.claude/learning/.session-active is created by the +learning skill at session start and removed at session end. +""" +import json +import os +import re +import sys + +MARKER_FILE = os.path.expanduser("~/.claude/learning/.session-active") + +# Directories the learning agent writes to +ALLOWED_PATHS = [ + os.path.expanduser("~/.claude/learning/"), + os.path.expanduser("~/.claude/plugins/marketplaces/learning-agent/"), +] + +# Tools allowed only when a learning session is active (marker exists) +SESSION_GATED_TOOLS = { + "WebSearch", + "WebFetch", + "Read", + "Grep", + "Glob", + "Agent", +} + +# Tools allowed if targeting learning directories (no session gate needed) +PATH_CHECKED_TOOLS = { + "Write", + "Edit", +} + + +# Bash commands that are always allowed (session lifecycle) +ALLOWED_BASH_PATTERNS = [ + re.compile(r"^mkdir -p ~/\.claude/learning\b"), + re.compile(r"^touch ~/\.claude/learning/\.session-active$"), + re.compile(r"^rm -f ~/\.claude/learning/\.session-active$"), + re.compile(r"^mkdir -p ~/\.claude/learning && touch ~/\.claude/learning/\.session-active$"), +] + + +def is_allowed_bash(command): + """Check if a Bash command is an allowed session lifecycle command.""" + if not command: + return False + cmd = command.strip() + return any(p.match(cmd) for p in ALLOWED_BASH_PATTERNS) + + +def is_learning_path(file_path): + """Check if a file path is within the learning agent's directories.""" + if not file_path: + return False + expanded = os.path.expanduser(file_path) + resolved = os.path.realpath(expanded) + for allowed in ALLOWED_PATHS: + resolved_allowed = os.path.realpath(allowed) + if resolved.startswith(resolved_allowed): + return True + return False + + +def is_session_active(): + """Check if a learning session is currently active.""" + return os.path.exists(MARKER_FILE) + + +def allow(): + print(json.dumps({ + "hookSpecificOutput": { + "permissionDecision": "allow" + } + })) + sys.exit(0) + + +def passthrough(): + """Don't interfere — let normal permission flow handle it.""" + print(json.dumps({})) + sys.exit(0) + + +def main(): + data = json.load(sys.stdin) + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) + + # Bash: allow session lifecycle commands (mkdir, touch, rm on marker) + if tool_name == "Bash": + command = tool_input.get("command", "") + if is_allowed_bash(command): + allow() + passthrough() + + # Write/Edit: allow if targeting learning directories (no session gate) + if tool_name in PATH_CHECKED_TOOLS: + file_path = tool_input.get("file_path", "") + if is_learning_path(file_path): + allow() + passthrough() + + # Research tools: allow only if a learning session is active + if tool_name in SESSION_GATED_TOOLS: + if is_session_active(): + allow() + passthrough() + + passthrough() + + +if __name__ == "__main__": + main()