From 9b818aa5c7006f8a8dfba7d6a5ac8235c73bd60a Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 18 Mar 2026 22:42:44 -0400 Subject: [PATCH] feat: orchestrator Agent mode UI + claude_allow_dir tool + fix DDG search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Agent mode toggle to web UI input row — routes through POST /orchestrate instead of /chat; polls for result with live tool-call count in thinking bubble - Add cortex/tools/system.py with claude_allow_dir tool; registers in tool registry - Fix web search: duckduckgo_search renamed to ddgs, update import + requirements.txt - Allow WebSearch and WebFetch in ~/.claude/settings.json for Claude CLI fallback - Add claude-allow-dir script docs and security note to CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 34 ++++++++++ cortex/requirements.txt | 2 +- cortex/static/app.js | 131 ++++++++++++++++++++++++++++++++++++--- cortex/static/index.html | 1 + cortex/tools/__init__.py | 30 +++++++++ cortex/tools/system.py | 44 +++++++++++++ cortex/tools/web.py | 2 +- 7 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 cortex/tools/system.py diff --git a/CLAUDE.md b/CLAUDE.md index 62f74ec..d2f9b25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ Cortex_and_Inara_dev/ tools/ __init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher) web.py ← DuckDuckGo web_search tool + system.py ← Local machine tools (claude_allow_dir) static/ ← Single-page web UI (index.html, style.css, app.js) data/sessions/ ← Persisted session JSON files @@ -142,6 +143,39 @@ http://localhost:8000/docs 3. Syntax check: `python3 -m py_compile cortex/tools/.py` 4. Restart Cortex +## Managing Claude Code Directory Permissions + +Claude Code prompts (or silently hangs) when it needs to read or write a directory outside +its current working directory. The `claude-allow-dir` script patches `~/.claude/settings.json` +to add auto-allow rules so Claude no longer blocks on those paths. + +### Script: `~/.local/bin/claude-allow-dir` + +```bash +# Allow read + write (default) +claude-allow-dir ~/OSIT_dev/aether_api_fastapi + +# Read-only +claude-allow-dir ~/agents_sync r + +# Write-only +claude-allow-dir /tmp w +``` + +Adds `Read(path/*)` and/or `Edit(path/*)` + `Write(path/*)` entries to the `permissions.allow` +array in `~/.claude/settings.json`. Idempotent — safe to run twice on the same path. +Changes take effect in the next Claude Code session (or after opening `/hooks` in the UI). + +### Orchestrator tool: `claude_allow_dir` + +Cortex exposes this as a Gemini tool (`cortex/tools/system.py`) so the orchestrator can add +allow rules on Inara's behalf without human intervention. + +**Security note:** This tool modifies Claude Code's own permission settings. The Gemini +orchestrator calling it can grant Claude access to any directory on the machine. Keep this +in mind when evaluating orchestrator behavior — it should only be invoked when Scott has +clearly asked for a directory to be unblocked. + ## Adding a New Router 1. Create `cortex/routers/.py` with `router = APIRouter()` diff --git a/cortex/requirements.txt b/cortex/requirements.txt index 4780641..4f5e2b3 100644 --- a/cortex/requirements.txt +++ b/cortex/requirements.txt @@ -6,7 +6,7 @@ python-dotenv>=1.0.0 # Orchestrator — Gemini API (native tool calling) + web search google-genai>=1.0.0 -duckduckgo-search>=6.3.0 +ddgs>=0.1.0 # anthropic SDK not needed — using claude CLI subprocess for auth # anthropic>=0.40.0 diff --git a/cortex/static/app.js b/cortex/static/app.js index 3218c23..0c4a5d6 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -9,9 +9,10 @@ const heightRow = document.getElementById('height-row'); const heightSel = document.getElementById('height-sel'); const enterToggle = document.getElementById('enter-toggle'); - const noteTypeBtnEl = document.getElementById('note-type-btn'); - const noteBtnEl = document.getElementById('note-btn'); - const stopBtn = document.getElementById('stop'); + const noteTypeBtnEl = document.getElementById('note-type-btn'); + const noteBtnEl = document.getElementById('note-btn'); + const agentModeBtnEl = document.getElementById('agent-mode-btn'); + const stopBtn = document.getElementById('stop'); let sessionId = null; let primaryBackend = 'claude'; @@ -58,6 +59,23 @@ syncHeight(); }); + // ── Agent mode ─────────────────────────────────────────────── + let agentMode = localStorage.getItem('agentMode') === 'true'; + + function updateAgentModeUI() { + agentModeBtnEl.classList.toggle('active', agentMode); + updateInputPlaceholder(); + if (agentMode) sendBtn.textContent = 'Run'; + else if (!noteMode) sendBtn.textContent = 'Send'; + } + + agentModeBtnEl.addEventListener('click', () => { + agentMode = !agentMode; + localStorage.setItem('agentMode', agentMode); + updateAgentModeUI(); + inputEl.focus(); + }); + // ── Note mode ──────────────────────────────────────────────── let noteMode = false; let notePublic = false; @@ -82,7 +100,7 @@ } else { noteBtnEl.classList.remove('active', 'public'); noteTypeBtnEl.style.display = 'none'; - sendBtn.textContent = 'Send'; + sendBtn.textContent = agentMode ? 'Run' : 'Send'; inputEl.classList.remove('note-mode', 'public'); } updateInputPlaceholder(); @@ -93,6 +111,10 @@ inputEl.placeholder = notePublic ? 'Public note — LLM sees this next turn…' : 'Private note — only you see this…'; + } else if (agentMode) { + inputEl.placeholder = ctrlEnterMode + ? 'Task for Inara… (Gemini tool loop — Ctrl+Enter to run)' + : 'Task for Inara… (Gemini tool loop)'; } else { inputEl.placeholder = ctrlEnterMode ? 'Message Inara… (Ctrl+Enter to send)' @@ -617,16 +639,108 @@ inputEl.focus(); } - sendBtn.addEventListener('click', () => { - if (noteMode) addNote(); else sendMessage(); - }); + async function sendOrchestrate() { + const text = inputEl.value.trim(); + if (!text || activeController) return; + + inputEl.value = ''; + syncHeight(); + sendBtn.style.display = 'none'; + stopBtn.style.display = 'block'; + headerEmoji.classList.add('processing'); + + activeController = new AbortController(); + + addMessage('user', text); + scrollToBottom(); + + const thinkingDiv = addMessage('assistant thinking', '⚡ working…'); + + try { + const res = await fetch('/orchestrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + task: text, + session_id: sessionId, + tier: currentTier, + include_long: memLong, + include_mid: memMid, + include_short: memShort, + }), + signal: activeController.signal, + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const { job_id } = await res.json(); + + // Poll until complete or stopped + let job; + while (true) { + if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); + + await new Promise(r => setTimeout(r, 2000)); + + if (activeController.signal.aborted) throw new DOMException('Aborted', 'AbortError'); + + const pollRes = await fetch(`/orchestrate/${job_id}`, { + signal: activeController.signal, + }); + if (!pollRes.ok) throw new Error(`Poll failed: HTTP ${pollRes.status}`); + job = await pollRes.json(); + + const n = job.tool_calls?.length || 0; + if (job.status === 'queued' || job.status === 'running') { + thinkingDiv.textContent = n + ? `⚡ working… (${n} tool${n !== 1 ? 's' : ''} used)` + : '⚡ working…'; + continue; + } + break; + } + + if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed'); + + thinkingDiv.className = 'message assistant'; + setMessageText(thinkingDiv, 'assistant', job.response || '(no response)'); + + const n = job.tool_calls?.length || 0; + if (n) { + const names = job.tool_calls.map(t => t.name).join(', '); + addMessage('system', `⚡ ${n} tool call${n !== 1 ? 's' : ''}: ${names}`); + } + + } catch (err) { + if (err.name === 'AbortError') { + thinkingDiv.className = 'message system'; + thinkingDiv.textContent = 'Stopped.'; + } else { + thinkingDiv.className = 'message error'; + thinkingDiv.textContent = `Error: ${err.message}`; + } + } + + activeController = null; + headerEmoji.classList.remove('processing'); + sendBtn.style.display = 'block'; + stopBtn.style.display = 'none'; + inputEl.focus(); + } + + function dispatchSend() { + if (noteMode) addNote(); + else if (agentMode) sendOrchestrate(); + else sendMessage(); + } + + sendBtn.addEventListener('click', dispatchSend); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey; if (shouldSend) { e.preventDefault(); - if (noteMode) addNote(); else sendMessage(); + dispatchSend(); } } }); @@ -923,6 +1037,7 @@ updateTierUI(); updateMemUI(); + updateAgentModeUI(); // ── Init ───────────────────────────────────────────────────── updateEnterToggleUI(); diff --git a/cortex/static/index.html b/cortex/static/index.html index 8a9a8a6..653cb23 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -137,6 +137,7 @@ + diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 9e38d08..593f797 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -20,6 +20,7 @@ from tools.ae_knowledge import journal_search as _ae_journal_search from tools.ae_knowledge import journal_entry_create as _ae_journal_entry_create from tools.ae_tasks import task_list as _ae_task_list from tools.files import file_read as _file_read +from tools.system import claude_allow_dir as _claude_allow_dir # --------------------------------------------------------------------------- @@ -171,8 +172,36 @@ _CALLABLES: dict[str, callable] = { "ae_journal_entry_create": _ae_journal_entry_create, "ae_task_list": _ae_task_list, "file_read": _file_read, + "claude_allow_dir": _claude_allow_dir, } +_claude_allow_dir_declaration = types.FunctionDeclaration( + name="claude_allow_dir", + description=( + "Add a directory to Claude Code's auto-allow list so Claude can read or write " + "files there without prompting. Edits ~/.claude/settings.json on the local machine. " + "Use this when Claude is silently hanging or being blocked from accessing a directory. " + "Changes take effect in the next Claude Code session." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "path": types.Schema( + type=types.Type.STRING, + description=( + "Absolute or home-relative path to the directory " + "(e.g. ~/OSIT_dev/aether_api_fastapi or /home/scott/agents_sync)" + ), + ), + "mode": types.Schema( + type=types.Type.STRING, + description="Permission mode: 'r' (read-only), 'w' (write-only), or 'rw' (both). Default: rw", + ), + }, + required=["path"], + ), +) + # Gemini Tool object — pass this to GenerateContentConfig TOOL_DECLARATIONS = [ types.Tool(function_declarations=[ @@ -181,6 +210,7 @@ TOOL_DECLARATIONS = [ _ae_journal_entry_create_declaration, _ae_task_list_declaration, _file_read_declaration, + _claude_allow_dir_declaration, ]) ] diff --git a/cortex/tools/system.py b/cortex/tools/system.py new file mode 100644 index 0000000..20bb2c2 --- /dev/null +++ b/cortex/tools/system.py @@ -0,0 +1,44 @@ +""" +System tools — local machine operations. + +These tools affect the host system directly. Use with care. +""" + +import asyncio +import logging + +logger = logging.getLogger(__name__) + +ALLOW_SCRIPT = "/home/scott/.local/bin/claude-allow-dir" + + +async def claude_allow_dir(path: str, mode: str = "rw") -> str: + """Add Read/Edit allow rules to ~/.claude/settings.json for a directory. + + Calls the claude-allow-dir script, which edits settings.json directly. + Changes take effect in the next Claude Code session (or after /hooks reload). + """ + if mode not in ("r", "w", "rw"): + return f"Error: mode must be r, w, or rw (got '{mode}')" + + try: + proc = await asyncio.create_subprocess_exec( + "python3", ALLOW_SCRIPT, path, mode, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + output = stdout.decode().strip() + err = stderr.decode().strip() + + if proc.returncode != 0: + logger.warning("claude-allow-dir failed (rc=%d): %s", proc.returncode, err) + return f"Failed (exit {proc.returncode}): {err or output}" + + return output or "Done." + + except asyncio.TimeoutError: + return "Error: script timed out" + except Exception as e: + logger.error("claude_allow_dir error: %s", e) + return f"Error: {e}" diff --git a/cortex/tools/web.py b/cortex/tools/web.py index b680406..5d87c2b 100644 --- a/cortex/tools/web.py +++ b/cortex/tools/web.py @@ -35,7 +35,7 @@ async def search(query: str, max_results: int | None = None) -> str: def _sync_search(query: str, max_results: int) -> list[dict]: """Synchronous DuckDuckGo search — run via asyncio.to_thread.""" - from duckduckgo_search import DDGS + from ddgs import DDGS kwargs = {} if settings.ddg_api_key: