feat: SSH dev routing, model registry UX, chat input toolbar, doc sync
Backend / infrastructure:
- cortex/tools/_projects.py (new): shared project alias registry with ssh_host
for workstation projects (aether_api, aether_frontend, aether_container)
- cortex/tools/git.py: all git tools route to workstation via SSH when ssh_host set
- cortex/tools/aider.py: aider_run SSH-routes to workstation using bash -l -c
- cortex/routers/local_llm.py: POST /api/models/{id}/edit AJAX endpoint — save
model edits without page reload or tab reset; returns JSON {ok, label, model_name}
- cortex/llm_client.py: remove Gemini CLI and Claude CLI backends; clean up
fallback chain and process group tracking (continuation of Gemini CLI removal)
- cortex/routers/auth.py: strip Claude/Gemini CLI auth status checks (CLI removed)
- cortex/routers/chat.py: remove legacy claude/gemini backend fields
- cortex/config.py: clean up CLI-related settings
- cortex/main.py: remove CLI lifecycle hooks
UI:
- cortex/static/local_llm.html: model edit forms now save via fetch() + toast;
stay on Models tab; update row header label in place on success
- cortex/static/index.html: restructure input area to column layout — textarea
above, compact toolbar below (Chat/Tools/Attach + Send); fixes dead space at
M/L/XL sizes; context panel "Role" → "Model" section label
- cortex/static/style.css: column input-area layout; #input-toolbar; flex:1 →
width:100% on textarea (fixes scrollHeight in column flex context); compact
send/stop button padding
- cortex/static/app.js: add XL (720px) to height cycle; default M (240px)
Docs:
- cortex/static/HELP.md: S/M/L → S/M/L/XL; add Rebuild to distill table; fix
"Role selector" references (no such UI); fix "your active role" → Chat role;
fix ⚡ toggle description; Model Registry section cleanup
- documentation/ARCH__BACKENDS.md: reflect CLI removal, current backend state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
31
cortex/tools/_projects.py
Normal file
31
cortex/tools/_projects.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Shared project alias registry for Cortex tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectDef:
|
||||
path: str # path on the host where the project lives (~ is expanded at runtime)
|
||||
ssh_host: str = "" # if set, git/aider commands run via SSH on this host
|
||||
|
||||
|
||||
_CORTEX_ROOT_STR: str = str(Path(__file__).parent.parent.parent.resolve())
|
||||
|
||||
PROJECT_ALIASES: dict[str, ProjectDef] = {
|
||||
"cortex": ProjectDef(path=_CORTEX_ROOT_STR),
|
||||
"aether_api": ProjectDef(
|
||||
path="~/OSIT_dev/aether_api_fastapi",
|
||||
ssh_host="scott-wks-main-i7",
|
||||
),
|
||||
"aether_frontend": ProjectDef(
|
||||
path="~/OSIT_dev/aether_app_sveltekit",
|
||||
ssh_host="scott-wks-main-i7",
|
||||
),
|
||||
"aether_container": ProjectDef(
|
||||
path="~/OSIT_dev/aether_container_env",
|
||||
ssh_host="scott-wks-main-i7",
|
||||
),
|
||||
}
|
||||
@@ -16,25 +16,16 @@ background=True runs the subprocess asynchronously and returns an agent_id immed
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
import agent_manager
|
||||
from ._projects import PROJECT_ALIASES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CORTEX_DIR = Path(__file__).parent # .../Cortex_and_Inara_dev/cortex/
|
||||
_PROJECT_ROOT = _CORTEX_DIR.parent # .../Cortex_and_Inara_dev/
|
||||
|
||||
# Known project aliases — expand before passing to subprocess
|
||||
_PROJECT_ALIASES: dict[str, str] = {
|
||||
"cortex": str(_PROJECT_ROOT),
|
||||
"aether_api": "~/OSIT_dev/aether_api_fastapi",
|
||||
"aether_frontend": "~/OSIT_dev/aether_app_sveltekit",
|
||||
"aether_container": "~/OSIT_dev/aether_container_env",
|
||||
}
|
||||
|
||||
_MAX_OUTPUT_CHARS = 12_000
|
||||
|
||||
# Maps URL fragments → Aider --api-key provider slug.
|
||||
@@ -192,11 +183,16 @@ async def aider_run(
|
||||
immediately. Use agent_status(agent_id) to check progress; set notify=True to
|
||||
receive a push/Talk notification on completion.
|
||||
"""
|
||||
resolved = _PROJECT_ALIASES.get(project, project)
|
||||
cwd = Path(os.path.expanduser(resolved))
|
||||
proj_def = PROJECT_ALIASES.get(project)
|
||||
if proj_def is not None:
|
||||
cwd = Path(os.path.expanduser(proj_def.path))
|
||||
ssh_host = proj_def.ssh_host
|
||||
else:
|
||||
cwd = Path(os.path.expanduser(project))
|
||||
ssh_host = ""
|
||||
|
||||
if not cwd.is_dir():
|
||||
return f"Error: project directory '{resolved}' does not exist."
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory '{cwd}' does not exist."
|
||||
|
||||
timeout = min(max(int(timeout), 10), 600)
|
||||
|
||||
@@ -232,17 +228,28 @@ async def aider_run(
|
||||
cmd += ["--file", f]
|
||||
|
||||
logger.info(
|
||||
"aider_run: project=%s model=%s host_label=%s auto_commit=%s background=%s task=%.120s",
|
||||
project, model, host_label, auto_commit, background, task,
|
||||
"aider_run: project=%s ssh_host=%s model=%s host_label=%s auto_commit=%s background=%s task=%.120s",
|
||||
project, ssh_host or "local", model, host_label, auto_commit, background, task,
|
||||
)
|
||||
|
||||
async def _run() -> str:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
if ssh_host:
|
||||
# Run aider natively on the remote host via a login shell so PATH
|
||||
# includes ~/.local/bin where aider is typically installed.
|
||||
inner_cmd = "cd " + shlex.quote(str(cwd)) + " && " + shlex.join(cmd)
|
||||
ssh_cmd = f"bash -l -c {shlex.quote(inner_cmd)}"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh", ssh_host, ssh_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(cwd),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=float(timeout))
|
||||
|
||||
out = stdout.decode(errors="replace").strip()
|
||||
@@ -323,6 +330,8 @@ DECLARATIONS = [
|
||||
"Credentials are resolved automatically from the Cortex model registry — "
|
||||
"OpenRouter, local Open WebUI/Ollama, Anthropic API, and other configured hosts "
|
||||
"are all supported. Use host_label to pick a specific host. "
|
||||
"aether_api, aether_frontend, and aether_container run aider natively on the "
|
||||
"workstation (scott-wks-main-i7) via SSH — aider must be installed there. "
|
||||
"Set background=True for long tasks — returns an agent_id immediately and sends "
|
||||
"a notification when done. ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
|
||||
@@ -13,26 +13,23 @@ Write operations (admin-only, confirm-required):
|
||||
All tools accept an optional `project` parameter using the same aliases as aider_run:
|
||||
"cortex" (default), "aether_api", "aether_frontend", "aether_container"
|
||||
Or pass an absolute path directly.
|
||||
|
||||
Projects with an ssh_host defined in _projects.py run all git commands on the remote
|
||||
host via SSH, using shlex-quoted commands to handle paths and arguments safely.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from google.genai import types
|
||||
|
||||
from ._projects import PROJECT_ALIASES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CORTEX_ROOT: Path = Path(__file__).parent.parent.parent.resolve()
|
||||
|
||||
_PROJECT_ALIASES: dict[str, str] = {
|
||||
"cortex": str(_CORTEX_ROOT),
|
||||
"aether_api": "~/OSIT_dev/aether_api_fastapi",
|
||||
"aether_frontend": "~/OSIT_dev/aether_app_sveltekit",
|
||||
"aether_container": "~/OSIT_dev/aether_container_env",
|
||||
}
|
||||
|
||||
_MAX_OUTPUT = 50_000
|
||||
|
||||
_PROJECT_PARAM = types.Schema(
|
||||
@@ -45,21 +42,34 @@ _PROJECT_PARAM = types.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_project(project: str) -> Path:
|
||||
"""Resolve a project alias or path string to an absolute Path."""
|
||||
def _resolve_project(project: str) -> tuple[Path, str]:
|
||||
"""Return (path, ssh_host). path may not exist locally when ssh_host is set."""
|
||||
if not project:
|
||||
return _CORTEX_ROOT
|
||||
resolved = _PROJECT_ALIASES.get(project, project)
|
||||
return Path(os.path.expanduser(resolved))
|
||||
d = PROJECT_ALIASES["cortex"]
|
||||
else:
|
||||
d = PROJECT_ALIASES.get(project)
|
||||
if d is None:
|
||||
# Raw path — no SSH routing
|
||||
return Path(os.path.expanduser(project)), ""
|
||||
return Path(os.path.expanduser(d.path)), d.ssh_host
|
||||
|
||||
|
||||
async def _git(*args: str, cwd: Path, timeout: int = 15) -> tuple[int, str]:
|
||||
"""Run a git command in cwd. Returns (returncode, combined output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(cwd), *args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
async def _git(*args: str, cwd: Path, ssh_host: str = "", timeout: int = 15) -> tuple[int, str]:
|
||||
"""Run a git command locally or via SSH. Returns (returncode, combined output)."""
|
||||
if ssh_host:
|
||||
# Build a single shell-safe command string for the remote shell
|
||||
remote_cmd = shlex.join(["git", "-C", str(cwd)] + list(args))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh", ssh_host, remote_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(cwd), *args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -80,10 +90,10 @@ def _cap(text: str) -> str:
|
||||
|
||||
async def git_status(project: str = "") -> str:
|
||||
"""Return the working tree status for a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
cwd, ssh_host = _resolve_project(project)
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
rc, out = await _git("status", cwd=cwd)
|
||||
rc, out = await _git("status", cwd=cwd, ssh_host=ssh_host)
|
||||
if rc != 0:
|
||||
return f"git status failed: {out}"
|
||||
return out or "Working tree clean — nothing to report."
|
||||
@@ -91,8 +101,8 @@ async def git_status(project: str = "") -> str:
|
||||
|
||||
async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: str = "") -> str:
|
||||
"""Return recent commit history for a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
cwd, ssh_host = _resolve_project(project)
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
args = ["log"]
|
||||
if oneline:
|
||||
@@ -102,7 +112,7 @@ async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: st
|
||||
args += [f"-{max(1, min(n, 200))}"]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args, cwd=cwd)
|
||||
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host)
|
||||
if rc != 0:
|
||||
return f"git log failed: {out}"
|
||||
return _cap(out) or "No commits found."
|
||||
@@ -110,8 +120,8 @@ async def git_log(n: int = 20, path: str = "", oneline: bool = True, project: st
|
||||
|
||||
async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only: bool = False, project: str = "") -> str:
|
||||
"""Show a diff for a project. Defaults to working tree vs HEAD."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
cwd, ssh_host = _resolve_project(project)
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
args = ["diff"]
|
||||
if stat_only:
|
||||
@@ -122,7 +132,7 @@ async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only:
|
||||
args += [ref_a]
|
||||
if path:
|
||||
args += ["--", path]
|
||||
rc, out = await _git(*args, cwd=cwd)
|
||||
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host)
|
||||
# diff exits 1 when differences exist — normal
|
||||
if rc not in (0, 1):
|
||||
return f"git diff failed: {out}"
|
||||
@@ -133,29 +143,27 @@ async def git_diff(ref_a: str = "", ref_b: str = "", path: str = "", stat_only:
|
||||
|
||||
async def git_commit(message: str, project: str = "", files: list[str] | None = None) -> str:
|
||||
"""Stage files and create a commit in a project."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
cwd, ssh_host = _resolve_project(project)
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
if not message.strip():
|
||||
return "Error: commit message is required."
|
||||
|
||||
# Stage specified files or all changes
|
||||
if files:
|
||||
for f in files:
|
||||
rc, out = await _git("add", "--", f, cwd=cwd)
|
||||
rc, out = await _git("add", "--", f, cwd=cwd, ssh_host=ssh_host)
|
||||
if rc != 0:
|
||||
return f"git add '{f}' failed: {out}"
|
||||
else:
|
||||
rc, out = await _git("add", "-A", cwd=cwd)
|
||||
rc, out = await _git("add", "-A", cwd=cwd, ssh_host=ssh_host)
|
||||
if rc != 0:
|
||||
return f"git add -A failed: {out}"
|
||||
|
||||
# Check that something is actually staged
|
||||
rc, staged = await _git("diff", "--cached", "--stat", cwd=cwd)
|
||||
rc, staged = await _git("diff", "--cached", "--stat", cwd=cwd, ssh_host=ssh_host)
|
||||
if not staged.strip():
|
||||
return "Nothing staged to commit — working tree already clean."
|
||||
|
||||
rc, out = await _git("commit", "-m", message, cwd=cwd)
|
||||
rc, out = await _git("commit", "-m", message, cwd=cwd, ssh_host=ssh_host)
|
||||
if rc != 0:
|
||||
return f"git commit failed: {out}"
|
||||
return out or "Committed successfully."
|
||||
@@ -163,15 +171,15 @@ async def git_commit(message: str, project: str = "", files: list[str] | None =
|
||||
|
||||
async def git_push(project: str = "", remote: str = "origin", branch: str = "") -> str:
|
||||
"""Push the current branch to a remote."""
|
||||
cwd = _resolve_project(project)
|
||||
if not cwd.is_dir():
|
||||
cwd, ssh_host = _resolve_project(project)
|
||||
if not ssh_host and not cwd.is_dir():
|
||||
return f"Error: project directory not found: {cwd}"
|
||||
|
||||
args = ["push", remote]
|
||||
if branch:
|
||||
args.append(branch)
|
||||
|
||||
rc, out = await _git(*args, cwd=cwd, timeout=30)
|
||||
rc, out = await _git(*args, cwd=cwd, ssh_host=ssh_host, timeout=30)
|
||||
if rc != 0:
|
||||
return f"git push failed: {out}"
|
||||
return out or f"Pushed to {remote} successfully."
|
||||
@@ -185,7 +193,8 @@ DECLARATIONS = [
|
||||
description=(
|
||||
"Show the working tree status for a project: staged changes, unstaged "
|
||||
"modifications, and untracked files. Use before committing to see what "
|
||||
"will be included. Defaults to the Cortex project."
|
||||
"will be included. Defaults to the Cortex project. "
|
||||
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -197,7 +206,8 @@ DECLARATIONS = [
|
||||
description=(
|
||||
"Show recent commit history for a project. Returns commit hashes, dates, "
|
||||
"and messages. Use after aider_run completes to see what was committed. "
|
||||
"Defaults to the Cortex project."
|
||||
"Defaults to the Cortex project. "
|
||||
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -226,7 +236,8 @@ DECLARATIONS = [
|
||||
"With ref_a only: changes between that ref and HEAD. "
|
||||
"With ref_a and ref_b: changes between the two refs. "
|
||||
"Use after aider_run (auto_commit=False) to review changes before committing. "
|
||||
"Defaults to the Cortex project."
|
||||
"Defaults to the Cortex project. "
|
||||
"aether_api, aether_frontend, and aether_container run on the workstation via SSH."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -257,6 +268,7 @@ DECLARATIONS = [
|
||||
"Stage files and create a git commit in a project. "
|
||||
"Use after reviewing changes with git_diff — especially when aider_run ran "
|
||||
"with auto_commit=False. Stages all changes by default (files=None). "
|
||||
"aether_api, aether_frontend, and aether_container commit on the workstation via SSH. "
|
||||
"ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
@@ -284,6 +296,7 @@ DECLARATIONS = [
|
||||
description=(
|
||||
"Push the current branch to a remote. "
|
||||
"Use after git_commit or after aider_run commits to share the changes. "
|
||||
"aether_api, aether_frontend, and aether_container push on the workstation via SSH. "
|
||||
"ADMIN ONLY. Requires confirmation."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
|
||||
Reference in New Issue
Block a user