agent-dispatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """TTL-based in-memory cache for dispatch results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import threading
9
+ import time
10
+
11
+ from .models import DispatchResult
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class DispatchCache:
17
+ """Thread-safe TTL cache for dispatch results.
18
+
19
+ Keyed on (agent, task, context) — identical requests within the TTL
20
+ window return the cached result without spawning a new subprocess.
21
+ """
22
+
23
+ def __init__(self, ttl: int = 300) -> None:
24
+ self._ttl = ttl
25
+ self._store: dict[str, tuple[float, DispatchResult]] = {}
26
+ self._lock = threading.Lock()
27
+ self._hits = 0
28
+ self._misses = 0
29
+
30
+ @staticmethod
31
+ def _make_key(agent: str, task: str, context: str | None) -> str:
32
+ canonical = json.dumps(
33
+ {"agent": agent, "task": task, "context": context or ""},
34
+ sort_keys=True,
35
+ )
36
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
37
+
38
+ def get(self, agent: str, task: str, context: str | None = None) -> DispatchResult | None:
39
+ key = self._make_key(agent, task, context)
40
+ with self._lock:
41
+ entry = self._store.get(key)
42
+ if entry is None:
43
+ self._misses += 1
44
+ return None
45
+ ts, result = entry
46
+ if time.monotonic() - ts > self._ttl:
47
+ del self._store[key]
48
+ self._misses += 1
49
+ return None
50
+ self._hits += 1
51
+ return result
52
+
53
+ def put(
54
+ self,
55
+ agent: str,
56
+ task: str,
57
+ result: DispatchResult,
58
+ context: str | None = None,
59
+ ) -> None:
60
+ if not result.success:
61
+ return # don't cache failures
62
+ key = self._make_key(agent, task, context)
63
+ with self._lock:
64
+ self._store[key] = (time.monotonic(), result)
65
+
66
+ def clear(self) -> int:
67
+ with self._lock:
68
+ count = len(self._store)
69
+ self._store.clear()
70
+ self._hits = 0
71
+ self._misses = 0
72
+ return count
73
+
74
+ def evict_expired(self) -> int:
75
+ now = time.monotonic()
76
+ with self._lock:
77
+ expired = [k for k, (ts, _) in self._store.items() if now - ts > self._ttl]
78
+ for k in expired:
79
+ del self._store[k]
80
+ return len(expired)
81
+
82
+ def stats(self) -> dict:
83
+ with self._lock:
84
+ total = self._hits + self._misses
85
+ return {
86
+ "size": len(self._store),
87
+ "hits": self._hits,
88
+ "misses": self._misses,
89
+ "hit_rate": round(self._hits / total, 3) if total else 0.0,
90
+ "ttl": self._ttl,
91
+ }
agent_dispatch/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """CLI: init, add, remove, list, test, serve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from .config import auto_describe, config_path, load_config, save_config
13
+ from .models import AgentConfig, DispatchConfig, validate_agent_name
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(package_name="agent-dispatch")
18
+ def cli() -> None:
19
+ """Delegate tasks between Claude Code agents across projects."""
20
+
21
+
22
+ @cli.command()
23
+ def init() -> None:
24
+ """Create config file and register MCP server with Claude Code."""
25
+ cp = config_path()
26
+
27
+ # Create config with example
28
+ if cp.exists():
29
+ click.echo(f"Config already exists: {cp}")
30
+ else:
31
+ cp.parent.mkdir(parents=True, exist_ok=True)
32
+ example = DispatchConfig()
33
+ save_config(example, cp)
34
+ click.echo(f"Created config: {cp}")
35
+
36
+ # Register MCP server with Claude Code
37
+ if shutil.which("claude") is None:
38
+ click.echo("Warning: claude CLI not found. Register MCP server manually.")
39
+ return
40
+
41
+ agent_dispatch_cmd = shutil.which("agent-dispatch")
42
+ if agent_dispatch_cmd is None:
43
+ click.echo(
44
+ "Warning: agent-dispatch not found in PATH. "
45
+ "Run 'pip install -e .' first, then re-run 'agent-dispatch init'."
46
+ )
47
+ return
48
+
49
+ mcp_config = json.dumps({
50
+ "type": "stdio",
51
+ "command": agent_dispatch_cmd,
52
+ "args": ["serve"],
53
+ })
54
+
55
+ result = subprocess.run(
56
+ ["claude", "mcp", "add-json", "agent-dispatch", mcp_config, "--scope", "user"],
57
+ capture_output=True,
58
+ text=True,
59
+ )
60
+
61
+ if result.returncode == 0:
62
+ click.echo("Registered MCP server with Claude Code (user scope).")
63
+ click.echo("\nNext steps:")
64
+ click.echo(" agent-dispatch add <name> <directory> # add your first agent")
65
+ click.echo(" agent-dispatch list # verify agents")
66
+ click.echo(" agent-dispatch test <name> # test it works")
67
+ else:
68
+ click.echo(f"Failed to register MCP server: {result.stderr.strip()}")
69
+ click.echo("You can register manually in ~/.claude/settings.json:")
70
+ click.echo(f' "mcpServers": {{ "agent-dispatch": {mcp_config} }}')
71
+
72
+
73
+ @cli.command()
74
+ @click.argument("name")
75
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False, resolve_path=True))
76
+ @click.option("-d", "--description", default=None, help="Agent description. Auto-generated if omitted.")
77
+ @click.option("--timeout", default=300, help="Timeout in seconds (default: 300).")
78
+ @click.option("--model", default=None, help="Model override for this agent.")
79
+ def add(
80
+ name: str,
81
+ directory: str,
82
+ description: str | None,
83
+ timeout: int,
84
+ model: str | None,
85
+ ) -> None:
86
+ """Add an agent. Auto-generates description from project files if omitted."""
87
+ try:
88
+ validate_agent_name(name)
89
+ except ValueError as e:
90
+ click.echo(f"Error: {e}")
91
+ raise SystemExit(1)
92
+
93
+ config = load_config()
94
+ dir_path = Path(directory).resolve()
95
+
96
+ if name in config.agents:
97
+ click.echo(f"Agent '{name}' already exists. Use 'agent-dispatch remove {name}' first.")
98
+ raise SystemExit(1)
99
+
100
+ if description is None:
101
+ description = auto_describe(dir_path)
102
+ click.echo(f"Auto-generated description: {description}")
103
+
104
+ config.agents[name] = AgentConfig(
105
+ directory=dir_path,
106
+ description=description,
107
+ timeout=timeout,
108
+ model=model,
109
+ )
110
+ save_config(config)
111
+ click.echo(f"Added agent '{name}' -> {dir_path}")
112
+
113
+
114
+ @cli.command()
115
+ @click.argument("name")
116
+ def remove(name: str) -> None:
117
+ """Remove an agent."""
118
+ config = load_config()
119
+ if name not in config.agents:
120
+ click.echo(f"Agent '{name}' not found.")
121
+ raise SystemExit(1)
122
+
123
+ del config.agents[name]
124
+ save_config(config)
125
+ click.echo(f"Removed agent '{name}'.")
126
+
127
+
128
+ @cli.command("list")
129
+ def list_agents() -> None:
130
+ """List configured agents with health status."""
131
+ config = load_config()
132
+ if not config.agents:
133
+ click.echo("No agents configured. Run: agent-dispatch add <name> <directory>")
134
+ return
135
+
136
+ for name, agent in config.agents.items():
137
+ healthy = agent.directory.is_dir()
138
+ status = click.style("OK", fg="green") if healthy else click.style("NOT FOUND", fg="red")
139
+ click.echo(f" {name} [{status}]")
140
+ click.echo(f" dir: {agent.directory}")
141
+ click.echo(f" desc: {agent.description}")
142
+ click.echo()
143
+
144
+
145
+ @cli.command()
146
+ @click.argument("name")
147
+ @click.argument("task", default="What project is this? Describe in one sentence.")
148
+ def test(name: str, task: str) -> None:
149
+ """Test an agent by dispatching a task."""
150
+ config = load_config()
151
+ if name not in config.agents:
152
+ click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
153
+ raise SystemExit(1)
154
+
155
+ agent = config.agents[name]
156
+ click.echo(f"Dispatching to '{name}' ({agent.directory})...")
157
+ click.echo(f"Task: {task}")
158
+ click.echo("---")
159
+
160
+ from .runner import dispatch
161
+
162
+ result = dispatch(name, task, agent, config.settings)
163
+
164
+ if result.success:
165
+ click.echo(result.result)
166
+ if result.cost_usd is not None:
167
+ click.echo(f"\n--- Cost: ${result.cost_usd:.4f} | Turns: {result.num_turns}")
168
+ else:
169
+ click.echo(click.style(f"Error: {result.error}", fg="red"))
170
+ raise SystemExit(1)
171
+
172
+
173
+ @cli.command()
174
+ def serve() -> None:
175
+ """Start the MCP server (stdio transport)."""
176
+ from .server import main
177
+
178
+ main()
@@ -0,0 +1,153 @@
1
+ """Configuration loading and saving."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from .models import DispatchConfig
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "agent-dispatch"
17
+ DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "agents.yaml"
18
+
19
+
20
+ def config_path() -> Path:
21
+ """Return config path, respecting AGENT_DISPATCH_CONFIG env var."""
22
+ return Path(os.environ.get("AGENT_DISPATCH_CONFIG", str(DEFAULT_CONFIG_PATH)))
23
+
24
+
25
+ def load_config(path: Path | None = None) -> DispatchConfig:
26
+ """Load config from YAML file. Returns empty config if file missing."""
27
+ p = path or config_path()
28
+ if not p.exists():
29
+ return DispatchConfig()
30
+ raw = yaml.safe_load(p.read_text(encoding="utf-8"))
31
+ if raw is None:
32
+ return DispatchConfig()
33
+ return DispatchConfig.model_validate(raw)
34
+
35
+
36
+ def save_config(config: DispatchConfig, path: Path | None = None) -> None:
37
+ """Save config to YAML file."""
38
+ p = path or config_path()
39
+ p.parent.mkdir(parents=True, exist_ok=True)
40
+ data = config.model_dump(mode="json", exclude_none=True)
41
+ p.write_text(
42
+ yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
43
+ encoding="utf-8",
44
+ )
45
+
46
+
47
+ def _collect_mcp_servers(directory: Path) -> list[str]:
48
+ """Collect MCP server names from all known config locations."""
49
+ servers: list[str] = []
50
+ for path in (
51
+ directory / ".mcp.json",
52
+ directory / ".claude" / "settings.local.json",
53
+ ):
54
+ if path.exists():
55
+ try:
56
+ data = json.loads(path.read_text(encoding="utf-8"))
57
+ servers.extend(data.get("mcpServers", {}).keys())
58
+ except (json.JSONDecodeError, KeyError):
59
+ pass
60
+ return list(dict.fromkeys(servers)) # deduplicate, preserve order
61
+
62
+
63
+ def auto_describe(directory: Path) -> str:
64
+ """Generate agent description by reading project files.
65
+
66
+ Produces a string like:
67
+ MCP server for cross-project agent delegation | MCP: portainer, postgres | Python, Docker
68
+ """
69
+ parts: list[str] = []
70
+
71
+ # CLAUDE.md — first meaningful lines (up to 2 sentences)
72
+ claude_md = directory / "CLAUDE.md"
73
+ if claude_md.exists():
74
+ sentences: list[str] = []
75
+ for line in claude_md.read_text(encoding="utf-8").strip().splitlines()[:40]:
76
+ stripped = line.strip()
77
+ if stripped and not stripped.startswith("#") and not stripped.startswith("--"):
78
+ sentences.append(stripped)
79
+ if len(sentences) >= 2:
80
+ break
81
+ if sentences:
82
+ parts.append(" ".join(sentences))
83
+
84
+ # README.md — fallback if no CLAUDE.md description
85
+ if not parts:
86
+ readme = directory / "README.md"
87
+ if readme.exists():
88
+ for line in readme.read_text(encoding="utf-8").strip().splitlines()[:20]:
89
+ stripped = line.strip()
90
+ if (
91
+ stripped
92
+ and not stripped.startswith("#")
93
+ and not stripped.startswith("[")
94
+ and not stripped.startswith("!")
95
+ and len(stripped) > 20
96
+ ):
97
+ parts.append(stripped)
98
+ break
99
+
100
+ # pyproject.toml — project description
101
+ pyproject = directory / "pyproject.toml"
102
+ if pyproject.exists():
103
+ for line in pyproject.read_text(encoding="utf-8").splitlines():
104
+ if line.strip().startswith("description"):
105
+ desc = line.split("=", 1)[1].strip().strip('"').strip("'")
106
+ if desc:
107
+ parts.append(desc)
108
+ break
109
+
110
+ # package.json — project description
111
+ pkg_json = directory / "package.json"
112
+ if pkg_json.exists():
113
+ try:
114
+ pkg = json.loads(pkg_json.read_text(encoding="utf-8"))
115
+ if pkg.get("description"):
116
+ parts.append(pkg["description"])
117
+ except (json.JSONDecodeError, KeyError):
118
+ pass
119
+
120
+ # MCP servers — critical for understanding what tools this agent has
121
+ servers = _collect_mcp_servers(directory)
122
+ if servers:
123
+ parts.append(f"MCP: {', '.join(servers)}")
124
+
125
+ # Stack indicators
126
+ indicators = []
127
+ if (directory / "Dockerfile").exists():
128
+ indicators.append("Docker")
129
+ if (directory / "docker-compose.yaml").exists() or (directory / "docker-compose.yml").exists():
130
+ indicators.append("Docker Compose")
131
+ if (directory / "Cargo.toml").exists():
132
+ indicators.append("Rust")
133
+ if (directory / "go.mod").exists():
134
+ indicators.append("Go")
135
+ if (directory / "requirements.txt").exists() or pyproject.exists():
136
+ indicators.append("Python")
137
+ if pkg_json.exists():
138
+ indicators.append("Node.js")
139
+ if indicators:
140
+ parts.append(f"Stack: {', '.join(indicators)}")
141
+
142
+ # Database indicators
143
+ db_indicators = []
144
+ if (directory / "prisma").is_dir() or (directory / "schema.prisma").exists():
145
+ db_indicators.append("Prisma")
146
+ if (directory / "alembic").is_dir() or (directory / "alembic.ini").exists():
147
+ db_indicators.append("Alembic")
148
+ if (directory / "migrations").is_dir():
149
+ db_indicators.append("migrations")
150
+ if db_indicators:
151
+ parts.append(f"DB: {', '.join(db_indicators)}")
152
+
153
+ return " | ".join(parts) if parts else f"Agent in {directory.name}"
@@ -0,0 +1,84 @@
1
+ """Data models for agent-dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+ _AGENT_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"
10
+
11
+
12
+ class AgentConfig(BaseModel):
13
+ """Configuration for a single agent."""
14
+
15
+ directory: Path
16
+ description: str = ""
17
+ timeout: int = 300
18
+ max_budget_usd: float | None = None
19
+ model: str | None = None
20
+ permission_mode: str | None = None
21
+ allowed_tools: list[str] = Field(default_factory=list)
22
+ disallowed_tools: list[str] = Field(default_factory=list)
23
+
24
+ @field_validator("directory", mode="before")
25
+ @classmethod
26
+ def expand_home(cls, v: str | Path) -> Path:
27
+ return Path(v).expanduser().resolve()
28
+
29
+
30
+ class CacheSettings(BaseModel):
31
+ """Cache configuration."""
32
+
33
+ enabled: bool = True
34
+ ttl: int = Field(default=300, ge=0) # seconds; 0 effectively disables
35
+
36
+ @field_validator("ttl", mode="after")
37
+ @classmethod
38
+ def warn_zero_ttl(cls, v: int) -> int:
39
+ # ttl=0 is valid (entries expire immediately) but likely a mistake.
40
+ # Let it through — cache.put() will store, cache.get() will evict.
41
+ return v
42
+
43
+
44
+ class Settings(BaseModel):
45
+ """Global settings for agent-dispatch."""
46
+
47
+ default_timeout: int = 300
48
+ default_max_budget_usd: float | None = None
49
+ max_dispatch_depth: int = Field(default=3, ge=1)
50
+ max_concurrency: int = Field(default=5, ge=1)
51
+ cache: CacheSettings = Field(default_factory=CacheSettings)
52
+
53
+
54
+ def validate_agent_name(name: str) -> str:
55
+ """Validate agent name: alphanumeric, hyphens, underscores, no leading special chars."""
56
+ import re
57
+
58
+ if not re.match(_AGENT_NAME_PATTERN, name):
59
+ raise ValueError(
60
+ f"Invalid agent name: {name!r}. "
61
+ "Use only letters, digits, hyphens, and underscores. "
62
+ "Must start with a letter or digit."
63
+ )
64
+ return name
65
+
66
+
67
+ class DispatchConfig(BaseModel):
68
+ """Top-level config: agents + settings."""
69
+
70
+ agents: dict[str, AgentConfig] = Field(default_factory=dict)
71
+ settings: Settings = Field(default_factory=Settings)
72
+
73
+
74
+ class DispatchResult(BaseModel):
75
+ """Result of a dispatch call."""
76
+
77
+ agent: str
78
+ success: bool
79
+ result: str
80
+ session_id: str | None = None
81
+ cost_usd: float | None = None
82
+ duration_ms: int | None = None
83
+ num_turns: int | None = None
84
+ error: str | None = None