From fc1351d0f88f2cc861eef82d5888158496ea93f1 Mon Sep 17 00:00:00 2001 From: "Tal.Yuan" Date: Fri, 26 Jun 2026 22:40:04 +0800 Subject: [PATCH] refactor(tools): split tool_implementations.py into src/tools/ package (#4423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(tools): add shim protection test for tool_implementations split Covers all 48 top-level functions (33 do_* + 15 _helpers) extracted from the original module. Guards the upcoming split: the shim must re-export every symbol so existing 'from src.tool_implementations import X' imports keep working. Passes on baseline (pre-split). * refactor(tools): add src/tools/ package with shared _common Slice 1 Task 2 (#4082/#4071). Adds the package skeleton and moves the shared _parse_tool_args helper into src/tools/_common.py. Domain modules will import from here. tool_implementations.py is untouched at this step. * refactor(tools): extract system domain into src/tools/system.py Slice 1 (#4082/#4071), Task 3: move the system-domain tool functions (do_manage_skills/_skill_dump/do_manage_tasks/do_manage_endpoints/ do_manage_mcp/do_manage_webhooks/do_manage_tokens/do_manage_settings/ do_api_call/do_app_api) and the app_api blocklist constants out of tool_implementations.py into a new src/tools/system.py module. tool_implementations.py re-imports all of them so it stays a working backward-compatible facade (shim test stays green). - do_manage_mcp resolves get_mcp_manager via a function-local import from tool_implementations so the test that patches src.tool_implementations.get_mcp_manager still applies post-move. - do_app_api imports _internal_headers and _INTERNAL_BASE (still in tool_implementations) function-locally to avoid a circular import. - Repoint test_context_budget introspection assertion to the moved code's new home in src/tools/system.py. Co-Authored-By: Claude Opus 4.8 * refactor(tools): extract cookbook domain into src/tools/cookbook.py Moves the model-serving (cookbook) tool domain out of tool_implementations.py into src/tools/cookbook.py as part of slice 1 (#4082/#4071): - 13 do_* tools: download/serve/list/stop/tail/search/adopt/cached models, list downloads/cancel, list cookbook servers, serve presets - 9 private helpers: _cookbook_servers, _resolve_cookbook_host, _cookbook_env_for_host, _infer_serve_{port,host}, _ensure_served_endpoint, _cookbook_register_task, _cookbook_apply_retry_suggestion, _scan_running_model_processes, _cookbook_kill_session - _MODEL_PROCESS_PATTERNS constant (used only by _scan_running_model_processes) tool_implementations.py stays a backward-compatible facade via a re-import from src.tools.cookbook; src/tools/__init__ re-exports the same symbols. _internal_headers and _INTERNAL_BASE stay in tool_implementations.py (shared by system.py's do_app_api and many cookbook funcs). Each cookbook function that needs them does a function-local import to avoid a top-level circular dependency, matching the system-domain split. Verified: compileall clean; shim test green; cookbook-touching suite (652 passed, 1 skipped); full suite 3587 passed, 2 failed (pre-existing test_api_chat_security, unrelated). Co-Authored-By: Claude Opus 4.8 * refactor(tools): extract search domain into src/tools/search.py * refactor(tools): extract notes domain into src/tools/notes.py * refactor(tools): extract calendar domain into src/tools/calendar.py Repoints tests/test_caldav_bidirectional_sync.py source-introspection to src/tools/calendar.py (do_manage_calendar moved there). * refactor(tools): extract image domain into src/tools/image.py * refactor(tools): extract research domain into src/tools/research.py * refactor(tools): extract contacts domain into src/tools/contacts.py * refactor(tools): extract vault domain into src/tools/vault.py Repoints tests/test_vault_password_not_in_argv.py source-introspection to src/tools/vault.py (the vault do_* helpers moved there). * refactor(tools): collapse tool_implementations to clean re-export shim Move shared _INTERNAL_BASE/_internal_headers to src/tools/_common.py and drop the duplicate _parse_tool_args (already in _common). tool_implementations.py is now a pure re-export facade (+ 3 pre-existing email-context helpers, out of scope). Domain files' function-local imports of these names still resolve via the facade re-export. Co-Authored-By: Claude Opus 4.8 * fix(tools): port upstream cookbook workflow changes to split module Rebase onto dev dropped c504214 ("Cookbook model workflow fixes") edits to do_serve_model / do_tail_serve_output: the extraction commit moved the pre-edit bodies into src/tools/cookbook.py and git auto-accepted the deletion from tool_implementations.py, losing dev's changes. Restore them in their post-split home: - do_serve_model: add where/log_path/next_tools and the expanded "Next required check" output message - do_tail_serve_output: empty-output fallback message replacing "(empty pane)" (do_manage_settings web_fetch alias edit was already applied to src/tools/system.py during the system-extract conflict resolution.) Co-Authored-By: Claude Opus 4.8 * fix(tools): break admin_tools circular import in split facade After rebasing onto dev (#3629 moved the admin manage_* tools into src/agent_tools/admin_tools), the facade re-exported them via a top-level `from src.agent_tools.admin_tools import ...`. But src.agent_tools.__init__ imports this facade at top level, so the eager import re-entered the partially-initialized agent_tools package and broke collection. Re-export the admin symbols (do_manage_endpoints/mcp/webhooks/tokens/ settings, _MCP_DENIED_COMMANDS, _validate_mcp_command) lazily through module __getattr__ instead, and drop them from src/tools/__init__ (they no longer live in the src.tools package). system.py now holds only the skills/tasks/api bridges; admin tools live solely in admin_tools.py, matching upstream. Co-Authored-By: Claude Opus 4.8 * fix(tools): re-export dropped helpers through the split shim Address review finding from #4423: the compatibility facade claimed to preserve every original top-level symbol but omitted three helpers the old src.tool_implementations exposed. Re-export them and pin them in the shim protection test: - _string_arg, _validate_cookbook_ssh_target <- src/tools/cookbook.py - _mcp_allowed_commands <- src/agent_tools/admin_tools.py (lazily via __getattr__, to keep the agent_tools.__init__ <-> facade import acyclic after the #3629 admin-tools migration) All three added to tests/test_tool_implementations_shim.py _EXPECTED so the test contract now matches its "every original top-level function" comment. Co-Authored-By: Claude Opus 4.8 * test(tools): self-verify shim re-exports every domain do_* The hand-maintained _EXPECTED list in the shim protection test can drift silently when a new tool is added to a domain module but not re-exported by the facade — exactly the omission a reviewer flagged post-split. Add an auto-discovering test that enumerates every do_* from the domain modules (incl. admin_tools) and asserts reachability through the shim, so a forgotten re-export fails the build automatically. Uses hasattr (not dir(ti)) because the admin symbols are re-exported lazily via module __getattr__ and don't appear in dir(ti). Co-Authored-By: Claude Opus 4.8 * test(tools): self-verify every in-repo facade import resolves RaresKeY's P3 on the shim test was a claim-vs-reality gap: the docstring said it protected "every from src.tool_implementations import X" but the hand-maintained _EXPECTED list omitted three underscore helpers, so the claim wasn't enforced. Re-exporting the three (cf1f5e3) fixed the known gap; this closes the structural one. Add test_every_facade_import_in_repo_resolves: ast-enumerate every `from src.tool_implementations import X` site in src/ and tests/ and assert hasattr(ti, X) for each. A forgotten re-export that anything in the repo imports now fails the build automatically — including underscore helpers, which the do_* discovery test does not cover. Together with test_shim_reexports_every_domain_do_function, the shim contract is now self-verifying. Demote _EXPECTED in the docstring to the curated historical/downstream surface (the three helpers have no in-repo consumer, so they stay manual by necessity) instead of "ground truth". Co-Authored-By: Claude Opus 4.8 * fix(tools): dedupe _parse_tool_args + align shim guard with route consumers Addresses two P3s from review (RaresKeY, 2026-06-26): 1. maintainability — _common carried a full copy of _parse_tool_args alongside the canonical src.tool_utils one; future parser fixes could diverge. The two bodies were byte-identical in logic, so _common now re-exports from tool_utils (a leaf module, no circular-import risk). The single-source test is extended to assert _common._parse_tool_args and tool_implementations._parse_tool_args are the same object as tool_utils._parse_tool_args. 2. test — the shim guard's import-site scan only walked src/ and tests/, missing routes/chat_routes.py's clear_active_email/set_active_email imports, and _EXPECTED omitted the active-email facade helpers. The scan now walks every first-party Python dir (pruning venvs/caches/data in-place), and set/get/clear_active_email are added to _EXPECTED (get_active_email has no in-repo importer, so the scan alone can't see it). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: yuandonghao Co-authored-by: Claude Opus 4.8 --- src/tool_implementations.py | 3527 +--------------------- src/tools/__init__.py | 32 + src/tools/_common.py | 25 + src/tools/calendar.py | 522 ++++ src/tools/contacts.py | 148 + src/tools/cookbook.py | 1519 ++++++++++ src/tools/image.py | 39 + src/tools/notes.py | 254 ++ src/tools/research.py | 142 + src/tools/search.py | 51 + src/tools/system.py | 700 +++++ src/tools/vault.py | 189 ++ tests/test_admin_tools_registry.py | 9 +- tests/test_caldav_bidirectional_sync.py | 2 +- tests/test_internal_api_base.py | 2 +- tests/test_tool_implementations_shim.py | 165 + tests/test_vault_password_not_in_argv.py | 2 +- 17 files changed, 3864 insertions(+), 3464 deletions(-) create mode 100644 src/tools/__init__.py create mode 100644 src/tools/_common.py create mode 100644 src/tools/calendar.py create mode 100644 src/tools/contacts.py create mode 100644 src/tools/cookbook.py create mode 100644 src/tools/image.py create mode 100644 src/tools/notes.py create mode 100644 src/tools/research.py create mode 100644 src/tools/search.py create mode 100644 src/tools/system.py create mode 100644 src/tools/vault.py create mode 100644 tests/test_tool_implementations_shim.py diff --git a/src/tool_implementations.py b/src/tool_implementations.py index cb0495aa2..09fabf0cf 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -5,31 +5,79 @@ Extracted tool implementation functions (do_* and helpers) from agent_tools.py. These handle the actual execution logic for each tool type. """ -import asyncio -import json import logging -import os -import re -from typing import Any, Dict, List, Optional +from typing import Dict, Optional -from fastapi import HTTPException -from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE -from src.tool_utils import get_mcp_manager, _parse_tool_args -from core.constants import internal_api_base -from routes._validators import validate_remote_host, validate_ssh_port +from src.tool_utils import get_mcp_manager # re-exported: tests patch src.tool_implementations.get_mcp_manager + +# System-domain tools were extracted to src/tools/system.py (slice 1, +# #4082/#4071); the admin manage_* tools live in src/agent_tools/admin_tools +# after the upstream registry migration (#3629). Re-imported here so this +# module stays a working facade. +from src.tools.system import ( # noqa: F401 + do_manage_skills, _skill_dump, do_manage_tasks, + do_api_call, do_app_api, + _APP_API_BLOCKLIST_PREFIXES, _APP_API_BLOCKLIST_METHOD_PATH, +) +# Admin manage_* tools (endpoints/mcp/webhooks/tokens/settings) live in +# src/agent_tools/admin_tools after the upstream registry migration (#3629). +# Re-exported lazily via __getattr__: src.agent_tools.__init__ imports this +# facade at top level, so a eager `from src.agent_tools.admin_tools import` +# here would re-enter the partially-initialized agent_tools package (circular). +_ADMIN_TOOL_SYMBOLS = ( + "do_manage_endpoints", "do_manage_mcp", "do_manage_webhooks", + "do_manage_tokens", "do_manage_settings", + "_MCP_DENIED_COMMANDS", "_validate_mcp_command", "_mcp_allowed_commands", +) + + +def __getattr__(name): + if name in _ADMIN_TOOL_SYMBOLS: + from src.agent_tools import admin_tools + return getattr(admin_tools, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +# Cookbook (model serving) domain extracted to src/tools/cookbook.py +# (slice 1, #4082/#4071). Re-imported here so this module stays a working +# facade. cookbook.py pulls `_internal_headers` / `_INTERNAL_BASE` back +# function-locally from this facade (which re-exports them from _common). +from src.tools.cookbook import ( # noqa: F401 + do_download_model, do_serve_model, do_list_served_models, + do_stop_served_model, do_tail_serve_output, do_list_downloads, + do_cancel_download, do_search_hf_models, do_adopt_served_model, + do_list_cookbook_servers, do_list_serve_presets, do_serve_preset, + do_list_cached_models, + _cookbook_servers, _resolve_cookbook_host, _cookbook_env_for_host, + _infer_serve_port, _infer_serve_host, _ensure_served_endpoint, + _cookbook_register_task, _cookbook_apply_retry_suggestion, + _scan_running_model_processes, _cookbook_kill_session, + _MODEL_PROCESS_PATTERNS, + _string_arg, _validate_cookbook_ssh_target, +) +# Search domain extracted to src/tools/search.py (slice 1, #4082/#4071). +# Re-imported here so this module stays a working facade. +from src.tools.search import do_search_chats # noqa: F401 +# Notes domain extracted to src/tools/notes.py (slice 1, #4082/#4071). +from src.tools.notes import do_manage_notes # noqa: F401 +# Calendar domain extracted to src/tools/calendar.py (slice 1, #4082/#4071). +from src.tools.calendar import do_manage_calendar # noqa: F401 +# Image domain extracted to src/tools/image.py (slice 1, #4082/#4071). +from src.tools.image import do_edit_image # noqa: F401 +# Research domain extracted to src/tools/research.py (slice 1, #4082/#4071). +from src.tools.research import do_manage_research, do_trigger_research # noqa: F401 +# Contacts domain extracted to src/tools/contacts.py (slice 1, #4082/#4071). +from src.tools.contacts import do_resolve_contact, do_manage_contact # noqa: F401 +# Vault domain extracted to src/tools/vault.py (slice 1, #4082/#4071). +from src.tools.vault import ( # noqa: F401 + _load_vault_config, _run_bw, + do_vault_search, do_vault_get, do_vault_unlock, +) +# Shared helpers live in src/tools/_common.py. Re-exported here so the +# function-local `from src.tool_implementations import _INTERNAL_BASE` (and +# friends) used by domain files still resolve through this facade. +from src.tools._common import _parse_tool_args, _INTERNAL_BASE, _internal_headers # noqa: F401 logger = logging.getLogger(__name__) - -def _string_arg(value: Any) -> str: - return "" if value is None else str(value).strip() - - -def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple[str, str]: - remote = validate_remote_host(_string_arg(remote_host) or None) or "" - sport = validate_ssh_port(_string_arg(ssh_port) or None) or "" - return remote, sport - # --------------------------------------------------------------------------- # Active email state # --------------------------------------------------------------------------- @@ -63,3442 +111,3 @@ def get_active_email() -> Optional[Dict[str, str]]: def clear_active_email() -> None: global _active_email_ref _active_email_ref = None - -# --------------------------------------------------------------------------- -# Argument parsing -# --------------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Search chats -# --------------------------------------------------------------------------- - -async def do_search_chats(query: str, limit: int = 20, owner: str | None = None) -> Dict: - """Search past session transcripts for the calling user's sessions only. - - Without an owner filter this used to leak EVERY user's chat history - into the agent's `search_chats` results (v2 review HIGH-11). The - caller in `tool_execution.execute_tool_block` now plumbs the owner - through; legacy callers without owner pass through as before but - will only see legacy/null-owner rows. - """ - try: - from src.session_search import search_session_messages - - results = search_session_messages(query, limit=limit, owner=owner) - if not results: - return {"results": f"No chats found matching \"{query}\"."} - - # Group by session to avoid duplicate links - seen_sessions = {} - for result in results: - if result.session_id not in seen_sessions: - seen_sessions[result.session_id] = result - - lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"] - for sid, result in seen_sessions.items(): - lines.append(f"- **{result.session_name}** (#{sid})") - lines.append(f" Link: [Open chat](#{sid})") - lines.append(f" Match ({result.role}): {result.content_snippet}") - if result.context_before: - before = result.context_before[-1] - lines.append(f" Before ({before['role']}): {before['content'][:180]}") - if result.context_after: - after = result.context_after[0] - lines.append(f" After ({after['role']}): {after['content'][:180]}") - lines.append("") - - return {"results": "\n".join(lines)} - except Exception as e: - logger.error(f"search_chats failed: {e}") - return {"error": str(e), "exit_code": 1} - - -# --------------------------------------------------------------------------- -# Skills management tool -# --------------------------------------------------------------------------- - -async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict: - """Handle manage_skills tool calls. - - SKILL.md-backed CRUD with progressive disclosure (Hermes-style). Actions: - - list / index — Level 0: name + description summary. - view {name} — Level 1: full SKILL.md. - view_ref {name, path} — Level 2: a sub-file under the skill dir. - add {name, description, when_to_use, procedure[], pitfalls[], - verification[], tags[], category, status} - — Create a new skill (draft by default). - patch {name, old_string, new_string} - — Token-efficient surgical edit on the - raw SKILL.md text. Fails on ambiguous - `old_string` (multiple matches). - edit {name, content} — Replace the entire SKILL.md. - publish {name} — Flip status: draft -> published. - delete {name} — Remove the skill directory. - search {query} — Relevance match on published skills. - """ - try: - args = _parse_tool_args(content) - except ValueError: - return {"error": "Invalid JSON arguments", "exit_code": 1} - - action = (args.get("action") or "").lower() - from services.memory.skills import SkillsManager - from services.memory.skill_format import Skill, slugify - from src.constants import DATA_DIR - sm = SkillsManager(DATA_DIR) - - # Accept legacy `skill_id` as an alias for `name`. - name = (args.get("name") or args.get("skill_id") or "").strip() - - if action in ("list", "index", ""): - all_skills = sm.load(owner=owner) - if not all_skills: - return {"results": "No skills yet. Create one with action='add'."} - published = [s for s in all_skills if s.get("status") == "published"] - drafts = [s for s in all_skills if s.get("status") == "draft"] - lines = [] - if published: - lines.append("## Published") - for s in sorted(published, key=lambda x: x["name"]): - lines.append(f"- **{s['name']}** ({s.get('category','general')}): {s.get('description','')}") - if drafts: - lines.append("\n## Drafts") - for s in sorted(drafts, key=lambda x: x["name"]): - lines.append(f"- **{s['name']}** [draft]: {s.get('description','')}") - return {"results": "\n".join(lines) if lines else "No skills yet."} - - if action == "view": - if not name: - return {"error": "name is required for view", "exit_code": 1} - md = sm.read_skill_md(name, owner=owner) - if md is None: - return {"error": f"Skill {name!r} not found", "exit_code": 1} - return {"results": md} - - if action == "view_ref": - if not name: - return {"error": "name is required for view_ref", "exit_code": 1} - ref = (args.get("path") or "").strip() - if not ref: - return {"error": "path is required for view_ref", "exit_code": 1} - text = sm.read_skill_reference(name, ref, owner=owner) - if text is None: - return {"error": f"Reference {ref!r} not found under {name!r}", "exit_code": 1} - return {"results": text} - - if action == "add": - if not name: - return { - "error": "name is required for add. Provide the exact slug the user should see, then report the returned name.", - "exit_code": 1, - } - proc = args.get("procedure") - if proc is None: - proc = args.get("steps") or [] - if not proc and not args.get("body_extra") and not args.get("solution"): - return {"error": "procedure (or solution body) is required", "exit_code": 1} - # Same auto-publish gate as the extractor path — when the user - # has auto_approve_skills on and the caller didn't pin an explicit - # status, publish immediately. Audit later demotes/removes on fail. - _status_arg = args.get("status") - if not _status_arg: - try: - from routes.prefs_routes import _load_for_user as _load_prefs - _prefs = _load_prefs(owner) or {} - _status_arg = "published" if _prefs.get("auto_approve_skills", True) else "draft" - except Exception: - _status_arg = "draft" - entry = sm.add_skill( - name=args.get("name"), - description=(args.get("description") or args.get("title") or "").strip(), - category=args.get("category") or "general", - tags=args.get("tags") or [], - platforms=args.get("platforms") or [], - requires_toolsets=args.get("requires_toolsets") or [], - fallback_for_toolsets=args.get("fallback_for_toolsets") or [], - when_to_use=(args.get("when_to_use") if args.get("when_to_use") is not None - else args.get("problem", "")), - procedure=proc, - pitfalls=args.get("pitfalls") or [], - verification=args.get("verification") or [], - status=_status_arg, - version=args.get("version") or "1.0.0", - confidence=args.get("confidence", 0.8), - source=args.get("source", "learned"), - teacher_model=args.get("teacher_model"), - owner=owner, - title=args.get("title", ""), - problem=args.get("problem", ""), - solution=args.get("solution", ""), - steps=args.get("steps") or [], - ) - if entry.get("_deduped"): - return {"results": ( - f"A near-identical skill already exists: `{entry['name']}` — not creating " - f"a duplicate. View or edit it with action='view', name='{entry['name']}'." - )} - try: - from src.event_bus import fire_event - fire_event("skill_added", owner) - except Exception: - logger.debug("skill_added event dispatch failed", exc_info=True) - verify_hint = "" - if entry.get("status") == "draft": - verify_hint = ( - "\n\nThis skill is a DRAFT. Run through the procedure once to verify, " - f"then publish with action='publish', name='{entry['name']}'." - ) - return {"results": f"Created skill `{entry['name']}` — {entry.get('description','')}{verify_hint}"} - - if action == "edit": - if not name: - return {"error": "name is required for edit", "exit_code": 1} - new_content = args.get("content") - if not isinstance(new_content, str) or not new_content.strip(): - return {"error": "content (full SKILL.md) is required for edit", "exit_code": 1} - try: - sk_new = Skill.from_markdown(new_content) - except Exception as e: - return {"error": f"Could not parse content as SKILL.md: {e}", "exit_code": 1} - sk_new.name = slugify(sk_new.name or name) - existing = sm.load(owner=owner) - match = next((s for s in existing if s.get("name") == name), None) - if not match: - return {"error": f"Skill {name!r} not found", "exit_code": 1} - if not sk_new.owner: - sk_new.owner = match.get("owner") or owner - ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner) - return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1} - - if action == "patch": - if not name: - return {"error": "name is required for patch", "exit_code": 1} - old = args.get("old_string") - new_str = args.get("new_string", "") - if not isinstance(old, str) or not old: - return {"error": "old_string is required and must be non-empty", "exit_code": 1} - md = sm.read_skill_md(name, owner=owner) - if md is None: - return {"error": f"Skill {name!r} not found", "exit_code": 1} - count = md.count(old) - if count == 0: - return {"error": "old_string not found in SKILL.md", "exit_code": 1} - if count > 1: - return {"error": f"old_string is ambiguous (appears {count} times). Make it more specific.", "exit_code": 1} - new_md = md.replace(old, new_str, 1) - try: - sk_new = Skill.from_markdown(new_md) - except Exception as e: - return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1} - sk_new.name = slugify(sk_new.name or name) - ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner) - return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1} - - if action == "publish": - if not name: - return {"error": "name is required for publish", "exit_code": 1} - all_skills = sm.load(owner=owner) - match = next((s for s in all_skills if s.get("name") == name), None) - if not match: - return {"error": f"Skill {name!r} not found", "exit_code": 1} - updates = {"status": "published"} - if args.get("confidence") is not None: - updates["confidence"] = max(0.0, min(1.0, float(args["confidence"]))) - sm.update_skill(name, updates, owner=owner) - return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."} - - if action == "delete": - if not name: - return {"error": "name is required for delete", "exit_code": 1} - ok = sm.delete_skill(name, owner=owner) - return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1} - - if action == "search": - query = (args.get("query") or "").strip() - if not query: - return {"error": "query is required for search", "exit_code": 1} - results = sm.get_relevant_skills(query, sm.load(owner=owner), max_items=5) - if not results: - return {"results": "No matching skills found."} - lines = [] - for sk in results: - proc = sk.get("procedure") or sk.get("steps") or [] - steps_str = " → ".join(proc[:5]) - lines.append(f"**{sk['name']}**: {sk.get('description','')}\n When: {sk.get('when_to_use','')}\n Steps: {steps_str}") - return {"results": "\n\n".join(lines)} - - return { - "error": ( - f"Unknown action: {action!r}. " - "Use one of: list, view, view_ref, add, edit, patch, publish, delete, search." - ), - "exit_code": 1, - } - - -def _skill_dump(sk) -> Dict: - """Translate a parsed Skill back into the kwargs `update_skill` expects.""" - return { - "name": sk.name, - "description": sk.description, - "version": sk.version, - "category": sk.category, - "tags": sk.tags, - "platforms": sk.platforms, - "requires_toolsets": sk.requires_toolsets, - "fallback_for_toolsets": sk.fallback_for_toolsets, - "status": sk.status, - "confidence": sk.confidence, - "source": sk.source, - "teacher_model": sk.teacher_model, - "owner": sk.owner, - "when_to_use": sk.when_to_use, - "procedure": sk.procedure, - "pitfalls": sk.pitfalls, - "verification": sk.verification, - "body_extra": sk.body_extra, - } - - -# --------------------------------------------------------------------------- -# Task management tool -# --------------------------------------------------------------------------- - -async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict: - """Handle manage_tasks tool calls: CRUD on scheduled tasks.""" - import uuid as _uuid - from core.database import SessionLocal, ScheduledTask - from src.task_scheduler import compute_next_run - - try: - args = _parse_tool_args(content) - except ValueError: - return {"error": "Invalid JSON arguments", "exit_code": 1} - - action = args.get("action", "list") - db = SessionLocal() - try: - if action == "list": - q = db.query(ScheduledTask) - if owner: - q = q.filter(ScheduledTask.owner == owner) - tasks = q.order_by(ScheduledTask.created_at.desc()).all() - task_list = [] - for t in tasks: - task_list.append({ - "id": t.id, "name": t.name, "status": t.status, - "task_type": t.task_type or "llm", - "action": t.action, - "trigger_type": t.trigger_type or "schedule", - "schedule": t.schedule, - "trigger_event": t.trigger_event, - "trigger_count": t.trigger_count, - "next_run": t.next_run.isoformat() + "Z" if t.next_run else None, - "last_run": t.last_run.isoformat() + "Z" if t.last_run else None, - "run_count": t.run_count or 0, - }) - return {"response": f"Found {len(task_list)} tasks", "tasks": task_list, "exit_code": 0} - - elif action == "create": - task_type = args.get("task_type", "llm") - trigger_type = args.get("trigger_type", "schedule") - - if task_type in ("llm", "research") and not args.get("prompt"): - return {"error": "Prompt is required for llm/research tasks", "exit_code": 1} - if task_type == "action" and not args.get("action_name"): - return {"error": "action_name is required for action tasks", "exit_code": 1} - - # Compute next_run for schedule triggers - next_run = None - if trigger_type == "schedule": - schedule = args.get("schedule", "daily") - next_run = compute_next_run( - schedule, args.get("scheduled_time", "09:00"), - args.get("scheduled_day"), - ) - - task_id = str(_uuid.uuid4()) - # Guard each fallback with `or`: args.get("prompt", default) returns - # None when the key is present but null, and None[:50] raises. - name = args.get("name") or (args.get("prompt") or args.get("action_name") or "Task")[:50] - - task = ScheduledTask( - id=task_id, - owner=owner, - name=name, - prompt=args.get("prompt"), - task_type=task_type, - action=args.get("action_name"), - schedule=args.get("schedule") if trigger_type == "schedule" else None, - scheduled_time=args.get("scheduled_time", "09:00") if trigger_type == "schedule" else None, - scheduled_day=args.get("scheduled_day"), - trigger_type=trigger_type, - trigger_event=args.get("trigger_event"), - trigger_count=args.get("trigger_count"), - trigger_counter=0, - next_run=next_run, - status="active", - output_target=args.get("output_target", "session"), - ) - db.add(task) - db.commit() - return {"response": f"Created task '{name}' (id: {task_id})", "task_id": task_id, "exit_code": 0} - - elif action == "edit": - task_id = args.get("task_id") - if not task_id: - return {"error": "task_id is required for edit", "exit_code": 1} - task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() - if not task: - return {"error": f"Task {task_id} not found", "exit_code": 1} - if owner and task.owner and task.owner != owner: - return {"error": "Access denied", "exit_code": 1} - - changed = [] - for field in ("name", "prompt", "output_target"): - if args.get(field) is not None: - setattr(task, field, args[field]) - changed.append(field) - if args.get("task_type") is not None: - task.task_type = args["task_type"] - changed.append("task_type") - if args.get("action_name") is not None: - task.action = args["action_name"] - changed.append("action") - if args.get("trigger_type") is not None: - task.trigger_type = args["trigger_type"] - changed.append("trigger_type") - if args.get("trigger_event") is not None: - task.trigger_event = args["trigger_event"] - changed.append("trigger_event") - if args.get("trigger_count") is not None: - task.trigger_count = args["trigger_count"] - changed.append("trigger_count") - - schedule_changed = False - for field in ("schedule", "scheduled_time", "scheduled_day"): - if args.get(field) is not None: - setattr(task, field, args[field]) - changed.append(field) - schedule_changed = True - - if schedule_changed and (task.trigger_type or "schedule") == "schedule": - task.next_run = compute_next_run( - task.schedule, task.scheduled_time, task.scheduled_day, - ) - - db.commit() - return {"response": f"Updated task '{task.name}': {', '.join(changed)}", "exit_code": 0} - - elif action == "delete": - task_id = args.get("task_id") - if not task_id: - return {"error": "task_id is required for delete", "exit_code": 1} - task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() - if not task: - return {"error": f"Task {task_id} not found", "exit_code": 1} - if owner and task.owner and task.owner != owner: - return {"error": "Access denied", "exit_code": 1} - name = task.name - db.delete(task) - db.commit() - return {"response": f"Deleted task '{name}'", "exit_code": 0} - - elif action in ("pause", "resume"): - task_id = args.get("task_id") - if not task_id: - return {"error": f"task_id is required for {action}", "exit_code": 1} - task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() - if not task: - return {"error": f"Task {task_id} not found", "exit_code": 1} - if owner and task.owner and task.owner != owner: - return {"error": "Access denied", "exit_code": 1} - - if action == "pause": - task.status = "paused" - else: - task.status = "active" - if (task.trigger_type or "schedule") == "schedule": - task.next_run = compute_next_run( - task.schedule, task.scheduled_time, task.scheduled_day, - ) - db.commit() - return {"response": f"Task '{task.name}' {action}d", "exit_code": 0} - - elif action == "run": - task_id = args.get("task_id") - if not task_id: - return {"error": "task_id is required for run", "exit_code": 1} - task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() - if not task: - return {"error": f"Task {task_id} not found", "exit_code": 1} - if owner and task.owner and task.owner != owner: - return {"error": "Access denied", "exit_code": 1} - - from src.event_bus import get_task_scheduler - scheduler = get_task_scheduler() - if scheduler: - started = await scheduler.run_task_now(task_id) - if started: - return {"response": f"Task '{task.name}' triggered", "exit_code": 0} - else: - return {"error": "Task is already running", "exit_code": 1} - return {"error": "Task scheduler not available", "exit_code": 1} - - else: - return {"error": f"Unknown action: {action}", "exit_code": 1} - - except Exception as e: - logger.error(f"manage_tasks error: {e}") - return {"error": str(e), "exit_code": 1} - finally: - db.close() - - -async def do_api_call(content: str) -> Dict: - """Execute an API call to a registered integration.""" - from src.integrations import execute_api_call, load_integrations - try: - args = json.loads(content) - except json.JSONDecodeError: - # Try line-based format: integration\nmethod path\nbody - lines = content.strip().split("\n") - args = {"integration": lines[0].strip() if lines else ""} - if len(lines) > 1: - parts = lines[1].strip().split(" ", 1) - args["method"] = parts[0] if parts else "GET" - args["path"] = parts[1] if len(parts) > 1 else "/" - if len(lines) > 2: - try: - args["body"] = json.loads("\n".join(lines[2:])) - except json.JSONDecodeError: - pass - - integration_name = args.get("integration", "") - integrations = load_integrations() - intg = next((i for i in integrations if i["id"] == integration_name - or i["name"].lower() == integration_name.lower()), None) - if not intg: - available = ", ".join(i["name"] for i in integrations if i.get("enabled", True)) - return {"error": f"No integration matching '{integration_name}'. Available: {available or 'none configured'}", "exit_code": 1} - - return await execute_api_call( - intg["id"], - args.get("method", "GET"), - args.get("path", "/"), - params=args.get("params"), - body=args.get("body"), - extra_headers=args.get("headers"), - ) - - -# --------------------------------------------------------------------------- -# Notes / checklists management tool -# --------------------------------------------------------------------------- - -async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict: - """Handle manage_notes tool calls: CRUD on notes and checklists.""" - import uuid as _uuid - from core.database import SessionLocal, Note - from sqlalchemy.orm.attributes import flag_modified - - try: - args = _parse_tool_args(content) - except ValueError: - return {"error": "Invalid JSON arguments", "exit_code": 1} - - # Action aliases — match what models actually emit. `create` is the most - # common alternative to `add`. Hyphenated forms also accepted. - action = (args.get("action") or "").replace("-", "_").strip().lower() - _NOTE_ACTION_ALIASES = { - "create": "add", - "new": "add", - "save": "add", - "remind": "add", - "remove": "delete", - "remove_item": "toggle_item", - } - action = _NOTE_ACTION_ALIASES.get(action, action) - db = SessionLocal() - - def _norm_note_title(value: str) -> str: - text = (value or "").strip().lower() - text = re.sub(r"^\s*reminder\s*:\s*", "", text) - return re.sub(r"\s+", " ", text) - - def _note_visible_to_owner(note, owner_value: Optional[str]) -> bool: - # Empty owner_value is single-user / auth-disabled mode. A real - # authenticated owner must match exactly; null/empty legacy rows are not - # shared between accounts. - if not owner_value: - return True - return getattr(note, "owner", None) == owner_value - - def _note_by_prefix(note_id: str): - if not note_id: - return None - q = db.query(Note).filter(Note.id.startswith(note_id)) - if owner: - q = q.filter(Note.owner == owner) - return q.first() - - try: - if action == "list": - q = db.query(Note) - if owner is not None: - q = q.filter(Note.owner == owner) - if args.get("label"): - q = q.filter(Note.label == args["label"]) - show_archived = args.get("archived", False) - q = q.filter(Note.archived == show_archived) - notes = q.order_by(Note.pinned.desc(), Note.updated_at.desc()).all() - if not notes: - return {"response": "No notes found.", "exit_code": 0} - lines = [] - for n in notes: - pin = " [PINNED]" if n.pinned else "" - typ = " [checklist]" if n.note_type == "checklist" else "" - lbl = f" #{n.label}" if n.label else "" - title = n.title or "(untitled)" - lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}") - if n.note_type == "checklist" and n.items: - try: - items = json.loads(n.items) - for i, item in enumerate(items): - mark = "x" if item.get("done") else " " - lines.append(f" [{mark}] {i}: {item.get('text', '')}") - except (json.JSONDecodeError, TypeError): - pass - elif n.content: - snippet = n.content[:80].replace("\n", " ") - lines.append(f" {snippet}") - return {"results": "\n".join(lines)} - - elif action == "add": - # Accept the various field names models emit: `text` is the most - # common stand-in for "title or body content" when the model - # treats the note as a single string. If text was supplied and - # neither title nor content, use it as the title. - title = (args.get("title") or "").strip() - content_raw = args.get("content") - text_raw = args.get("text") or args.get("body") - if not title and not content_raw and text_raw: - title = text_raw.strip() - elif not content_raw and text_raw: - content_raw = text_raw - # Accept both `items` (legacy/internal field) and `checklist_items` - # (the schema-exposed name used by native function calls). Models - # following the schema emit `checklist_items`; older code paths - # and direct API callers still use `items`. - items_raw = args.get("checklist_items") - if items_raw is None: - items_raw = args.get("items") - items_json = json.dumps(items_raw) if items_raw is not None else None - note_type = args.get("note_type", "checklist" if items_raw else "note") - # Accept natural-language due_date ("tomorrow at 1pm") in - # addition to ISO. Use the user-tz-aware parser so the LLM's - # naive times ("today at 9pm") are anchored to the USER's clock, - # not the server's. Returns ISO with explicit offset so frontend - # `new Date()` resolves the right absolute moment regardless of - # where the user is. - due_raw = args.get("due_date") - due_iso = None - if due_raw: - try: - from routes.calendar_routes import parse_due_for_user as _pdt_user - due_iso = _pdt_user(due_raw) - except Exception: - due_iso = due_raw # fall through; trust the model - if due_iso and title: - # Calendar event reminders are represented as Notes. If the - # model creates a calendar event with reminder_minutes and then - # also creates a separate note reminder for the same title/time, - # keep the existing note so the user gets only one dispatch. - existing_q = db.query(Note).filter( - Note.archived == False, # noqa: E712 - Note.due_date == due_iso, - ) - if owner is not None: - existing_q = existing_q.filter(Note.owner == owner) - target_title = _norm_note_title(title) - for existing in existing_q.limit(25).all(): - if _norm_note_title(existing.title or "") == target_title: - return { - "response": f"Reminder already exists: \"{existing.title or title}\" (id: {existing.id[:8]})", - "note_id": existing.id, - "duplicate": True, - "exit_code": 0, - } - note = Note( - id=str(_uuid.uuid4()), - owner=owner, - title=title, - content=content_raw, - items=items_json, - note_type=note_type, - color=args.get("color"), - label=args.get("label"), - pinned=args.get("pinned", False), - due_date=due_iso, - source="agent", - session_id=args.get("session_id"), - ) - db.add(note) - db.commit() - # Return note_id so the chat-side renderer can build a real - # "View note" button that opens the notes modal at this id. - # Previously the create response only included a prose - # confirmation; the model would type "View note" as a markdown - # link with no target, leaving the user with a click that - # did nothing and uncertainty about whether the note was made. - return { - "response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})", - "note_id": note.id, - "note_title": title or "", - "open_url": f"/#open=notes¬e={note.id}", - "exit_code": 0, - } - - elif action == "update": - note_id = args.get("id", "") - note = _note_by_prefix(note_id) - if not note: - return {"error": f"Note '{note_id}' not found", "exit_code": 1} - if not _note_visible_to_owner(note, owner): - return {"error": "Note not found", "exit_code": 1} - for field in ("title", "content", "note_type", "color", "label"): - if field in args and args[field] is not None: - setattr(note, field, args[field]) - # Parse due_date the same way the `add` action does. The schema - # advertises natural language ("tomorrow at 9am"), and naive ISO - # strings need the user's tz offset attached so the frontend's - # `new Date()` resolves the right absolute moment. Storing the raw - # value here left updated reminders as unparseable literals that - # never fired. - if args.get("due_date") is not None: - due_raw = args["due_date"] - try: - from routes.calendar_routes import parse_due_for_user as _pdt_user - note.due_date = _pdt_user(due_raw) - except Exception: - note.due_date = due_raw # fall through; trust the model - new_items = args.get("checklist_items") - if new_items is None: - new_items = args.get("items") - if new_items is not None: - note.items = json.dumps(new_items) - flag_modified(note, "items") - if "pinned" in args: - note.pinned = args["pinned"] - if "archived" in args: - note.archived = args["archived"] - db.commit() - return {"response": f"Note updated: \"{note.title or '(untitled)'}\"", "exit_code": 0} - - elif action == "delete": - note_id = args.get("id", "") - note = _note_by_prefix(note_id) - if not note: - return {"error": f"Note '{note_id}' not found", "exit_code": 1} - if not _note_visible_to_owner(note, owner): - return {"error": "Note not found", "exit_code": 1} - title = note.title - db.delete(note) - db.commit() - return {"response": f"Deleted note: \"{title or '(untitled)'}\"", "exit_code": 0} - - elif action == "toggle_item": - note_id = args.get("id", "") - index = args.get("index", 0) - note = _note_by_prefix(note_id) - if not note: - return {"error": f"Note '{note_id}' not found", "exit_code": 1} - if not _note_visible_to_owner(note, owner): - return {"error": "Note not found", "exit_code": 1} - if not note.items: - return {"error": "Note has no checklist items", "exit_code": 1} - items = json.loads(note.items) - if index < 0 or index >= len(items): - return {"error": f"Item index {index} out of range (0-{len(items)-1})", "exit_code": 1} - items[index]["done"] = not items[index].get("done", False) - note.items = json.dumps(items) - flag_modified(note, "items") - db.commit() - mark = "done" if items[index]["done"] else "undone" - return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0} - - else: - return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1} - except Exception as e: - logger.error(f"manage_notes error: {e}") - return {"error": str(e), "exit_code": 1} - finally: - db.close() - - -# --------------------------------------------------------------------------- -# Calendar tool — CalDAV-backed event CRUD -# --------------------------------------------------------------------------- - -async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict: - """Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite).""" - from datetime import datetime, timedelta - from core.database import SessionLocal, CalendarCal, CalendarEvent, Note - from routes.calendar_routes import ( - _ensure_default_calendar, - _parse_dt, - _parse_dt_pair, - parse_due_for_user, - _resolve_base_uid, - _push_caldav_event_after_commit, - _record_caldav_delete_tombstone, - ) - import uuid as _uuid - - try: - args = _parse_tool_args(content) - except ValueError: - return {"error": "Invalid JSON arguments", "exit_code": 1} - - # ── Batch normalization ── - # Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]} - # instead of individual create_event calls. Iterate and create each. - if isinstance(args.get("events"), list) and not args.get("action"): - results = [] - for ev in args["events"]: - if not isinstance(ev, dict): - continue - # Normalize start/end from {dateTime: "..."} object to flat string - for field, target in [("start", "dtstart"), ("end", "dtend")]: - val = ev.pop(field, None) - if val and target not in ev: - ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val - ev.setdefault("action", "create_event") - r = await do_manage_calendar(json.dumps(ev), owner=owner) - results.append(r) - created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")] - failed = [r for r in results if r.get("error")] - - if not results: - return {"error": "No events to create", "exit_code": 1} - - # Surface both successes and failures - parts = [] - if created: - summaries = [r.get("response", "") for r in created] - parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries)) - if failed: - first_error = failed[0].get("error", "Unknown error") - parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}") - - response = "\n\n".join(parts) - # Non-zero exit code for partial or total failure - exit_code = 0 if not failed else 1 - return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)} - - # Normalize action — some models emit hyphens ("list-calendars") instead - # of underscores. Treat them as equivalent so we don't bounce a - # cosmetic typo back to the model and waste a round-trip. Also accept - # short forms (`create`, `update`, `delete`) as aliases for the - # full `_event` names — models keep emitting the short forms. - action = (args.get("action") or "list_events").replace("-", "_").strip().lower() - _ACTION_ALIASES = { - "create": "create_event", - "update": "update_event", - "delete": "delete_event", - "list": "list_events", - } - action = _ACTION_ALIASES.get(action, action) - db = SessionLocal() - - def _calendar_query(): - q = db.query(CalendarCal) - if owner is not None: - q = q.filter(CalendarCal.owner == owner) - return q - - def _event_query(): - q = db.query(CalendarEvent).join(CalendarCal) - if owner is not None: - q = q.filter(CalendarCal.owner == owner) - return q - - def _reminder_minutes(raw_args) -> Optional[int]: - raw = ( - raw_args.get("reminder_minutes") - or raw_args.get("remind_before_minutes") - or raw_args.get("alarm_minutes") - or raw_args.get("reminder") - or raw_args.get("alarm") - ) - if raw in (None, ""): - desc = str(raw_args.get("description") or "") - if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I): - raw = desc - if raw in (None, "", False): - return None - if raw is True: - return 10 - if isinstance(raw, (int, float)): - return max(0, int(raw)) - text = str(raw).strip().lower() - if text in {"none", "no", "off", "false"}: - return None - m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text) - if m: - return max(0, int(m.group(1))) - m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text) - if m: - return max(0, int(m.group(1)) * 60) - if text.isdigit(): - return max(0, int(text)) - return None - - def _event_description(raw_args, minutes_before: Optional[int]) -> str: - desc = str(raw_args.get("description", "") or "") - if minutes_before is None: - return desc - reminder_only = re.compile( - r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*" - r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$", - re.I, - ) - return "" if reminder_only.match(desc) else desc - - def _parse_event_dt(raw: str) -> tuple[datetime, bool]: - """Parse agent event datetimes in the user's timezone when available.""" - return _parse_dt_pair(parse_due_for_user(raw)) - - def _first_nonempty_arg(*names: str): - for name in names: - value = args.get(name) - if value not in (None, ""): - return value - return None - - def _create_calendar_reminder(summary: str, location: str, dtstart: datetime, - all_day: bool, minutes_before: int, - is_utc: bool = False) -> tuple[Optional[str], Optional[str]]: - remind_at = dtstart - timedelta(minutes=minutes_before) - now = datetime.utcnow() if is_utc else datetime.now() - if dtstart <= now: - return None, "event already passed" - if remind_at <= now: - # If the requested "before" time already passed but the event is - # still upcoming, create an immediate Note reminder instead of - # silently dropping it. - remind_at = now - start_fmt = dtstart.strftime("%a %b %d") if all_day else dtstart.strftime("%a %b %d %H:%M") - loc = f" @ {location}" if location else "" - text = f"{summary}{loc} — {start_fmt}" - due_date = remind_at.isoformat() + ("Z" if is_utc else "") - expected_title = f"Reminder: {summary}" - existing_q = db.query(Note).filter( - Note.archived == False, # noqa: E712 - Note.due_date == due_date, - ) - if owner is not None: - existing_q = existing_q.filter(Note.owner == owner) - target_title = re.sub(r"^\s*reminder\s*:\s*", "", expected_title.strip().lower()) - for existing in existing_q.limit(25).all(): - existing_title = re.sub(r"^\s*reminder\s*:\s*", "", (existing.title or "").strip().lower()) - if existing_title == target_title: - return existing.id, "duplicate reminder already exists" - note = Note( - id=str(_uuid.uuid4()), - owner=owner, - title=expected_title, - items=json.dumps([{"text": text, "done": False, "checked": False}]), - note_type="todo", - label="calendar", - due_date=due_date, - source="calendar", - ) - db.add(note) - return note.id, None - - try: - if action == "list_calendars": - _ensure_default_calendar(db, owner) - cals = _calendar_query().all() - result = [{"name": c.name, "href": c.id} for c in cals] - if result: - lines = [f"Found {len(result)} calendar(s):"] - for c in result: - lines.append(f"- {c['name']} ({c['href'][:8]})") - response_text = "\n".join(lines) - else: - response_text = "No calendars found." - return {"response": response_text, "calendars": result, "exit_code": 0} - - elif action == "list_events": - try: - start_raw = _first_nonempty_arg( - "start", "start_date", "range_start", "from", "dtstart", "since" - ) - end_raw = _first_nonempty_arg( - "end", "end_date", "range_end", "to", "dtend", "until" - ) - if start_raw: - start_dt = _parse_dt(start_raw) - else: - start_dt = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - if end_raw: - end_dt = _parse_dt(end_raw) - else: - end_dt = start_dt + timedelta(days=14) - except ValueError as e: - return {"error": f"Invalid date format: {e}", "exit_code": 1} - - if end_dt <= start_dt: - end_dt = start_dt + timedelta(days=1) - - q = _event_query().filter( - CalendarEvent.dtstart < end_dt, - CalendarEvent.dtend > start_dt, - CalendarEvent.status != "cancelled", - ) - calendar_filter = args.get("calendar") - if calendar_filter: - q = q.filter( - (CalendarEvent.calendar_id == calendar_filter) | - (CalendarCal.name == calendar_filter) - ) - rows = q.order_by(CalendarEvent.dtstart).all() - events = [] - for ev in rows: - if ev.all_day: - s, e = ev.dtstart.strftime("%Y-%m-%d"), ev.dtend.strftime("%Y-%m-%d") - else: - suffix = "Z" if getattr(ev, "is_utc", False) else "" - s, e = ev.dtstart.isoformat() + suffix, ev.dtend.isoformat() + suffix - events.append({ - "uid": ev.uid, "summary": ev.summary or "", "dtstart": s, "dtend": e, - "all_day": ev.all_day, "description": ev.description or "", - "location": ev.location or "", - "calendar": ev.calendar.name if ev.calendar else "", - "calendar_href": ev.calendar_id, - "event_type": ev.event_type or "", - "importance": ev.importance or "normal", - }) - if not events: - response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}." - else: - lines = [f"Found {len(events)} event(s) between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}:"] - for ev in events: - when = ev["dtstart"] - when_str = f"{when} (all day)" if ev.get("all_day") else f"{when} -> {ev.get('dtend', '')}" - # Clickable anchor — opens the calendar on the event's day. - line = f"- {when_str}: [{ev['summary']}](#event-{ev['uid']})" - if ev.get("event_type"): - line += f" #{ev['event_type']}" - if ev.get("importance") and ev["importance"] != "normal": - line += f" !{ev['importance']}" - if ev.get("location"): - line += f" @ {ev['location']}" - if ev.get("calendar"): - line += f" ({ev['calendar']})" - if ev.get("description"): - desc = ev["description"].strip().replace("\n", " ") - if len(desc) > 120: - desc = desc[:117] + "..." - line += f"\n {desc}" - lines.append(line) - response_text = "\n".join(lines) - return {"response": response_text, "events": events, "exit_code": 0} - - elif action == "create_event": - summary = args.get("summary") - # Accept the various names models like to use for the start - # field: dtstart (canonical), start, start_time, when. - dtstart_str = (args.get("dtstart") or args.get("start") - or args.get("start_time") or args.get("when")) - if not summary or not dtstart_str: - return {"error": "summary and dtstart are required", "exit_code": 1} - - # Accept either an href OR a calendar name/short-id like "Main" - # or "62e545d8" — saves the model from having to memorize hrefs - # after a `list_calendars` call returned short prefixes. - cal_href = args.get("calendar_href") or args.get("calendar") - cal = None - if cal_href: - cal = (_calendar_query() - .filter(CalendarCal.id == cal_href) - .first()) - if not cal: - # Try by name (case-insensitive) or by short-id prefix - cal = (_calendar_query() - .filter(CalendarCal.name.ilike(cal_href)) - .first()) - if not cal: - cal = (_calendar_query() - .filter(CalendarCal.id.like(f"{cal_href}%")) - .first()) - if not cal: - cal = _ensure_default_calendar(db, owner) - - all_day = bool(args.get("all_day", False)) - try: - dtstart, dtstart_is_utc = _parse_event_dt(dtstart_str) - except ValueError as e: - return {"error": f"Could not parse dtstart {dtstart_str!r}: {e}", "exit_code": 1} - dtend_raw = args.get("dtend") or args.get("end") or args.get("end_time") - if dtend_raw: - try: - dtend, dtend_is_utc = _parse_event_dt(dtend_raw) - dtstart_is_utc = dtstart_is_utc or dtend_is_utc - except ValueError as e: - return {"error": f"Could not parse dtend {dtend_raw!r}: {e}", "exit_code": 1} - else: - # Support duration: "1h", "30m", "90min", "1hr30m" - dur = (args.get("duration") or "").strip().lower() - delta = None - if dur: - import re as _re_d - h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur) - m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur) - secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0) - if secs > 0: - delta = timedelta(seconds=secs) - if delta is not None: - dtend = dtstart + delta - elif all_day: - dtend = dtstart + timedelta(days=1) - else: - dtend = dtstart + timedelta(hours=1) - - # Dedup: if a non-cancelled event with the same title + start time already - # exists, return its UID instead of creating a fresh copy. Prevents the - # email triage from multiplying events when several emails reference the - # same meeting. Compare case-insensitively since LLM-extracted titles - # can vary in capitalisation. - from sqlalchemy import func as _func - existing = ( - _event_query() - .filter( - CalendarEvent.dtstart == dtstart, - CalendarEvent.status != "cancelled", - _func.lower(CalendarEvent.summary) == summary.lower(), - ) - .first() - ) - if existing is not None: - reminder_note_id = None - reminder_skipped_reason = None - minutes_before = _reminder_minutes(args) - if minutes_before is not None: - reminder_note_id, reminder_skipped_reason = _create_calendar_reminder( - existing.summary or summary, - existing.location or "", - existing.dtstart, - existing.all_day, - minutes_before, - bool(existing.is_utc), - ) - if reminder_note_id: - db.commit() - reminder_text = "" - if minutes_before is not None: - reminder_text = ( - f"; reminder set {minutes_before} min before" - if reminder_note_id - else f"; reminder not set ({reminder_skipped_reason or 'reminder time already passed'})" - ) - return { - "response": ( - f"Event already exists: '{summary}' on {dtstart_str}" - + reminder_text - ), - "uid": existing.uid, - "reminder_note_id": reminder_note_id, - "reminder_skipped_reason": reminder_skipped_reason, - "duplicate": True, - "exit_code": 0, - } - - # Optional tag/category and importance — friendly aliases. - event_type = (args.get("event_type") or args.get("tag") - or args.get("category") or args.get("type") or "") or None - importance = args.get("importance") or "normal" - minutes_before = _reminder_minutes(args) - - uid = str(_uuid.uuid4()) - ev = CalendarEvent( - uid=uid, calendar_id=cal.id, summary=summary, - description=_event_description(args, minutes_before), - location=args.get("location", "") or "", - dtstart=dtstart, dtend=dtend, all_day=all_day, - is_utc=dtstart_is_utc and not all_day, - rrule=args.get("rrule", "") or "", - event_type=event_type, - importance=importance, - caldav_sync_pending="create" if cal.source == "caldav" else None, - ) - db.add(ev) - reminder_note_id = None - reminder_skipped_reason = None - if minutes_before is not None: - reminder_note_id, reminder_skipped_reason = _create_calendar_reminder( - summary, - args.get("location", "") or "", - dtstart, - all_day, - minutes_before, - dtstart_is_utc and not all_day, - ) - db.commit() - if cal.source == "caldav": - await _push_caldav_event_after_commit(owner, uid, "create") - tag_blurb = f" [{event_type}]" if event_type else "" - if minutes_before is None: - reminder_blurb = "" - elif reminder_note_id: - reminder_blurb = f" with reminder {minutes_before} min before" - else: - reminder_blurb = f" without reminder ({reminder_skipped_reason or 'reminder time already passed'})" - # Return a clickable anchor so the agent can surface a link - # that opens the calendar on that day. See the markdown - # anchor convention ([Name](#event-)). - return { - "response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {dtstart_str}{reminder_blurb}", - "uid": uid, - "anchor": f"[{summary}](#event-{uid})", - "reminder_note_id": reminder_note_id, - "reminder_skipped_reason": reminder_skipped_reason, - "exit_code": 0, - } - - elif action == "update_event": - uid = args.get("uid") - if not uid: - return {"error": "uid is required", "exit_code": 1} - try: - base_uid = _resolve_base_uid(uid) - except ValueError as e: - return {"error": str(e), "exit_code": 1} - ev = _event_query().filter(CalendarEvent.uid == base_uid).first() - if not ev: - return {"error": f"Event {uid} not found", "exit_code": 1} - if args.get("summary") is not None: - ev.summary = args["summary"] - if args.get("description") is not None: - ev.description = args["description"] - if args.get("location") is not None: - ev.location = args["location"] - if args.get("dtstart") is not None: - # Anchor naive/natural-language input to the USER's timezone and - # refresh is_utc, exactly like create_event. Parsing with the - # raw server-local _parse_dt here (and never touching is_utc) - # silently shifted an updated event by the user's UTC offset. - _eff_all_day = ( - args["all_day"] if args.get("all_day") is not None else ev.all_day - ) - ev.dtstart, _su = _parse_event_dt(args["dtstart"]) - ev.is_utc = bool(_su and not _eff_all_day) - if args.get("dtend") is not None: - ev.dtend, _eu = _parse_event_dt(args["dtend"]) - if args.get("all_day") is not None: - ev.all_day = args["all_day"] - # Tag/category + importance updates (any of these aliases). - _tag = (args.get("event_type") or args.get("tag") - or args.get("category") or args.get("type")) - if _tag is not None: - ev.event_type = _tag or None - if args.get("importance") is not None: - ev.importance = args["importance"] - is_caldav = ev.calendar and ev.calendar.source == "caldav" - if is_caldav: - ev.caldav_sync_pending = "update" - db.commit() - if is_caldav: - await _push_caldav_event_after_commit(owner, base_uid, "update") - return {"response": f"Updated event {uid}", "exit_code": 0} - - elif action == "delete_event": - uid = args.get("uid") - if not uid: - return {"error": "uid is required", "exit_code": 1} - try: - base_uid = _resolve_base_uid(uid) - except ValueError as e: - return {"error": str(e), "exit_code": 1} - ev = _event_query().filter(CalendarEvent.uid == base_uid).first() - if not ev: - return {"error": f"Event {uid} not found", "exit_code": 1} - is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href - if is_caldav: - _record_caldav_delete_tombstone(db, ev, owner) - db.delete(ev) - db.commit() - if is_caldav: - await _push_caldav_event_after_commit(owner, base_uid, "delete") - return {"response": f"Deleted event {uid}", "exit_code": 0} - - else: - return { - "error": f"Unknown action: {action}. Use list_events, create_event, update_event, delete_event, list_calendars", - "exit_code": 1, - } - - except Exception as e: - db.rollback() - logger.error(f"manage_calendar error: {e}") - return {"error": str(e), "exit_code": 1} - finally: - db.close() - - -# ── Cookbook tools ── - -# In-process loopback base for agent tools that call Odysseus's own API -# (cookbook state, model serve, gallery, email, calendar). We ride the -# per-process internal token so require_admin lets us through. See -# core/middleware.py. Resolution (override / APP_PORT / 7000) lives in -# core.constants.internal_api_base(). -_INTERNAL_BASE = internal_api_base() - - -def _internal_headers(owner: Optional[str] = None) -> Dict[str, str]: - from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN - headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN} - if owner: - headers["X-Odysseus-Owner"] = owner - return headers - - -async def _cookbook_servers() -> Dict[str, Any]: - """Return the cookbook's configured servers + the currently-selected - default host. Shape: {default_host, hosts: [{host, platform, env, envPath}]}. - The agent uses this to route downloads/serves to the right machine - instead of silently defaulting to localhost.""" - import httpx - try: - async with httpx.AsyncClient(timeout=10) as client: - r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=_internal_headers()) - state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - except Exception: - return {"default_host": "", "hosts": []} - env = (state or {}).get("env") or {} - if not isinstance(env, dict): - return {"default_host": "", "hosts": []} - hosts = [] - for s in (env.get("servers") or []): - if isinstance(s, dict): - hosts.append({ - "name": s.get("name") or "", - "host": s.get("host") or "", # "" = Local - "platform": s.get("platform") or "", - "env": s.get("env") or "", - "envPath": s.get("envPath") or "", - "port": s.get("port") or "", - }) - return {"default_host": env.get("remoteHost") or "", "hosts": hosts} - - -async def _resolve_cookbook_host(name_or_host: str) -> str: - """Map a friendly server NAME ('gpu-box', 'workstation') to its ssh host - string ('user@192.0.2.10'). If the input already looks like an - ssh host (contains '@' or matches a known host), or matches nothing, - it's returned unchanged. 'local'/'localhost' → '' (this machine).""" - if not name_or_host: - return "" - val = name_or_host.strip() - low = val.lower() - if low in ("local", "localhost", "this machine", "here"): - return "" - servers = await _cookbook_servers() - # Exact host match → already an ssh host - for h in servers.get("hosts") or []: - if h.get("host") and h["host"] == val: - return val - # Name match (case-insensitive) - for h in servers.get("hosts") or []: - if (h.get("name") or "").lower() == low: - return h.get("host") or "" # "" for the Local entry - # Substring name match as a fallback - for h in servers.get("hosts") or []: - if low and low in (h.get("name") or "").lower(): - return h.get("host") or "" - # No match — assume the caller passed a raw host/alias; return as-is - # (ssh can resolve aliases from ~/.ssh/config). - return val - - -async def _cookbook_env_for_host(host: str) -> Dict[str, Any]: - """Resolve env_prefix / gpus / platform / hf_token / ssh_port for a - given host by looking it up in cookbook_state.env. The user - configures these per-host in the Cookbook UI; without them, raw - `vllm serve …` fails with 'command not found' because vLLM lives - inside a venv that has to be sourced first. - - Returns a dict with keys ready to drop into the /api/model/serve - payload: env_prefix, gpus, platform, hf_token, ssh_port. - Falls back to the top-level env settings if no per-host entry exists. - """ - import httpx - headers = _internal_headers() - state: Dict[str, Any] = {} - try: - async with httpx.AsyncClient(timeout=10) as client: - r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers) - state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - except Exception as e: - logger.debug(f"cookbook env lookup failed for host={host!r}: {e}") - return {} - if not isinstance(state, dict): - return {} - env_root = state.get("env") or {} - if not isinstance(env_root, dict): - return {} - - # Per-host entry takes precedence over top-level. - per_host: Dict[str, Any] = {} - for s in (env_root.get("servers") or []): - if isinstance(s, dict) and (s.get("host") or "") == (host or ""): - per_host = s - break - - env_kind = per_host.get("env") or env_root.get("env") or "none" - env_path = per_host.get("envPath") or env_root.get("envPath") or "" - platform = per_host.get("platform") or env_root.get("platform") or "linux" - ssh_port = per_host.get("sshPort") or env_root.get("sshPort") or "" - - env_prefix = "" - if env_kind == "venv" and env_path: - if platform == "windows": - activate = env_path if env_path.endswith("\\Scripts\\Activate.ps1") else env_path.rstrip("\\") + "\\Scripts\\Activate.ps1" - env_prefix = f"& {activate}" - else: - activate = env_path if env_path.endswith("/bin/activate") else env_path.rstrip("/") + "/bin/activate" - env_prefix = f"source {activate}" - elif env_kind == "conda" and env_path: - if platform == "windows": - env_prefix = f"conda activate {env_path}" - else: - env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}' - - from routes.cookbook_helpers import load_stored_hf_token - return { - "env_prefix": env_prefix, - "env_type": env_kind, - "env_path": env_path, - "gpus": env_root.get("gpus") or "", - "platform": platform, - "hf_token": load_stored_hf_token(), - "ssh_port": ssh_port, - } - - -def _infer_serve_port(cmd: str) -> int: - """Infer likely listen port from a serve command.""" - if not cmd: - return 8080 - m = re.search(r"--port\\s+(\\d+)", cmd) - if m: - try: - return int(m.group(1)) - except Exception: - pass - m = re.search(r"OLLAMA_HOST=[^\\s]*?:(\\d+)", cmd) - if m: - try: - return int(m.group(1)) - except Exception: - pass - if "ollama" in cmd: - return 11434 - return 8080 - - -def _infer_serve_host(host: str | None) -> tuple[str, bool]: - """Return (host, container_local) for registering a served endpoint.""" - if not (host or "").strip(): - return "localhost", True - base_host = host.split("@", 1)[-1] if "@" in host else host - return base_host, False - - -async def _ensure_served_endpoint( - *, - model: str, - cmd: str, - host: str | None, -) -> Dict[str, Any]: - """Register/fetch a model endpoint for a running serve session.""" - import httpx - endpoint_host, container_local = _infer_serve_host(host) - port = _infer_serve_port(cmd) - base_url = f"http://{endpoint_host}:{port}/v1" - short_name = model.split("/")[-1] if "/" in model else model - is_image = "diffusion_server.py" in (cmd or "") - payload = { - "name": short_name if not is_image else f"{short_name} (image)", - "base_url": base_url, - "skip_probe": "true", - "model_type": "image" if is_image else "llm", - "container_local": "true" if container_local else "false", - } - try: - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post( - f"{_INTERNAL_BASE}/api/model-endpoints", - data=payload, - headers=_internal_headers(), - ) - data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} - if resp.status_code >= 400: - logger.debug( - f"ensure endpoint failed for {model!r}: status={resp.status_code} data={data}" - ) - return {"added": False, "endpoint_id": "", "base_url": base_url, "error": data} - ep_id = data.get("id") if isinstance(data, dict) else None - return { - "added": bool(ep_id), - "endpoint_id": ep_id or "", - "base_url": base_url, - "data": data, - } - except Exception as e: - logger.debug(f"ensure endpoint exception for {model!r}: {e}") - return {"added": False, "endpoint_id": "", "base_url": base_url, "error": str(e)} - - -async def _cookbook_register_task( - session_id: str, - model: str, - host: str, - cmd: str, - task_type: str = "serve", - *, - endpoint_added: bool = False, - endpoint_id: str = "", -) -> bool: - """Append a task entry to cookbook_state.json after the agent - launches via /api/model/serve or /api/model/download. The route - spawns tmux but leaves state-writing to the UI; the agent needs to - do that here so the task shows up in the Cookbook tab. - Returns True on success, False if the write failed (best-effort).""" - import httpx - import time as _time - headers = _internal_headers() - try: - async with httpx.AsyncClient(timeout=10) as client: - r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers) - state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - except Exception as e: - logger.debug(f"cookbook state read failed: {e}") - return False - if not isinstance(state, dict): - state = {} - tasks = state.get("tasks") if isinstance(state.get("tasks"), list) else [] - # Skip duplicate (same session_id) entries - if any(isinstance(t, dict) and t.get("sessionId") == session_id for t in tasks): - return True - display_name = model.split("/")[-1] if "/" in model else model - # Placeholder output — the cookbook UI's CSS hides empty
-    # via `.cookbook-output-pre:empty { display: none }`, so an
-    # empty-string output makes the expansion appear broken until the
-    # frontend's reconnect-polling loop captures tmux output. A short
-    # placeholder gives the user something to see immediately; it gets
-    # replaced by real tmux output within a few seconds.
-    target = f"{host}:" if host else "local:"
-    placeholder = (
-        f"Launched via agent — waiting for tmux output…\n"
-        f"  session: {session_id}\n"
-        f"  target:  {target}{(cmd.split() or [''])[0] if cmd else ''}\n"
-        f"  cmd:     {cmd[:200]}{'…' if len(cmd) > 200 else ''}"
-    )
-    tasks.append({
-        "id": session_id,
-        "sessionId": session_id,
-        "name": display_name,
-        "modelId": model,
-        "type": task_type,
-        "status": "running",
-        "output": placeholder,
-        "ts": int(_time.time() * 1000),
-        "payload": {"repo_id": model, "remote_host": host or "", "_cmd": cmd},
-        "remoteHost": host or "",
-        "sshPort": "",
-        "platform": "linux",
-        "_serveReady": False,
-        "_endpointAdded": bool(endpoint_added),
-        "_endpointId": endpoint_id or "",
-    })
-    state["tasks"] = tasks
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            r = await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
-                                  json=state, headers=headers)
-        return r.status_code < 400
-    except Exception as e:
-        logger.debug(f"cookbook state write failed: {e}")
-        return False
-
-
-# Paths the generic `app_api` tool will refuse to call. Auth/token/user
-# administration and host shell execution are too risky to route through an
-# agent surface even when the agent is admin-context; accidental account or
-# command mistakes have permanent blast radius.
-_APP_API_BLOCKLIST_PREFIXES = (
-    "/api/auth",           # login/logout/password
-    "/api/users",          # user CRUD (bare /api/users list+create+delete must also block)
-    "/api/tokens",         # api token mgmt (bare /api/tokens list+create must also block)
-    "/api/admin",          # admin one-shots (wipe etc.)
-    "/api/shell",          # host shell execution must stay behind named command tooling
-    "/api/backup/restore", # destructive restore
-)
-
-# (method, prefix) pairs to refuse specifically. Used for endpoints
-# where GET is fine but writes are destructive or host-control shaped.
-# Saw the agent wipe cookbook_state.json (presets + tasks) by POSTing
-# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
-# Use dedicated tools or UI flows instead.
-_APP_API_BLOCKLIST_METHOD_PATH = (
-    ("GET",    "/api/email/accounts"),  # owner-filtered in tool context; use list_email_accounts MCP tool
-    ("POST",   "/api/cookbook/state"),   # whole-file overwrite — agent must use serve_preset/serve_model instead
-    ("DELETE", "/api/cookbook/state"),
-    # Host-control routes: package install, engine rebuild, and process
-    # signalling should not be reachable through the generic API bridge.
-    ("POST",   "/api/cookbook/packages/install"),
-    ("POST",   "/api/cookbook/rebuild-engine"),
-    ("POST",   "/api/cookbook/kill-pid"),
-    # Use the named tools (download_model / serve_model) — they handle
-    # host-name resolution, per-host env_prefix, AND register the task
-    # in cookbook state so it shows in the UI + list_downloads. Hitting
-    # the raw endpoint via app_api skips all of that → orphan task.
-    ("POST",   "/api/model/download"),
-    ("POST",   "/api/model/serve"),
-    # Use trigger_research — it returns a UI hint so the Deep Research
-    # sidebar surfaces the session. Raw start works but the agent
-    # fumbles the payload + the session doesn't reliably show up.
-    ("POST",   "/api/research/start"),
-    # Use the named tools — they handle owner attribution, natural-
-    # language due_date parsing, timezone, dedup, and tag/category
-    # normalization. Hitting the raw endpoint via app_api saves a
-    # note/event with the wrong fields, no reminder, or the wrong tz.
-    ("POST",   "/api/notes"),
-    ("PUT",    "/api/notes"),
-    ("DELETE", "/api/notes"),
-    ("POST",   "/api/calendar/events"),
-    ("PUT",    "/api/calendar/events"),
-    ("DELETE", "/api/calendar/events"),
-)
-
-
-async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
-    """Generic loopback to allowed internal Odysseus API endpoints. Lets the
-    agent reach the full UI-button surface (cookbook, email, notes,
-    calendar, skills, sessions, gallery, research, etc.) without us
-    landing a named tool wrapper for every one.
-
-    Args (JSON):
-      action: "call" (default) | "endpoints"
-      path:   "/api/cookbook/gpus"     # required for call
-      method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" (default GET)
-      body:                    # JSON body for POST/PUT/PATCH
-      query:                   # querystring params
-
-    The `endpoints` action returns the OpenAPI surface (method + path +
-    summary) so the agent can discover what's reachable. A blocklist
-    refuses sensitive auth/user/admin/shell paths and method-specific
-    host-control routes to keep blast radius bounded.
-    """
-    import httpx
-    try:
-        args = _parse_tool_args(content) if content.strip() else {}
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-
-    action = (args.get("action") or "call").lower()
-    base = _INTERNAL_BASE
-
-    if action == "endpoints":
-        # Fetch FastAPI's OpenAPI schema so the agent can discover any
-        # endpoint without us pre-listing them. Filter by an optional
-        # `filter` keyword (substring match on path or summary).
-        kw = (args.get("filter") or "").lower()
-        try:
-            async with httpx.AsyncClient(timeout=15) as client:
-                resp = await client.get(f"{base}/openapi.json",
-                                        headers=_internal_headers())
-                data = resp.json()
-        except Exception as e:
-            return {"error": f"OpenAPI fetch failed: {e}", "exit_code": 1}
-        rows: List[Dict[str, Any]] = []
-        for path, methods in (data.get("paths") or {}).items():
-            if not isinstance(methods, dict):
-                continue
-            if any(path.startswith(p) for p in _APP_API_BLOCKLIST_PREFIXES):
-                continue
-            for method, op in methods.items():
-                if method.lower() not in ("get", "post", "put", "patch", "delete"):
-                    continue
-                if any(method.upper() == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
-                    continue
-                summary = (op or {}).get("summary") or (op or {}).get("description") or ""
-                if isinstance(summary, str):
-                    summary = summary.strip().split("\n")[0][:140]
-                if kw and kw not in path.lower() and kw not in (summary or "").lower():
-                    continue
-                rows.append({"method": method.upper(), "path": path, "summary": summary})
-        rows.sort(key=lambda r: (r["path"], r["method"]))
-        if not rows:
-            return {"output": f"No endpoints match filter {kw!r}." if kw else "No endpoints found.", "exit_code": 0}
-        lines = [f"{len(rows)} endpoint(s)" + (f" matching {kw!r}" if kw else "") + ":"]
-        for r in rows[:200]:
-            line = f"  {r['method']:6s} {r['path']}"
-            if r["summary"]:
-                line += f"  — {r['summary']}"
-            lines.append(line)
-        if len(rows) > 200:
-            lines.append(f"  ...({len(rows) - 200} more — filter to narrow)")
-        return {"output": "\n".join(lines), "endpoints": rows, "exit_code": 0}
-
-    # action == "call"
-    path = args.get("path") or ""
-    if not path:
-        return {"error": "path is required (e.g. '/api/cookbook/gpus')", "exit_code": 1}
-    if not path.startswith("/"):
-        path = "/" + path
-    if any(path.startswith(p) for p in _APP_API_BLOCKLIST_PREFIXES):
-        return {"error": f"Path blocked for safety: {path}. Sensitive endpoints are off-limits via app_api.", "exit_code": 1}
-
-    method = (args.get("method") or "GET").upper()
-    if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
-        return {"error": f"Unsupported method: {method}", "exit_code": 1}
-    if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
-        if "/api/email/accounts" in path:
-            return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
-        if "/api/cookbook/packages/install" in path:
-            return {"error": "Don't POST /api/cookbook/packages/install via app_api — package installation is host code execution. Use the dedicated Cookbook dependency UI/flow instead.", "exit_code": 1}
-        if "/api/cookbook/rebuild-engine" in path:
-            return {"error": "Don't POST /api/cookbook/rebuild-engine via app_api — engine rebuild mutates local or remote host state. Use the dedicated Cookbook UI/flow instead.", "exit_code": 1}
-        if "/api/cookbook/kill-pid" in path:
-            return {"error": "Don't POST /api/cookbook/kill-pid via app_api — process signalling is host control. Use the dedicated Cookbook stop/diagnostic flow instead.", "exit_code": 1}
-        if "/api/model/download" in path:
-            return {"error": "Don't POST /api/model/download directly — use the `download_model` tool (it resolves the server name, sets the venv env_prefix, and registers the task so it shows in the UI).", "exit_code": 1}
-        if "/api/model/serve" in path:
-            return {"error": "Don't POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking).", "exit_code": 1}
-        if "/api/research/start" in path:
-            return {"error": "Don't POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar).", "exit_code": 1}
-        if "/api/notes" in path:
-            return {"error": "Don't hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ('11pm today', 'tomorrow at 9am'), fires reminders from the due_date itself (no separate calendar event), and uses the caller's timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong.", "exit_code": 1}
-        if "/api/calendar/events" in path:
-            return {"error": "Don't hit /api/calendar/events via app_api — use the `manage_calendar` tool. It handles tz-aware natural-language datetimes and reminder_minutes correctly. If the user wants a note + reminder, prefer `manage_notes` with due_date — it bundles both.", "exit_code": 1}
-        return {"error": f"{method} {path} is blocked — it overwrites the whole cookbook state file. Use list_serve_presets / serve_preset / serve_model instead.", "exit_code": 1}
-
-    body = args.get("body")
-    query = args.get("query") or None
-    # Pass owner so the backend impersonates the user — without this,
-    # POSTs (notes, calendar, todos, ...) get owner="internal-tool"
-    # and the user that asked for them can't see the result.
-    headers = {**_internal_headers(owner=owner), "Content-Type": "application/json"}
-
-    try:
-        async with httpx.AsyncClient(timeout=60) as client:
-            resp = await client.request(
-                method, f"{base}{path}",
-                json=body if body is not None and method in ("POST", "PUT", "PATCH") else None,
-                params=query,
-                headers=headers,
-            )
-        # Try to parse JSON; fall back to raw text.
-        try:
-            payload = resp.json()
-            preview = json.dumps(payload, indent=2, default=str)
-            if len(preview) > 4000:
-                preview = preview[:4000] + "\n... (truncated)"
-        except Exception:
-            payload = None
-            preview = (resp.text or "")[:4000]
-        if resp.status_code >= 400:
-            return {
-                "error": f"{method} {path} -> HTTP {resp.status_code}",
-                "status_code": resp.status_code,
-                "body": preview,
-                "exit_code": 1,
-            }
-        return {
-            "output": f"{method} {path} -> {resp.status_code}\n{preview}",
-            "status_code": resp.status_code,
-            "json": payload,
-            "exit_code": 0,
-        }
-    except Exception as e:
-        return {"error": f"{method} {path} failed: {e}", "exit_code": 1}
-
-
-# Patterns for detecting running LLM/diffusion model servers outside
-# the cookbook's task tracker. Each entry: (label, substring-list).
-# Match is case-insensitive against the FULL cmdline. First-match wins.
-_MODEL_PROCESS_PATTERNS = [
-    ("vLLM",            ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
-    ("SGLang",          ["sglang.launch_server", "sglang/launch_server"]),
-    ("llama.cpp",       ["llama-server", "llama_cpp_server", "llamacppserver"]),
-    ("Ollama",          ["ollama serve", "ollama runner", "/ollama "]),
-    ("ComfyUI",         ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
-    ("A1111 WebUI",     ["stable-diffusion-webui/webui", "stable-diffusion-webui/launch", "webui.sh"]),
-    ("Fooocus",         ["Fooocus/entry_with_update", "Fooocus/launch"]),
-    ("InvokeAI",        ["invokeai-web", "invokeai.app", "invokeai/api_app"]),
-    ("Forge WebUI",     ["stable-diffusion-webui-forge", "forge/webui"]),
-    ("SD.Next",         ["automatic/webui", "sd.next"]),
-    ("TGI",             ["text-generation-launcher", "text_generation_launcher"]),
-    ("Aphrodite",       ["aphrodite.endpoints", "aphrodite-engine"]),
-    ("Triton",          ["tritonserver", "triton/main"]),
-    ("Diffusers",       ["diffusers.pipelines", "StableDiffusionInpaintPipeline", "DiffusionPipeline"]),
-]
-
-
-def _cookbook_apply_retry_suggestion(cmd: str, suggestion: Dict[str, Any]) -> str:
-    """Apply a structured Cookbook diagnosis suggestion to a serve command."""
-    if not cmd or not suggestion:
-        return cmd
-    op = suggestion.get("op")
-    if op == "append":
-        arg = (suggestion.get("arg") or "").strip()
-        if not arg or arg in cmd:
-            return cmd
-        return f"{cmd.rstrip()} {arg}"
-    if op == "remove":
-        flag = (suggestion.get("flag") or "").strip()
-        if not flag:
-            return cmd
-        return re.sub(rf"\s*{re.escape(flag)}(?:\s+\S+)?", "", cmd).strip()
-    if op == "replace":
-        flag = (suggestion.get("flag") or "").strip()
-        value = str(suggestion.get("value") or "").strip()
-        if not flag or not value:
-            return cmd
-        repl = f"{flag} {value}"
-        if re.search(rf"(^|\s){re.escape(flag)}(\s+\S+)?", cmd):
-            return re.sub(rf"(^|\s){re.escape(flag)}(?:\s+\S+)?", lambda m: (m.group(1) or " ") + repl, cmd).strip()
-        return f"{cmd.rstrip()} {repl}"
-    return cmd
-
-
-def _scan_running_model_processes() -> List[Dict[str, Any]]:
-    """Scan /proc for running model server processes. Linux-only; returns
-    [] on other platforms or if /proc isn't accessible. Each match returns
-    a dict shaped like a cookbook task so the caller can merge cleanly.
-    """
-    import os
-    if not os.path.isdir("/proc"):
-        return []
-    out: List[Dict[str, Any]] = []
-    seen_keys = set()
-    try:
-        for pid_dir in os.listdir("/proc"):
-            if not pid_dir.isdigit():
-                continue
-            try:
-                with open(f"/proc/{pid_dir}/cmdline", "rb") as f:
-                    raw = f.read()
-            except (OSError, PermissionError):
-                continue
-            if not raw:
-                continue
-            # cmdline is NUL-separated; join with spaces for matching/display
-            cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
-            if not cmdline:
-                continue
-            lower = cmdline.lower()
-            for label, needles in _MODEL_PROCESS_PATTERNS:
-                if any(n.lower() in lower for n in needles):
-                    # Dedupe by (label, first-arg) — multi-worker servers
-                    # spawn N processes; only show one row per server.
-                    key = (label, cmdline.split(" ")[0])
-                    if key in seen_keys:
-                        break
-                    seen_keys.add(key)
-                    # Try to pluck a model name out of the cmdline.
-                    model = ""
-                    for tok in cmdline.split():
-                        if "/" in tok and any(s in tok.lower() for s in (
-                            "model", "checkpoint", ".safetensors", ".gguf", ".bin", "huggingface"
-                        )):
-                            model = tok
-                            break
-                    out.append({
-                        "session_id": f"pid-{pid_dir}",
-                        "model": model or label,
-                        "phase": "running (external)",
-                        "type": "serve",
-                        "remote": "local",
-                        "pid": int(pid_dir),
-                        "label": label,
-                        "cmdline_preview": cmdline[:140] + ("…" if len(cmdline) > 140 else ""),
-                        "external": True,
-                    })
-                    break
-    except Exception as e:
-        logger.debug(f"_scan_running_model_processes failed: {e}")
-    return out
-
-
-async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
-    """Download a HuggingFace model via the cookbook API."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    repo_id = args.get("repo_id", "")
-    if not repo_id:
-        return {"error": "repo_id is required", "exit_code": 1}
-    host = (args.get("host") or "").strip()
-    # Resolve a friendly server NAME ("gpu-box") to its ssh host string.
-    if host:
-        host = await _resolve_cookbook_host(host)
-    # No host specified → default to the cookbook's currently-selected
-    # server rather than silently downloading to localhost (which is
-    # usually NOT where the GPUs / model cache live).
-    _host_defaulted = False
-    if not host and not args.get("local"):
-        _servers = await _cookbook_servers()
-        if _servers.get("default_host"):
-            host = _servers["default_host"]
-            _host_defaulted = True
-    backend = (args.get("backend") or "").strip().lower()
-    if not backend and "/" not in repo_id and ":" in repo_id:
-        backend = "ollama"
-    payload = {"repo_id": repo_id}
-    if backend:
-        payload["backend"] = backend
-    if host:
-        payload["remote_host"] = host
-    if args.get("include"):
-        payload["include"] = args["include"]
-    # Per-host env_prefix + hf_token from cookbook_state (same as serve).
-    env_cfg = await _cookbook_env_for_host(host)
-    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
-    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
-    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
-    if env_cfg.get("ssh_port"):   payload["ssh_port"]   = env_cfg["ssh_port"]
-    try:
-        async with httpx.AsyncClient(timeout=30) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
-                                     json=payload, headers=_internal_headers())
-            data = resp.json()
-        if data.get("ok"):
-            sid = data.get("session_id", "?")
-            registered = await _cookbook_register_task(
-                session_id=sid, model=repo_id, host=host,
-                cmd=(f"ollama pull {repo_id}" if backend == "ollama" else f"hf download {repo_id}"),
-                task_type="download",
-            )
-            note = "" if registered else " (state-write failed — download may not show in UI)"
-            where = host or "local"
-            default_note = " (defaulted to the cookbook's selected server — pass host= or local=true to override)" if _host_defaulted else ""
-            return {
-                "output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}",
-                "session_id": sid,
-                "host": host,
-                "task_type": "download",
-                "phase": "running",
-                "exit_code": 0,
-            }
-        return {"error": data.get("error", "Download failed"), "exit_code": 1}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
-    """Start serving a model via the cookbook API."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    repo_id = args.get("repo_id", "")
-    cmd = args.get("cmd", "")
-    if not repo_id or not cmd:
-        return {"error": "repo_id and cmd are required", "exit_code": 1}
-    host = (args.get("host") or "").strip()
-    if host:
-        host = await _resolve_cookbook_host(host)
-    if not host and not args.get("local"):
-        _servers = await _cookbook_servers()
-        if _servers.get("default_host"):
-            host = _servers["default_host"]
-    payload = {"repo_id": repo_id, "cmd": cmd}
-    if host:
-        payload["remote_host"] = host
-    # Resolve per-host env settings (venv/conda activate, gpus,
-    # hf_token, platform, ssh_port) from cookbook_state — same path
-    # the UI uses. Without env_prefix, `vllm serve …` lands in a shell
-    # without the user's venv and fails 'command not found'.
-    env_cfg = await _cookbook_env_for_host(host)
-    # Rewrite bare `vllm` / `python3` leading tokens to the venv's absolute
-    # binary path when the target host has a venv configured. SSH non-
-    # interactive shells often leave ~/.local/bin ahead of the venv bin on
-    # PATH even with the venv activated, so `vllm serve` finds the wrong
-    # binary and crashes early (e.g. compute_89 torch ABI errors on an old
-    # user-site torch). This mirrors what static/js/cookbook.js does in
-    # _buildServeCmd for the UI launch path.
-    env_path = (env_cfg.get("env_path") or "").rstrip("/")
-    env_type = (env_cfg.get("env_type") or env_cfg.get("env") or "").lower()
-    if env_type == "venv" and env_path:
-        venv_bin = f"{env_path}/bin"
-        # Match the FIRST shell-token: skip leading KEY=VAL env-var prefixes
-        # (CUDA_VISIBLE_DEVICES=… VLLM_USE_FLASHINFER_SAMPLER=…) before the binary.
-        import re as _re3
-        tokens = cmd.split()
-        idx = 0
-        env_re = _re3.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
-        while idx < len(tokens) and env_re.match(tokens[idx]):
-            idx += 1
-        if idx < len(tokens):
-            head = tokens[idx]
-            if head in ("vllm", "python3", "python"):
-                tokens[idx] = f"{venv_bin}/{head}"
-                cmd = " ".join(tokens)
-                payload["cmd"] = cmd
-    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
-    if env_cfg.get("gpus"):       payload["gpus"]       = env_cfg["gpus"]
-    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
-    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
-    if env_cfg.get("ssh_port"):   payload["ssh_port"]   = env_cfg["ssh_port"]
-    try:
-        async with httpx.AsyncClient(timeout=30) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
-                                     json=payload, headers=_internal_headers())
-            data = resp.json()
-        if data.get("ok"):
-            sid = data.get("session_id", "?")
-            endpoint_id = data.get("endpoint_id") or ""
-            if endpoint_id:
-                endpoint_added = True
-            else:
-                endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
-                endpoint_added = bool(endpoint_meta.get("added"))
-                endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
-            registered = await _cookbook_register_task(
-                session_id=sid, model=repo_id,
-                host=host, cmd=cmd, task_type="serve",
-                endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
-            )
-            note = "" if registered else " (state-write failed — task may not show in UI)"
-            where = host or "local"
-            log_path = f"/tmp/odysseus-tmux/{sid}.log"
-            return {
-                "output": (
-                    f"Serving {repo_id} on {where} (session: {sid}){note}\n"
-                    f"Next required check: call list_served_models. If this task is not ready, "
-                    f"call tail_serve_output with session_id={sid} and tail=400 before answering. "
-                    f"Do not tell the user to check logs; you have the log tool."
-                ),
-                "session_id": sid,
-                "task_type": "serve",
-                "phase": "running",
-                "host": host,
-                "endpoint_id": endpoint_id,
-                "log_path": log_path,
-                "next_tools": [
-                    {"name": "list_served_models", "arguments": {}},
-                    {"name": "tail_serve_output", "arguments": {"session_id": sid, "tail": 400}},
-                ],
-                "exit_code": 0,
-            }
-        # FastAPI HTTPException puts the message under `detail`, not `error`.
-        # Surface BOTH so the agent sees "Invalid characters in cmd" (from
-        # _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of
-        # the generic "Serve failed", which leaves it with nothing to act on.
-        err_msg = data.get("error") or data.get("detail") or "Serve failed"
-        hint = ""
-        if isinstance(err_msg, str) and "cmd" in err_msg.lower():
-            hint = (" — the cmd must START with an allowlisted binary "
-                    "(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
-                    "Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
-                    "env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
-                    "automatically from the host's saved venv settings.")
-        return {"error": f"{err_msg}{hint}", "exit_code": 1}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_list_served_models(content: str, owner: Optional[str] = None) -> Dict:
-    """List running model servers — merges cookbook-tracked tasks with
-    a /proc scan for externally-launched LLM/diffusion processes
-    (vLLM, sglang, llama.cpp, Ollama, ComfyUI, A1111, Fooocus, etc.)."""
-    import asyncio
-    import httpx
-
-    # Cookbook-tracked tasks (best-effort; don't fail the whole call if
-    # this is unreachable).
-    cookbook_tasks: List[Dict[str, Any]] = []
-    try:
-        async with httpx.AsyncClient(timeout=15) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
-                                    headers=_internal_headers())
-            cookbook_tasks = (resp.json() or {}).get("tasks") or []
-    except Exception as e:
-        logger.debug(f"cookbook tasks/status fetch failed: {e}")
-
-    # Local process scan — runs in a worker thread so it doesn't block.
-    external = await asyncio.to_thread(_scan_running_model_processes)
-
-    merged: List[Dict[str, Any]] = []
-    merged.extend(cookbook_tasks)
-    # Dedupe: if a process's PID is already mentioned by a cookbook task
-    # (cookbook may track the PID via session_id), skip it.
-    cookbook_pids = set()
-    for t in cookbook_tasks:
-        if isinstance(t, dict) and t.get("pid"):
-            cookbook_pids.add(t["pid"])
-    for p in external:
-        if p.get("pid") not in cookbook_pids:
-            merged.append(p)
-
-    if not merged:
-        return {
-            "output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
-            "exit_code": 0,
-        }
-
-    # Sort so the agent sees what's actually LIVE first. Stopped/error/
-    # completed tasks are mostly historical noise — they shouldn't lead
-    # the list when something is genuinely serving.
-    _ORDER = {
-        "ready": 0, "running": 1, "loading": 1, "warming": 1,
-        "queued": 2, "starting": 2,
-        "error": 5, "crashed": 5, "failed": 5,
-        "stopped": 6, "killed": 6, "cancelled": 6, "canceled": 6,
-        "done": 7, "completed": 7, "finished": 7,
-    }
-    def _rank(t: Dict[str, Any]) -> int:
-        phase = (t.get("phase") or t.get("status") or "unknown").lower()
-        return _ORDER.get(phase, 3)
-    merged.sort(key=_rank)
-
-    cb_n = len(cookbook_tasks)
-    ext_n = len(external)
-    live_n = sum(1 for t in merged if _rank(t) <= 2)
-    header = []
-    if cb_n:
-        header.append(f"{cb_n} cookbook-tracked")
-    if ext_n:
-        header.append(f"{ext_n} external")
-    if live_n:
-        header.insert(0, f"{live_n} LIVE")
-    lines = [f"Running: {', '.join(header)}."]
-    for t in merged:
-        phase = t.get("phase") or t.get("status", "unknown")
-        model = t.get("model", "?")
-        remote = t.get("remote", "local")
-        sid = t.get("session_id", "?")
-        tag = " [external]" if t.get("external") else ""
-        lines.append(f"- {model}: {phase} ({remote}, session: {sid}){tag}")
-        diag = t.get("diagnosis") if isinstance(t.get("diagnosis"), dict) else None
-        if diag:
-            lines.append(f"    diagnosis: {diag.get('message')}")
-            cmd = t.get("cmd") or ""
-            suggestions = diag.get("suggestions") or []
-            actionable = []
-            for s in suggestions[:3]:
-                label = s.get("label") or "retry"
-                retry_cmd = _cookbook_apply_retry_suggestion(cmd, s)
-                if retry_cmd and retry_cmd != cmd and s.get("op") in {"append", "replace", "remove"}:
-                    actionable.append(f"{label}: `{retry_cmd}`")
-                else:
-                    actionable.append(label)
-            if actionable:
-                lines.append("    suggestions: " + " | ".join(actionable))
-        if t.get("status") == "error" and t.get("output_tail"):
-            tail = str(t.get("output_tail") or "").strip()
-            if tail:
-                # Prefer a window around a Python traceback if one exists,
-                # falling back to the last 30 lines. The previous 6-line
-                # tail showed only the post-crash bash prompt / neofetch
-                # banner ("Locale: C / Ubuntu_Odysseus ❯") — useless for
-                # diagnosis. The traceback we want is usually 50-200 lines
-                # earlier in the buffer.
-                _tail_lines = tail.splitlines()
-                _shown = _tail_lines[-30:]
-                for _i, _ln in enumerate(_tail_lines):
-                    if "Traceback (most recent call last)" in _ln or "ERROR" in _ln or "Error:" in _ln:
-                        _shown = _tail_lines[_i:_i + 40]
-                        break
-                lines.append("    recent log:")
-                for line in _shown:
-                    lines.append(f"      {line[:220]}")
-        if t.get("external") and t.get("cmdline_preview"):
-            lines.append(f"    cmd: {t['cmdline_preview']}")
-    return {"output": "\n".join(lines), "tasks": merged, "exit_code": 0}
-
-
-async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
-                                 ssh_port: str = "", verb: str = "Stopped") -> Dict:
-    """Kill a cookbook tmux session — remote-aware — AND mark the task
-    stopped in cookbook_state.json. Shared by stop_served_model and
-    cancel_download so both behave identically.
-
-    Resolves the task's remote host from state when not passed in. A
-    local-only `tmux kill-session` silently no-ops for remote tasks —
-    that's the bug where "stop the download" appeared to work but the
-    download kept running on the remote host.
-    """
-    import httpx
-    import shlex
-    headers = _internal_headers()
-    remote = remote_host or ""
-    sport = ssh_port or ""
-
-    # Look up the task's host + confirm it exists in state.
-    state: Dict[str, Any] = {}
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
-            state = resp.json() or {}
-    except Exception as e:
-        logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
-    if not isinstance(state, dict):
-        state = {}
-    matched = None
-    for t in (state.get("tasks") or []):
-        if isinstance(t, dict) and (t.get("sessionId") == session_id or t.get("id") == session_id):
-            matched = t
-            if not remote:
-                remote = t.get("remoteHost") or ""
-            if not sport:
-                sport = t.get("sshPort") or ""
-            break
-
-    if remote:
-        try:
-            remote, sport = _validate_cookbook_ssh_target(remote, sport)
-        except HTTPException as e:
-            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
-        _pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
-        cmd = (
-            f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
-            f"{_pf}{shlex.quote(remote)} 'tmux kill-session -t {shlex.quote(session_id)}'"
-        )
-        target_label = f"{session_id} on {remote}"
-    else:
-        cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
-        target_label = session_id
-
-    try:
-        async with httpx.AsyncClient(timeout=15) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
-                                     json={"command": cmd}, headers=headers)
-        if resp.status_code >= 400:
-            return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
-        try:
-            data = resp.json()
-        except Exception:
-            data = {}
-        kill_failed = isinstance(data, dict) and data.get("exit_code") not in (None, 0)
-        kill_err = ((data.get("stderr") or data.get("error") or "").strip() if isinstance(data, dict) else "")
-        # "no server running" / "can't find session" means it was already
-        # gone — treat as success (the goal is "not running").
-        already_gone = any(s in kill_err.lower() for s in ("no server running", "can't find session", "session not found"))
-        if kill_failed and not already_gone:
-            return {"error": f"Failed to {verb.lower()} {target_label}: {kill_err or 'kill-session returned non-zero'}", "exit_code": 1}
-
-        # Update state: mark stopped (so the UI + list reflect reality).
-        if matched is not None:
-            try:
-                matched["status"] = "stopped"
-                async with httpx.AsyncClient(timeout=10) as client:
-                    await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
-                                      json=state, headers=headers)
-            except Exception as e:
-                logger.debug(f"failed to mark {session_id} stopped in state: {e}")
-
-        suffix = " (was already gone)" if already_gone else ""
-        return {"output": f"{verb} {target_label}{suffix}", "exit_code": 0}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_stop_served_model(content: str, owner: Optional[str] = None) -> Dict:
-    """Stop a running model server by killing its tmux session (remote-aware)."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    session_id = args.get("session_id", "")
-    if not session_id:
-        return {"error": "session_id is required", "exit_code": 1}
-    return await _cookbook_kill_session(
-        session_id,
-        remote_host=args.get("remote_host") or args.get("host") or "",
-        ssh_port=args.get("ssh_port") or "",
-        verb="Stopped server",
-    )
-
-
-async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dict:
-    """Capture the last N lines of a cookbook task's tmux pane — remote-aware.
-
-    Used by the agent to debug a failed/stuck serve: list_served_models tells
-    you the task is `crashed`, this tool returns the actual stderr/traceback
-    so the agent can match it against a known fix (compute_89 nvcc mismatch,
-    flashinfer version mismatch, OOM, missing kernels, etc.) and decide
-    whether to relaunch via serve_model with new flags.
-    """
-    import httpx
-    import shlex
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    session_id = (args.get("session_id") or "").strip()
-    if not session_id:
-        return {"error": "session_id is required (from list_served_models)", "exit_code": 1}
-    import re as _re
-    if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
-        return {"error": "Invalid session_id format", "exit_code": 1}
-    try:
-        tail = int(args.get("tail") or 400)
-    except (TypeError, ValueError):
-        tail = 400
-    tail = max(20, min(tail, 4000))
-    headers = _internal_headers()
-    remote = _string_arg(args.get("remote_host") or args.get("host"))
-    sport = _string_arg(args.get("ssh_port"))
-    # Resolve host from cookbook state if caller didn't pass one — same
-    # lookup _cookbook_kill_session uses.
-    if not remote:
-        state: Dict[str, Any] = {}
-        try:
-            async with httpx.AsyncClient(timeout=10) as client:
-                resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
-                state = resp.json() or {}
-        except Exception as e:
-            logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
-        if isinstance(state, dict):
-            for t in (state.get("tasks") or []):
-                if isinstance(t, dict) and (t.get("sessionId") == session_id or t.get("id") == session_id):
-                    remote = t.get("remoteHost") or ""
-                    if not sport:
-                        sport = t.get("sshPort") or ""
-                    break
-    if remote:
-        try:
-            remote, sport = _validate_cookbook_ssh_target(remote, sport)
-        except HTTPException as e:
-            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
-
-    # Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
-    # live tmux pane. The pane is what the user would see scrolling on
-    # their screen — including the post-crash neofetch banner and the
-    # idle bash prompt that overwrites the actual traceback the moment
-    # vllm exits. The log file is the raw stdout/stderr of the wrapped
-    # process and survives the crash unchanged. We only fall back to
-    # the pane when the log file doesn't exist (older sessions launched
-    # before the tmux+tee wrapper was added).
-    log_path = f"/tmp/odysseus-tmux/{session_id}.log"
-    pane_inner = f"tmux capture-pane -t {shlex.quote(session_id)} -p -S -{tail} 2>/dev/null"
-    file_inner = f"tail -n {tail} {shlex.quote(log_path)} 2>/dev/null"
-    inner = (
-        f"if [ -s {shlex.quote(log_path)} ]; then {file_inner}; "
-        f"else {pane_inner}; fi"
-    )
-    if remote:
-        _pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
-        cmd = (
-            f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
-            f"{_pf}{shlex.quote(remote)} {shlex.quote(inner)}"
-        )
-        host_label = remote
-    else:
-        cmd = inner
-        host_label = "local"
-    try:
-        async with httpx.AsyncClient(timeout=20) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
-                                     json={"command": cmd}, headers=headers)
-        if resp.status_code >= 400:
-            return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
-        data = resp.json() if resp.content else {}
-        output_text = (data.get("stdout") or "").strip()
-        stderr_text = (data.get("stderr") or "").strip()
-        rc = data.get("exit_code")
-        if rc not in (None, 0) and not output_text:
-            already_gone = any(s in (stderr_text or "").lower() for s in ("no server running", "can't find session", "session not found"))
-            if already_gone:
-                return {"output": f"Tmux session {session_id} on {host_label} is gone (task already exited).", "exit_code": 0, "session_id": session_id, "host": host_label}
-            return {"error": f"capture-pane failed on {host_label}: {stderr_text or f'exit {rc}'}", "exit_code": 1}
-        # Dedupe download-progress noise. A 100-shard HF download produces
-        # tens of thousands of `model-NN-of-MM.safetensors: 91%|...` lines
-        # that all look the same to the agent and drown the actual error.
-        # Keep only one sample per (file, decile-percent) bucket.
-        import re as _re2
-        lines = output_text.splitlines()
-        dedup_lines = []
-        seen_progress = set()
-        progress_re = _re2.compile(r"^([\w./\-]+):\s+(\d+)%")
-        for ln in lines:
-            m = progress_re.match(ln.strip())
-            if m:
-                key = (m.group(1), int(m.group(2)) // 10)  # bucket by 10%
-                if key in seen_progress:
-                    continue
-                seen_progress.add(key)
-            dedup_lines.append(ln)
-        output_text = "\n".join(dedup_lines)
-        # Hard cap so the agent doesn't blow its token budget.
-        MAX_CHARS = 8000
-        if len(output_text) > MAX_CHARS:
-            output_text = "…(earlier output truncated)…\n" + output_text[-MAX_CHARS:]
-        if not output_text:
-            output_text = (
-                f"No log output captured yet for {session_id} on {host_label}. "
-                "This usually means the tmux wrapper has started but the model process "
-                "has not printed anything yet. Do not stop here: call list_served_models "
-                "again to check whether it is still loading, ready, or crashed; if it is "
-                "still not ready, call tail_serve_output again with a larger tail after "
-                "the next status check."
-            )
-        return {
-            "output": output_text,
-            "session_id": session_id,
-            "host": host_label,
-            "tail_lines": tail,
-            "exit_code": 0,
-        }
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_list_downloads(content: str, owner: Optional[str] = None) -> Dict:
-    """List in-flight model downloads (filters /api/cookbook/tasks/status to type=download)."""
-    import httpx
-    try:
-        async with httpx.AsyncClient(timeout=15) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
-                                    headers=_internal_headers())
-            data = resp.json()
-        tasks = [t for t in data.get("tasks", []) if (t.get("type") or "").lower() == "download"]
-        if not tasks:
-            return {"output": "No downloads in progress.", "exit_code": 0}
-        lines = [f"{len(tasks)} download(s) in progress:"]
-        for t in tasks:
-            phase = t.get("phase") or t.get("status", "unknown")
-            model = t.get("model", "?")
-            pct = t.get("progress_percent") or t.get("percent")
-            pct_str = f" {pct}%" if pct is not None else ""
-            lines.append(f"- {model}: {phase}{pct_str} ({t.get('remote', 'local')}, session: {t.get('session_id', '?')})")
-        return {"output": "\n".join(lines), "downloads": tasks, "exit_code": 0}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_cancel_download(content: str, owner: Optional[str] = None) -> Dict:
-    """Cancel a model download by killing its tmux session (remote-aware)."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    session_id = args.get("session_id", "")
-    if not session_id:
-        return {"error": "session_id is required (from list_downloads)", "exit_code": 1}
-    return await _cookbook_kill_session(
-        session_id,
-        remote_host=args.get("remote_host") or args.get("host") or "",
-        ssh_port=args.get("ssh_port") or "",
-        verb="Cancelled download",
-    )
-
-
-async def do_search_hf_models(content: str, owner: Optional[str] = None) -> Dict:
-    """Search HuggingFace via the cookbook /api/cookbook/hf-latest endpoint."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    query = args.get("query", "") or args.get("search", "")
-    limit = args.get("limit", 10)
-    params: Dict[str, str] = {}
-    if query:
-        params["search"] = query
-    if limit:
-        params["limit"] = str(limit)
-    try:
-        async with httpx.AsyncClient(timeout=30) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/hf-latest",
-                                    params=params, headers=_internal_headers())
-            data = resp.json()
-        models = data.get("models") if isinstance(data, dict) else data
-        if not models:
-            return {"output": f"No models found for query: {query!r}", "exit_code": 0}
-        lines = [f"Found {len(models)} model(s) for {query!r}:" if query else f"{len(models)} model(s):"]
-        for m in models[:limit if isinstance(limit, int) else 10]:
-            if isinstance(m, dict):
-                name = m.get("repo_id") or m.get("modelId") or m.get("id") or "?"
-                dl = m.get("downloads")
-                size = m.get("size_gb") or m.get("needed_vram_gb")
-                bits = []
-                if size:
-                    bits.append(f"~{size}GB")
-                if dl:
-                    bits.append(f"{dl} downloads")
-                tail = f" ({', '.join(bits)})" if bits else ""
-                lines.append(f"- {name}{tail}")
-            else:
-                lines.append(f"- {m}")
-        return {"output": "\n".join(lines), "models": models, "exit_code": 0}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Dict:
-    """Register an externally-launched model server (bash + tmux + ssh, or
-    anything else) into the Cookbook so it appears in list_served_models,
-    can be stopped via stop_served_model, and is added to the user's
-    endpoint list for chat. Use this when a model was started outside
-    the cookbook's serve flow but you want first-class tracking.
-
-    Args (JSON):
-      host:          "user@192.0.2.10" (or omit for localhost)
-      tmux_session:  "minimax-m27"  (existing tmux session name)
-      model:         "cyankiwi/MiniMax-M2.7-AWQ-4bit" (HF repo or display name)
-      port:          8000
-      name:          optional display name (defaults to model basename)
-      add_endpoint:  bool (default true) — also register as a chat endpoint
-    """
-    import httpx
-    import shlex
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-
-    host = _string_arg(args.get("host") or args.get("remote_host"))
-    sess = (args.get("tmux_session") or args.get("session_id") or "").strip()
-    model = (args.get("model") or args.get("repo_id") or "").strip()
-    port = args.get("port") or 8000
-    display_name = (args.get("name") or "").strip() or (model.split("/")[-1] if "/" in model else model)
-    add_endpoint = args.get("add_endpoint", True)
-
-    if not sess or not model:
-        return {"error": "tmux_session and model are required", "exit_code": 1}
-
-    # Verify tmux session exists on the target host
-    if host:
-        try:
-            host, _ = _validate_cookbook_ssh_target(host)
-        except HTTPException as e:
-            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
-
-    headers = _internal_headers()
-    if host:
-        check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'"
-    else:
-        check = f"tmux has-session -t {shlex.quote(sess)} 2>&1"
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
-                                  json={"command": check}, headers=headers)
-            data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
-        if r.status_code >= 400 or (data.get("exit_code") not in (None, 0)):
-            err = (data.get("stderr") or data.get("error") or r.text[:200]).strip()
-            return {"error": f"tmux session {sess!r} not found on {host or 'local'}: {err}", "exit_code": 1}
-    except Exception as e:
-        return {"error": f"verify failed: {e}", "exit_code": 1}
-
-    # Best-effort health check — does port respond to /v1/models?
-    if host:
-        health_cmd = f"ssh -o ConnectTimeout=5 {shlex.quote(host)} 'curl -s -m 3 http://localhost:{int(port)}/v1/models'"
-    else:
-        health_cmd = f"curl -s -m 3 http://localhost:{int(port)}/v1/models"
-    server_up = False
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
-                                  json={"command": health_cmd}, headers=headers)
-            body = (r.json() or {}).get("stdout", "") if r.headers.get("content-type", "").startswith("application/json") else ""
-            server_up = '"data"' in body or '"object"' in body
-    except Exception:
-        pass
-
-    # Read+modify+write cookbook state. APPEND a task entry; do NOT
-    # overwrite the whole file (that'd nuke presets).
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
-            state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
-    except Exception as e:
-        return {"error": f"could not read cookbook state: {e}", "exit_code": 1}
-    if not isinstance(state, dict):
-        state = {}
-    tasks = state.get("tasks") if isinstance(state.get("tasks"), list) else []
-    # Skip duplicate adopt of the same session
-    if any(isinstance(t, dict) and t.get("sessionId") == sess for t in tasks):
-        adopted_already = True
-    else:
-        adopted_already = False
-        import time as _time
-        new_task = {
-            "id": sess,
-            "sessionId": sess,
-            "name": display_name,
-            "type": "serve",
-            "status": "running",
-            "output": (
-                f"Adopted externally-launched session {sess!r} on {host or 'local'}.\n"
-                "Reconnect polling will start streaming tmux output shortly."
-            ),
-            "ts": int(_time.time() * 1000),
-            "payload": {"repo_id": model, "remote_host": host or "", "_cmd": "(adopted — launched outside cookbook)"},
-            "remoteHost": host or "",
-            "sshPort": "",
-            "platform": "linux",
-            "_serveReady": bool(server_up),
-            "_endpointAdded": False,
-            "_adoptedExternally": True,
-        }
-        tasks.append(new_task)
-        state["tasks"] = tasks
-        try:
-            async with httpx.AsyncClient(timeout=10) as client:
-                await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
-                                  json=state, headers=headers)
-        except Exception as e:
-            return {"error": f"could not save cookbook state: {e}", "exit_code": 1}
-
-    # Optionally register as a chat endpoint
-    endpoint_msg = ""
-    if add_endpoint:
-        # Resolve host to a URL. SSH form `user@host` → just take host.
-        host_only = host.split("@", 1)[-1] if host else "localhost"
-        endpoint_url = f"http://{host_only}:{int(port)}/v1"
-        try:
-            from src.agent_tools.admin_tools import do_manage_endpoints  # moved in #3629
-        except Exception:
-            do_manage_endpoints = None
-        if do_manage_endpoints is not None:
-            try:
-                ep_result = await do_manage_endpoints(json.dumps({
-                    "action": "add",
-                    "name": display_name,
-                    "endpoint_url": endpoint_url,
-                    "is_local": False,
-                }), owner=owner)
-                if isinstance(ep_result, dict) and not ep_result.get("error"):
-                    endpoint_msg = f" Endpoint {endpoint_url} added as {display_name!r}."
-                else:
-                    endpoint_msg = f" Endpoint registration skipped: {(ep_result or {}).get('error', 'unknown')}"
-            except Exception as e:
-                endpoint_msg = f" Endpoint registration failed: {e}"
-
-    return {
-        "output": (
-            f"Adopted session {sess!r} ({model}) on {host or 'local'}:{port}. "
-            + ("Already tracked — skipped state write. " if adopted_already else "Added to cookbook state. ")
-            + ("Server responding. " if server_up else "Server not responding yet (still loading?). ")
-            + endpoint_msg
-        ).strip(),
-        "session_id": sess,
-        "host": host,
-        "port": int(port),
-        "server_up": server_up,
-        "exit_code": 0,
-    }
-
-
-async def do_list_cookbook_servers(content: str, owner: Optional[str] = None) -> Dict:
-    """List the cookbook's configured servers and which one is the
-    current default. Use this to decide where to download/serve a
-    model, or to show the user options when the target host is
-    ambiguous."""
-    servers = await _cookbook_servers()
-    hosts = servers.get("hosts") or []
-    default = servers.get("default_host") or ""
-    if not hosts:
-        return {"output": "No cookbook servers configured. Downloads/serves default to localhost.", "servers": [], "default_host": "", "exit_code": 0}
-    # Resolve which server is the default by its friendly name too.
-    default_name = next((h.get("name") for h in hosts if h.get("host") == default and h.get("name")), default or "local")
-    lines = [f"{len(hosts)} configured server(s) (default: {default_name}):"]
-    for h in hosts:
-        name = h.get("name") or "(unnamed)"
-        host = h.get("host") or "local"
-        mark = " ← default" if h.get("host") == default else ""
-        env_bit = f" [{h.get('env')}: {h.get('envPath')}]" if h.get("env") and h.get("env") != "none" else ""
-        plat = f" ({h.get('platform')})" if h.get("platform") else ""
-        lines.append(f"- {name} → {host}{plat}{env_bit}{mark}")
-    lines.append("\nRefer to servers by their name (e.g. download_model with host=\"gpu-box\").")
-    return {"output": "\n".join(lines), "servers": hosts, "default_host": default, "exit_code": 0}
-
-
-async def do_list_serve_presets(content: str, owner: Optional[str] = None) -> Dict:
-    """List saved serve presets from cookbook_state.json. Each preset
-    is a launch template: name, model, host, port, cmd. Use this to
-    discover what the user has previously configured so you can
-    launch by preset instead of fabricating tmux commands."""
-    import httpx
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
-                                    headers=_internal_headers())
-            state = resp.json() or {}
-    except Exception as e:
-        return {"error": f"Failed to fetch cookbook state: {e}", "exit_code": 1}
-
-    presets = state.get("presets") or []
-    if not presets:
-        return {
-            "output": "No serve presets saved. Tell the user to save one from the Cookbook UI first, or use serve_model with explicit repo_id + cmd + host.",
-            "presets": [],
-            "exit_code": 0,
-        }
-    lines = [f"{len(presets)} saved serve preset(s):"]
-    for p in presets:
-        if not isinstance(p, dict):
-            continue
-        name = p.get("name", "?")
-        model = p.get("model") or p.get("modelId") or "?"
-        host = p.get("host") or p.get("remoteHost") or "local"
-        port = p.get("port", "")
-        cmd = (p.get("cmd") or "").strip()
-        bits = [f"- {name}: {model}", f"host={host}"]
-        if port:
-            bits.append(f"port={port}")
-        lines.append("  ".join(bits))
-        if cmd:
-            cmd_preview = cmd if len(cmd) < 140 else cmd[:140] + "…"
-            lines.append(f"    cmd: {cmd_preview}")
-    return {"output": "\n".join(lines), "presets": presets, "exit_code": 0}
-
-
-async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
-    """Launch a saved serve preset by name. Resolves the preset's
-    cmd + host + model from cookbook_state.json, then calls the
-    standard model/serve endpoint. Saves the agent from having to
-    reinvent tmux launch commands the user already saved."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    name = (args.get("name") or args.get("preset") or "").strip()
-    if not name:
-        return {"error": "name (preset name) is required. Call list_serve_presets to see what's available.", "exit_code": 1}
-
-    try:
-        async with httpx.AsyncClient(timeout=10) as client:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
-                                    headers=_internal_headers())
-            state = resp.json() or {}
-    except Exception as e:
-        return {"error": f"Failed to fetch cookbook state: {e}", "exit_code": 1}
-
-    presets = state.get("presets") or []
-    # Match by exact name first, then case-insensitive substring.
-    chosen = None
-    lname = name.lower()
-    for p in presets:
-        if isinstance(p, dict) and (p.get("name") or "").lower() == lname:
-            chosen = p
-            break
-    if chosen is None:
-        for p in presets:
-            if isinstance(p, dict) and lname in (p.get("name") or "").lower():
-                chosen = p
-                break
-    if chosen is None:
-        sample = ", ".join((p.get("name") or "?") for p in presets[:8] if isinstance(p, dict))
-        return {"error": f"No preset matching {name!r}. Available: {sample or '(none)'}", "exit_code": 1}
-
-    repo_id = chosen.get("model") or chosen.get("modelId") or ""
-    cmd = (chosen.get("cmd") or "").strip()
-    host = chosen.get("host") or chosen.get("remoteHost") or ""
-    if not repo_id or not cmd:
-        return {"error": f"Preset {chosen.get('name')!r} is missing model or cmd — can't launch.", "exit_code": 1}
-
-    payload: Dict[str, Any] = {"repo_id": repo_id, "cmd": cmd}
-    if host:
-        payload["remote_host"] = host
-    # Resolve per-host env settings the same way the UI does — pulls
-    # env_prefix (source ~/vllm-env/bin/activate), gpus, hf_token,
-    # etc. from cookbook_state.env so launches actually find vllm.
-    env_cfg = await _cookbook_env_for_host(host)
-    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
-    if env_cfg.get("gpus"):       payload["gpus"]       = env_cfg["gpus"]
-    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
-    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
-    if env_cfg.get("ssh_port"):
-        payload["ssh_port"] = env_cfg["ssh_port"]
-
-    try:
-        async with httpx.AsyncClient(timeout=30) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
-                                     json=payload, headers=_internal_headers())
-            data = resp.json()
-        if data.get("ok"):
-            sid = data.get("session_id", "?")
-            endpoint_id = data.get("endpoint_id") or ""
-            if endpoint_id:
-                endpoint_added = True
-            else:
-                endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
-                endpoint_added = bool(endpoint_meta.get("added"))
-                endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
-            registered = await _cookbook_register_task(
-                session_id=sid, model=repo_id, host=host,
-                cmd=cmd, task_type="serve",
-                endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
-            )
-            note = "" if registered else " (state-write failed — task may not show in UI)"
-            return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "host": host, "endpoint_id": endpoint_id, "exit_code": 0}
-        return {"error": data.get("error", "Serve failed"), "exit_code": 1}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Dict:
-    """List models already cached locally and/or on remote hosts.
-
-    With no `host` arg, scans EVERY configured Cookbook server (and local)
-    and aggregates — so the agent sees the full inventory in one call
-    instead of having to query each server individually.
-    """
-    import httpx
-    try:
-        args = _parse_tool_args(content) if content.strip() else {}
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    raw_host = (args.get("host") or "").strip()
-    headers = _internal_headers()
-
-    async def _scan_one(host_label: str, host_val: str, ssh_port: str = "",
-                        platform: str = "", model_dir: str = "") -> list:
-        """Hit /api/model/cached for one host; tag each returned model with its source."""
-        p: Dict[str, str] = {}
-        if host_val:
-            p["host"] = host_val
-        # Caller-provided override beats per-server config beats nothing.
-        if args.get("model_dir"):
-            p["model_dir"] = args["model_dir"]
-        elif model_dir:
-            p["model_dir"] = model_dir
-        if ssh_port:
-            p["ssh_port"] = ssh_port
-        elif args.get("ssh_port"):
-            p["ssh_port"] = str(args["ssh_port"])
-        if platform:
-            p["platform"] = platform
-        elif args.get("platform"):
-            p["platform"] = args["platform"]
-        try:
-            async with httpx.AsyncClient(timeout=60) as client:
-                resp = await client.get(f"{_INTERNAL_BASE}/api/model/cached",
-                                        params=p, headers=headers)
-                data = resp.json()
-            ms = data.get("models", []) if isinstance(data, dict) else (data or [])
-            for m in ms:
-                m["host"] = host_label or "local"
-            return ms or []
-        except Exception as e:
-            logger.debug(f"list_cached_models scan({host_label}) failed: {e}")
-            return []
-
-    # When the caller specifies a host explicitly, scan only that one (old behaviour).
-    # Otherwise iterate every configured server + local so the agent doesn't
-    # have to repeat the call per server.
-    try:
-        # Pull configured servers from cookbook state (used for resolving
-        # modelDirs both when caller specifies a host and when we scan all).
-        servers: list = []
-        try:
-            async with httpx.AsyncClient(timeout=10) as client:
-                st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
-                st_data = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
-            servers = (st_data.get("env", {}) or {}).get("servers") or []
-        except Exception as e:
-            logger.debug(f"server list fetch failed: {e}")
-            st_data = {}
-
-        def _dirs_for(server_record: Dict[str, Any]) -> str:
-            """Comma-joined modelDirs from a saved server record (Settings).
-
-            Filters out the HF cache (~/.cache/huggingface/hub) — the backend
-            scan script always scans it by default, so re-passing it as an
-            extra model_dir is redundant AND confuses some path-handling
-            edge cases where the extra dir suppresses the deeper scan.
-            We only need to forward the NON-default dirs (e.g. /mnt/HADES/models).
-            """
-            mds = server_record.get("modelDirs") if isinstance(server_record, dict) else None
-            HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
-            if isinstance(mds, list):
-                extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
-                return ",".join(extras)
-            if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
-                return mds
-            return ""
-
-        if raw_host:
-            host = await _resolve_cookbook_host(raw_host)
-            # Find this host's saved record so its modelDirs apply too.
-            srv = next(
-                (s for s in servers if isinstance(s, dict)
-                 and (s.get("name") == raw_host or s.get("host") == host or s.get("host") == raw_host)),
-                {},
-            )
-            models = await _scan_one(raw_host, host, model_dir=_dirs_for(srv))
-        else:
-            # Always include local. Local's saved record is the one with no host.
-            local_srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
-            scans: list = [_scan_one("local", "", model_dir=_dirs_for(local_srv))]
-            for s in servers:
-                if not isinstance(s, dict):
-                    continue
-                name = s.get("name") or s.get("host")
-                host_val = s.get("host") or ""
-                if not host_val:
-                    continue
-                scans.append(_scan_one(
-                    name,
-                    host_val,
-                    ssh_port=str(s.get("port") or ""),
-                    platform=s.get("platform") or "",
-                    model_dir=_dirs_for(s),
-                ))
-            results = await asyncio.gather(*scans, return_exceptions=False)
-            # Dedupe by (host, repo_id) — same model could appear in both HF cache + Ollama list.
-            seen = set()
-            models: list = []
-            for batch in results:
-                for m in batch:
-                    key = (m.get("host", ""), m.get("repo_id", ""))
-                    if key in seen:
-                        continue
-                    seen.add(key)
-                    models.append(m)
-        if not models:
-            # Cache scans can miss models downloaded into the HF default cache
-            # when the server has no explicit model_dir configured. Surface
-            # completed Cookbook download tasks so the agent doesn't conclude
-            # a model is absent and re-download it.
-            downloaded = []
-            try:
-                async with httpx.AsyncClient(timeout=10) as client:
-                    st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
-                    state = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
-                for t in (state.get("tasks") or []):
-                    if not isinstance(t, dict) or t.get("type") != "download":
-                        continue
-                    if (t.get("status") or "").lower() not in {"done", "completed"}:
-                        continue
-                    task_host = t.get("remoteHost") or (t.get("payload") or {}).get("remote_host") or ""
-                    if raw_host and task_host != raw_host:
-                        continue
-                    repo = t.get("modelId") or t.get("repoId") or (t.get("payload") or {}).get("repo_id") or t.get("name")
-                    if repo and repo not in downloaded:
-                        downloaded.append(repo)
-            except Exception:
-                downloaded = []
-            host_str = f" on {raw_host}" if raw_host else ""
-            if downloaded:
-                lines = [f"No cache paths were detected{host_str}, but Cookbook has completed download task(s):"]
-                lines.extend(f"- {repo} — downloaded via Cookbook task" for repo in downloaded)
-                return {"output": "\n".join(lines), "models": [{"repo_id": repo, "source": "cookbook_task"} for repo in downloaded], "exit_code": 0}
-            return {"output": f"No cached models found{host_str}.", "exit_code": 0}
-        # Multi-host scan: group by host so the agent sees inventory per server.
-        # Single-host scan: flat list (matches old output shape).
-        if raw_host:
-            lines = [f"{len(models)} cached model(s) on {raw_host}:"]
-            for m in models:
-                name = m.get("repo_id", "?")
-                sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
-                inc = " (incomplete)" if m.get("has_incomplete") else ""
-                kind = " [diffusion]" if m.get("is_diffusion") else ""
-                lines.append(f"- {name}{kind} — {sz}{inc}")
-        else:
-            from collections import defaultdict as _dd
-            by_host = _dd(list)
-            for m in models:
-                by_host[m.get("host", "local")].append(m)
-            lines = [f"{len(models)} cached model(s) across {len(by_host)} server(s):"]
-            for host_name in sorted(by_host.keys()):
-                lines.append(f"\n[{host_name}]")
-                for m in by_host[host_name]:
-                    name = m.get("repo_id", "?")
-                    sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
-                    inc = " (incomplete)" if m.get("has_incomplete") else ""
-                    kind = " [diffusion]" if m.get("is_diffusion") else ""
-                    backend = f" ({m.get('backend')})" if m.get("backend") else ""
-                    lines.append(f"- {name}{kind}{backend} — {sz}{inc}")
-        return {"output": "\n".join(lines), "models": models, "exit_code": 0}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-# ── Gallery tools ──
-
-async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
-    """Edit a gallery image (upscale, rembg, inpaint, harmonize)."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    image_id = args.get("image_id", "")
-    action = args.get("action", "")
-    if not image_id or not action:
-        return {"error": "image_id and action are required", "exit_code": 1}
-    payload = {"image_id": image_id}
-    if args.get("prompt"):
-        payload["prompt"] = args["prompt"]
-    if args.get("scale"):
-        payload["scale"] = args["scale"]
-    try:
-        async with httpx.AsyncClient(timeout=120) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/gallery/{action}", json=payload)
-            data = resp.json()
-        if data.get("success") or data.get("id"):
-            return {"output": f"Image edited ({action}). New image ID: {data.get('id', '?')}", "exit_code": 0}
-        return {"error": data.get("error", f"{action} failed"), "exit_code": 1}
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-# ── Research tools ──
-
-async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
-    """List, read/open, or delete saved deep-research results from the Library.
-    Args (JSON): {"action": "list|read|delete", "id": "", "search": "..."}.
-    Research is stored as data/deep_research/.json (query, summary, sources)."""
-    import json as _json
-    from pathlib import Path as _Path
-    try:
-        args = _parse_tool_args(content) if content.strip().startswith("{") else {}
-    except ValueError:
-        args = {}
-    if not isinstance(args, dict):
-        args = {}
-    action = (args.get("action") or "list").lower()
-    rid = (args.get("id") or args.get("session_id") or args.get("research_id") or "").strip()
-    data_dir = _Path(DEEP_RESEARCH_DIR)
-
-    # SECURITY: the research id is interpolated straight into a filesystem
-    # path (data/deep_research/.json) for read AND delete. Without this
-    # gate an agent-supplied id like "../settings" or "../../etc/passwd"
-    # escapes the research dir — reading exfiltrates arbitrary *.json into
-    # chat, deleting unlinks arbitrary *.json on disk. Allow only a bare
-    # token (research session ids are hex/uuid/slug — no separators).
-    if rid and not re.fullmatch(r"[A-Za-z0-9_-]+", rid):
-        return {"error": "Invalid research id."}
-
-    def _load(p):
-        try:
-            return _json.loads(p.read_text(encoding="utf-8"))
-        except Exception:
-            return None
-
-    if action in ("read", "open", "view", "get"):
-        if not rid:
-            return {"error": "Provide the research id (from action='list')."}
-        p = data_dir / f"{rid}.json"
-        if not p.exists():
-            return {"error": f"Research '{rid}' not found."}
-        d = _load(p) or {}
-        summary = d.get("result") or d.get("raw_report") or d.get("summary") or d.get("report") or "(no report body)"
-        srcs = d.get("sources", []) or []
-        out = f"# {d.get('query', '(untitled)')}\n\n{summary}"
-        if srcs:
-            out += "\n\nSources:\n" + "\n".join(
-                f"- {s.get('title') or s.get('url', '')}: {s.get('url', '')}" for s in srcs[:30]
-            )
-        return {"output": out[:16000], "exit_code": 0}
-
-    if action == "delete":
-        if not rid:
-            return {"error": "Provide the research id to delete (from action='list')."}
-        p = data_dir / f"{rid}.json"
-        if p.exists():
-            try:
-                p.unlink()
-            except Exception as e:
-                return {"error": f"Failed to delete: {e}"}
-            return {"output": f"Deleted research '{rid}'.", "exit_code": 0}
-        return {"error": f"Research '{rid}' not found."}
-
-    # default: list — clickable [query](#research-) rows, most-recent first
-    search = (args.get("search") or "").lower()
-    items = []
-    if data_dir.exists():
-        for p in data_dir.glob("*.json"):
-            d = _load(p)
-            if not d:
-                continue
-            q = d.get("query", "")
-            if search and search not in q.lower():
-                continue
-            items.append((d.get("completed_at", 0) or 0, p.stem, q, len(d.get("sources", []) or [])))
-    items.sort(reverse=True)
-    if not items:
-        return {"output": "No research found in the library." + (f" (search: {search})" if search else ""), "exit_code": 0}
-    rows = "\n".join(f"- [{q or '(untitled)'}](#research-{sid}) — {n} sources" for _, sid, q, n in items[:50])
-    return {"output": f"Research library ({len(items)} item{'s' if len(items) != 1 else ''}):\n{rows}", "exit_code": 0}
-
-
-async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict:
-    """Start a live deep-research job that appears in the Deep Research
-    sidebar. Hits /api/research/start (the same path the sidebar's
-    'Research' button uses) so the session is discoverable + streamable
-    there, rather than creating a scheduled task that never surfaces."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    topic = args.get("topic", "") or args.get("query", "")
-    if not topic:
-        return {"error": "topic (or query) is required", "exit_code": 1}
-    payload: Dict[str, Any] = {"query": topic}
-    # Optional knobs the research panel supports.
-    if args.get("max_rounds") is not None:
-        try: payload["max_rounds"] = int(args["max_rounds"])
-        except (ValueError, TypeError): pass
-    if args.get("max_time") is not None:
-        try: payload["max_time"] = int(args["max_time"])
-        except (ValueError, TypeError): pass
-    if args.get("category"):
-        payload["category"] = args["category"]
-    if args.get("search_provider"):
-        payload["search_provider"] = args["search_provider"]
-    try:
-        async with httpx.AsyncClient(timeout=30) as client:
-            resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
-                                     json=payload, headers=_internal_headers(owner))
-        if resp.status_code >= 400:
-            return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
-        data = resp.json()
-        sid = data.get("session_id", "?")
-        return {
-            "output": (
-                f"Deep research started: [{topic}](#research-{sid}). "
-                "Click to open the Deep Research sidebar and watch progress / read the report."
-            ),
-            "session_id": sid,
-            "anchor": f"[{topic}](#research-{sid})",
-            # UI hint so the frontend can open/refresh the research panel.
-            "ui_event": "research_started",
-            "research_session_id": sid,
-            "exit_code": 0,
-        }
-    except Exception as e:
-        return {"error": str(e), "exit_code": 1}
-
-
-# ── Contact tools ──
-
-async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
-    """Look up a contact by name. Searches: CardDAV -> email history -> memory."""
-    import httpx
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    name = args.get("name", "")
-    if not name:
-        return {"error": "name is required", "exit_code": 1}
-
-    contacts = {}  # email_or_phone -> {name, source, phone?}
-
-    # 1. CardDAV (Radicale) — structured contacts. Call in-process: a
-    # server-side httpx GET to /api/contacts/search carries no session
-    # cookie and would 401 under require_user.
-    try:
-        import asyncio
-        from routes import contacts_routes as cc
-        all_contacts = await asyncio.to_thread(cc._fetch_contacts)
-        q = name.lower()
-        for c in (all_contacts or []):
-            hay_name = (c.get("name") or "").lower()
-            match = q in hay_name or any(q in (e or "").lower() for e in c.get("emails", []))
-            if not match:
-                continue
-            has_email = False
-            for email in (c.get("emails") or []):
-                email = (email or "").strip().lower()
-                if email and "@" in email:
-                    contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
-                    has_email = True
-            # Fall back to phone numbers when the contact has no email address
-            if not has_email:
-                for phone in (c.get("phones") or []):
-                    phone = (phone or "").strip()
-                    if phone:
-                        contacts[phone] = {"name": c.get("name") or phone, "source": "contacts", "phone": phone}
-    except Exception:
-        pass
-
-    async with httpx.AsyncClient(timeout=30) as client:
-        # 2. Email history (sent/received)
-        try:
-            resp = await client.get(f"{_INTERNAL_BASE}/api/email/resolve-contact", params={"name": name})
-            if resp.status_code == 200:
-                for c in (resp.json().get("contacts") or []):
-                    email = (c.get("email") or "").strip().lower()
-                    if email and email not in contacts:
-                        contacts[email] = {"name": c.get("name") or email, "source": "email history"}
-        except Exception:
-            pass
-
-    if not contacts:
-        return {"output": f"No contacts found matching '{name}'.", "exit_code": 0}
-
-    lines = [f"Contacts matching '{name}':"]
-    for key, info in contacts.items():
-        if info.get("phone"):
-            lines.append(f"- {info['name']} — phone: {info['phone']} ({info['source']})")
-        else:
-            lines.append(f"- {info['name']} <{key}> ({info['source']})")
-    return {"output": "\n".join(lines), "exit_code": 0}
-
-
-async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
-    """Add / update / delete / list CardDAV contacts. Calls the contacts
-    helpers IN-PROCESS rather than over HTTP — a server-side httpx call to
-    /api/contacts/* carries no session cookie and would be rejected by
-    require_user (401), so the tool would see zero contacts even though
-    the browser-side UI works fine."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    action = (args.get("action") or "").strip().lower()
-    try:
-        from routes import contacts_routes as cc
-    except Exception as e:
-        return {"error": f"Contacts module unavailable: {e}", "exit_code": 1}
-    # The contacts helpers are sync (httpx blocking calls to CardDAV) — run
-    # them in a thread so we don't block the event loop.
-    import asyncio
-    try:
-        if action == "list":
-            rows = await asyncio.to_thread(cc._fetch_contacts, True)
-            if not rows:
-                return {"output": "No contacts.", "exit_code": 0}
-            lines = [f"{len(rows)} contacts:"]
-            for c in rows:
-                em = ", ".join(c.get("emails") or [])
-                lines.append(f"- {c.get('name') or '(no name)'} <{em}>  [uid={c.get('uid','')}]")
-            return {"output": "\n".join(lines), "exit_code": 0}
-
-        if action == "add":
-            email = (args.get("email") or "").strip()
-            if not email:
-                return {"error": "email is required for add", "exit_code": 1}
-            name = (args.get("name") or "").strip() or email.split("@")[0]
-            # Dedupe by email (same as the /add route).
-            existing = await asyncio.to_thread(cc._fetch_contacts)
-            for c in existing:
-                if email.lower() in [e.lower() for e in c.get("emails", [])]:
-                    return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
-            ok = await asyncio.to_thread(cc._create_contact, name, email)
-            return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
-
-        if action in ("update", "edit"):
-            uid = (args.get("uid") or "").strip()
-            if not uid:
-                return {"error": "uid is required for update (use action=list to find it)", "exit_code": 1}
-            name = (args.get("name") or "").strip()
-            emails = args.get("emails")
-            if emails is None and args.get("email"):
-                emails = [args["email"]]
-            emails = [e.strip() for e in (emails or []) if e and e.strip()]
-            phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
-            if not name and not emails:
-                return {"error": "Provide a name or emails to update", "exit_code": 1}
-            if not name and emails:
-                name = emails[0].split("@")[0]
-            ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones)
-            return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
-
-        if action == "delete":
-            uid = (args.get("uid") or "").strip()
-            if not uid:
-                return {"error": "uid is required for delete (use action=list to find it)", "exit_code": 1}
-            ok = await asyncio.to_thread(cc._delete_contact, uid)
-            return {"output": "Contact deleted." if ok else "Delete failed.", "exit_code": 0 if ok else 1}
-
-        return {"error": f"Unknown action '{action}'. Use list, add, update, or delete.", "exit_code": 1}
-    except Exception as e:
-        return {"error": f"Contact operation failed: {e}", "exit_code": 1}
-
-
-# ── Vaultwarden / Bitwarden CLI tools ──
-
-def _load_vault_config() -> Dict:
-    """Load Vaultwarden config from data/vault.json."""
-    from pathlib import Path
-    p = Path(VAULT_FILE)
-    if p.exists():
-        try:
-            return json.loads(p.read_text(encoding="utf-8"))
-        except Exception:
-            pass
-    return {}
-
-
-async def _run_bw(args: list, session: Optional[str] = None, input_text: Optional[str] = None) -> tuple:
-    """Run a bw CLI command with optional session + stdin. Returns (stdout, stderr, returncode)."""
-    import asyncio
-    env = {}
-    import os as _os
-    env.update(_os.environ)
-    if session:
-        env["BW_SESSION"] = session
-
-    proc = await asyncio.create_subprocess_exec(
-        "bw", *args,
-        stdin=asyncio.subprocess.PIPE if input_text else None,
-        stdout=asyncio.subprocess.PIPE,
-        stderr=asyncio.subprocess.PIPE,
-        env=env,
-    )
-    stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
-    return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
-
-
-async def do_vault_search(content: str, owner: Optional[str] = None) -> Dict:
-    """Search the vault by keyword. Returns matching item names + URLs, NO passwords."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    query = args.get("query", "").strip()
-    if not query:
-        return {"error": "query is required", "exit_code": 1}
-
-    cfg = _load_vault_config()
-    session = cfg.get("session")
-    if not session:
-        return {"error": "Vault is locked. Run vault_unlock or provide session key in settings.", "exit_code": 1}
-
-    stdout, stderr, rc = await _run_bw(["list", "items", "--search", query], session=session)
-    if rc != 0:
-        return {"error": f"bw failed: {stderr[:300]}", "exit_code": 1}
-
-    try:
-        items = json.loads(stdout)
-    except json.JSONDecodeError:
-        return {"error": "Failed to parse bw output", "exit_code": 1}
-
-    if not items:
-        return {"output": f"No vault items match '{query}'.", "exit_code": 0}
-
-    lines = [f"Found {len(items)} item(s) matching '{query}':"]
-    for it in items[:20]:
-        item_id = it.get("id", "?")
-        name = it.get("name", "?")
-        login = it.get("login") or {}
-        username = login.get("username", "")
-        uris = login.get("uris") or []
-        url = uris[0].get("uri", "") if uris else ""
-        parts = [f"[{item_id[:8]}] {name}"]
-        if username:
-            parts.append(f"user: {username}")
-        if url:
-            parts.append(f"url: {url}")
-        lines.append("- " + " · ".join(parts))
-    lines.append("\nUse vault_get(item_id, reason) to retrieve the password.")
-    return {"output": "\n".join(lines), "exit_code": 0}
-
-
-async def do_vault_get(content: str, owner: Optional[str] = None) -> Dict:
-    """Retrieve a full vault entry (including password) by item ID. Logs access to assistant chat."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    item_id = args.get("item_id", "").strip()
-    reason = args.get("reason", "").strip()
-    if not item_id:
-        return {"error": "item_id is required", "exit_code": 1}
-    if not reason:
-        return {"error": "reason is required — explain WHY you need this password", "exit_code": 1}
-
-    cfg = _load_vault_config()
-    session = cfg.get("session")
-    if not session:
-        return {"error": "Vault is locked. Unlock first.", "exit_code": 1}
-
-    stdout, stderr, rc = await _run_bw(["get", "item", item_id], session=session)
-    if rc != 0:
-        return {"error": f"bw failed: {stderr[:300]}", "exit_code": 1}
-
-    try:
-        item = json.loads(stdout)
-    except json.JSONDecodeError:
-        return {"error": "Failed to parse bw output", "exit_code": 1}
-
-    login = item.get("login") or {}
-    name = item.get("name", "?")
-
-    # Audit log to assistant chat
-    try:
-        from src.assistant_log import log_to_assistant
-        if owner:
-            log_to_assistant(
-                owner,
-                f"Retrieved password for **{name}** — reason: {reason}",
-                category="Vault",
-            )
-    except Exception:
-        pass
-
-    output = [
-        f"Vault item: {name}",
-        f"Username: {login.get('username', '(none)')}",
-        f"Password: {login.get('password', '(none)')}",
-    ]
-    if login.get("totp"):
-        output.append(f"TOTP secret: {login['totp']}")
-    uris = login.get("uris") or []
-    if uris:
-        output.append("URLs: " + ", ".join(u.get("uri", "") for u in uris))
-    if item.get("notes"):
-        output.append(f"Notes: {item['notes']}")
-
-    return {"output": "\n".join(output), "exit_code": 0}
-
-
-async def do_vault_unlock(content: str, owner: Optional[str] = None) -> Dict:
-    """Unlock the vault using a master password. Stores the resulting session key."""
-    try:
-        args = _parse_tool_args(content)
-    except ValueError:
-        return {"error": "Invalid JSON arguments", "exit_code": 1}
-    master_password = args.get("master_password", "")
-    if not master_password:
-        return {"error": "master_password is required", "exit_code": 1}
-
-    # Do not pass the master password as an argv element. Local process lists
-    # can expose argv to other users; stdin keeps the secret out of `ps`.
-    stdout, stderr, rc = await _run_bw(["unlock", "--raw"], input_text=master_password + "\n")
-    if rc != 0:
-        return {"error": f"Unlock failed: {stderr[:300]}", "exit_code": 1}
-
-    session = stdout.strip()
-    if not session:
-        return {"error": "bw returned empty session", "exit_code": 1}
-
-    # Save session to vault.json
-    from pathlib import Path
-    p = Path(VAULT_FILE)
-    cfg = {}
-    if p.exists():
-        try:
-            cfg = json.loads(p.read_text(encoding="utf-8"))
-        except Exception:
-            pass
-    cfg["session"] = session
-    from datetime import datetime as _dt
-    cfg["unlocked_at"] = _dt.utcnow().isoformat()
-    p.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
-    try:
-        import os as _os
-        _os.chmod(str(p), 0o600)
-    except Exception:
-        pass
-
-    return {"output": "Vault unlocked. Session saved.", "exit_code": 0}
diff --git a/src/tools/__init__.py b/src/tools/__init__.py
new file mode 100644
index 000000000..5e18f4f92
--- /dev/null
+++ b/src/tools/__init__.py
@@ -0,0 +1,32 @@
+"""Tool implementation package, split by domain (slice 1, #4082/#4071).
+
+Public tool functions live in domain modules. ``src.tool_implementations``
+re-exports from here for backward compatibility.
+"""
+from src.tools._common import _parse_tool_args  # noqa: F401
+from src.tools.system import (  # noqa: F401
+    do_manage_skills, _skill_dump, do_manage_tasks,
+    do_api_call, do_app_api,
+)
+from src.tools.cookbook import (  # noqa: F401
+    do_download_model, do_serve_model, do_list_served_models,
+    do_stop_served_model, do_tail_serve_output, do_list_downloads,
+    do_cancel_download, do_search_hf_models, do_adopt_served_model,
+    do_list_cookbook_servers, do_list_serve_presets, do_serve_preset,
+    do_list_cached_models,
+    _cookbook_servers, _resolve_cookbook_host, _cookbook_env_for_host,
+    _infer_serve_port, _infer_serve_host, _ensure_served_endpoint,
+    _cookbook_register_task, _cookbook_apply_retry_suggestion,
+    _scan_running_model_processes, _cookbook_kill_session,
+    _MODEL_PROCESS_PATTERNS,
+)
+from src.tools.search import do_search_chats  # noqa: F401
+from src.tools.notes import do_manage_notes  # noqa: F401
+from src.tools.calendar import do_manage_calendar  # noqa: F401
+from src.tools.image import do_edit_image  # noqa: F401
+from src.tools.research import do_manage_research, do_trigger_research  # noqa: F401
+from src.tools.contacts import do_resolve_contact, do_manage_contact  # noqa: F401
+from src.tools.vault import (  # noqa: F401
+    _load_vault_config, _run_bw,
+    do_vault_search, do_vault_get, do_vault_unlock,
+)
diff --git a/src/tools/_common.py b/src/tools/_common.py
new file mode 100644
index 000000000..a7ae6c798
--- /dev/null
+++ b/src/tools/_common.py
@@ -0,0 +1,25 @@
+"""Shared helpers used across tool implementation domains.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Domain modules under src/tools/ import from here.
+"""
+from typing import Dict, Optional
+
+from core.constants import internal_api_base
+from src.tool_utils import _parse_tool_args  # noqa: F401 — single source of the tool-arg parser; tool_utils is a leaf module (imports nothing from src)
+
+
+# In-process loopback base for agent tools that call Odysseus's own API
+# (cookbook state, model serve, gallery, email, calendar). We ride the
+# per-process internal token so require_admin lets us through. See
+# core/middleware.py. Resolution (override / APP_PORT / 7000) lives in
+# core.constants.internal_api_base().
+_INTERNAL_BASE = internal_api_base()
+
+
+def _internal_headers(owner: Optional[str] = None) -> Dict[str, str]:
+    from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
+    headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
+    if owner:
+        headers["X-Odysseus-Owner"] = owner
+    return headers
diff --git a/src/tools/calendar.py b/src/tools/calendar.py
new file mode 100644
index 000000000..9cd994d8a
--- /dev/null
+++ b/src/tools/calendar.py
@@ -0,0 +1,522 @@
+"""Calendar-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the manage_calendar tool (CalDAV-backed event CRUD).
+``src.tool_implementations`` re-exports these for backward compatibility.
+"""
+import json
+import logging
+import re
+from typing import Dict, Optional
+
+from src.tools._common import _parse_tool_args
+
+logger = logging.getLogger(__name__)
+
+
+async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
+    """Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
+    from datetime import datetime, timedelta
+    from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
+    from routes.calendar_routes import (
+        _ensure_default_calendar,
+        _parse_dt,
+        _parse_dt_pair,
+        parse_due_for_user,
+        _resolve_base_uid,
+        _push_caldav_event_after_commit,
+        _record_caldav_delete_tombstone,
+    )
+    import uuid as _uuid
+
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    # ── Batch normalization ──
+    # Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]}
+    # instead of individual create_event calls. Iterate and create each.
+    if isinstance(args.get("events"), list) and not args.get("action"):
+        results = []
+        for ev in args["events"]:
+            if not isinstance(ev, dict):
+                continue
+            # Normalize start/end from {dateTime: "..."} object to flat string
+            for field, target in [("start", "dtstart"), ("end", "dtend")]:
+                val = ev.pop(field, None)
+                if val and target not in ev:
+                    ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val
+            ev.setdefault("action", "create_event")
+            r = await do_manage_calendar(json.dumps(ev), owner=owner)
+            results.append(r)
+        created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")]
+        failed = [r for r in results if r.get("error")]
+
+        if not results:
+            return {"error": "No events to create", "exit_code": 1}
+
+        # Surface both successes and failures
+        parts = []
+        if created:
+            summaries = [r.get("response", "") for r in created]
+            parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries))
+        if failed:
+            first_error = failed[0].get("error", "Unknown error")
+            parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}")
+
+        response = "\n\n".join(parts)
+        # Non-zero exit code for partial or total failure
+        exit_code = 0 if not failed else 1
+        return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)}
+
+    # Normalize action — some models emit hyphens ("list-calendars") instead
+    # of underscores. Treat them as equivalent so we don't bounce a
+    # cosmetic typo back to the model and waste a round-trip. Also accept
+    # short forms (`create`, `update`, `delete`) as aliases for the
+    # full `_event` names — models keep emitting the short forms.
+    action = (args.get("action") or "list_events").replace("-", "_").strip().lower()
+    _ACTION_ALIASES = {
+        "create": "create_event",
+        "update": "update_event",
+        "delete": "delete_event",
+        "list": "list_events",
+    }
+    action = _ACTION_ALIASES.get(action, action)
+    db = SessionLocal()
+
+    def _calendar_query():
+        q = db.query(CalendarCal)
+        if owner is not None:
+            q = q.filter(CalendarCal.owner == owner)
+        return q
+
+    def _event_query():
+        q = db.query(CalendarEvent).join(CalendarCal)
+        if owner is not None:
+            q = q.filter(CalendarCal.owner == owner)
+        return q
+
+    def _reminder_minutes(raw_args) -> Optional[int]:
+        raw = (
+            raw_args.get("reminder_minutes")
+            or raw_args.get("remind_before_minutes")
+            or raw_args.get("alarm_minutes")
+            or raw_args.get("reminder")
+            or raw_args.get("alarm")
+        )
+        if raw in (None, ""):
+            desc = str(raw_args.get("description") or "")
+            if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
+                raw = desc
+        if raw in (None, "", False):
+            return None
+        if raw is True:
+            return 10
+        if isinstance(raw, (int, float)):
+            return max(0, int(raw))
+        text = str(raw).strip().lower()
+        if text in {"none", "no", "off", "false"}:
+            return None
+        m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
+        if m:
+            return max(0, int(m.group(1)))
+        m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
+        if m:
+            return max(0, int(m.group(1)) * 60)
+        if text.isdigit():
+            return max(0, int(text))
+        return None
+
+    def _event_description(raw_args, minutes_before: Optional[int]) -> str:
+        desc = str(raw_args.get("description", "") or "")
+        if minutes_before is None:
+            return desc
+        reminder_only = re.compile(
+            r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
+            r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
+            re.I,
+        )
+        return "" if reminder_only.match(desc) else desc
+
+    def _parse_event_dt(raw: str) -> tuple[datetime, bool]:
+        """Parse agent event datetimes in the user's timezone when available."""
+        return _parse_dt_pair(parse_due_for_user(raw))
+
+    def _first_nonempty_arg(*names: str):
+        for name in names:
+            value = args.get(name)
+            if value not in (None, ""):
+                return value
+        return None
+
+    def _create_calendar_reminder(summary: str, location: str, dtstart: datetime,
+                                  all_day: bool, minutes_before: int,
+                                  is_utc: bool = False) -> tuple[Optional[str], Optional[str]]:
+        remind_at = dtstart - timedelta(minutes=minutes_before)
+        now = datetime.utcnow() if is_utc else datetime.now()
+        if dtstart <= now:
+            return None, "event already passed"
+        if remind_at <= now:
+            # If the requested "before" time already passed but the event is
+            # still upcoming, create an immediate Note reminder instead of
+            # silently dropping it.
+            remind_at = now
+        start_fmt = dtstart.strftime("%a %b %d") if all_day else dtstart.strftime("%a %b %d %H:%M")
+        loc = f" @ {location}" if location else ""
+        text = f"{summary}{loc} — {start_fmt}"
+        due_date = remind_at.isoformat() + ("Z" if is_utc else "")
+        expected_title = f"Reminder: {summary}"
+        existing_q = db.query(Note).filter(
+            Note.archived == False,  # noqa: E712
+            Note.due_date == due_date,
+        )
+        if owner is not None:
+            existing_q = existing_q.filter(Note.owner == owner)
+        target_title = re.sub(r"^\s*reminder\s*:\s*", "", expected_title.strip().lower())
+        for existing in existing_q.limit(25).all():
+            existing_title = re.sub(r"^\s*reminder\s*:\s*", "", (existing.title or "").strip().lower())
+            if existing_title == target_title:
+                return existing.id, "duplicate reminder already exists"
+        note = Note(
+            id=str(_uuid.uuid4()),
+            owner=owner,
+            title=expected_title,
+            items=json.dumps([{"text": text, "done": False, "checked": False}]),
+            note_type="todo",
+            label="calendar",
+            due_date=due_date,
+            source="calendar",
+        )
+        db.add(note)
+        return note.id, None
+
+    try:
+        if action == "list_calendars":
+            _ensure_default_calendar(db, owner)
+            cals = _calendar_query().all()
+            result = [{"name": c.name, "href": c.id} for c in cals]
+            if result:
+                lines = [f"Found {len(result)} calendar(s):"]
+                for c in result:
+                    lines.append(f"- {c['name']} ({c['href'][:8]})")
+                response_text = "\n".join(lines)
+            else:
+                response_text = "No calendars found."
+            return {"response": response_text, "calendars": result, "exit_code": 0}
+
+        elif action == "list_events":
+            try:
+                start_raw = _first_nonempty_arg(
+                    "start", "start_date", "range_start", "from", "dtstart", "since"
+                )
+                end_raw = _first_nonempty_arg(
+                    "end", "end_date", "range_end", "to", "dtend", "until"
+                )
+                if start_raw:
+                    start_dt = _parse_dt(start_raw)
+                else:
+                    start_dt = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+                if end_raw:
+                    end_dt = _parse_dt(end_raw)
+                else:
+                    end_dt = start_dt + timedelta(days=14)
+            except ValueError as e:
+                return {"error": f"Invalid date format: {e}", "exit_code": 1}
+
+            if end_dt <= start_dt:
+                end_dt = start_dt + timedelta(days=1)
+
+            q = _event_query().filter(
+                CalendarEvent.dtstart < end_dt,
+                CalendarEvent.dtend > start_dt,
+                CalendarEvent.status != "cancelled",
+            )
+            calendar_filter = args.get("calendar")
+            if calendar_filter:
+                q = q.filter(
+                    (CalendarEvent.calendar_id == calendar_filter) |
+                    (CalendarCal.name == calendar_filter)
+                )
+            rows = q.order_by(CalendarEvent.dtstart).all()
+            events = []
+            for ev in rows:
+                if ev.all_day:
+                    s, e = ev.dtstart.strftime("%Y-%m-%d"), ev.dtend.strftime("%Y-%m-%d")
+                else:
+                    suffix = "Z" if getattr(ev, "is_utc", False) else ""
+                    s, e = ev.dtstart.isoformat() + suffix, ev.dtend.isoformat() + suffix
+                events.append({
+                    "uid": ev.uid, "summary": ev.summary or "", "dtstart": s, "dtend": e,
+                    "all_day": ev.all_day, "description": ev.description or "",
+                    "location": ev.location or "",
+                    "calendar": ev.calendar.name if ev.calendar else "",
+                    "calendar_href": ev.calendar_id,
+                    "event_type": ev.event_type or "",
+                    "importance": ev.importance or "normal",
+                })
+            if not events:
+                response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
+            else:
+                lines = [f"Found {len(events)} event(s) between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}:"]
+                for ev in events:
+                    when = ev["dtstart"]
+                    when_str = f"{when} (all day)" if ev.get("all_day") else f"{when} -> {ev.get('dtend', '')}"
+                    # Clickable anchor — opens the calendar on the event's day.
+                    line = f"- {when_str}: [{ev['summary']}](#event-{ev['uid']})"
+                    if ev.get("event_type"):
+                        line += f" #{ev['event_type']}"
+                    if ev.get("importance") and ev["importance"] != "normal":
+                        line += f" !{ev['importance']}"
+                    if ev.get("location"):
+                        line += f" @ {ev['location']}"
+                    if ev.get("calendar"):
+                        line += f" ({ev['calendar']})"
+                    if ev.get("description"):
+                        desc = ev["description"].strip().replace("\n", " ")
+                        if len(desc) > 120:
+                            desc = desc[:117] + "..."
+                        line += f"\n    {desc}"
+                    lines.append(line)
+                response_text = "\n".join(lines)
+            return {"response": response_text, "events": events, "exit_code": 0}
+
+        elif action == "create_event":
+            summary = args.get("summary")
+            # Accept the various names models like to use for the start
+            # field: dtstart (canonical), start, start_time, when.
+            dtstart_str = (args.get("dtstart") or args.get("start")
+                           or args.get("start_time") or args.get("when"))
+            if not summary or not dtstart_str:
+                return {"error": "summary and dtstart are required", "exit_code": 1}
+
+            # Accept either an href OR a calendar name/short-id like "Main"
+            # or "62e545d8" — saves the model from having to memorize hrefs
+            # after a `list_calendars` call returned short prefixes.
+            cal_href = args.get("calendar_href") or args.get("calendar")
+            cal = None
+            if cal_href:
+                cal = (_calendar_query()
+                       .filter(CalendarCal.id == cal_href)
+                       .first())
+                if not cal:
+                    # Try by name (case-insensitive) or by short-id prefix
+                    cal = (_calendar_query()
+                           .filter(CalendarCal.name.ilike(cal_href))
+                           .first())
+                if not cal:
+                    cal = (_calendar_query()
+                           .filter(CalendarCal.id.like(f"{cal_href}%"))
+                           .first())
+            if not cal:
+                cal = _ensure_default_calendar(db, owner)
+
+            all_day = bool(args.get("all_day", False))
+            try:
+                dtstart, dtstart_is_utc = _parse_event_dt(dtstart_str)
+            except ValueError as e:
+                return {"error": f"Could not parse dtstart {dtstart_str!r}: {e}", "exit_code": 1}
+            dtend_raw = args.get("dtend") or args.get("end") or args.get("end_time")
+            if dtend_raw:
+                try:
+                    dtend, dtend_is_utc = _parse_event_dt(dtend_raw)
+                    dtstart_is_utc = dtstart_is_utc or dtend_is_utc
+                except ValueError as e:
+                    return {"error": f"Could not parse dtend {dtend_raw!r}: {e}", "exit_code": 1}
+            else:
+                # Support duration: "1h", "30m", "90min", "1hr30m"
+                dur = (args.get("duration") or "").strip().lower()
+                delta = None
+                if dur:
+                    import re as _re_d
+                    h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
+                    m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
+                    secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
+                    if secs > 0:
+                        delta = timedelta(seconds=secs)
+                if delta is not None:
+                    dtend = dtstart + delta
+                elif all_day:
+                    dtend = dtstart + timedelta(days=1)
+                else:
+                    dtend = dtstart + timedelta(hours=1)
+
+            # Dedup: if a non-cancelled event with the same title + start time already
+            # exists, return its UID instead of creating a fresh copy. Prevents the
+            # email triage from multiplying events when several emails reference the
+            # same meeting. Compare case-insensitively since LLM-extracted titles
+            # can vary in capitalisation.
+            from sqlalchemy import func as _func
+            existing = (
+                _event_query()
+                .filter(
+                    CalendarEvent.dtstart == dtstart,
+                    CalendarEvent.status != "cancelled",
+                    _func.lower(CalendarEvent.summary) == summary.lower(),
+                )
+                .first()
+            )
+            if existing is not None:
+                reminder_note_id = None
+                reminder_skipped_reason = None
+                minutes_before = _reminder_minutes(args)
+                if minutes_before is not None:
+                    reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
+                        existing.summary or summary,
+                        existing.location or "",
+                        existing.dtstart,
+                        existing.all_day,
+                        minutes_before,
+                        bool(existing.is_utc),
+                    )
+                    if reminder_note_id:
+                        db.commit()
+                reminder_text = ""
+                if minutes_before is not None:
+                    reminder_text = (
+                        f"; reminder set {minutes_before} min before"
+                        if reminder_note_id
+                        else f"; reminder not set ({reminder_skipped_reason or 'reminder time already passed'})"
+                    )
+                return {
+                    "response": (
+                        f"Event already exists: '{summary}' on {dtstart_str}"
+                        + reminder_text
+                    ),
+                    "uid": existing.uid,
+                    "reminder_note_id": reminder_note_id,
+                    "reminder_skipped_reason": reminder_skipped_reason,
+                    "duplicate": True,
+                    "exit_code": 0,
+                }
+
+            # Optional tag/category and importance — friendly aliases.
+            event_type = (args.get("event_type") or args.get("tag")
+                          or args.get("category") or args.get("type") or "") or None
+            importance = args.get("importance") or "normal"
+            minutes_before = _reminder_minutes(args)
+
+            uid = str(_uuid.uuid4())
+            ev = CalendarEvent(
+                uid=uid, calendar_id=cal.id, summary=summary,
+                description=_event_description(args, minutes_before),
+                location=args.get("location", "") or "",
+                dtstart=dtstart, dtend=dtend, all_day=all_day,
+                is_utc=dtstart_is_utc and not all_day,
+                rrule=args.get("rrule", "") or "",
+                event_type=event_type,
+                importance=importance,
+                caldav_sync_pending="create" if cal.source == "caldav" else None,
+            )
+            db.add(ev)
+            reminder_note_id = None
+            reminder_skipped_reason = None
+            if minutes_before is not None:
+                reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
+                    summary,
+                    args.get("location", "") or "",
+                    dtstart,
+                    all_day,
+                    minutes_before,
+                    dtstart_is_utc and not all_day,
+                )
+            db.commit()
+            if cal.source == "caldav":
+                await _push_caldav_event_after_commit(owner, uid, "create")
+            tag_blurb = f" [{event_type}]" if event_type else ""
+            if minutes_before is None:
+                reminder_blurb = ""
+            elif reminder_note_id:
+                reminder_blurb = f" with reminder {minutes_before} min before"
+            else:
+                reminder_blurb = f" without reminder ({reminder_skipped_reason or 'reminder time already passed'})"
+            # Return a clickable anchor so the agent can surface a link
+            # that opens the calendar on that day. See the markdown
+            # anchor convention ([Name](#event-)).
+            return {
+                "response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {dtstart_str}{reminder_blurb}",
+                "uid": uid,
+                "anchor": f"[{summary}](#event-{uid})",
+                "reminder_note_id": reminder_note_id,
+                "reminder_skipped_reason": reminder_skipped_reason,
+                "exit_code": 0,
+            }
+
+        elif action == "update_event":
+            uid = args.get("uid")
+            if not uid:
+                return {"error": "uid is required", "exit_code": 1}
+            try:
+                base_uid = _resolve_base_uid(uid)
+            except ValueError as e:
+                return {"error": str(e), "exit_code": 1}
+            ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
+            if not ev:
+                return {"error": f"Event {uid} not found", "exit_code": 1}
+            if args.get("summary") is not None:
+                ev.summary = args["summary"]
+            if args.get("description") is not None:
+                ev.description = args["description"]
+            if args.get("location") is not None:
+                ev.location = args["location"]
+            if args.get("dtstart") is not None:
+                # Anchor naive/natural-language input to the USER's timezone and
+                # refresh is_utc, exactly like create_event. Parsing with the
+                # raw server-local _parse_dt here (and never touching is_utc)
+                # silently shifted an updated event by the user's UTC offset.
+                _eff_all_day = (
+                    args["all_day"] if args.get("all_day") is not None else ev.all_day
+                )
+                ev.dtstart, _su = _parse_event_dt(args["dtstart"])
+                ev.is_utc = bool(_su and not _eff_all_day)
+            if args.get("dtend") is not None:
+                ev.dtend, _eu = _parse_event_dt(args["dtend"])
+            if args.get("all_day") is not None:
+                ev.all_day = args["all_day"]
+            # Tag/category + importance updates (any of these aliases).
+            _tag = (args.get("event_type") or args.get("tag")
+                    or args.get("category") or args.get("type"))
+            if _tag is not None:
+                ev.event_type = _tag or None
+            if args.get("importance") is not None:
+                ev.importance = args["importance"]
+            is_caldav = ev.calendar and ev.calendar.source == "caldav"
+            if is_caldav:
+                ev.caldav_sync_pending = "update"
+            db.commit()
+            if is_caldav:
+                await _push_caldav_event_after_commit(owner, base_uid, "update")
+            return {"response": f"Updated event {uid}", "exit_code": 0}
+
+        elif action == "delete_event":
+            uid = args.get("uid")
+            if not uid:
+                return {"error": "uid is required", "exit_code": 1}
+            try:
+                base_uid = _resolve_base_uid(uid)
+            except ValueError as e:
+                return {"error": str(e), "exit_code": 1}
+            ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
+            if not ev:
+                return {"error": f"Event {uid} not found", "exit_code": 1}
+            is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
+            if is_caldav:
+                _record_caldav_delete_tombstone(db, ev, owner)
+            db.delete(ev)
+            db.commit()
+            if is_caldav:
+                await _push_caldav_event_after_commit(owner, base_uid, "delete")
+            return {"response": f"Deleted event {uid}", "exit_code": 0}
+
+        else:
+            return {
+                "error": f"Unknown action: {action}. Use list_events, create_event, update_event, delete_event, list_calendars",
+                "exit_code": 1,
+            }
+
+    except Exception as e:
+        db.rollback()
+        logger.error(f"manage_calendar error: {e}")
+        return {"error": str(e), "exit_code": 1}
+    finally:
+        db.close()
diff --git a/src/tools/contacts.py b/src/tools/contacts.py
new file mode 100644
index 000000000..3569d3272
--- /dev/null
+++ b/src/tools/contacts.py
@@ -0,0 +1,148 @@
+"""Contacts-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the resolve_contact and manage_contact (CardDAV CRUD) tools.
+``src.tool_implementations`` re-exports these for backward compatibility.
+``_INTERNAL_BASE`` still lives in tool_implementations.py and is pulled
+back function-locally where needed.
+"""
+from typing import Dict, Optional
+
+from src.tools._common import _parse_tool_args
+
+
+async def do_resolve_contact(content: str, owner: Optional[str] = None) -> Dict:
+    """Look up a contact by name. Searches: CardDAV -> email history -> memory."""
+    import httpx
+    from src.tool_implementations import _INTERNAL_BASE  # shared constant, still lives in the facade
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    name = args.get("name", "")
+    if not name:
+        return {"error": "name is required", "exit_code": 1}
+
+    contacts = {}  # email_or_phone -> {name, source, phone?}
+
+    # 1. CardDAV (Radicale) — structured contacts. Call in-process: a
+    # server-side httpx GET to /api/contacts/search carries no session
+    # cookie and would 401 under require_user.
+    try:
+        import asyncio
+        from routes import contacts_routes as cc
+        all_contacts = await asyncio.to_thread(cc._fetch_contacts)
+        q = name.lower()
+        for c in (all_contacts or []):
+            hay_name = (c.get("name") or "").lower()
+            match = q in hay_name or any(q in (e or "").lower() for e in c.get("emails", []))
+            if not match:
+                continue
+            has_email = False
+            for email in (c.get("emails") or []):
+                email = (email or "").strip().lower()
+                if email and "@" in email:
+                    contacts[email] = {"name": c.get("name") or email, "source": "contacts"}
+                    has_email = True
+            # Fall back to phone numbers when the contact has no email address
+            if not has_email:
+                for phone in (c.get("phones") or []):
+                    phone = (phone or "").strip()
+                    if phone:
+                        contacts[phone] = {"name": c.get("name") or phone, "source": "contacts", "phone": phone}
+    except Exception:
+        pass
+
+    async with httpx.AsyncClient(timeout=30) as client:
+        # 2. Email history (sent/received)
+        try:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/email/resolve-contact", params={"name": name})
+            if resp.status_code == 200:
+                for c in (resp.json().get("contacts") or []):
+                    email = (c.get("email") or "").strip().lower()
+                    if email and email not in contacts:
+                        contacts[email] = {"name": c.get("name") or email, "source": "email history"}
+        except Exception:
+            pass
+
+    if not contacts:
+        return {"output": f"No contacts found matching '{name}'.", "exit_code": 0}
+
+    lines = [f"Contacts matching '{name}':"]
+    for key, info in contacts.items():
+        if info.get("phone"):
+            lines.append(f"- {info['name']} — phone: {info['phone']} ({info['source']})")
+        else:
+            lines.append(f"- {info['name']} <{key}> ({info['source']})")
+    return {"output": "\n".join(lines), "exit_code": 0}
+
+
+async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
+    """Add / update / delete / list CardDAV contacts. Calls the contacts
+    helpers IN-PROCESS rather than over HTTP — a server-side httpx call to
+    /api/contacts/* carries no session cookie and would be rejected by
+    require_user (401), so the tool would see zero contacts even though
+    the browser-side UI works fine."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    action = (args.get("action") or "").strip().lower()
+    try:
+        from routes import contacts_routes as cc
+    except Exception as e:
+        return {"error": f"Contacts module unavailable: {e}", "exit_code": 1}
+    # The contacts helpers are sync (httpx blocking calls to CardDAV) — run
+    # them in a thread so we don't block the event loop.
+    import asyncio
+    try:
+        if action == "list":
+            rows = await asyncio.to_thread(cc._fetch_contacts, True)
+            if not rows:
+                return {"output": "No contacts.", "exit_code": 0}
+            lines = [f"{len(rows)} contacts:"]
+            for c in rows:
+                em = ", ".join(c.get("emails") or [])
+                lines.append(f"- {c.get('name') or '(no name)'} <{em}>  [uid={c.get('uid','')}]")
+            return {"output": "\n".join(lines), "exit_code": 0}
+
+        if action == "add":
+            email = (args.get("email") or "").strip()
+            if not email:
+                return {"error": "email is required for add", "exit_code": 1}
+            name = (args.get("name") or "").strip() or email.split("@")[0]
+            # Dedupe by email (same as the /add route).
+            existing = await asyncio.to_thread(cc._fetch_contacts)
+            for c in existing:
+                if email.lower() in [e.lower() for e in c.get("emails", [])]:
+                    return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
+            ok = await asyncio.to_thread(cc._create_contact, name, email)
+            return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
+
+        if action in ("update", "edit"):
+            uid = (args.get("uid") or "").strip()
+            if not uid:
+                return {"error": "uid is required for update (use action=list to find it)", "exit_code": 1}
+            name = (args.get("name") or "").strip()
+            emails = args.get("emails")
+            if emails is None and args.get("email"):
+                emails = [args["email"]]
+            emails = [e.strip() for e in (emails or []) if e and e.strip()]
+            phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
+            if not name and not emails:
+                return {"error": "Provide a name or emails to update", "exit_code": 1}
+            if not name and emails:
+                name = emails[0].split("@")[0]
+            ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones)
+            return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
+
+        if action == "delete":
+            uid = (args.get("uid") or "").strip()
+            if not uid:
+                return {"error": "uid is required for delete (use action=list to find it)", "exit_code": 1}
+            ok = await asyncio.to_thread(cc._delete_contact, uid)
+            return {"output": "Contact deleted." if ok else "Delete failed.", "exit_code": 0 if ok else 1}
+
+        return {"error": f"Unknown action '{action}'. Use list, add, update, or delete.", "exit_code": 1}
+    except Exception as e:
+        return {"error": f"Contact operation failed: {e}", "exit_code": 1}
diff --git a/src/tools/cookbook.py b/src/tools/cookbook.py
new file mode 100644
index 000000000..05877020d
--- /dev/null
+++ b/src/tools/cookbook.py
@@ -0,0 +1,1519 @@
+"""Cookbook (model serving) tool domain — slice 1 (#4082/#4071).
+
+Download, serve, list, stop, tail, search, adopt and cache HuggingFace / model
+serving operations, plus their private helpers. Extracted verbatim from
+``src.tool_implementations.py``; this module is re-exported by the facade.
+
+Shared constants ``_internal_headers`` and ``_INTERNAL_BASE`` still live in
+``src.tool_implementations`` (used by many domains); each function that needs
+them does a function-local import to avoid a top-level circular dependency,
+matching the system-domain split.
+"""
+import asyncio
+import json
+import logging
+import re
+from typing import Any, Dict, List, Optional
+
+from fastapi import HTTPException
+from routes._validators import validate_remote_host, validate_ssh_port
+
+from src.tools._common import _parse_tool_args
+
+logger = logging.getLogger(__name__)
+
+
+def _string_arg(value: Any) -> str:
+    return "" if value is None else str(value).strip()
+
+
+def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple[str, str]:
+    remote = validate_remote_host(_string_arg(remote_host) or None) or ""
+    sport = validate_ssh_port(_string_arg(ssh_port) or None) or ""
+    return remote, sport
+
+
+async def _cookbook_servers() -> Dict[str, Any]:
+    """Return the cookbook's configured servers + the currently-selected
+    default host. Shape: {default_host, hosts: [{host, platform, env, envPath}]}.
+    The agent uses this to route downloads/serves to the right machine
+    instead of silently defaulting to localhost."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=_internal_headers())
+            state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception:
+        return {"default_host": "", "hosts": []}
+    env = (state or {}).get("env") or {}
+    if not isinstance(env, dict):
+        return {"default_host": "", "hosts": []}
+    hosts = []
+    for s in (env.get("servers") or []):
+        if isinstance(s, dict):
+            hosts.append({
+                "name": s.get("name") or "",
+                "host": s.get("host") or "",   # "" = Local
+                "platform": s.get("platform") or "",
+                "env": s.get("env") or "",
+                "envPath": s.get("envPath") or "",
+                "port": s.get("port") or "",
+            })
+    return {"default_host": env.get("remoteHost") or "", "hosts": hosts}
+
+
+async def _resolve_cookbook_host(name_or_host: str) -> str:
+    """Map a friendly server NAME ('gpu-box', 'workstation') to its ssh host
+    string ('user@192.0.2.10'). If the input already looks like an
+    ssh host (contains '@' or matches a known host), or matches nothing,
+    it's returned unchanged. 'local'/'localhost' → '' (this machine)."""
+    if not name_or_host:
+        return ""
+    val = name_or_host.strip()
+    low = val.lower()
+    if low in ("local", "localhost", "this machine", "here"):
+        return ""
+    servers = await _cookbook_servers()
+    # Exact host match → already an ssh host
+    for h in servers.get("hosts") or []:
+        if h.get("host") and h["host"] == val:
+            return val
+    # Name match (case-insensitive)
+    for h in servers.get("hosts") or []:
+        if (h.get("name") or "").lower() == low:
+            return h.get("host") or ""   # "" for the Local entry
+    # Substring name match as a fallback
+    for h in servers.get("hosts") or []:
+        if low and low in (h.get("name") or "").lower():
+            return h.get("host") or ""
+    # No match — assume the caller passed a raw host/alias; return as-is
+    # (ssh can resolve aliases from ~/.ssh/config).
+    return val
+
+
+async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
+    """Resolve env_prefix / gpus / platform / hf_token / ssh_port for a
+    given host by looking it up in cookbook_state.env. The user
+    configures these per-host in the Cookbook UI; without them, raw
+    `vllm serve …` fails with 'command not found' because vLLM lives
+    inside a venv that has to be sourced first.
+
+    Returns a dict with keys ready to drop into the /api/model/serve
+    payload: env_prefix, gpus, platform, hf_token, ssh_port.
+    Falls back to the top-level env settings if no per-host entry exists.
+    """
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    headers = _internal_headers()
+    state: Dict[str, Any] = {}
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+            state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception as e:
+        logger.debug(f"cookbook env lookup failed for host={host!r}: {e}")
+        return {}
+    if not isinstance(state, dict):
+        return {}
+    env_root = state.get("env") or {}
+    if not isinstance(env_root, dict):
+        return {}
+
+    # Per-host entry takes precedence over top-level.
+    per_host: Dict[str, Any] = {}
+    for s in (env_root.get("servers") or []):
+        if isinstance(s, dict) and (s.get("host") or "") == (host or ""):
+            per_host = s
+            break
+
+    env_kind = per_host.get("env") or env_root.get("env") or "none"
+    env_path = per_host.get("envPath") or env_root.get("envPath") or ""
+    platform = per_host.get("platform") or env_root.get("platform") or "linux"
+    ssh_port = per_host.get("sshPort") or env_root.get("sshPort") or ""
+
+    env_prefix = ""
+    if env_kind == "venv" and env_path:
+        if platform == "windows":
+            activate = env_path if env_path.endswith("\\Scripts\\Activate.ps1") else env_path.rstrip("\\") + "\\Scripts\\Activate.ps1"
+            env_prefix = f"& {activate}"
+        else:
+            activate = env_path if env_path.endswith("/bin/activate") else env_path.rstrip("/") + "/bin/activate"
+            env_prefix = f"source {activate}"
+    elif env_kind == "conda" and env_path:
+        if platform == "windows":
+            env_prefix = f"conda activate {env_path}"
+        else:
+            env_prefix = f'eval "$(conda shell.bash hook)" && conda activate {env_path}'
+
+    from routes.cookbook_helpers import load_stored_hf_token
+    return {
+        "env_prefix": env_prefix,
+        "env_type": env_kind,
+        "env_path": env_path,
+        "gpus": env_root.get("gpus") or "",
+        "platform": platform,
+        "hf_token": load_stored_hf_token(),
+        "ssh_port": ssh_port,
+    }
+
+
+def _infer_serve_port(cmd: str) -> int:
+    """Infer likely listen port from a serve command."""
+    if not cmd:
+        return 8080
+    m = re.search(r"--port\\s+(\\d+)", cmd)
+    if m:
+        try:
+            return int(m.group(1))
+        except Exception:
+            pass
+    m = re.search(r"OLLAMA_HOST=[^\\s]*?:(\\d+)", cmd)
+    if m:
+        try:
+            return int(m.group(1))
+        except Exception:
+            pass
+    if "ollama" in cmd:
+        return 11434
+    return 8080
+
+
+def _infer_serve_host(host: str | None) -> tuple[str, bool]:
+    """Return (host, container_local) for registering a served endpoint."""
+    if not (host or "").strip():
+        return "localhost", True
+    base_host = host.split("@", 1)[-1] if "@" in host else host
+    return base_host, False
+
+
+async def _ensure_served_endpoint(
+    *,
+    model: str,
+    cmd: str,
+    host: str | None,
+) -> Dict[str, Any]:
+    """Register/fetch a model endpoint for a running serve session."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    endpoint_host, container_local = _infer_serve_host(host)
+    port = _infer_serve_port(cmd)
+    base_url = f"http://{endpoint_host}:{port}/v1"
+    short_name = model.split("/")[-1] if "/" in model else model
+    is_image = "diffusion_server.py" in (cmd or "")
+    payload = {
+        "name": short_name if not is_image else f"{short_name} (image)",
+        "base_url": base_url,
+        "skip_probe": "true",
+        "model_type": "image" if is_image else "llm",
+        "container_local": "true" if container_local else "false",
+    }
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.post(
+                f"{_INTERNAL_BASE}/api/model-endpoints",
+                data=payload,
+                headers=_internal_headers(),
+            )
+            data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
+        if resp.status_code >= 400:
+            logger.debug(
+                f"ensure endpoint failed for {model!r}: status={resp.status_code} data={data}"
+            )
+            return {"added": False, "endpoint_id": "", "base_url": base_url, "error": data}
+        ep_id = data.get("id") if isinstance(data, dict) else None
+        return {
+            "added": bool(ep_id),
+            "endpoint_id": ep_id or "",
+            "base_url": base_url,
+            "data": data,
+        }
+    except Exception as e:
+        logger.debug(f"ensure endpoint exception for {model!r}: {e}")
+        return {"added": False, "endpoint_id": "", "base_url": base_url, "error": str(e)}
+
+
+async def _cookbook_register_task(
+    session_id: str,
+    model: str,
+    host: str,
+    cmd: str,
+    task_type: str = "serve",
+    *,
+    endpoint_added: bool = False,
+    endpoint_id: str = "",
+) -> bool:
+    """Append a task entry to cookbook_state.json after the agent
+    launches via /api/model/serve or /api/model/download. The route
+    spawns tmux but leaves state-writing to the UI; the agent needs to
+    do that here so the task shows up in the Cookbook tab.
+    Returns True on success, False if the write failed (best-effort)."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    import time as _time
+    headers = _internal_headers()
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+            state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception as e:
+        logger.debug(f"cookbook state read failed: {e}")
+        return False
+    if not isinstance(state, dict):
+        state = {}
+    tasks = state.get("tasks") if isinstance(state.get("tasks"), list) else []
+    # Skip duplicate (same session_id) entries
+    if any(isinstance(t, dict) and t.get("sessionId") == session_id for t in tasks):
+        return True
+    display_name = model.split("/")[-1] if "/" in model else model
+    # Placeholder output — the cookbook UI's CSS hides empty 
+    # via `.cookbook-output-pre:empty { display: none }`, so an
+    # empty-string output makes the expansion appear broken until the
+    # frontend's reconnect-polling loop captures tmux output. A short
+    # placeholder gives the user something to see immediately; it gets
+    # replaced by real tmux output within a few seconds.
+    target = f"{host}:" if host else "local:"
+    placeholder = (
+        f"Launched via agent — waiting for tmux output…\n"
+        f"  session: {session_id}\n"
+        f"  target:  {target}{(cmd.split() or [''])[0] if cmd else ''}\n"
+        f"  cmd:     {cmd[:200]}{'…' if len(cmd) > 200 else ''}"
+    )
+    tasks.append({
+        "id": session_id,
+        "sessionId": session_id,
+        "name": display_name,
+        "modelId": model,
+        "type": task_type,
+        "status": "running",
+        "output": placeholder,
+        "ts": int(_time.time() * 1000),
+        "payload": {"repo_id": model, "remote_host": host or "", "_cmd": cmd},
+        "remoteHost": host or "",
+        "sshPort": "",
+        "platform": "linux",
+        "_serveReady": False,
+        "_endpointAdded": bool(endpoint_added),
+        "_endpointId": endpoint_id or "",
+    })
+    state["tasks"] = tasks
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
+                                  json=state, headers=headers)
+        return r.status_code < 400
+    except Exception as e:
+        logger.debug(f"cookbook state write failed: {e}")
+        return False
+
+
+
+
+# Patterns for detecting running LLM/diffusion model servers outside
+# the cookbook's task tracker. Each entry: (label, substring-list).
+# Match is case-insensitive against the FULL cmdline. First-match wins.
+_MODEL_PROCESS_PATTERNS = [
+    ("vLLM",            ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
+    ("SGLang",          ["sglang.launch_server", "sglang/launch_server"]),
+    ("llama.cpp",       ["llama-server", "llama_cpp_server", "llamacppserver"]),
+    ("Ollama",          ["ollama serve", "ollama runner", "/ollama "]),
+    ("ComfyUI",         ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
+    ("A1111 WebUI",     ["stable-diffusion-webui/webui", "stable-diffusion-webui/launch", "webui.sh"]),
+    ("Fooocus",         ["Fooocus/entry_with_update", "Fooocus/launch"]),
+    ("InvokeAI",        ["invokeai-web", "invokeai.app", "invokeai/api_app"]),
+    ("Forge WebUI",     ["stable-diffusion-webui-forge", "forge/webui"]),
+    ("SD.Next",         ["automatic/webui", "sd.next"]),
+    ("TGI",             ["text-generation-launcher", "text_generation_launcher"]),
+    ("Aphrodite",       ["aphrodite.endpoints", "aphrodite-engine"]),
+    ("Triton",          ["tritonserver", "triton/main"]),
+    ("Diffusers",       ["diffusers.pipelines", "StableDiffusionInpaintPipeline", "DiffusionPipeline"]),
+]
+
+
+def _cookbook_apply_retry_suggestion(cmd: str, suggestion: Dict[str, Any]) -> str:
+    """Apply a structured Cookbook diagnosis suggestion to a serve command."""
+    if not cmd or not suggestion:
+        return cmd
+    op = suggestion.get("op")
+    if op == "append":
+        arg = (suggestion.get("arg") or "").strip()
+        if not arg or arg in cmd:
+            return cmd
+        return f"{cmd.rstrip()} {arg}"
+    if op == "remove":
+        flag = (suggestion.get("flag") or "").strip()
+        if not flag:
+            return cmd
+        return re.sub(rf"\s*{re.escape(flag)}(?:\s+\S+)?", "", cmd).strip()
+    if op == "replace":
+        flag = (suggestion.get("flag") or "").strip()
+        value = str(suggestion.get("value") or "").strip()
+        if not flag or not value:
+            return cmd
+        repl = f"{flag} {value}"
+        if re.search(rf"(^|\s){re.escape(flag)}(\s+\S+)?", cmd):
+            return re.sub(rf"(^|\s){re.escape(flag)}(?:\s+\S+)?", lambda m: (m.group(1) or " ") + repl, cmd).strip()
+        return f"{cmd.rstrip()} {repl}"
+    return cmd
+
+
+def _scan_running_model_processes() -> List[Dict[str, Any]]:
+    """Scan /proc for running model server processes. Linux-only; returns
+    [] on other platforms or if /proc isn't accessible. Each match returns
+    a dict shaped like a cookbook task so the caller can merge cleanly.
+    """
+    import os
+    if not os.path.isdir("/proc"):
+        return []
+    out: List[Dict[str, Any]] = []
+    seen_keys = set()
+    try:
+        for pid_dir in os.listdir("/proc"):
+            if not pid_dir.isdigit():
+                continue
+            try:
+                with open(f"/proc/{pid_dir}/cmdline", "rb") as f:
+                    raw = f.read()
+            except (OSError, PermissionError):
+                continue
+            if not raw:
+                continue
+            # cmdline is NUL-separated; join with spaces for matching/display
+            cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip()
+            if not cmdline:
+                continue
+            lower = cmdline.lower()
+            for label, needles in _MODEL_PROCESS_PATTERNS:
+                if any(n.lower() in lower for n in needles):
+                    # Dedupe by (label, first-arg) — multi-worker servers
+                    # spawn N processes; only show one row per server.
+                    key = (label, cmdline.split(" ")[0])
+                    if key in seen_keys:
+                        break
+                    seen_keys.add(key)
+                    # Try to pluck a model name out of the cmdline.
+                    model = ""
+                    for tok in cmdline.split():
+                        if "/" in tok and any(s in tok.lower() for s in (
+                            "model", "checkpoint", ".safetensors", ".gguf", ".bin", "huggingface"
+                        )):
+                            model = tok
+                            break
+                    out.append({
+                        "session_id": f"pid-{pid_dir}",
+                        "model": model or label,
+                        "phase": "running (external)",
+                        "type": "serve",
+                        "remote": "local",
+                        "pid": int(pid_dir),
+                        "label": label,
+                        "cmdline_preview": cmdline[:140] + ("…" if len(cmdline) > 140 else ""),
+                        "external": True,
+                    })
+                    break
+    except Exception as e:
+        logger.debug(f"_scan_running_model_processes failed: {e}")
+    return out
+
+
+async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
+    """Download a HuggingFace model via the cookbook API."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    repo_id = args.get("repo_id", "")
+    if not repo_id:
+        return {"error": "repo_id is required", "exit_code": 1}
+    host = (args.get("host") or "").strip()
+    # Resolve a friendly server NAME ("gpu-box") to its ssh host string.
+    if host:
+        host = await _resolve_cookbook_host(host)
+    # No host specified → default to the cookbook's currently-selected
+    # server rather than silently downloading to localhost (which is
+    # usually NOT where the GPUs / model cache live).
+    _host_defaulted = False
+    if not host and not args.get("local"):
+        _servers = await _cookbook_servers()
+        if _servers.get("default_host"):
+            host = _servers["default_host"]
+            _host_defaulted = True
+    backend = (args.get("backend") or "").strip().lower()
+    if not backend and "/" not in repo_id and ":" in repo_id:
+        backend = "ollama"
+    payload = {"repo_id": repo_id}
+    if backend:
+        payload["backend"] = backend
+    if host:
+        payload["remote_host"] = host
+    if args.get("include"):
+        payload["include"] = args["include"]
+    # Per-host env_prefix + hf_token from cookbook_state (same as serve).
+    env_cfg = await _cookbook_env_for_host(host)
+    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
+    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
+    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
+    if env_cfg.get("ssh_port"):   payload["ssh_port"]   = env_cfg["ssh_port"]
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/model/download",
+                                     json=payload, headers=_internal_headers())
+            data = resp.json()
+        if data.get("ok"):
+            sid = data.get("session_id", "?")
+            registered = await _cookbook_register_task(
+                session_id=sid, model=repo_id, host=host,
+                cmd=(f"ollama pull {repo_id}" if backend == "ollama" else f"hf download {repo_id}"),
+                task_type="download",
+            )
+            note = "" if registered else " (state-write failed — download may not show in UI)"
+            where = host or "local"
+            default_note = " (defaulted to the cookbook's selected server — pass host= or local=true to override)" if _host_defaulted else ""
+            return {
+                "output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}",
+                "session_id": sid,
+                "host": host,
+                "task_type": "download",
+                "phase": "running",
+                "exit_code": 0,
+            }
+        return {"error": data.get("error", "Download failed"), "exit_code": 1}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
+    """Start serving a model via the cookbook API."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    repo_id = args.get("repo_id", "")
+    cmd = args.get("cmd", "")
+    if not repo_id or not cmd:
+        return {"error": "repo_id and cmd are required", "exit_code": 1}
+    host = (args.get("host") or "").strip()
+    if host:
+        host = await _resolve_cookbook_host(host)
+    if not host and not args.get("local"):
+        _servers = await _cookbook_servers()
+        if _servers.get("default_host"):
+            host = _servers["default_host"]
+    payload = {"repo_id": repo_id, "cmd": cmd}
+    if host:
+        payload["remote_host"] = host
+    # Resolve per-host env settings (venv/conda activate, gpus,
+    # hf_token, platform, ssh_port) from cookbook_state — same path
+    # the UI uses. Without env_prefix, `vllm serve …` lands in a shell
+    # without the user's venv and fails 'command not found'.
+    env_cfg = await _cookbook_env_for_host(host)
+    # Rewrite bare `vllm` / `python3` leading tokens to the venv's absolute
+    # binary path when the target host has a venv configured. SSH non-
+    # interactive shells often leave ~/.local/bin ahead of the venv bin on
+    # PATH even with the venv activated, so `vllm serve` finds the wrong
+    # binary and crashes early (e.g. compute_89 torch ABI errors on an old
+    # user-site torch). This mirrors what static/js/cookbook.js does in
+    # _buildServeCmd for the UI launch path.
+    env_path = (env_cfg.get("env_path") or "").rstrip("/")
+    env_type = (env_cfg.get("env_type") or env_cfg.get("env") or "").lower()
+    if env_type == "venv" and env_path:
+        venv_bin = f"{env_path}/bin"
+        # Match the FIRST shell-token: skip leading KEY=VAL env-var prefixes
+        # (CUDA_VISIBLE_DEVICES=… VLLM_USE_FLASHINFER_SAMPLER=…) before the binary.
+        import re as _re3
+        tokens = cmd.split()
+        idx = 0
+        env_re = _re3.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
+        while idx < len(tokens) and env_re.match(tokens[idx]):
+            idx += 1
+        if idx < len(tokens):
+            head = tokens[idx]
+            if head in ("vllm", "python3", "python"):
+                tokens[idx] = f"{venv_bin}/{head}"
+                cmd = " ".join(tokens)
+                payload["cmd"] = cmd
+    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
+    if env_cfg.get("gpus"):       payload["gpus"]       = env_cfg["gpus"]
+    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
+    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
+    if env_cfg.get("ssh_port"):   payload["ssh_port"]   = env_cfg["ssh_port"]
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
+                                     json=payload, headers=_internal_headers())
+            data = resp.json()
+        if data.get("ok"):
+            sid = data.get("session_id", "?")
+            endpoint_id = data.get("endpoint_id") or ""
+            if endpoint_id:
+                endpoint_added = True
+            else:
+                endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
+                endpoint_added = bool(endpoint_meta.get("added"))
+                endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
+            registered = await _cookbook_register_task(
+                session_id=sid, model=repo_id,
+                host=host, cmd=cmd, task_type="serve",
+                endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
+            )
+            note = "" if registered else " (state-write failed — task may not show in UI)"
+            where = host or "local"
+            log_path = f"/tmp/odysseus-tmux/{sid}.log"
+            return {
+                "output": (
+                    f"Serving {repo_id} on {where} (session: {sid}){note}\n"
+                    f"Next required check: call list_served_models. If this task is not ready, "
+                    f"call tail_serve_output with session_id={sid} and tail=400 before answering. "
+                    f"Do not tell the user to check logs; you have the log tool."
+                ),
+                "session_id": sid,
+                "task_type": "serve",
+                "phase": "running",
+                "host": host,
+                "endpoint_id": endpoint_id,
+                "log_path": log_path,
+                "next_tools": [
+                    {"name": "list_served_models", "arguments": {}},
+                    {"name": "tail_serve_output", "arguments": {"session_id": sid, "tail": 400}},
+                ],
+                "exit_code": 0,
+            }
+        # FastAPI HTTPException puts the message under `detail`, not `error`.
+        # Surface BOTH so the agent sees "Invalid characters in cmd" (from
+        # _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of
+        # the generic "Serve failed", which leaves it with nothing to act on.
+        err_msg = data.get("error") or data.get("detail") or "Serve failed"
+        hint = ""
+        if isinstance(err_msg, str) and "cmd" in err_msg.lower():
+            hint = (" — the cmd must START with an allowlisted binary "
+                    "(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
+                    "Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
+                    "env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
+                    "automatically from the host's saved venv settings.")
+        return {"error": f"{err_msg}{hint}", "exit_code": 1}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_list_served_models(content: str, owner: Optional[str] = None) -> Dict:
+    """List running model servers — merges cookbook-tracked tasks with
+    a /proc scan for externally-launched LLM/diffusion processes
+    (vLLM, sglang, llama.cpp, Ollama, ComfyUI, A1111, Fooocus, etc.)."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import asyncio
+    import httpx
+
+    # Cookbook-tracked tasks (best-effort; don't fail the whole call if
+    # this is unreachable).
+    cookbook_tasks: List[Dict[str, Any]] = []
+    try:
+        async with httpx.AsyncClient(timeout=15) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
+                                    headers=_internal_headers())
+            cookbook_tasks = (resp.json() or {}).get("tasks") or []
+    except Exception as e:
+        logger.debug(f"cookbook tasks/status fetch failed: {e}")
+
+    # Local process scan — runs in a worker thread so it doesn't block.
+    external = await asyncio.to_thread(_scan_running_model_processes)
+
+    merged: List[Dict[str, Any]] = []
+    merged.extend(cookbook_tasks)
+    # Dedupe: if a process's PID is already mentioned by a cookbook task
+    # (cookbook may track the PID via session_id), skip it.
+    cookbook_pids = set()
+    for t in cookbook_tasks:
+        if isinstance(t, dict) and t.get("pid"):
+            cookbook_pids.add(t["pid"])
+    for p in external:
+        if p.get("pid") not in cookbook_pids:
+            merged.append(p)
+
+    if not merged:
+        return {
+            "output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
+            "exit_code": 0,
+        }
+
+    # Sort so the agent sees what's actually LIVE first. Stopped/error/
+    # completed tasks are mostly historical noise — they shouldn't lead
+    # the list when something is genuinely serving.
+    _ORDER = {
+        "ready": 0, "running": 1, "loading": 1, "warming": 1,
+        "queued": 2, "starting": 2,
+        "error": 5, "crashed": 5, "failed": 5,
+        "stopped": 6, "killed": 6, "cancelled": 6, "canceled": 6,
+        "done": 7, "completed": 7, "finished": 7,
+    }
+    def _rank(t: Dict[str, Any]) -> int:
+        phase = (t.get("phase") or t.get("status") or "unknown").lower()
+        return _ORDER.get(phase, 3)
+    merged.sort(key=_rank)
+
+    cb_n = len(cookbook_tasks)
+    ext_n = len(external)
+    live_n = sum(1 for t in merged if _rank(t) <= 2)
+    header = []
+    if cb_n:
+        header.append(f"{cb_n} cookbook-tracked")
+    if ext_n:
+        header.append(f"{ext_n} external")
+    if live_n:
+        header.insert(0, f"{live_n} LIVE")
+    lines = [f"Running: {', '.join(header)}."]
+    for t in merged:
+        phase = t.get("phase") or t.get("status", "unknown")
+        model = t.get("model", "?")
+        remote = t.get("remote", "local")
+        sid = t.get("session_id", "?")
+        tag = " [external]" if t.get("external") else ""
+        lines.append(f"- {model}: {phase} ({remote}, session: {sid}){tag}")
+        diag = t.get("diagnosis") if isinstance(t.get("diagnosis"), dict) else None
+        if diag:
+            lines.append(f"    diagnosis: {diag.get('message')}")
+            cmd = t.get("cmd") or ""
+            suggestions = diag.get("suggestions") or []
+            actionable = []
+            for s in suggestions[:3]:
+                label = s.get("label") or "retry"
+                retry_cmd = _cookbook_apply_retry_suggestion(cmd, s)
+                if retry_cmd and retry_cmd != cmd and s.get("op") in {"append", "replace", "remove"}:
+                    actionable.append(f"{label}: `{retry_cmd}`")
+                else:
+                    actionable.append(label)
+            if actionable:
+                lines.append("    suggestions: " + " | ".join(actionable))
+        if t.get("status") == "error" and t.get("output_tail"):
+            tail = str(t.get("output_tail") or "").strip()
+            if tail:
+                # Prefer a window around a Python traceback if one exists,
+                # falling back to the last 30 lines. The previous 6-line
+                # tail showed only the post-crash bash prompt / neofetch
+                # banner ("Locale: C / Ubuntu_Odysseus ❯") — useless for
+                # diagnosis. The traceback we want is usually 50-200 lines
+                # earlier in the buffer.
+                _tail_lines = tail.splitlines()
+                _shown = _tail_lines[-30:]
+                for _i, _ln in enumerate(_tail_lines):
+                    if "Traceback (most recent call last)" in _ln or "ERROR" in _ln or "Error:" in _ln:
+                        _shown = _tail_lines[_i:_i + 40]
+                        break
+                lines.append("    recent log:")
+                for line in _shown:
+                    lines.append(f"      {line[:220]}")
+        if t.get("external") and t.get("cmdline_preview"):
+            lines.append(f"    cmd: {t['cmdline_preview']}")
+    return {"output": "\n".join(lines), "tasks": merged, "exit_code": 0}
+
+
+async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
+                                 ssh_port: str = "", verb: str = "Stopped") -> Dict:
+    """Kill a cookbook tmux session — remote-aware — AND mark the task
+    stopped in cookbook_state.json. Shared by stop_served_model and
+    cancel_download so both behave identically.
+
+    Resolves the task's remote host from state when not passed in. A
+    local-only `tmux kill-session` silently no-ops for remote tasks —
+    that's the bug where "stop the download" appeared to work but the
+    download kept running on the remote host.
+    """
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    import shlex
+    headers = _internal_headers()
+    remote = remote_host or ""
+    sport = ssh_port or ""
+
+    # Look up the task's host + confirm it exists in state.
+    state: Dict[str, Any] = {}
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+            state = resp.json() or {}
+    except Exception as e:
+        logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
+    if not isinstance(state, dict):
+        state = {}
+    matched = None
+    for t in (state.get("tasks") or []):
+        if isinstance(t, dict) and (t.get("sessionId") == session_id or t.get("id") == session_id):
+            matched = t
+            if not remote:
+                remote = t.get("remoteHost") or ""
+            if not sport:
+                sport = t.get("sshPort") or ""
+            break
+
+    if remote:
+        try:
+            remote, sport = _validate_cookbook_ssh_target(remote, sport)
+        except HTTPException as e:
+            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
+        _pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
+        cmd = (
+            f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
+            f"{_pf}{shlex.quote(remote)} 'tmux kill-session -t {shlex.quote(session_id)}'"
+        )
+        target_label = f"{session_id} on {remote}"
+    else:
+        cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
+        target_label = session_id
+
+    try:
+        async with httpx.AsyncClient(timeout=15) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
+                                     json={"command": cmd}, headers=headers)
+        if resp.status_code >= 400:
+            return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
+        try:
+            data = resp.json()
+        except Exception:
+            data = {}
+        kill_failed = isinstance(data, dict) and data.get("exit_code") not in (None, 0)
+        kill_err = ((data.get("stderr") or data.get("error") or "").strip() if isinstance(data, dict) else "")
+        # "no server running" / "can't find session" means it was already
+        # gone — treat as success (the goal is "not running").
+        already_gone = any(s in kill_err.lower() for s in ("no server running", "can't find session", "session not found"))
+        if kill_failed and not already_gone:
+            return {"error": f"Failed to {verb.lower()} {target_label}: {kill_err or 'kill-session returned non-zero'}", "exit_code": 1}
+
+        # Update state: mark stopped (so the UI + list reflect reality).
+        if matched is not None:
+            try:
+                matched["status"] = "stopped"
+                async with httpx.AsyncClient(timeout=10) as client:
+                    await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
+                                      json=state, headers=headers)
+            except Exception as e:
+                logger.debug(f"failed to mark {session_id} stopped in state: {e}")
+
+        suffix = " (was already gone)" if already_gone else ""
+        return {"output": f"{verb} {target_label}{suffix}", "exit_code": 0}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_stop_served_model(content: str, owner: Optional[str] = None) -> Dict:
+    """Stop a running model server by killing its tmux session (remote-aware)."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    session_id = args.get("session_id", "")
+    if not session_id:
+        return {"error": "session_id is required", "exit_code": 1}
+    return await _cookbook_kill_session(
+        session_id,
+        remote_host=args.get("remote_host") or args.get("host") or "",
+        ssh_port=args.get("ssh_port") or "",
+        verb="Stopped server",
+    )
+
+
+async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dict:
+    """Capture the last N lines of a cookbook task's tmux pane — remote-aware.
+
+    Used by the agent to debug a failed/stuck serve: list_served_models tells
+    you the task is `crashed`, this tool returns the actual stderr/traceback
+    so the agent can match it against a known fix (compute_89 nvcc mismatch,
+    flashinfer version mismatch, OOM, missing kernels, etc.) and decide
+    whether to relaunch via serve_model with new flags.
+    """
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    import shlex
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    session_id = (args.get("session_id") or "").strip()
+    if not session_id:
+        return {"error": "session_id is required (from list_served_models)", "exit_code": 1}
+    import re as _re
+    if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
+        return {"error": "Invalid session_id format", "exit_code": 1}
+    try:
+        tail = int(args.get("tail") or 400)
+    except (TypeError, ValueError):
+        tail = 400
+    tail = max(20, min(tail, 4000))
+    headers = _internal_headers()
+    remote = _string_arg(args.get("remote_host") or args.get("host"))
+    sport = _string_arg(args.get("ssh_port"))
+    # Resolve host from cookbook state if caller didn't pass one — same
+    # lookup _cookbook_kill_session uses.
+    if not remote:
+        state: Dict[str, Any] = {}
+        try:
+            async with httpx.AsyncClient(timeout=10) as client:
+                resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+                state = resp.json() or {}
+        except Exception as e:
+            logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
+        if isinstance(state, dict):
+            for t in (state.get("tasks") or []):
+                if isinstance(t, dict) and (t.get("sessionId") == session_id or t.get("id") == session_id):
+                    remote = t.get("remoteHost") or ""
+                    if not sport:
+                        sport = t.get("sshPort") or ""
+                    break
+    if remote:
+        try:
+            remote, sport = _validate_cookbook_ssh_target(remote, sport)
+        except HTTPException as e:
+            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
+
+    # Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
+    # live tmux pane. The pane is what the user would see scrolling on
+    # their screen — including the post-crash neofetch banner and the
+    # idle bash prompt that overwrites the actual traceback the moment
+    # vllm exits. The log file is the raw stdout/stderr of the wrapped
+    # process and survives the crash unchanged. We only fall back to
+    # the pane when the log file doesn't exist (older sessions launched
+    # before the tmux+tee wrapper was added).
+    log_path = f"/tmp/odysseus-tmux/{session_id}.log"
+    pane_inner = f"tmux capture-pane -t {shlex.quote(session_id)} -p -S -{tail} 2>/dev/null"
+    file_inner = f"tail -n {tail} {shlex.quote(log_path)} 2>/dev/null"
+    inner = (
+        f"if [ -s {shlex.quote(log_path)} ]; then {file_inner}; "
+        f"else {pane_inner}; fi"
+    )
+    if remote:
+        _pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
+        cmd = (
+            f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
+            f"{_pf}{shlex.quote(remote)} {shlex.quote(inner)}"
+        )
+        host_label = remote
+    else:
+        cmd = inner
+        host_label = "local"
+    try:
+        async with httpx.AsyncClient(timeout=20) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
+                                     json={"command": cmd}, headers=headers)
+        if resp.status_code >= 400:
+            return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
+        data = resp.json() if resp.content else {}
+        output_text = (data.get("stdout") or "").strip()
+        stderr_text = (data.get("stderr") or "").strip()
+        rc = data.get("exit_code")
+        if rc not in (None, 0) and not output_text:
+            already_gone = any(s in (stderr_text or "").lower() for s in ("no server running", "can't find session", "session not found"))
+            if already_gone:
+                return {"output": f"Tmux session {session_id} on {host_label} is gone (task already exited).", "exit_code": 0, "session_id": session_id, "host": host_label}
+            return {"error": f"capture-pane failed on {host_label}: {stderr_text or f'exit {rc}'}", "exit_code": 1}
+        # Dedupe download-progress noise. A 100-shard HF download produces
+        # tens of thousands of `model-NN-of-MM.safetensors: 91%|...` lines
+        # that all look the same to the agent and drown the actual error.
+        # Keep only one sample per (file, decile-percent) bucket.
+        import re as _re2
+        lines = output_text.splitlines()
+        dedup_lines = []
+        seen_progress = set()
+        progress_re = _re2.compile(r"^([\w./\-]+):\s+(\d+)%")
+        for ln in lines:
+            m = progress_re.match(ln.strip())
+            if m:
+                key = (m.group(1), int(m.group(2)) // 10)  # bucket by 10%
+                if key in seen_progress:
+                    continue
+                seen_progress.add(key)
+            dedup_lines.append(ln)
+        output_text = "\n".join(dedup_lines)
+        # Hard cap so the agent doesn't blow its token budget.
+        MAX_CHARS = 8000
+        if len(output_text) > MAX_CHARS:
+            output_text = "…(earlier output truncated)…\n" + output_text[-MAX_CHARS:]
+        if not output_text:
+            output_text = (
+                f"No log output captured yet for {session_id} on {host_label}. "
+                "This usually means the tmux wrapper has started but the model process "
+                "has not printed anything yet. Do not stop here: call list_served_models "
+                "again to check whether it is still loading, ready, or crashed; if it is "
+                "still not ready, call tail_serve_output again with a larger tail after "
+                "the next status check."
+            )
+        return {
+            "output": output_text,
+            "session_id": session_id,
+            "host": host_label,
+            "tail_lines": tail,
+            "exit_code": 0,
+        }
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_list_downloads(content: str, owner: Optional[str] = None) -> Dict:
+    """List in-flight model downloads (filters /api/cookbook/tasks/status to type=download)."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        async with httpx.AsyncClient(timeout=15) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/tasks/status",
+                                    headers=_internal_headers())
+            data = resp.json()
+        tasks = [t for t in data.get("tasks", []) if (t.get("type") or "").lower() == "download"]
+        if not tasks:
+            return {"output": "No downloads in progress.", "exit_code": 0}
+        lines = [f"{len(tasks)} download(s) in progress:"]
+        for t in tasks:
+            phase = t.get("phase") or t.get("status", "unknown")
+            model = t.get("model", "?")
+            pct = t.get("progress_percent") or t.get("percent")
+            pct_str = f" {pct}%" if pct is not None else ""
+            lines.append(f"- {model}: {phase}{pct_str} ({t.get('remote', 'local')}, session: {t.get('session_id', '?')})")
+        return {"output": "\n".join(lines), "downloads": tasks, "exit_code": 0}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_cancel_download(content: str, owner: Optional[str] = None) -> Dict:
+    """Cancel a model download by killing its tmux session (remote-aware)."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    session_id = args.get("session_id", "")
+    if not session_id:
+        return {"error": "session_id is required (from list_downloads)", "exit_code": 1}
+    return await _cookbook_kill_session(
+        session_id,
+        remote_host=args.get("remote_host") or args.get("host") or "",
+        ssh_port=args.get("ssh_port") or "",
+        verb="Cancelled download",
+    )
+
+
+async def do_search_hf_models(content: str, owner: Optional[str] = None) -> Dict:
+    """Search HuggingFace via the cookbook /api/cookbook/hf-latest endpoint."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    query = args.get("query", "") or args.get("search", "")
+    limit = args.get("limit", 10)
+    params: Dict[str, str] = {}
+    if query:
+        params["search"] = query
+    if limit:
+        params["limit"] = str(limit)
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/hf-latest",
+                                    params=params, headers=_internal_headers())
+            data = resp.json()
+        models = data.get("models") if isinstance(data, dict) else data
+        if not models:
+            return {"output": f"No models found for query: {query!r}", "exit_code": 0}
+        lines = [f"Found {len(models)} model(s) for {query!r}:" if query else f"{len(models)} model(s):"]
+        for m in models[:limit if isinstance(limit, int) else 10]:
+            if isinstance(m, dict):
+                name = m.get("repo_id") or m.get("modelId") or m.get("id") or "?"
+                dl = m.get("downloads")
+                size = m.get("size_gb") or m.get("needed_vram_gb")
+                bits = []
+                if size:
+                    bits.append(f"~{size}GB")
+                if dl:
+                    bits.append(f"{dl} downloads")
+                tail = f" ({', '.join(bits)})" if bits else ""
+                lines.append(f"- {name}{tail}")
+            else:
+                lines.append(f"- {m}")
+        return {"output": "\n".join(lines), "models": models, "exit_code": 0}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Dict:
+    """Register an externally-launched model server (bash + tmux + ssh, or
+    anything else) into the Cookbook so it appears in list_served_models,
+    can be stopped via stop_served_model, and is added to the user's
+    endpoint list for chat. Use this when a model was started outside
+    the cookbook's serve flow but you want first-class tracking.
+
+    Args (JSON):
+      host:          "user@192.0.2.10" (or omit for localhost)
+      tmux_session:  "minimax-m27"  (existing tmux session name)
+      model:         "cyankiwi/MiniMax-M2.7-AWQ-4bit" (HF repo or display name)
+      port:          8000
+      name:          optional display name (defaults to model basename)
+      add_endpoint:  bool (default true) — also register as a chat endpoint
+    """
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    import shlex
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    host = _string_arg(args.get("host") or args.get("remote_host"))
+    sess = (args.get("tmux_session") or args.get("session_id") or "").strip()
+    model = (args.get("model") or args.get("repo_id") or "").strip()
+    port = args.get("port") or 8000
+    display_name = (args.get("name") or "").strip() or (model.split("/")[-1] if "/" in model else model)
+    add_endpoint = args.get("add_endpoint", True)
+
+    if not sess or not model:
+        return {"error": "tmux_session and model are required", "exit_code": 1}
+
+    # Verify tmux session exists on the target host
+    if host:
+        try:
+            host, _ = _validate_cookbook_ssh_target(host)
+        except HTTPException as e:
+            return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
+
+    headers = _internal_headers()
+    if host:
+        check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'"
+    else:
+        check = f"tmux has-session -t {shlex.quote(sess)} 2>&1"
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
+                                  json={"command": check}, headers=headers)
+            data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+        if r.status_code >= 400 or (data.get("exit_code") not in (None, 0)):
+            err = (data.get("stderr") or data.get("error") or r.text[:200]).strip()
+            return {"error": f"tmux session {sess!r} not found on {host or 'local'}: {err}", "exit_code": 1}
+    except Exception as e:
+        return {"error": f"verify failed: {e}", "exit_code": 1}
+
+    # Best-effort health check — does port respond to /v1/models?
+    if host:
+        health_cmd = f"ssh -o ConnectTimeout=5 {shlex.quote(host)} 'curl -s -m 3 http://localhost:{int(port)}/v1/models'"
+    else:
+        health_cmd = f"curl -s -m 3 http://localhost:{int(port)}/v1/models"
+    server_up = False
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.post(f"{_INTERNAL_BASE}/api/shell/exec",
+                                  json={"command": health_cmd}, headers=headers)
+            body = (r.json() or {}).get("stdout", "") if r.headers.get("content-type", "").startswith("application/json") else ""
+            server_up = '"data"' in body or '"object"' in body
+    except Exception:
+        pass
+
+    # Read+modify+write cookbook state. APPEND a task entry; do NOT
+    # overwrite the whole file (that'd nuke presets).
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            r = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+            state = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
+    except Exception as e:
+        return {"error": f"could not read cookbook state: {e}", "exit_code": 1}
+    if not isinstance(state, dict):
+        state = {}
+    tasks = state.get("tasks") if isinstance(state.get("tasks"), list) else []
+    # Skip duplicate adopt of the same session
+    if any(isinstance(t, dict) and t.get("sessionId") == sess for t in tasks):
+        adopted_already = True
+    else:
+        adopted_already = False
+        import time as _time
+        new_task = {
+            "id": sess,
+            "sessionId": sess,
+            "name": display_name,
+            "type": "serve",
+            "status": "running",
+            "output": (
+                f"Adopted externally-launched session {sess!r} on {host or 'local'}.\n"
+                "Reconnect polling will start streaming tmux output shortly."
+            ),
+            "ts": int(_time.time() * 1000),
+            "payload": {"repo_id": model, "remote_host": host or "", "_cmd": "(adopted — launched outside cookbook)"},
+            "remoteHost": host or "",
+            "sshPort": "",
+            "platform": "linux",
+            "_serveReady": bool(server_up),
+            "_endpointAdded": False,
+            "_adoptedExternally": True,
+        }
+        tasks.append(new_task)
+        state["tasks"] = tasks
+        try:
+            async with httpx.AsyncClient(timeout=10) as client:
+                await client.post(f"{_INTERNAL_BASE}/api/cookbook/state",
+                                  json=state, headers=headers)
+        except Exception as e:
+            return {"error": f"could not save cookbook state: {e}", "exit_code": 1}
+
+    # Optionally register as a chat endpoint
+    endpoint_msg = ""
+    if add_endpoint:
+        # Resolve host to a URL. SSH form `user@host` → just take host.
+        host_only = host.split("@", 1)[-1] if host else "localhost"
+        endpoint_url = f"http://{host_only}:{int(port)}/v1"
+        try:
+            from src.tool_implementations import do_manage_endpoints  # avoid forward ref issues
+        except Exception:
+            do_manage_endpoints = None
+        if do_manage_endpoints is not None:
+            try:
+                ep_result = await do_manage_endpoints(json.dumps({
+                    "action": "add",
+                    "name": display_name,
+                    "endpoint_url": endpoint_url,
+                    "is_local": False,
+                }), owner=owner)
+                if isinstance(ep_result, dict) and not ep_result.get("error"):
+                    endpoint_msg = f" Endpoint {endpoint_url} added as {display_name!r}."
+                else:
+                    endpoint_msg = f" Endpoint registration skipped: {(ep_result or {}).get('error', 'unknown')}"
+            except Exception as e:
+                endpoint_msg = f" Endpoint registration failed: {e}"
+
+    return {
+        "output": (
+            f"Adopted session {sess!r} ({model}) on {host or 'local'}:{port}. "
+            + ("Already tracked — skipped state write. " if adopted_already else "Added to cookbook state. ")
+            + ("Server responding. " if server_up else "Server not responding yet (still loading?). ")
+            + endpoint_msg
+        ).strip(),
+        "session_id": sess,
+        "host": host,
+        "port": int(port),
+        "server_up": server_up,
+        "exit_code": 0,
+    }
+
+
+async def do_list_cookbook_servers(content: str, owner: Optional[str] = None) -> Dict:
+    """List the cookbook's configured servers and which one is the
+    current default. Use this to decide where to download/serve a
+    model, or to show the user options when the target host is
+    ambiguous."""
+    servers = await _cookbook_servers()
+    hosts = servers.get("hosts") or []
+    default = servers.get("default_host") or ""
+    if not hosts:
+        return {"output": "No cookbook servers configured. Downloads/serves default to localhost.", "servers": [], "default_host": "", "exit_code": 0}
+    # Resolve which server is the default by its friendly name too.
+    default_name = next((h.get("name") for h in hosts if h.get("host") == default and h.get("name")), default or "local")
+    lines = [f"{len(hosts)} configured server(s) (default: {default_name}):"]
+    for h in hosts:
+        name = h.get("name") or "(unnamed)"
+        host = h.get("host") or "local"
+        mark = " ← default" if h.get("host") == default else ""
+        env_bit = f" [{h.get('env')}: {h.get('envPath')}]" if h.get("env") and h.get("env") != "none" else ""
+        plat = f" ({h.get('platform')})" if h.get("platform") else ""
+        lines.append(f"- {name} → {host}{plat}{env_bit}{mark}")
+    lines.append("\nRefer to servers by their name (e.g. download_model with host=\"gpu-box\").")
+    return {"output": "\n".join(lines), "servers": hosts, "default_host": default, "exit_code": 0}
+
+
+async def do_list_serve_presets(content: str, owner: Optional[str] = None) -> Dict:
+    """List saved serve presets from cookbook_state.json. Each preset
+    is a launch template: name, model, host, port, cmd. Use this to
+    discover what the user has previously configured so you can
+    launch by preset instead of fabricating tmux commands."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
+                                    headers=_internal_headers())
+            state = resp.json() or {}
+    except Exception as e:
+        return {"error": f"Failed to fetch cookbook state: {e}", "exit_code": 1}
+
+    presets = state.get("presets") or []
+    if not presets:
+        return {
+            "output": "No serve presets saved. Tell the user to save one from the Cookbook UI first, or use serve_model with explicit repo_id + cmd + host.",
+            "presets": [],
+            "exit_code": 0,
+        }
+    lines = [f"{len(presets)} saved serve preset(s):"]
+    for p in presets:
+        if not isinstance(p, dict):
+            continue
+        name = p.get("name", "?")
+        model = p.get("model") or p.get("modelId") or "?"
+        host = p.get("host") or p.get("remoteHost") or "local"
+        port = p.get("port", "")
+        cmd = (p.get("cmd") or "").strip()
+        bits = [f"- {name}: {model}", f"host={host}"]
+        if port:
+            bits.append(f"port={port}")
+        lines.append("  ".join(bits))
+        if cmd:
+            cmd_preview = cmd if len(cmd) < 140 else cmd[:140] + "…"
+            lines.append(f"    cmd: {cmd_preview}")
+    return {"output": "\n".join(lines), "presets": presets, "exit_code": 0}
+
+
+async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
+    """Launch a saved serve preset by name. Resolves the preset's
+    cmd + host + model from cookbook_state.json, then calls the
+    standard model/serve endpoint. Saves the agent from having to
+    reinvent tmux launch commands the user already saved."""
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    name = (args.get("name") or args.get("preset") or "").strip()
+    if not name:
+        return {"error": "name (preset name) is required. Call list_serve_presets to see what's available.", "exit_code": 1}
+
+    try:
+        async with httpx.AsyncClient(timeout=10) as client:
+            resp = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state",
+                                    headers=_internal_headers())
+            state = resp.json() or {}
+    except Exception as e:
+        return {"error": f"Failed to fetch cookbook state: {e}", "exit_code": 1}
+
+    presets = state.get("presets") or []
+    # Match by exact name first, then case-insensitive substring.
+    chosen = None
+    lname = name.lower()
+    for p in presets:
+        if isinstance(p, dict) and (p.get("name") or "").lower() == lname:
+            chosen = p
+            break
+    if chosen is None:
+        for p in presets:
+            if isinstance(p, dict) and lname in (p.get("name") or "").lower():
+                chosen = p
+                break
+    if chosen is None:
+        sample = ", ".join((p.get("name") or "?") for p in presets[:8] if isinstance(p, dict))
+        return {"error": f"No preset matching {name!r}. Available: {sample or '(none)'}", "exit_code": 1}
+
+    repo_id = chosen.get("model") or chosen.get("modelId") or ""
+    cmd = (chosen.get("cmd") or "").strip()
+    host = chosen.get("host") or chosen.get("remoteHost") or ""
+    if not repo_id or not cmd:
+        return {"error": f"Preset {chosen.get('name')!r} is missing model or cmd — can't launch.", "exit_code": 1}
+
+    payload: Dict[str, Any] = {"repo_id": repo_id, "cmd": cmd}
+    if host:
+        payload["remote_host"] = host
+    # Resolve per-host env settings the same way the UI does — pulls
+    # env_prefix (source ~/vllm-env/bin/activate), gpus, hf_token,
+    # etc. from cookbook_state.env so launches actually find vllm.
+    env_cfg = await _cookbook_env_for_host(host)
+    if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
+    if env_cfg.get("gpus"):       payload["gpus"]       = env_cfg["gpus"]
+    if env_cfg.get("hf_token"):   payload["hf_token"]   = env_cfg["hf_token"]
+    if env_cfg.get("platform"):   payload["platform"]   = env_cfg["platform"]
+    if env_cfg.get("ssh_port"):
+        payload["ssh_port"] = env_cfg["ssh_port"]
+
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/model/serve",
+                                     json=payload, headers=_internal_headers())
+            data = resp.json()
+        if data.get("ok"):
+            sid = data.get("session_id", "?")
+            endpoint_id = data.get("endpoint_id") or ""
+            if endpoint_id:
+                endpoint_added = True
+            else:
+                endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
+                endpoint_added = bool(endpoint_meta.get("added"))
+                endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
+            registered = await _cookbook_register_task(
+                session_id=sid, model=repo_id, host=host,
+                cmd=cmd, task_type="serve",
+                endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
+            )
+            note = "" if registered else " (state-write failed — task may not show in UI)"
+            return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "host": host, "endpoint_id": endpoint_id, "exit_code": 0}
+        return {"error": data.get("error", "Serve failed"), "exit_code": 1}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
+
+
+async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Dict:
+    """List models already cached locally and/or on remote hosts.
+
+    With no `host` arg, scans EVERY configured Cookbook server (and local)
+    and aggregates — so the agent sees the full inventory in one call
+    instead of having to query each server individually.
+    """
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared, lives in facade
+    import httpx
+    try:
+        args = _parse_tool_args(content) if content.strip() else {}
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    raw_host = (args.get("host") or "").strip()
+    headers = _internal_headers()
+
+    async def _scan_one(host_label: str, host_val: str, ssh_port: str = "",
+                        platform: str = "", model_dir: str = "") -> list:
+        """Hit /api/model/cached for one host; tag each returned model with its source."""
+        p: Dict[str, str] = {}
+        if host_val:
+            p["host"] = host_val
+        # Caller-provided override beats per-server config beats nothing.
+        if args.get("model_dir"):
+            p["model_dir"] = args["model_dir"]
+        elif model_dir:
+            p["model_dir"] = model_dir
+        if ssh_port:
+            p["ssh_port"] = ssh_port
+        elif args.get("ssh_port"):
+            p["ssh_port"] = str(args["ssh_port"])
+        if platform:
+            p["platform"] = platform
+        elif args.get("platform"):
+            p["platform"] = args["platform"]
+        try:
+            async with httpx.AsyncClient(timeout=60) as client:
+                resp = await client.get(f"{_INTERNAL_BASE}/api/model/cached",
+                                        params=p, headers=headers)
+                data = resp.json()
+            ms = data.get("models", []) if isinstance(data, dict) else (data or [])
+            for m in ms:
+                m["host"] = host_label or "local"
+            return ms or []
+        except Exception as e:
+            logger.debug(f"list_cached_models scan({host_label}) failed: {e}")
+            return []
+
+    # When the caller specifies a host explicitly, scan only that one (old behaviour).
+    # Otherwise iterate every configured server + local so the agent doesn't
+    # have to repeat the call per server.
+    try:
+        # Pull configured servers from cookbook state (used for resolving
+        # modelDirs both when caller specifies a host and when we scan all).
+        servers: list = []
+        try:
+            async with httpx.AsyncClient(timeout=10) as client:
+                st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+                st_data = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
+            servers = (st_data.get("env", {}) or {}).get("servers") or []
+        except Exception as e:
+            logger.debug(f"server list fetch failed: {e}")
+            st_data = {}
+
+        def _dirs_for(server_record: Dict[str, Any]) -> str:
+            """Comma-joined modelDirs from a saved server record (Settings).
+
+            Filters out the HF cache (~/.cache/huggingface/hub) — the backend
+            scan script always scans it by default, so re-passing it as an
+            extra model_dir is redundant AND confuses some path-handling
+            edge cases where the extra dir suppresses the deeper scan.
+            We only need to forward the NON-default dirs (e.g. /mnt/HADES/models).
+            """
+            mds = server_record.get("modelDirs") if isinstance(server_record, dict) else None
+            HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
+            if isinstance(mds, list):
+                extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
+                return ",".join(extras)
+            if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
+                return mds
+            return ""
+
+        if raw_host:
+            host = await _resolve_cookbook_host(raw_host)
+            # Find this host's saved record so its modelDirs apply too.
+            srv = next(
+                (s for s in servers if isinstance(s, dict)
+                 and (s.get("name") == raw_host or s.get("host") == host or s.get("host") == raw_host)),
+                {},
+            )
+            models = await _scan_one(raw_host, host, model_dir=_dirs_for(srv))
+        else:
+            # Always include local. Local's saved record is the one with no host.
+            local_srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
+            scans: list = [_scan_one("local", "", model_dir=_dirs_for(local_srv))]
+            for s in servers:
+                if not isinstance(s, dict):
+                    continue
+                name = s.get("name") or s.get("host")
+                host_val = s.get("host") or ""
+                if not host_val:
+                    continue
+                scans.append(_scan_one(
+                    name,
+                    host_val,
+                    ssh_port=str(s.get("port") or ""),
+                    platform=s.get("platform") or "",
+                    model_dir=_dirs_for(s),
+                ))
+            results = await asyncio.gather(*scans, return_exceptions=False)
+            # Dedupe by (host, repo_id) — same model could appear in both HF cache + Ollama list.
+            seen = set()
+            models: list = []
+            for batch in results:
+                for m in batch:
+                    key = (m.get("host", ""), m.get("repo_id", ""))
+                    if key in seen:
+                        continue
+                    seen.add(key)
+                    models.append(m)
+        if not models:
+            # Cache scans can miss models downloaded into the HF default cache
+            # when the server has no explicit model_dir configured. Surface
+            # completed Cookbook download tasks so the agent doesn't conclude
+            # a model is absent and re-download it.
+            downloaded = []
+            try:
+                async with httpx.AsyncClient(timeout=10) as client:
+                    st = await client.get(f"{_INTERNAL_BASE}/api/cookbook/state", headers=headers)
+                    state = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
+                for t in (state.get("tasks") or []):
+                    if not isinstance(t, dict) or t.get("type") != "download":
+                        continue
+                    if (t.get("status") or "").lower() not in {"done", "completed"}:
+                        continue
+                    task_host = t.get("remoteHost") or (t.get("payload") or {}).get("remote_host") or ""
+                    if raw_host and task_host != raw_host:
+                        continue
+                    repo = t.get("modelId") or t.get("repoId") or (t.get("payload") or {}).get("repo_id") or t.get("name")
+                    if repo and repo not in downloaded:
+                        downloaded.append(repo)
+            except Exception:
+                downloaded = []
+            host_str = f" on {raw_host}" if raw_host else ""
+            if downloaded:
+                lines = [f"No cache paths were detected{host_str}, but Cookbook has completed download task(s):"]
+                lines.extend(f"- {repo} — downloaded via Cookbook task" for repo in downloaded)
+                return {"output": "\n".join(lines), "models": [{"repo_id": repo, "source": "cookbook_task"} for repo in downloaded], "exit_code": 0}
+            return {"output": f"No cached models found{host_str}.", "exit_code": 0}
+        # Multi-host scan: group by host so the agent sees inventory per server.
+        # Single-host scan: flat list (matches old output shape).
+        if raw_host:
+            lines = [f"{len(models)} cached model(s) on {raw_host}:"]
+            for m in models:
+                name = m.get("repo_id", "?")
+                sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
+                inc = " (incomplete)" if m.get("has_incomplete") else ""
+                kind = " [diffusion]" if m.get("is_diffusion") else ""
+                lines.append(f"- {name}{kind} — {sz}{inc}")
+        else:
+            from collections import defaultdict as _dd
+            by_host = _dd(list)
+            for m in models:
+                by_host[m.get("host", "local")].append(m)
+            lines = [f"{len(models)} cached model(s) across {len(by_host)} server(s):"]
+            for host_name in sorted(by_host.keys()):
+                lines.append(f"\n[{host_name}]")
+                for m in by_host[host_name]:
+                    name = m.get("repo_id", "?")
+                    sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
+                    inc = " (incomplete)" if m.get("has_incomplete") else ""
+                    kind = " [diffusion]" if m.get("is_diffusion") else ""
+                    backend = f" ({m.get('backend')})" if m.get("backend") else ""
+                    lines.append(f"- {name}{kind}{backend} — {sz}{inc}")
+        return {"output": "\n".join(lines), "models": models, "exit_code": 0}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
diff --git a/src/tools/image.py b/src/tools/image.py
new file mode 100644
index 000000000..25e879ca2
--- /dev/null
+++ b/src/tools/image.py
@@ -0,0 +1,39 @@
+"""Image-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the edit_image (gallery) tool.
+``src.tool_implementations`` re-exports these for backward compatibility.
+``_INTERNAL_BASE`` still lives in tool_implementations.py and is pulled back
+function-locally here.
+"""
+from typing import Dict, Optional
+
+from src.tools._common import _parse_tool_args
+
+
+async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
+    """Edit a gallery image (upscale, rembg, inpaint, harmonize)."""
+    import httpx
+    from src.tool_implementations import _INTERNAL_BASE  # shared constant, still lives in the facade
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    image_id = args.get("image_id", "")
+    action = args.get("action", "")
+    if not image_id or not action:
+        return {"error": "image_id and action are required", "exit_code": 1}
+    payload = {"image_id": image_id}
+    if args.get("prompt"):
+        payload["prompt"] = args["prompt"]
+    if args.get("scale"):
+        payload["scale"] = args["scale"]
+    try:
+        async with httpx.AsyncClient(timeout=120) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/gallery/{action}", json=payload)
+            data = resp.json()
+        if data.get("success") or data.get("id"):
+            return {"output": f"Image edited ({action}). New image ID: {data.get('id', '?')}", "exit_code": 0}
+        return {"error": data.get("error", f"{action} failed"), "exit_code": 1}
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
diff --git a/src/tools/notes.py b/src/tools/notes.py
new file mode 100644
index 000000000..fd7d8d5e0
--- /dev/null
+++ b/src/tools/notes.py
@@ -0,0 +1,254 @@
+"""Notes-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the manage_notes tool (notes + checklists CRUD).
+``src.tool_implementations`` re-exports these for backward compatibility.
+"""
+import json
+import logging
+import re
+from typing import Dict, Optional
+
+from src.tools._common import _parse_tool_args
+
+logger = logging.getLogger(__name__)
+
+
+async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
+    """Handle manage_notes tool calls: CRUD on notes and checklists."""
+    import uuid as _uuid
+    from core.database import SessionLocal, Note
+    from sqlalchemy.orm.attributes import flag_modified
+
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    # Action aliases — match what models actually emit. `create` is the most
+    # common alternative to `add`. Hyphenated forms also accepted.
+    action = (args.get("action") or "").replace("-", "_").strip().lower()
+    _NOTE_ACTION_ALIASES = {
+        "create": "add",
+        "new": "add",
+        "save": "add",
+        "remind": "add",
+        "remove": "delete",
+        "remove_item": "toggle_item",
+    }
+    action = _NOTE_ACTION_ALIASES.get(action, action)
+    db = SessionLocal()
+
+    def _norm_note_title(value: str) -> str:
+        text = (value or "").strip().lower()
+        text = re.sub(r"^\s*reminder\s*:\s*", "", text)
+        return re.sub(r"\s+", " ", text)
+
+    def _note_visible_to_owner(note, owner_value: Optional[str]) -> bool:
+        # Empty owner_value is single-user / auth-disabled mode. A real
+        # authenticated owner must match exactly; null/empty legacy rows are not
+        # shared between accounts.
+        if not owner_value:
+            return True
+        return getattr(note, "owner", None) == owner_value
+
+    def _note_by_prefix(note_id: str):
+        if not note_id:
+            return None
+        q = db.query(Note).filter(Note.id.startswith(note_id))
+        if owner:
+            q = q.filter(Note.owner == owner)
+        return q.first()
+
+    try:
+        if action == "list":
+            q = db.query(Note)
+            if owner is not None:
+                q = q.filter(Note.owner == owner)
+            if args.get("label"):
+                q = q.filter(Note.label == args["label"])
+            show_archived = args.get("archived", False)
+            q = q.filter(Note.archived == show_archived)
+            notes = q.order_by(Note.pinned.desc(), Note.updated_at.desc()).all()
+            if not notes:
+                return {"response": "No notes found.", "exit_code": 0}
+            lines = []
+            for n in notes:
+                pin = " [PINNED]" if n.pinned else ""
+                typ = " [checklist]" if n.note_type == "checklist" else ""
+                lbl = f" #{n.label}" if n.label else ""
+                title = n.title or "(untitled)"
+                lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
+                if n.note_type == "checklist" and n.items:
+                    try:
+                        items = json.loads(n.items)
+                        for i, item in enumerate(items):
+                            mark = "x" if item.get("done") else " "
+                            lines.append(f"  [{mark}] {i}: {item.get('text', '')}")
+                    except (json.JSONDecodeError, TypeError):
+                        pass
+                elif n.content:
+                    snippet = n.content[:80].replace("\n", " ")
+                    lines.append(f"  {snippet}")
+            return {"results": "\n".join(lines)}
+
+        elif action == "add":
+            # Accept the various field names models emit: `text` is the most
+            # common stand-in for "title or body content" when the model
+            # treats the note as a single string. If text was supplied and
+            # neither title nor content, use it as the title.
+            title = (args.get("title") or "").strip()
+            content_raw = args.get("content")
+            text_raw = args.get("text") or args.get("body")
+            if not title and not content_raw and text_raw:
+                title = text_raw.strip()
+            elif not content_raw and text_raw:
+                content_raw = text_raw
+            # Accept both `items` (legacy/internal field) and `checklist_items`
+            # (the schema-exposed name used by native function calls). Models
+            # following the schema emit `checklist_items`; older code paths
+            # and direct API callers still use `items`.
+            items_raw = args.get("checklist_items")
+            if items_raw is None:
+                items_raw = args.get("items")
+            items_json = json.dumps(items_raw) if items_raw is not None else None
+            note_type = args.get("note_type", "checklist" if items_raw else "note")
+            # Accept natural-language due_date ("tomorrow at 1pm") in
+            # addition to ISO. Use the user-tz-aware parser so the LLM's
+            # naive times ("today at 9pm") are anchored to the USER's clock,
+            # not the server's. Returns ISO with explicit offset so frontend
+            # `new Date()` resolves the right absolute moment regardless of
+            # where the user is.
+            due_raw = args.get("due_date")
+            due_iso = None
+            if due_raw:
+                try:
+                    from routes.calendar_routes import parse_due_for_user as _pdt_user
+                    due_iso = _pdt_user(due_raw)
+                except Exception:
+                    due_iso = due_raw  # fall through; trust the model
+            if due_iso and title:
+                # Calendar event reminders are represented as Notes. If the
+                # model creates a calendar event with reminder_minutes and then
+                # also creates a separate note reminder for the same title/time,
+                # keep the existing note so the user gets only one dispatch.
+                existing_q = db.query(Note).filter(
+                    Note.archived == False,  # noqa: E712
+                    Note.due_date == due_iso,
+                )
+                if owner is not None:
+                    existing_q = existing_q.filter(Note.owner == owner)
+                target_title = _norm_note_title(title)
+                for existing in existing_q.limit(25).all():
+                    if _norm_note_title(existing.title or "") == target_title:
+                        return {
+                            "response": f"Reminder already exists: \"{existing.title or title}\" (id: {existing.id[:8]})",
+                            "note_id": existing.id,
+                            "duplicate": True,
+                            "exit_code": 0,
+                        }
+            note = Note(
+                id=str(_uuid.uuid4()),
+                owner=owner,
+                title=title,
+                content=content_raw,
+                items=items_json,
+                note_type=note_type,
+                color=args.get("color"),
+                label=args.get("label"),
+                pinned=args.get("pinned", False),
+                due_date=due_iso,
+                source="agent",
+                session_id=args.get("session_id"),
+            )
+            db.add(note)
+            db.commit()
+            # Return note_id so the chat-side renderer can build a real
+            # "View note" button that opens the notes modal at this id.
+            # Previously the create response only included a prose
+            # confirmation; the model would type "View note" as a markdown
+            # link with no target, leaving the user with a click that
+            # did nothing and uncertainty about whether the note was made.
+            return {
+                "response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
+                "note_id": note.id,
+                "note_title": title or "",
+                "open_url": f"/#open=notes¬e={note.id}",
+                "exit_code": 0,
+            }
+
+        elif action == "update":
+            note_id = args.get("id", "")
+            note = _note_by_prefix(note_id)
+            if not note:
+                return {"error": f"Note '{note_id}' not found", "exit_code": 1}
+            if not _note_visible_to_owner(note, owner):
+                return {"error": "Note not found", "exit_code": 1}
+            for field in ("title", "content", "note_type", "color", "label"):
+                if field in args and args[field] is not None:
+                    setattr(note, field, args[field])
+            # Parse due_date the same way the `add` action does. The schema
+            # advertises natural language ("tomorrow at 9am"), and naive ISO
+            # strings need the user's tz offset attached so the frontend's
+            # `new Date()` resolves the right absolute moment. Storing the raw
+            # value here left updated reminders as unparseable literals that
+            # never fired.
+            if args.get("due_date") is not None:
+                due_raw = args["due_date"]
+                try:
+                    from routes.calendar_routes import parse_due_for_user as _pdt_user
+                    note.due_date = _pdt_user(due_raw)
+                except Exception:
+                    note.due_date = due_raw  # fall through; trust the model
+            new_items = args.get("checklist_items")
+            if new_items is None:
+                new_items = args.get("items")
+            if new_items is not None:
+                note.items = json.dumps(new_items)
+                flag_modified(note, "items")
+            if "pinned" in args:
+                note.pinned = args["pinned"]
+            if "archived" in args:
+                note.archived = args["archived"]
+            db.commit()
+            return {"response": f"Note updated: \"{note.title or '(untitled)'}\"", "exit_code": 0}
+
+        elif action == "delete":
+            note_id = args.get("id", "")
+            note = _note_by_prefix(note_id)
+            if not note:
+                return {"error": f"Note '{note_id}' not found", "exit_code": 1}
+            if not _note_visible_to_owner(note, owner):
+                return {"error": "Note not found", "exit_code": 1}
+            title = note.title
+            db.delete(note)
+            db.commit()
+            return {"response": f"Deleted note: \"{title or '(untitled)'}\"", "exit_code": 0}
+
+        elif action == "toggle_item":
+            note_id = args.get("id", "")
+            index = args.get("index", 0)
+            note = _note_by_prefix(note_id)
+            if not note:
+                return {"error": f"Note '{note_id}' not found", "exit_code": 1}
+            if not _note_visible_to_owner(note, owner):
+                return {"error": "Note not found", "exit_code": 1}
+            if not note.items:
+                return {"error": "Note has no checklist items", "exit_code": 1}
+            items = json.loads(note.items)
+            if index < 0 or index >= len(items):
+                return {"error": f"Item index {index} out of range (0-{len(items)-1})", "exit_code": 1}
+            items[index]["done"] = not items[index].get("done", False)
+            note.items = json.dumps(items)
+            flag_modified(note, "items")
+            db.commit()
+            mark = "done" if items[index]["done"] else "undone"
+            return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
+
+        else:
+            return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1}
+    except Exception as e:
+        logger.error(f"manage_notes error: {e}")
+        return {"error": str(e), "exit_code": 1}
+    finally:
+        db.close()
diff --git a/src/tools/research.py b/src/tools/research.py
new file mode 100644
index 000000000..625122aef
--- /dev/null
+++ b/src/tools/research.py
@@ -0,0 +1,142 @@
+"""Research-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the manage_research (library CRUD) and trigger_research (live job)
+tools.
+``src.tool_implementations`` re-exports these for backward compatibility.
+``_internal_headers`` and ``_INTERNAL_BASE`` still live in
+tool_implementations.py and are pulled back function-locally where needed.
+"""
+import re
+from typing import Any, Dict, Optional
+
+from src.constants import DEEP_RESEARCH_DIR
+from src.tools._common import _parse_tool_args
+
+
+async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
+    """List, read/open, or delete saved deep-research results from the Library.
+    Args (JSON): {"action": "list|read|delete", "id": "", "search": "..."}.
+    Research is stored as data/deep_research/.json (query, summary, sources)."""
+    import json as _json
+    from pathlib import Path as _Path
+    try:
+        args = _parse_tool_args(content) if content.strip().startswith("{") else {}
+    except ValueError:
+        args = {}
+    if not isinstance(args, dict):
+        args = {}
+    action = (args.get("action") or "list").lower()
+    rid = (args.get("id") or args.get("session_id") or args.get("research_id") or "").strip()
+    data_dir = _Path(DEEP_RESEARCH_DIR)
+
+    # SECURITY: the research id is interpolated straight into a filesystem
+    # path (data/deep_research/.json) for read AND delete. Without this
+    # gate an agent-supplied id like "../settings" or "../../etc/passwd"
+    # escapes the research dir — reading exfiltrates arbitrary *.json into
+    # chat, deleting unlinks arbitrary *.json on disk. Allow only a bare
+    # token (research session ids are hex/uuid/slug — no separators).
+    if rid and not re.fullmatch(r"[A-Za-z0-9_-]+", rid):
+        return {"error": "Invalid research id."}
+
+    def _load(p):
+        try:
+            return _json.loads(p.read_text(encoding="utf-8"))
+        except Exception:
+            return None
+
+    if action in ("read", "open", "view", "get"):
+        if not rid:
+            return {"error": "Provide the research id (from action='list')."}
+        p = data_dir / f"{rid}.json"
+        if not p.exists():
+            return {"error": f"Research '{rid}' not found."}
+        d = _load(p) or {}
+        summary = d.get("result") or d.get("raw_report") or d.get("summary") or d.get("report") or "(no report body)"
+        srcs = d.get("sources", []) or []
+        out = f"# {d.get('query', '(untitled)')}\n\n{summary}"
+        if srcs:
+            out += "\n\nSources:\n" + "\n".join(
+                f"- {s.get('title') or s.get('url', '')}: {s.get('url', '')}" for s in srcs[:30]
+            )
+        return {"output": out[:16000], "exit_code": 0}
+
+    if action == "delete":
+        if not rid:
+            return {"error": "Provide the research id to delete (from action='list')."}
+        p = data_dir / f"{rid}.json"
+        if p.exists():
+            try:
+                p.unlink()
+            except Exception as e:
+                return {"error": f"Failed to delete: {e}"}
+            return {"output": f"Deleted research '{rid}'.", "exit_code": 0}
+        return {"error": f"Research '{rid}' not found."}
+
+    # default: list — clickable [query](#research-) rows, most-recent first
+    search = (args.get("search") or "").lower()
+    items = []
+    if data_dir.exists():
+        for p in data_dir.glob("*.json"):
+            d = _load(p)
+            if not d:
+                continue
+            q = d.get("query", "")
+            if search and search not in q.lower():
+                continue
+            items.append((d.get("completed_at", 0) or 0, p.stem, q, len(d.get("sources", []) or [])))
+    items.sort(reverse=True)
+    if not items:
+        return {"output": "No research found in the library." + (f" (search: {search})" if search else ""), "exit_code": 0}
+    rows = "\n".join(f"- [{q or '(untitled)'}](#research-{sid}) — {n} sources" for _, sid, q, n in items[:50])
+    return {"output": f"Research library ({len(items)} item{'s' if len(items) != 1 else ''}):\n{rows}", "exit_code": 0}
+
+
+async def do_trigger_research(content: str, owner: Optional[str] = None) -> Dict:
+    """Start a live deep-research job that appears in the Deep Research
+    sidebar. Hits /api/research/start (the same path the sidebar's
+    'Research' button uses) so the session is discoverable + streamable
+    there, rather than creating a scheduled task that never surfaces."""
+    import httpx
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE  # shared constants, still live in the facade
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    topic = args.get("topic", "") or args.get("query", "")
+    if not topic:
+        return {"error": "topic (or query) is required", "exit_code": 1}
+    payload: Dict[str, Any] = {"query": topic}
+    # Optional knobs the research panel supports.
+    if args.get("max_rounds") is not None:
+        try: payload["max_rounds"] = int(args["max_rounds"])
+        except (ValueError, TypeError): pass
+    if args.get("max_time") is not None:
+        try: payload["max_time"] = int(args["max_time"])
+        except (ValueError, TypeError): pass
+    if args.get("category"):
+        payload["category"] = args["category"]
+    if args.get("search_provider"):
+        payload["search_provider"] = args["search_provider"]
+    try:
+        async with httpx.AsyncClient(timeout=30) as client:
+            resp = await client.post(f"{_INTERNAL_BASE}/api/research/start",
+                                     json=payload, headers=_internal_headers(owner))
+        if resp.status_code >= 400:
+            return {"error": f"research/start returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
+        data = resp.json()
+        sid = data.get("session_id", "?")
+        return {
+            "output": (
+                f"Deep research started: [{topic}](#research-{sid}). "
+                "Click to open the Deep Research sidebar and watch progress / read the report."
+            ),
+            "session_id": sid,
+            "anchor": f"[{topic}](#research-{sid})",
+            # UI hint so the frontend can open/refresh the research panel.
+            "ui_event": "research_started",
+            "research_session_id": sid,
+            "exit_code": 0,
+        }
+    except Exception as e:
+        return {"error": str(e), "exit_code": 1}
diff --git a/src/tools/search.py b/src/tools/search.py
new file mode 100644
index 000000000..882951333
--- /dev/null
+++ b/src/tools/search.py
@@ -0,0 +1,51 @@
+"""Search-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the search_chats tool.
+``src.tool_implementations`` re-exports these for backward compatibility.
+"""
+import logging
+from typing import Dict
+
+logger = logging.getLogger(__name__)
+
+
+async def do_search_chats(query: str, limit: int = 20, owner: str | None = None) -> Dict:
+    """Search past session transcripts for the calling user's sessions only.
+
+    Without an owner filter this used to leak EVERY user's chat history
+    into the agent's `search_chats` results (v2 review HIGH-11). The
+    caller in `tool_execution.execute_tool_block` now plumbs the owner
+    through; legacy callers without owner pass through as before but
+    will only see legacy/null-owner rows.
+    """
+    try:
+        from src.session_search import search_session_messages
+
+        results = search_session_messages(query, limit=limit, owner=owner)
+        if not results:
+            return {"results": f"No chats found matching \"{query}\"."}
+
+        # Group by session to avoid duplicate links
+        seen_sessions = {}
+        for result in results:
+            if result.session_id not in seen_sessions:
+                seen_sessions[result.session_id] = result
+
+        lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"]
+        for sid, result in seen_sessions.items():
+            lines.append(f"- **{result.session_name}** (#{sid})")
+            lines.append(f"  Link: [Open chat](#{sid})")
+            lines.append(f"  Match ({result.role}): {result.content_snippet}")
+            if result.context_before:
+                before = result.context_before[-1]
+                lines.append(f"  Before ({before['role']}): {before['content'][:180]}")
+            if result.context_after:
+                after = result.context_after[0]
+                lines.append(f"  After ({after['role']}): {after['content'][:180]}")
+            lines.append("")
+
+        return {"results": "\n".join(lines)}
+    except Exception as e:
+        logger.error(f"search_chats failed: {e}")
+        return {"error": str(e), "exit_code": 1}
diff --git a/src/tools/system.py b/src/tools/system.py
new file mode 100644
index 000000000..bb75ff0e7
--- /dev/null
+++ b/src/tools/system.py
@@ -0,0 +1,700 @@
+"""System-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the skills/tasks tools plus the generic API bridges (api_call, app_api).
+The admin manage_* tools (endpoints, mcp, webhooks, tokens, settings) live in
+``src.agent_tools.admin_tools`` after the upstream registry migration (#3629);
+``src.tool_implementations`` re-exports both sets for backward compatibility.
+"""
+import json
+import logging
+import os
+import re
+from typing import Any, Dict, List, Optional
+
+from src.tools._common import _parse_tool_args
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Skills management tool
+# ---------------------------------------------------------------------------
+
+async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
+    """Handle manage_skills tool calls.
+
+    SKILL.md-backed CRUD with progressive disclosure (Hermes-style). Actions:
+
+      list / index               — Level 0: name + description summary.
+      view {name}                — Level 1: full SKILL.md.
+      view_ref {name, path}      — Level 2: a sub-file under the skill dir.
+      add  {name, description, when_to_use, procedure[], pitfalls[],
+            verification[], tags[], category, status}
+                                 — Create a new skill (draft by default).
+      patch {name, old_string, new_string}
+                                 — Token-efficient surgical edit on the
+                                   raw SKILL.md text. Fails on ambiguous
+                                   `old_string` (multiple matches).
+      edit  {name, content}      — Replace the entire SKILL.md.
+      publish {name}             — Flip status: draft -> published.
+      delete {name}              — Remove the skill directory.
+      search {query}             — Relevance match on published skills.
+    """
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    action = (args.get("action") or "").lower()
+    from services.memory.skills import SkillsManager
+    from services.memory.skill_format import Skill, slugify
+    from src.constants import DATA_DIR
+    sm = SkillsManager(DATA_DIR)
+
+    # Accept legacy `skill_id` as an alias for `name`.
+    name = (args.get("name") or args.get("skill_id") or "").strip()
+
+    if action in ("list", "index", ""):
+        all_skills = sm.load(owner=owner)
+        if not all_skills:
+            return {"results": "No skills yet. Create one with action='add'."}
+        published = [s for s in all_skills if s.get("status") == "published"]
+        drafts = [s for s in all_skills if s.get("status") == "draft"]
+        lines = []
+        if published:
+            lines.append("## Published")
+            for s in sorted(published, key=lambda x: x["name"]):
+                lines.append(f"- **{s['name']}** ({s.get('category','general')}): {s.get('description','')}")
+        if drafts:
+            lines.append("\n## Drafts")
+            for s in sorted(drafts, key=lambda x: x["name"]):
+                lines.append(f"- **{s['name']}** [draft]: {s.get('description','')}")
+        return {"results": "\n".join(lines) if lines else "No skills yet."}
+
+    if action == "view":
+        if not name:
+            return {"error": "name is required for view", "exit_code": 1}
+        md = sm.read_skill_md(name, owner=owner)
+        if md is None:
+            return {"error": f"Skill {name!r} not found", "exit_code": 1}
+        return {"results": md}
+
+    if action == "view_ref":
+        if not name:
+            return {"error": "name is required for view_ref", "exit_code": 1}
+        ref = (args.get("path") or "").strip()
+        if not ref:
+            return {"error": "path is required for view_ref", "exit_code": 1}
+        text = sm.read_skill_reference(name, ref, owner=owner)
+        if text is None:
+            return {"error": f"Reference {ref!r} not found under {name!r}", "exit_code": 1}
+        return {"results": text}
+
+    if action == "add":
+        if not name:
+            return {
+                "error": "name is required for add. Provide the exact slug the user should see, then report the returned name.",
+                "exit_code": 1,
+            }
+        proc = args.get("procedure")
+        if proc is None:
+            proc = args.get("steps") or []
+        if not proc and not args.get("body_extra") and not args.get("solution"):
+            return {"error": "procedure (or solution body) is required", "exit_code": 1}
+        # Same auto-publish gate as the extractor path — when the user
+        # has auto_approve_skills on and the caller didn't pin an explicit
+        # status, publish immediately. Audit later demotes/removes on fail.
+        _status_arg = args.get("status")
+        if not _status_arg:
+            try:
+                from routes.prefs_routes import _load_for_user as _load_prefs
+                _prefs = _load_prefs(owner) or {}
+                _status_arg = "published" if _prefs.get("auto_approve_skills", True) else "draft"
+            except Exception:
+                _status_arg = "draft"
+        entry = sm.add_skill(
+            name=args.get("name"),
+            description=(args.get("description") or args.get("title") or "").strip(),
+            category=args.get("category") or "general",
+            tags=args.get("tags") or [],
+            platforms=args.get("platforms") or [],
+            requires_toolsets=args.get("requires_toolsets") or [],
+            fallback_for_toolsets=args.get("fallback_for_toolsets") or [],
+            when_to_use=(args.get("when_to_use") if args.get("when_to_use") is not None
+                         else args.get("problem", "")),
+            procedure=proc,
+            pitfalls=args.get("pitfalls") or [],
+            verification=args.get("verification") or [],
+            status=_status_arg,
+            version=args.get("version") or "1.0.0",
+            confidence=args.get("confidence", 0.8),
+            source=args.get("source", "learned"),
+            teacher_model=args.get("teacher_model"),
+            owner=owner,
+            title=args.get("title", ""),
+            problem=args.get("problem", ""),
+            solution=args.get("solution", ""),
+            steps=args.get("steps") or [],
+        )
+        if entry.get("_deduped"):
+            return {"results": (
+                f"A near-identical skill already exists: `{entry['name']}` — not creating "
+                f"a duplicate. View or edit it with action='view', name='{entry['name']}'."
+            )}
+        try:
+            from src.event_bus import fire_event
+            fire_event("skill_added", owner)
+        except Exception:
+            logger.debug("skill_added event dispatch failed", exc_info=True)
+        verify_hint = ""
+        if entry.get("status") == "draft":
+            verify_hint = (
+                "\n\nThis skill is a DRAFT. Run through the procedure once to verify, "
+                f"then publish with action='publish', name='{entry['name']}'."
+            )
+        return {"results": f"Created skill `{entry['name']}` — {entry.get('description','')}{verify_hint}"}
+
+    if action == "edit":
+        if not name:
+            return {"error": "name is required for edit", "exit_code": 1}
+        new_content = args.get("content")
+        if not isinstance(new_content, str) or not new_content.strip():
+            return {"error": "content (full SKILL.md) is required for edit", "exit_code": 1}
+        try:
+            sk_new = Skill.from_markdown(new_content)
+        except Exception as e:
+            return {"error": f"Could not parse content as SKILL.md: {e}", "exit_code": 1}
+        sk_new.name = slugify(sk_new.name or name)
+        existing = sm.load(owner=owner)
+        match = next((s for s in existing if s.get("name") == name), None)
+        if not match:
+            return {"error": f"Skill {name!r} not found", "exit_code": 1}
+        if not sk_new.owner:
+            sk_new.owner = match.get("owner") or owner
+        ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
+        return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1}
+
+    if action == "patch":
+        if not name:
+            return {"error": "name is required for patch", "exit_code": 1}
+        old = args.get("old_string")
+        new_str = args.get("new_string", "")
+        if not isinstance(old, str) or not old:
+            return {"error": "old_string is required and must be non-empty", "exit_code": 1}
+        md = sm.read_skill_md(name, owner=owner)
+        if md is None:
+            return {"error": f"Skill {name!r} not found", "exit_code": 1}
+        count = md.count(old)
+        if count == 0:
+            return {"error": "old_string not found in SKILL.md", "exit_code": 1}
+        if count > 1:
+            return {"error": f"old_string is ambiguous (appears {count} times). Make it more specific.", "exit_code": 1}
+        new_md = md.replace(old, new_str, 1)
+        try:
+            sk_new = Skill.from_markdown(new_md)
+        except Exception as e:
+            return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
+        sk_new.name = slugify(sk_new.name or name)
+        ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
+        return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1}
+
+    if action == "publish":
+        if not name:
+            return {"error": "name is required for publish", "exit_code": 1}
+        all_skills = sm.load(owner=owner)
+        match = next((s for s in all_skills if s.get("name") == name), None)
+        if not match:
+            return {"error": f"Skill {name!r} not found", "exit_code": 1}
+        updates = {"status": "published"}
+        if args.get("confidence") is not None:
+            updates["confidence"] = max(0.0, min(1.0, float(args["confidence"])))
+        sm.update_skill(name, updates, owner=owner)
+        return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."}
+
+    if action == "delete":
+        if not name:
+            return {"error": "name is required for delete", "exit_code": 1}
+        ok = sm.delete_skill(name, owner=owner)
+        return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1}
+
+    if action == "search":
+        query = (args.get("query") or "").strip()
+        if not query:
+            return {"error": "query is required for search", "exit_code": 1}
+        results = sm.get_relevant_skills(query, sm.load(owner=owner), max_items=5)
+        if not results:
+            return {"results": "No matching skills found."}
+        lines = []
+        for sk in results:
+            proc = sk.get("procedure") or sk.get("steps") or []
+            steps_str = " → ".join(proc[:5])
+            lines.append(f"**{sk['name']}**: {sk.get('description','')}\n  When: {sk.get('when_to_use','')}\n  Steps: {steps_str}")
+        return {"results": "\n\n".join(lines)}
+
+    return {
+        "error": (
+            f"Unknown action: {action!r}. "
+            "Use one of: list, view, view_ref, add, edit, patch, publish, delete, search."
+        ),
+        "exit_code": 1,
+    }
+
+
+def _skill_dump(sk) -> Dict:
+    """Translate a parsed Skill back into the kwargs `update_skill` expects."""
+    return {
+        "name": sk.name,
+        "description": sk.description,
+        "version": sk.version,
+        "category": sk.category,
+        "tags": sk.tags,
+        "platforms": sk.platforms,
+        "requires_toolsets": sk.requires_toolsets,
+        "fallback_for_toolsets": sk.fallback_for_toolsets,
+        "status": sk.status,
+        "confidence": sk.confidence,
+        "source": sk.source,
+        "teacher_model": sk.teacher_model,
+        "owner": sk.owner,
+        "when_to_use": sk.when_to_use,
+        "procedure": sk.procedure,
+        "pitfalls": sk.pitfalls,
+        "verification": sk.verification,
+        "body_extra": sk.body_extra,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Task management tool
+# ---------------------------------------------------------------------------
+
+async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
+    """Handle manage_tasks tool calls: CRUD on scheduled tasks."""
+    import uuid as _uuid
+    from core.database import SessionLocal, ScheduledTask
+    from src.task_scheduler import compute_next_run
+
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    action = args.get("action", "list")
+    db = SessionLocal()
+    try:
+        if action == "list":
+            q = db.query(ScheduledTask)
+            if owner:
+                q = q.filter(ScheduledTask.owner == owner)
+            tasks = q.order_by(ScheduledTask.created_at.desc()).all()
+            task_list = []
+            for t in tasks:
+                task_list.append({
+                    "id": t.id, "name": t.name, "status": t.status,
+                    "task_type": t.task_type or "llm",
+                    "action": t.action,
+                    "trigger_type": t.trigger_type or "schedule",
+                    "schedule": t.schedule,
+                    "trigger_event": t.trigger_event,
+                    "trigger_count": t.trigger_count,
+                    "next_run": t.next_run.isoformat() + "Z" if t.next_run else None,
+                    "last_run": t.last_run.isoformat() + "Z" if t.last_run else None,
+                    "run_count": t.run_count or 0,
+                })
+            return {"response": f"Found {len(task_list)} tasks", "tasks": task_list, "exit_code": 0}
+
+        elif action == "create":
+            task_type = args.get("task_type", "llm")
+            trigger_type = args.get("trigger_type", "schedule")
+
+            if task_type in ("llm", "research") and not args.get("prompt"):
+                return {"error": "Prompt is required for llm/research tasks", "exit_code": 1}
+            if task_type == "action" and not args.get("action_name"):
+                return {"error": "action_name is required for action tasks", "exit_code": 1}
+
+            # Compute next_run for schedule triggers
+            next_run = None
+            if trigger_type == "schedule":
+                schedule = args.get("schedule", "daily")
+                next_run = compute_next_run(
+                    schedule, args.get("scheduled_time", "09:00"),
+                    args.get("scheduled_day"),
+                )
+
+            task_id = str(_uuid.uuid4())
+            # Guard each fallback with `or`: args.get("prompt", default) returns
+            # None when the key is present but null, and None[:50] raises.
+            name = args.get("name") or (args.get("prompt") or args.get("action_name") or "Task")[:50]
+
+            task = ScheduledTask(
+                id=task_id,
+                owner=owner,
+                name=name,
+                prompt=args.get("prompt"),
+                task_type=task_type,
+                action=args.get("action_name"),
+                schedule=args.get("schedule") if trigger_type == "schedule" else None,
+                scheduled_time=args.get("scheduled_time", "09:00") if trigger_type == "schedule" else None,
+                scheduled_day=args.get("scheduled_day"),
+                trigger_type=trigger_type,
+                trigger_event=args.get("trigger_event"),
+                trigger_count=args.get("trigger_count"),
+                trigger_counter=0,
+                next_run=next_run,
+                status="active",
+                output_target=args.get("output_target", "session"),
+            )
+            db.add(task)
+            db.commit()
+            return {"response": f"Created task '{name}' (id: {task_id})", "task_id": task_id, "exit_code": 0}
+
+        elif action == "edit":
+            task_id = args.get("task_id")
+            if not task_id:
+                return {"error": "task_id is required for edit", "exit_code": 1}
+            task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
+            if not task:
+                return {"error": f"Task {task_id} not found", "exit_code": 1}
+            if owner and task.owner and task.owner != owner:
+                return {"error": "Access denied", "exit_code": 1}
+
+            changed = []
+            for field in ("name", "prompt", "output_target"):
+                if args.get(field) is not None:
+                    setattr(task, field, args[field])
+                    changed.append(field)
+            if args.get("task_type") is not None:
+                task.task_type = args["task_type"]
+                changed.append("task_type")
+            if args.get("action_name") is not None:
+                task.action = args["action_name"]
+                changed.append("action")
+            if args.get("trigger_type") is not None:
+                task.trigger_type = args["trigger_type"]
+                changed.append("trigger_type")
+            if args.get("trigger_event") is not None:
+                task.trigger_event = args["trigger_event"]
+                changed.append("trigger_event")
+            if args.get("trigger_count") is not None:
+                task.trigger_count = args["trigger_count"]
+                changed.append("trigger_count")
+
+            schedule_changed = False
+            for field in ("schedule", "scheduled_time", "scheduled_day"):
+                if args.get(field) is not None:
+                    setattr(task, field, args[field])
+                    changed.append(field)
+                    schedule_changed = True
+
+            if schedule_changed and (task.trigger_type or "schedule") == "schedule":
+                task.next_run = compute_next_run(
+                    task.schedule, task.scheduled_time, task.scheduled_day,
+                )
+
+            db.commit()
+            return {"response": f"Updated task '{task.name}': {', '.join(changed)}", "exit_code": 0}
+
+        elif action == "delete":
+            task_id = args.get("task_id")
+            if not task_id:
+                return {"error": "task_id is required for delete", "exit_code": 1}
+            task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
+            if not task:
+                return {"error": f"Task {task_id} not found", "exit_code": 1}
+            if owner and task.owner and task.owner != owner:
+                return {"error": "Access denied", "exit_code": 1}
+            name = task.name
+            db.delete(task)
+            db.commit()
+            return {"response": f"Deleted task '{name}'", "exit_code": 0}
+
+        elif action in ("pause", "resume"):
+            task_id = args.get("task_id")
+            if not task_id:
+                return {"error": f"task_id is required for {action}", "exit_code": 1}
+            task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
+            if not task:
+                return {"error": f"Task {task_id} not found", "exit_code": 1}
+            if owner and task.owner and task.owner != owner:
+                return {"error": "Access denied", "exit_code": 1}
+
+            if action == "pause":
+                task.status = "paused"
+            else:
+                task.status = "active"
+                if (task.trigger_type or "schedule") == "schedule":
+                    task.next_run = compute_next_run(
+                        task.schedule, task.scheduled_time, task.scheduled_day,
+                    )
+            db.commit()
+            return {"response": f"Task '{task.name}' {action}d", "exit_code": 0}
+
+        elif action == "run":
+            task_id = args.get("task_id")
+            if not task_id:
+                return {"error": "task_id is required for run", "exit_code": 1}
+            task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
+            if not task:
+                return {"error": f"Task {task_id} not found", "exit_code": 1}
+            if owner and task.owner and task.owner != owner:
+                return {"error": "Access denied", "exit_code": 1}
+
+            from src.event_bus import get_task_scheduler
+            scheduler = get_task_scheduler()
+            if scheduler:
+                started = await scheduler.run_task_now(task_id)
+                if started:
+                    return {"response": f"Task '{task.name}' triggered", "exit_code": 0}
+                else:
+                    return {"error": "Task is already running", "exit_code": 1}
+            return {"error": "Task scheduler not available", "exit_code": 1}
+
+        else:
+            return {"error": f"Unknown action: {action}", "exit_code": 1}
+
+    except Exception as e:
+        logger.error(f"manage_tasks error: {e}")
+        return {"error": str(e), "exit_code": 1}
+    finally:
+        db.close()
+
+
+# ---------------------------------------------------------------------------
+# API call tool
+# ---------------------------------------------------------------------------
+
+async def do_api_call(content: str) -> Dict:
+    """Execute an API call to a registered integration."""
+    from src.integrations import execute_api_call, load_integrations
+    try:
+        args = json.loads(content)
+    except json.JSONDecodeError:
+        # Try line-based format: integration\nmethod path\nbody
+        lines = content.strip().split("\n")
+        args = {"integration": lines[0].strip() if lines else ""}
+        if len(lines) > 1:
+            parts = lines[1].strip().split(" ", 1)
+            args["method"] = parts[0] if parts else "GET"
+            args["path"] = parts[1] if len(parts) > 1 else "/"
+        if len(lines) > 2:
+            try:
+                args["body"] = json.loads("\n".join(lines[2:]))
+            except json.JSONDecodeError:
+                pass
+
+    integration_name = args.get("integration", "")
+    integrations = load_integrations()
+    intg = next((i for i in integrations if i["id"] == integration_name
+                 or i["name"].lower() == integration_name.lower()), None)
+    if not intg:
+        available = ", ".join(i["name"] for i in integrations if i.get("enabled", True))
+        return {"error": f"No integration matching '{integration_name}'. Available: {available or 'none configured'}", "exit_code": 1}
+
+    return await execute_api_call(
+        intg["id"],
+        args.get("method", "GET"),
+        args.get("path", "/"),
+        params=args.get("params"),
+        body=args.get("body"),
+        extra_headers=args.get("headers"),
+    )
+
+
+# Paths the generic `app_api` tool will refuse to call. Auth/token/user
+# administration and host shell execution are too risky to route through an
+# agent surface even when the agent is admin-context; accidental account or
+# command mistakes have permanent blast radius.
+_APP_API_BLOCKLIST_PREFIXES = (
+    "/api/auth",           # login/logout/password
+    "/api/users",          # user CRUD (bare /api/users list+create+delete must also block)
+    "/api/tokens",         # api token mgmt (bare /api/tokens list+create must also block)
+    "/api/admin",          # admin one-shots (wipe etc.)
+    "/api/shell",          # host shell execution must stay behind named command tooling
+    "/api/backup/restore", # destructive restore
+)
+
+# (method, prefix) pairs to refuse specifically. Used for endpoints
+# where GET is fine but writes are destructive or host-control shaped.
+# Saw the agent wipe cookbook_state.json (presets + tasks) by POSTing
+# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
+# Use dedicated tools or UI flows instead.
+_APP_API_BLOCKLIST_METHOD_PATH = (
+    ("GET",    "/api/email/accounts"),  # owner-filtered in tool context; use list_email_accounts MCP tool
+    ("POST",   "/api/cookbook/state"),   # whole-file overwrite — agent must use serve_preset/serve_model instead
+    ("DELETE", "/api/cookbook/state"),
+    # Host-control routes: package install, engine rebuild, and process
+    # signalling should not be reachable through the generic API bridge.
+    ("POST",   "/api/cookbook/packages/install"),
+    ("POST",   "/api/cookbook/rebuild-engine"),
+    ("POST",   "/api/cookbook/kill-pid"),
+    # Use the named tools (download_model / serve_model) — they handle
+    # host-name resolution, per-host env_prefix, AND register the task
+    # in cookbook state so it shows in the UI + list_downloads. Hitting
+    # the raw endpoint via app_api skips all of that → orphan task.
+    ("POST",   "/api/model/download"),
+    ("POST",   "/api/model/serve"),
+    # Use trigger_research — it returns a UI hint so the Deep Research
+    # sidebar surfaces the session. Raw start works but the agent
+    # fumbles the payload + the session doesn't reliably show up.
+    ("POST",   "/api/research/start"),
+    # Use the named tools — they handle owner attribution, natural-
+    # language due_date parsing, timezone, dedup, and tag/category
+    # normalization. Hitting the raw endpoint via app_api saves a
+    # note/event with the wrong fields, no reminder, or the wrong tz.
+    ("POST",   "/api/notes"),
+    ("PUT",    "/api/notes"),
+    ("DELETE", "/api/notes"),
+    ("POST",   "/api/calendar/events"),
+    ("PUT",    "/api/calendar/events"),
+    ("DELETE", "/api/calendar/events"),
+)
+
+
+async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
+    """Generic loopback to allowed internal Odysseus API endpoints. Lets the
+    agent reach the full UI-button surface (cookbook, email, notes,
+    calendar, skills, sessions, gallery, research, etc.) without us
+    landing a named tool wrapper for every one.
+
+    Args (JSON):
+      action: "call" (default) | "endpoints"
+      path:   "/api/cookbook/gpus"     # required for call
+      method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" (default GET)
+      body:                    # JSON body for POST/PUT/PATCH
+      query:                   # querystring params
+
+    The `endpoints` action returns the OpenAPI surface (method + path +
+    summary) so the agent can discover what's reachable. A blocklist
+    refuses sensitive auth/user/admin/shell paths and method-specific
+    host-control routes to keep blast radius bounded.
+    """
+    # `_internal_headers` and `_INTERNAL_BASE` still live in
+    # tool_implementations.py (shared by many domain tools). Function-local
+    # import avoids a top-level circular dependency until a later task
+    # relocates them.
+    from src.tool_implementations import _internal_headers, _INTERNAL_BASE
+
+    import httpx
+    try:
+        args = _parse_tool_args(content) if content.strip() else {}
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+
+    action = (args.get("action") or "call").lower()
+    base = _INTERNAL_BASE
+
+    if action == "endpoints":
+        # Fetch FastAPI's OpenAPI schema so the agent can discover any
+        # endpoint without us pre-listing them. Filter by an optional
+        # `filter` keyword (substring match on path or summary).
+        kw = (args.get("filter") or "").lower()
+        try:
+            async with httpx.AsyncClient(timeout=15) as client:
+                resp = await client.get(f"{base}/openapi.json",
+                                        headers=_internal_headers())
+                data = resp.json()
+        except Exception as e:
+            return {"error": f"OpenAPI fetch failed: {e}", "exit_code": 1}
+        rows: List[Dict[str, Any]] = []
+        for path, methods in (data.get("paths") or {}).items():
+            if not isinstance(methods, dict):
+                continue
+            if any(path.startswith(p) for p in _APP_API_BLOCKLIST_PREFIXES):
+                continue
+            for method, op in methods.items():
+                if method.lower() not in ("get", "post", "put", "patch", "delete"):
+                    continue
+                if any(method.upper() == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
+                    continue
+                summary = (op or {}).get("summary") or (op or {}).get("description") or ""
+                if isinstance(summary, str):
+                    summary = summary.strip().split("\n")[0][:140]
+                if kw and kw not in path.lower() and kw not in (summary or "").lower():
+                    continue
+                rows.append({"method": method.upper(), "path": path, "summary": summary})
+        rows.sort(key=lambda r: (r["path"], r["method"]))
+        if not rows:
+            return {"output": f"No endpoints match filter {kw!r}." if kw else "No endpoints found.", "exit_code": 0}
+        lines = [f"{len(rows)} endpoint(s)" + (f" matching {kw!r}" if kw else "") + ":"]
+        for r in rows[:200]:
+            line = f"  {r['method']:6s} {r['path']}"
+            if r["summary"]:
+                line += f"  — {r['summary']}"
+            lines.append(line)
+        if len(rows) > 200:
+            lines.append(f"  ...({len(rows) - 200} more — filter to narrow)")
+        return {"output": "\n".join(lines), "endpoints": rows, "exit_code": 0}
+
+    # action == "call"
+    path = args.get("path") or ""
+    if not path:
+        return {"error": "path is required (e.g. '/api/cookbook/gpus')", "exit_code": 1}
+    if not path.startswith("/"):
+        path = "/" + path
+    if any(path.startswith(p) for p in _APP_API_BLOCKLIST_PREFIXES):
+        return {"error": f"Path blocked for safety: {path}. Sensitive endpoints are off-limits via app_api.", "exit_code": 1}
+
+    method = (args.get("method") or "GET").upper()
+    if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
+        return {"error": f"Unsupported method: {method}", "exit_code": 1}
+    if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
+        if "/api/email/accounts" in path:
+            return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
+        if "/api/cookbook/packages/install" in path:
+            return {"error": "Don't POST /api/cookbook/packages/install via app_api — package installation is host code execution. Use the dedicated Cookbook dependency UI/flow instead.", "exit_code": 1}
+        if "/api/cookbook/rebuild-engine" in path:
+            return {"error": "Don't POST /api/cookbook/rebuild-engine via app_api — engine rebuild mutates local or remote host state. Use the dedicated Cookbook UI/flow instead.", "exit_code": 1}
+        if "/api/cookbook/kill-pid" in path:
+            return {"error": "Don't POST /api/cookbook/kill-pid via app_api — process signalling is host control. Use the dedicated Cookbook stop/diagnostic flow instead.", "exit_code": 1}
+        if "/api/model/download" in path:
+            return {"error": "Don't POST /api/model/download directly — use the `download_model` tool (it resolves the server name, sets the venv env_prefix, and registers the task so it shows in the UI).", "exit_code": 1}
+        if "/api/model/serve" in path:
+            return {"error": "Don't POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking).", "exit_code": 1}
+        if "/api/research/start" in path:
+            return {"error": "Don't POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar).", "exit_code": 1}
+        if "/api/notes" in path:
+            return {"error": "Don't hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ('11pm today', 'tomorrow at 9am'), fires reminders from the due_date itself (no separate calendar event), and uses the caller's timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong.", "exit_code": 1}
+        if "/api/calendar/events" in path:
+            return {"error": "Don't hit /api/calendar/events via app_api — use the `manage_calendar` tool. It handles tz-aware natural-language datetimes and reminder_minutes correctly. If the user wants a note + reminder, prefer `manage_notes` with due_date — it bundles both.", "exit_code": 1}
+        return {"error": f"{method} {path} is blocked — it overwrites the whole cookbook state file. Use list_serve_presets / serve_preset / serve_model instead.", "exit_code": 1}
+
+    body = args.get("body")
+    query = args.get("query") or None
+    # Pass owner so the backend impersonates the user — without this,
+    # POSTs (notes, calendar, todos, ...) get owner="internal-tool"
+    # and the user that asked for them can't see the result.
+    headers = {**_internal_headers(owner=owner), "Content-Type": "application/json"}
+
+    try:
+        async with httpx.AsyncClient(timeout=60) as client:
+            resp = await client.request(
+                method, f"{base}{path}",
+                json=body if body is not None and method in ("POST", "PUT", "PATCH") else None,
+                params=query,
+                headers=headers,
+            )
+        # Try to parse JSON; fall back to raw text.
+        try:
+            payload = resp.json()
+            preview = json.dumps(payload, indent=2, default=str)
+            if len(preview) > 4000:
+                preview = preview[:4000] + "\n... (truncated)"
+        except Exception:
+            payload = None
+            preview = (resp.text or "")[:4000]
+        if resp.status_code >= 400:
+            return {
+                "error": f"{method} {path} -> HTTP {resp.status_code}",
+                "status_code": resp.status_code,
+                "body": preview,
+                "exit_code": 1,
+            }
+        return {
+            "output": f"{method} {path} -> {resp.status_code}\n{preview}",
+            "status_code": resp.status_code,
+            "json": payload,
+            "exit_code": 0,
+        }
+    except Exception as e:
+        return {"error": f"{method} {path} failed: {e}", "exit_code": 1}
diff --git a/src/tools/vault.py b/src/tools/vault.py
new file mode 100644
index 000000000..fbb3bfcf9
--- /dev/null
+++ b/src/tools/vault.py
@@ -0,0 +1,189 @@
+"""Vault-domain tool implementations.
+
+Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
+Holds the Bitwarden CLI wrappers (vault_search / vault_get / vault_unlock)
+and their helpers (_load_vault_config, _run_bw).
+``src.tool_implementations`` re-exports these for backward compatibility.
+"""
+import json
+from typing import Dict, Optional
+
+from src.constants import VAULT_FILE
+from src.tools._common import _parse_tool_args
+
+
+def _load_vault_config() -> Dict:
+    """Load Vaultwarden config from data/vault.json."""
+    from pathlib import Path
+    p = Path(VAULT_FILE)
+    if p.exists():
+        try:
+            return json.loads(p.read_text(encoding="utf-8"))
+        except Exception:
+            pass
+    return {}
+
+
+async def _run_bw(args: list, session: Optional[str] = None, input_text: Optional[str] = None) -> tuple:
+    """Run a bw CLI command with optional session + stdin. Returns (stdout, stderr, returncode)."""
+    import asyncio
+    env = {}
+    import os as _os
+    env.update(_os.environ)
+    if session:
+        env["BW_SESSION"] = session
+
+    proc = await asyncio.create_subprocess_exec(
+        "bw", *args,
+        stdin=asyncio.subprocess.PIPE if input_text else None,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+        env=env,
+    )
+    stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
+    return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
+
+
+async def do_vault_search(content: str, owner: Optional[str] = None) -> Dict:
+    """Search the vault by keyword. Returns matching item names + URLs, NO passwords."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    query = args.get("query", "").strip()
+    if not query:
+        return {"error": "query is required", "exit_code": 1}
+
+    cfg = _load_vault_config()
+    session = cfg.get("session")
+    if not session:
+        return {"error": "Vault is locked. Run vault_unlock or provide session key in settings.", "exit_code": 1}
+
+    stdout, stderr, rc = await _run_bw(["list", "items", "--search", query], session=session)
+    if rc != 0:
+        return {"error": f"bw failed: {stderr[:300]}", "exit_code": 1}
+
+    try:
+        items = json.loads(stdout)
+    except json.JSONDecodeError:
+        return {"error": "Failed to parse bw output", "exit_code": 1}
+
+    if not items:
+        return {"output": f"No vault items match '{query}'.", "exit_code": 0}
+
+    lines = [f"Found {len(items)} item(s) matching '{query}':"]
+    for it in items[:20]:
+        item_id = it.get("id", "?")
+        name = it.get("name", "?")
+        login = it.get("login") or {}
+        username = login.get("username", "")
+        uris = login.get("uris") or []
+        url = uris[0].get("uri", "") if uris else ""
+        parts = [f"[{item_id[:8]}] {name}"]
+        if username:
+            parts.append(f"user: {username}")
+        if url:
+            parts.append(f"url: {url}")
+        lines.append("- " + " · ".join(parts))
+    lines.append("\nUse vault_get(item_id, reason) to retrieve the password.")
+    return {"output": "\n".join(lines), "exit_code": 0}
+
+
+async def do_vault_get(content: str, owner: Optional[str] = None) -> Dict:
+    """Retrieve a full vault entry (including password) by item ID. Logs access to assistant chat."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    item_id = args.get("item_id", "").strip()
+    reason = args.get("reason", "").strip()
+    if not item_id:
+        return {"error": "item_id is required", "exit_code": 1}
+    if not reason:
+        return {"error": "reason is required — explain WHY you need this password", "exit_code": 1}
+
+    cfg = _load_vault_config()
+    session = cfg.get("session")
+    if not session:
+        return {"error": "Vault is locked. Unlock first.", "exit_code": 1}
+
+    stdout, stderr, rc = await _run_bw(["get", "item", item_id], session=session)
+    if rc != 0:
+        return {"error": f"bw failed: {stderr[:300]}", "exit_code": 1}
+
+    try:
+        item = json.loads(stdout)
+    except json.JSONDecodeError:
+        return {"error": "Failed to parse bw output", "exit_code": 1}
+
+    login = item.get("login") or {}
+    name = item.get("name", "?")
+
+    # Audit log to assistant chat
+    try:
+        from src.assistant_log import log_to_assistant
+        if owner:
+            log_to_assistant(
+                owner,
+                f"Retrieved password for **{name}** — reason: {reason}",
+                category="Vault",
+            )
+    except Exception:
+        pass
+
+    output = [
+        f"Vault item: {name}",
+        f"Username: {login.get('username', '(none)')}",
+        f"Password: {login.get('password', '(none)')}",
+    ]
+    if login.get("totp"):
+        output.append(f"TOTP secret: {login['totp']}")
+    uris = login.get("uris") or []
+    if uris:
+        output.append("URLs: " + ", ".join(u.get("uri", "") for u in uris))
+    if item.get("notes"):
+        output.append(f"Notes: {item['notes']}")
+
+    return {"output": "\n".join(output), "exit_code": 0}
+
+
+async def do_vault_unlock(content: str, owner: Optional[str] = None) -> Dict:
+    """Unlock the vault using a master password. Stores the resulting session key."""
+    try:
+        args = _parse_tool_args(content)
+    except ValueError:
+        return {"error": "Invalid JSON arguments", "exit_code": 1}
+    master_password = args.get("master_password", "")
+    if not master_password:
+        return {"error": "master_password is required", "exit_code": 1}
+
+    # Do not pass the master password as an argv element. Local process lists
+    # can expose argv to other users; stdin keeps the secret out of `ps`.
+    stdout, stderr, rc = await _run_bw(["unlock", "--raw"], input_text=master_password + "\n")
+    if rc != 0:
+        return {"error": f"Unlock failed: {stderr[:300]}", "exit_code": 1}
+
+    session = stdout.strip()
+    if not session:
+        return {"error": "bw returned empty session", "exit_code": 1}
+
+    # Save session to vault.json
+    from pathlib import Path
+    p = Path(VAULT_FILE)
+    cfg = {}
+    if p.exists():
+        try:
+            cfg = json.loads(p.read_text(encoding="utf-8"))
+        except Exception:
+            pass
+    cfg["session"] = session
+    from datetime import datetime as _dt
+    cfg["unlocked_at"] = _dt.utcnow().isoformat()
+    p.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
+    try:
+        import os as _os
+        _os.chmod(str(p), 0o600)
+    except Exception:
+        pass
+
+    return {"output": "Vault unlocked. Session saved.", "exit_code": 0}
diff --git a/tests/test_admin_tools_registry.py b/tests/test_admin_tools_registry.py
index 6d50d5601..4bde9ea9e 100644
--- a/tests/test_admin_tools_registry.py
+++ b/tests/test_admin_tools_registry.py
@@ -58,12 +58,17 @@ def test_owner_adapter_defaults_owner_to_none():
 
 
 def test_parse_tool_args_lives_in_tool_utils_single_source():
-    # The helper was de-duplicated into tool_utils; admin_tools imports it
-    # from there rather than carrying its own copy.
+    # The helper was de-duplicated into tool_utils; every consumer imports it
+    # from there rather than carrying its own copy. After the tool_implementations
+    # split, _common and the facade must also re-export the same object.
     from src.tool_utils import _parse_tool_args
     from src.agent_tools import admin_tools, document_tools
+    from src.tools import _common
+    import src.tool_implementations as ti
     assert admin_tools._parse_tool_args is _parse_tool_args
     assert document_tools._parse_tool_args is _parse_tool_args
+    assert _common._parse_tool_args is _parse_tool_args
+    assert ti._parse_tool_args is _parse_tool_args
     assert _parse_tool_args('{"action":"add"}') == {"action": "add"}
     # body-envelope unwrap still works
     assert _parse_tool_args('{"body":{"action":"x"}}') == {"action": "x"}
diff --git a/tests/test_caldav_bidirectional_sync.py b/tests/test_caldav_bidirectional_sync.py
index f83dc450d..0211966af 100644
--- a/tests/test_caldav_bidirectional_sync.py
+++ b/tests/test_caldav_bidirectional_sync.py
@@ -53,7 +53,7 @@ def test_http_calendar_writes_mark_pending_and_push_after_commit():
 
 
 def test_agent_calendar_writes_share_caldav_push_path():
-    source = Path("src/tool_implementations.py").read_text()
+    source = Path("src/tools/calendar.py").read_text()
 
     assert "_push_caldav_event_after_commit" in source
     assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
diff --git a/tests/test_internal_api_base.py b/tests/test_internal_api_base.py
index 83900ad93..6ef3e76d7 100644
--- a/tests/test_internal_api_base.py
+++ b/tests/test_internal_api_base.py
@@ -38,7 +38,7 @@ def test_no_hardcoded_loopback_left_in_call_sites():
     # Regression guard: the converted files must not reintroduce the literal.
     root = pathlib.Path(__file__).resolve().parent.parent
     for rel in (
-        "src/tool_implementations.py",
+        "src/tools/_common.py",
         "src/cookbook_serve_lifecycle.py",
         "src/builtin_actions.py",
         "routes/task_routes.py",
diff --git a/tests/test_tool_implementations_shim.py b/tests/test_tool_implementations_shim.py
new file mode 100644
index 000000000..8180b7a44
--- /dev/null
+++ b/tests/test_tool_implementations_shim.py
@@ -0,0 +1,165 @@
+"""Protection test: the tool_implementations compatibility shim must keep
+re-exporting every symbol importers depend on.
+
+Guards the slice-1 split (tool_implementations.py -> src/tools/*) from
+accidentally dropping a symbol. The contract is enforced by two
+self-verifying tests, not by the hand-maintained list below:
+
+* ``test_shim_reexports_every_domain_do_function`` discovers every ``do_*``
+  from the domain modules and asserts reachability through the shim.
+* ``test_every_facade_import_in_repo_resolves`` discovers every
+  ``from src.tool_implementations import X`` site across first-party Python
+  dirs (src/, tests/, routes/, ...) and asserts ``X`` resolves through the
+  shim.
+
+Both fail automatically if a re-export is forgotten (the do_* discovery
+covers the tool surface; the import-site scan covers underscore helpers a
+reviewer's P3 finding showed could otherwise slip through the list). The
+``_EXPECTED`` list below is the curated historical surface (the original
+module's top-level names), kept as a belt-and-suspenders check and as the
+async-shape contract for ``do_*``; it is not the ground truth.
+"""
+
+import inspect
+
+import src.tool_implementations as ti
+
+# 33 do_* tool functions
+_EXPECTED = [
+    "do_adopt_served_model", "do_api_call", "do_app_api", "do_cancel_download",
+    "do_download_model", "do_edit_image", "do_list_cached_models",
+    "do_list_cookbook_servers", "do_list_downloads", "do_list_served_models",
+    "do_list_serve_presets", "do_manage_calendar", "do_manage_contact",
+    "do_manage_endpoints", "do_manage_mcp", "do_manage_notes",
+    "do_manage_research", "do_manage_settings", "do_manage_skills",
+    "do_manage_tasks", "do_manage_tokens", "do_manage_webhooks",
+    "do_resolve_contact", "do_search_chats", "do_search_hf_models",
+    "do_serve_model", "do_serve_preset", "do_stop_served_model",
+    "do_tail_serve_output", "do_trigger_research", "do_vault_get",
+    "do_vault_search", "do_vault_unlock",
+    # module-private helpers (importable by name too)
+    "_cookbook_apply_retry_suggestion", "_cookbook_env_for_host",
+    "_cookbook_kill_session", "_cookbook_register_task", "_cookbook_servers",
+    "_ensure_served_endpoint", "_infer_serve_host", "_infer_serve_port",
+    "_internal_headers", "_load_vault_config", "_mcp_allowed_commands",
+    "_parse_tool_args", "_resolve_cookbook_host", "_run_bw",
+    "_scan_running_model_processes", "_skill_dump", "_string_arg",
+    "_validate_cookbook_ssh_target",
+    # active-email facade helpers (no do_* prefix); consumed by
+    # routes/chat_routes.py — listed here because get_active_email has no
+    # in-repo importer, so the import-site scan below can't see it alone.
+    "set_active_email", "get_active_email", "clear_active_email",
+]
+
+
+def test_shim_reexports_all_top_level_symbols():
+    """Every original top-level function must remain importable via the module."""
+    missing = [name for name in _EXPECTED if not hasattr(ti, name)]
+    assert not missing, f"shim dropped symbols: {missing}"
+
+
+def test_do_functions_remain_async_through_shim():
+    """Every do_* must remain a coroutine function through the shim."""
+    for name in _EXPECTED:
+        if name.startswith("do_"):
+            obj = getattr(ti, name)
+            assert inspect.iscoroutinefunction(obj), (
+                f"{name} is not async via shim (got {type(obj).__name__})"
+            )
+
+
+# Domain modules that own tool implementations after the slice-1 split.
+# The shim must re-export every public do_* from each so existing
+# `from src.tool_implementations import do_X` imports keep resolving.
+_DOMAIN_MODULES = (
+    "src.tools.system",
+    "src.tools.cookbook",
+    "src.tools.search",
+    "src.tools.notes",
+    "src.tools.calendar",
+    "src.tools.image",
+    "src.tools.research",
+    "src.tools.contacts",
+    "src.tools.vault",
+    "src.agent_tools.admin_tools",  # admin manage_* tools migrated here (#3629)
+)
+
+
+def test_shim_reexports_every_domain_do_function():
+    """Auto-discovered guard: every do_* defined in a domain module must be
+    reachable through the shim.
+
+    The hand-maintained ``_EXPECTED`` list above can drift silently when a
+    new tool is added to a domain module but not re-exported by the facade
+    (exactly the omission a reviewer found post-split). This test discovers
+    the ground truth from the domain modules themselves, so a forgotten
+    re-export fails the build automatically. ``hasattr`` is used (not
+    ``dir(ti)``) because the admin symbols are re-exported lazily via
+    module ``__getattr__`` and therefore do not appear in ``dir(ti)``.
+    """
+    import importlib
+
+    dropped = []
+    for mod_name in _DOMAIN_MODULES:
+        mod = importlib.import_module(mod_name)
+        for name in dir(mod):
+            if not name.startswith("do_"):
+                continue
+            if not inspect.iscoroutinefunction(getattr(mod, name, None)):
+                continue
+            if not hasattr(ti, name):
+                dropped.append(f"{mod_name}.{name}")
+    assert not dropped, f"shim dropped domain do_* (re-export forgotten): {dropped}"
+
+
+def test_every_facade_import_in_repo_resolves():
+    """Every ``from src.tool_implementations import X`` in any first-party
+    Python dir (src/, tests/, routes/, ...) must resolve through the shim.
+
+    This makes the module-docstring contract ("existing ``from
+    src.tool_implementations import X`` imports keep working") self-verifying
+    instead of reliant on the hand-maintained ``_EXPECTED`` list, which
+    omitted three underscore helpers in a reviewer's P3 finding and can drift
+    again. The import sites are enumerated with ``ast`` rather than checked
+    at runtime because the invariant is *which names the rest of the
+    codebase asks the facade for* — no runtime hook enumerates that set,
+    only the import statements do (the narrow source-scanning exception to
+    the behavioral-first rule). The per-name assertion is runtime
+    (``hasattr``), so any forgotten re-export — helper or ``do_*`` — fails
+    here automatically.
+    """
+    import ast
+    import os
+    from pathlib import Path
+
+    repo = Path(__file__).resolve().parents[1]
+    # Walk every first-party Python dir so route-level (and any future)
+    # facade consumers are covered, not just src/ and tests/. Prune
+    # non-source trees (venvs, caches, data, build artifacts) in-place.
+    _SKIP_DIRS = {
+        "__pycache__", "venv", "node_modules", "data", "logs",
+        "odysseus.egg-info", "static", "specs", "licenses", "docker",
+    }
+    names = set()
+    for root, _dirs, files in os.walk(repo):
+        _dirs[:] = [d for d in _dirs if not (d.startswith(".") or d in _SKIP_DIRS)]
+        for fn in files:
+            if not fn.endswith(".py"):
+                continue
+            path = Path(root) / fn
+            text = path.read_text(encoding="utf-8")
+            if "src.tool_implementations" not in text:
+                continue
+            try:
+                tree = ast.parse(text, filename=str(path))
+            except SyntaxError:
+                continue  # unrelated to the facade contract
+            for node in ast.walk(tree):
+                if isinstance(node, ast.ImportFrom) and node.module == "src.tool_implementations":
+                    for alias in node.names:
+                        if alias.name != "*":
+                            names.add(alias.name)
+    unresolved = sorted(n for n in names if not hasattr(ti, n))
+    assert not unresolved, (
+        f"facade consumers import names the shim does not re-export: {unresolved}"
+    )
diff --git a/tests/test_vault_password_not_in_argv.py b/tests/test_vault_password_not_in_argv.py
index 32267a925..f23cddcd8 100644
--- a/tests/test_vault_password_not_in_argv.py
+++ b/tests/test_vault_password_not_in_argv.py
@@ -102,7 +102,7 @@ def test_unlock_handler_feeds_password_on_stdin_not_argv():
 
 
 def test_tool_vault_unlock_feeds_password_on_stdin_not_argv():
-    text = open("src/tool_implementations.py", encoding="utf-8").read()
+    text = open("src/tools/vault.py", encoding="utf-8").read()
 
     assert '["unlock", master_password, "--raw"]' not in text
     assert '_run_bw(["unlock", master_password' not in text