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:
34
CLAUDE.md
34
CLAUDE.md
@@ -40,6 +40,7 @@ Cortex_and_Inara_dev/
|
|||||||
tools/
|
tools/
|
||||||
__init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher)
|
__init__.py ← Tool registry (Gemini FunctionDeclarations + dispatcher)
|
||||||
web.py ← DuckDuckGo web_search tool
|
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)
|
static/ ← Single-page web UI (index.html, style.css, app.js)
|
||||||
data/sessions/ ← Persisted session JSON files
|
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`
|
3. Syntax check: `python3 -m py_compile cortex/tools/<domain>.py`
|
||||||
4. Restart Cortex
|
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
|
## Adding a New Router
|
||||||
|
|
||||||
1. Create `cortex/routers/<name>.py` with `router = APIRouter()`
|
1. Create `cortex/routers/<name>.py` with `router = APIRouter()`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ python-dotenv>=1.0.0
|
|||||||
|
|
||||||
# Orchestrator — Gemini API (native tool calling) + web search
|
# Orchestrator — Gemini API (native tool calling) + web search
|
||||||
google-genai>=1.0.0
|
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 SDK not needed — using claude CLI subprocess for auth
|
||||||
# anthropic>=0.40.0
|
# anthropic>=0.40.0
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
const enterToggle = document.getElementById('enter-toggle');
|
const enterToggle = document.getElementById('enter-toggle');
|
||||||
const noteTypeBtnEl = document.getElementById('note-type-btn');
|
const noteTypeBtnEl = document.getElementById('note-type-btn');
|
||||||
const noteBtnEl = document.getElementById('note-btn');
|
const noteBtnEl = document.getElementById('note-btn');
|
||||||
|
const agentModeBtnEl = document.getElementById('agent-mode-btn');
|
||||||
const stopBtn = document.getElementById('stop');
|
const stopBtn = document.getElementById('stop');
|
||||||
|
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
@@ -58,6 +59,23 @@
|
|||||||
syncHeight();
|
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 ────────────────────────────────────────────────
|
// ── Note mode ────────────────────────────────────────────────
|
||||||
let noteMode = false;
|
let noteMode = false;
|
||||||
let notePublic = false;
|
let notePublic = false;
|
||||||
@@ -82,7 +100,7 @@
|
|||||||
} else {
|
} else {
|
||||||
noteBtnEl.classList.remove('active', 'public');
|
noteBtnEl.classList.remove('active', 'public');
|
||||||
noteTypeBtnEl.style.display = 'none';
|
noteTypeBtnEl.style.display = 'none';
|
||||||
sendBtn.textContent = 'Send';
|
sendBtn.textContent = agentMode ? 'Run' : 'Send';
|
||||||
inputEl.classList.remove('note-mode', 'public');
|
inputEl.classList.remove('note-mode', 'public');
|
||||||
}
|
}
|
||||||
updateInputPlaceholder();
|
updateInputPlaceholder();
|
||||||
@@ -93,6 +111,10 @@
|
|||||||
inputEl.placeholder = notePublic
|
inputEl.placeholder = notePublic
|
||||||
? 'Public note — LLM sees this next turn…'
|
? 'Public note — LLM sees this next turn…'
|
||||||
: 'Private note — only you see this…';
|
: '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 {
|
} else {
|
||||||
inputEl.placeholder = ctrlEnterMode
|
inputEl.placeholder = ctrlEnterMode
|
||||||
? 'Message Inara… (Ctrl+Enter to send)'
|
? 'Message Inara… (Ctrl+Enter to send)'
|
||||||
@@ -617,16 +639,108 @@
|
|||||||
inputEl.focus();
|
inputEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
sendBtn.addEventListener('click', () => {
|
async function sendOrchestrate() {
|
||||||
if (noteMode) addNote(); else sendMessage();
|
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) => {
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
|
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (noteMode) addNote(); else sendMessage();
|
dispatchSend();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -923,6 +1037,7 @@
|
|||||||
|
|
||||||
updateTierUI();
|
updateTierUI();
|
||||||
updateMemUI();
|
updateMemUI();
|
||||||
|
updateAgentModeUI();
|
||||||
|
|
||||||
// ── Init ─────────────────────────────────────────────────────
|
// ── Init ─────────────────────────────────────────────────────
|
||||||
updateEnterToggleUI();
|
updateEnterToggleUI();
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
<!-- Note mode controls -->
|
<!-- Note mode controls -->
|
||||||
<button id="note-type-btn">private</button>
|
<button id="note-type-btn">private</button>
|
||||||
<button id="note-btn">Note</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="send">Send</button>
|
||||||
<button id="stop">Stop</button>
|
<button id="stop">Stop</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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_knowledge import journal_entry_create as _ae_journal_entry_create
|
||||||
from tools.ae_tasks import task_list as _ae_task_list
|
from tools.ae_tasks import task_list as _ae_task_list
|
||||||
from tools.files import file_read as _file_read
|
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_journal_entry_create": _ae_journal_entry_create,
|
||||||
"ae_task_list": _ae_task_list,
|
"ae_task_list": _ae_task_list,
|
||||||
"file_read": _file_read,
|
"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
|
# Gemini Tool object — pass this to GenerateContentConfig
|
||||||
TOOL_DECLARATIONS = [
|
TOOL_DECLARATIONS = [
|
||||||
types.Tool(function_declarations=[
|
types.Tool(function_declarations=[
|
||||||
@@ -181,6 +210,7 @@ TOOL_DECLARATIONS = [
|
|||||||
_ae_journal_entry_create_declaration,
|
_ae_journal_entry_create_declaration,
|
||||||
_ae_task_list_declaration,
|
_ae_task_list_declaration,
|
||||||
_file_read_declaration,
|
_file_read_declaration,
|
||||||
|
_claude_allow_dir_declaration,
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
44
cortex/tools/system.py
Normal file
44
cortex/tools/system.py
Normal 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}"
|
||||||
@@ -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]:
|
def _sync_search(query: str, max_results: int) -> list[dict]:
|
||||||
"""Synchronous DuckDuckGo search — run via asyncio.to_thread."""
|
"""Synchronous DuckDuckGo search — run via asyncio.to_thread."""
|
||||||
from duckduckgo_search import DDGS
|
from ddgs import DDGS
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if settings.ddg_api_key:
|
if settings.ddg_api_key:
|
||||||
|
|||||||
Reference in New Issue
Block a user