feat: orchestrator Agent mode UI + claude_allow_dir tool + fix DDG search

- 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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-18 22:42:44 -04:00
parent 97438f1a0f
commit 9b818aa5c7
7 changed files with 234 additions and 10 deletions

View File

@@ -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/<domain>.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/<name>.py` with `router = APIRouter()`

View File

@@ -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

View File

@@ -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();

View File

@@ -137,6 +137,7 @@
<!-- Note mode controls -->
<button id="note-type-btn">private</button>
<button id="note-btn">Note</button>
<button id="agent-mode-btn" title="Agent mode — Gemini tool loop + Claude response">Agent</button>
<button id="send">Send</button>
<button id="stop">Stop</button>
</div>

View File

@@ -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,
])
]

44
cortex/tools/system.py Normal file
View File

@@ -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}"

View File

@@ -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: