agent-dispatch 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (28) hide show
  1. agent_dispatch-0.3.0/CHANGELOG.md +98 -0
  2. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/PKG-INFO +7 -2
  3. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/README.md +6 -1
  4. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/pyproject.toml +1 -1
  5. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/__init__.py +1 -1
  6. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/cache.py +31 -7
  7. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/cli.py +195 -8
  8. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/server.py +27 -11
  9. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_cache.py +42 -0
  10. agent_dispatch-0.3.0/tests/test_cli.py +750 -0
  11. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_server.py +126 -0
  12. agent_dispatch-0.2.1/tests/test_cli.py +0 -381
  13. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/dependabot.yml +0 -0
  14. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/workflows/ci.yml +0 -0
  15. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.github/workflows/publish.yml +0 -0
  16. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/.gitignore +0 -0
  17. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/LICENSE +0 -0
  18. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/SECURITY.md +0 -0
  19. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/agents.example.yaml +0 -0
  20. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/assets/mascot.png +0 -0
  21. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/config.py +0 -0
  22. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/models.py +0 -0
  23. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/src/agent_dispatch/runner.py +0 -0
  24. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/__init__.py +0 -0
  25. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/conftest.py +0 -0
  26. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_config.py +0 -0
  27. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_models.py +0 -0
  28. {agent_dispatch-0.2.1 → agent_dispatch-0.3.0}/tests/test_runner.py +0 -0
@@ -0,0 +1,98 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-05-08
11
+
12
+ ### Added
13
+ - `agent-dispatch doctor` CLI command — diagnoses installation issues:
14
+ checks `claude` CLI on PATH, `agent-dispatch` on PATH, config validity,
15
+ MCP registration with Claude Code, and per-agent directory health.
16
+ Exits non-zero if any blocking issue is found.
17
+ - `agent-dispatch describe <name>` CLI command — show one agent's full
18
+ configuration: directory, description, timeout, model, budget, permission
19
+ mode, tri-state tool fields (`(inherit defaults)` vs `(none — explicit
20
+ override)` vs explicit list), and which project files would be inherited.
21
+ - `--stream` flag for `agent-dispatch test` — surfaces live progress
22
+ (assistant text + tool use) while the agent works, useful for long
23
+ tasks where you'd otherwise see nothing until completion.
24
+
25
+ ### Fixed
26
+ - `list_agents` MCP tool no longer crashes the entire response when one
27
+ agent's directory is unreadable (`PermissionError`, network FS hiccup,
28
+ etc.). The bad agent now reports `healthy: "UNREADABLE"` and the rest
29
+ of the listing succeeds — matching the documented response shape.
30
+ - Dispatch cache key now includes `caller` and `goal`. Previously two
31
+ requests with the same `(agent, task, context)` but different framing
32
+ (e.g. `caller="frontend"` vs `caller="backend"`) would collide and the
33
+ second request would receive the cached response from the first — even
34
+ though the structured prompt sent to Claude is materially different.
35
+
36
+ ## [0.2.2] - 2026-04-17
37
+
38
+ ### Fixed
39
+ - `agent-dispatch list` now distinguishes `allowed_tools: None` (inherit
40
+ from settings defaults) from `allowed_tools: []` (explicitly no tools).
41
+ Previously both were rendered identically.
42
+
43
+ ## [0.2.1] - 2026-04-17
44
+
45
+ ### Fixed
46
+ - 13 bugs across the runner, server, CLI, config, and models:
47
+ - Runner: defensive coercion in `_classify_error` for non-string inputs;
48
+ fallback messages when `is_error=True` produces empty `result`;
49
+ correct error_type classification on plain-text stdout fallbacks;
50
+ orphan subprocess cleanup on stream exit paths.
51
+ - Server: up-front validation in `dispatch_parallel` (rejects bad items
52
+ before any dispatch runs); `dispatch_dialogue` surfaces per-turn errors;
53
+ `cache_stats` evicts expired entries before reporting.
54
+ - CLI: friendly error messages on malformed YAML / invalid schema;
55
+ `list` handles `OSError` from unreachable directories;
56
+ sentinel patterns for `update` to clear fields (`"none"` / `""`).
57
+ - Config: deduplication when collecting MCP servers from multiple paths.
58
+ - Models: tighter validation bounds (`ge=0`, `ge=1`).
59
+
60
+ ## [0.2.0] - 2026-04-16
61
+
62
+ ### Added
63
+ - Error classification — `DispatchResult.error_type` now reports
64
+ `permission`, `timeout`, `recursion`, `not_found`, or `cli_error`.
65
+ Permission errors include an actionable hint with suggested fixes.
66
+ - Permission management — agents and global settings support
67
+ `permission_mode`, `allowed_tools`, and `disallowed_tools`. Tool lists
68
+ use tri-state semantics: `None` inherits from defaults, `[]` overrides
69
+ to "no tools", a list specifies the allowed/disallowed set.
70
+ - `update_agent` MCP tool — modify an existing agent's configuration
71
+ without remove + re-add. CLI parity via `agent-dispatch update`.
72
+ - CLI tests for `init` and `test` commands.
73
+
74
+ ## [0.1.0] - 2026-04-10
75
+
76
+ ### Added
77
+ - Initial release.
78
+ - 11 MCP tools: `list_agents`, `add_agent`, `remove_agent`, `dispatch`,
79
+ `dispatch_session`, `dispatch_parallel` (with optional aggregation),
80
+ `dispatch_stream`, `dispatch_dialogue`, `cache_stats`, `cache_clear`.
81
+ - CLI: `init`, `add`, `remove`, `list`, `test`, `serve`.
82
+ - Recursion protection via `AGENT_DISPATCH_DEPTH` env var.
83
+ - In-memory TTL cache (thread-safe).
84
+ - Concurrency control via `asyncio.Semaphore` (default: 5 parallel
85
+ `claude -p` processes).
86
+ - Auto-description from `CLAUDE.md`, `README.md`, `pyproject.toml`,
87
+ `package.json`, `.mcp.json`, and stack/DB indicators.
88
+ - PyPI publishing via Trusted Publisher (OIDC).
89
+ - CI matrix on Python 3.10, 3.11, 3.12, 3.13.
90
+ - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
91
+ commit SHAs for supply-chain integrity.
92
+
93
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...HEAD
94
+ [0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
95
+ [0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
96
+ [0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
97
+ [0.2.0]: https://github.com/ginkida/agent-dispatch/compare/v0.1.0...v0.2.0
98
+ [0.1.0]: https://github.com/ginkida/agent-dispatch/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
5
5
  Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
6
6
  Project-URL: Repository, https://github.com/ginkida/agent-dispatch
@@ -62,6 +62,9 @@ agent-dispatch test infra
62
62
 
63
63
  # If agents hit permission errors, grant tool access:
64
64
  agent-dispatch update infra --permission-mode bypassPermissions
65
+
66
+ # If something doesn't work, run the diagnostic:
67
+ agent-dispatch doctor
65
68
  ```
66
69
 
67
70
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -389,7 +392,9 @@ agent-dispatch MCP server
389
392
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
390
393
  | `agent-dispatch remove <name>` | Remove an agent |
391
394
  | `agent-dispatch list` | List agents with health status and permissions |
392
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
395
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
396
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
397
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
393
398
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
394
399
 
395
400
  ## Requirements
@@ -32,6 +32,9 @@ agent-dispatch test infra
32
32
 
33
33
  # If agents hit permission errors, grant tool access:
34
34
  agent-dispatch update infra --permission-mode bypassPermissions
35
+
36
+ # If something doesn't work, run the diagnostic:
37
+ agent-dispatch doctor
35
38
  ```
36
39
 
37
40
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -359,7 +362,9 @@ agent-dispatch MCP server
359
362
  | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
360
363
  | `agent-dispatch remove <name>` | Remove an agent |
361
364
  | `agent-dispatch list` | List agents with health status and permissions |
362
- | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
365
+ | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
366
+ | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
367
+ | `agent-dispatch doctor` | Diagnose installation: claude CLI, MCP registration, agent health |
363
368
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
364
369
 
365
370
  ## Requirements
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
@@ -16,8 +16,11 @@ logger = logging.getLogger(__name__)
16
16
  class DispatchCache:
17
17
  """Thread-safe TTL cache for dispatch results.
18
18
 
19
- Keyed on (agent, task, context) — identical requests within the TTL
20
- window return the cached result without spawning a new subprocess.
19
+ Keyed on (agent, task, context, caller, goal) — identical requests within
20
+ the TTL window return the cached result without spawning a new subprocess.
21
+ `caller` and `goal` affect the prompt sent to the agent, so they must be
22
+ part of the key: otherwise two requests with different framing would
23
+ collide and return the wrong response.
21
24
  """
22
25
 
23
26
  def __init__(self, ttl: int = 300) -> None:
@@ -28,15 +31,34 @@ class DispatchCache:
28
31
  self._misses = 0
29
32
 
30
33
  @staticmethod
31
- def _make_key(agent: str, task: str, context: str | None) -> str:
34
+ def _make_key(
35
+ agent: str,
36
+ task: str,
37
+ context: str | None,
38
+ caller: str | None = None,
39
+ goal: str | None = None,
40
+ ) -> str:
32
41
  canonical = json.dumps(
33
- {"agent": agent, "task": task, "context": context or ""},
42
+ {
43
+ "agent": agent,
44
+ "task": task,
45
+ "context": context or "",
46
+ "caller": caller or "",
47
+ "goal": goal or "",
48
+ },
34
49
  sort_keys=True,
35
50
  )
36
51
  return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
37
52
 
38
- def get(self, agent: str, task: str, context: str | None = None) -> DispatchResult | None:
39
- key = self._make_key(agent, task, context)
53
+ def get(
54
+ self,
55
+ agent: str,
56
+ task: str,
57
+ context: str | None = None,
58
+ caller: str | None = None,
59
+ goal: str | None = None,
60
+ ) -> DispatchResult | None:
61
+ key = self._make_key(agent, task, context, caller, goal)
40
62
  with self._lock:
41
63
  entry = self._store.get(key)
42
64
  if entry is None:
@@ -56,10 +78,12 @@ class DispatchCache:
56
78
  task: str,
57
79
  result: DispatchResult,
58
80
  context: str | None = None,
81
+ caller: str | None = None,
82
+ goal: str | None = None,
59
83
  ) -> None:
60
84
  if not result.success:
61
85
  return # don't cache failures
62
- key = self._make_key(agent, task, context)
86
+ key = self._make_key(agent, task, context, caller, goal)
63
87
  with self._lock:
64
88
  self._store[key] = (time.monotonic(), result)
65
89
 
@@ -1,8 +1,9 @@
1
- """CLI: init, add, remove, list, test, serve."""
1
+ """CLI: init, add, remove, list, update, test, describe, doctor, serve."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
  from pathlib import Path
@@ -202,10 +203,12 @@ def list_agents() -> None:
202
203
  click.echo(f" config: {', '.join(extras)}")
203
204
  if agent.permission_mode:
204
205
  click.echo(f" permission_mode: {agent.permission_mode}")
205
- if agent.allowed_tools:
206
- click.echo(f" allowed_tools: {', '.join(agent.allowed_tools)}")
207
- if agent.disallowed_tools:
208
- click.echo(f" disallowed_tools: {', '.join(agent.disallowed_tools)}")
206
+ if agent.allowed_tools is not None:
207
+ rendered = ", ".join(agent.allowed_tools) if agent.allowed_tools else "(none)"
208
+ click.echo(f" allowed_tools: {rendered}")
209
+ if agent.disallowed_tools is not None:
210
+ rendered = ", ".join(agent.disallowed_tools) if agent.disallowed_tools else "(none)"
211
+ click.echo(f" disallowed_tools: {rendered}")
209
212
  click.echo()
210
213
 
211
214
 
@@ -293,7 +296,11 @@ def update(
293
296
  @cli.command()
294
297
  @click.argument("name")
295
298
  @click.argument("task", default="What project is this? Describe in one sentence.")
296
- def test(name: str, task: str) -> None:
299
+ @click.option(
300
+ "--stream", "stream", is_flag=True,
301
+ help="Show live progress (assistant text + tool use) while the agent works.",
302
+ )
303
+ def test(name: str, task: str, stream: bool) -> None:
297
304
  """Test an agent by dispatching a task."""
298
305
  config = _load_or_exit()
299
306
  if name not in config.agents:
@@ -305,9 +312,18 @@ def test(name: str, task: str) -> None:
305
312
  click.echo(f"Task: {task}")
306
313
  click.echo("---")
307
314
 
308
- from .runner import dispatch
315
+ if stream:
316
+ from .runner import dispatch_stream
317
+
318
+ def _on_progress(msg: str) -> None:
319
+ click.echo(click.style(f" -> {msg}", fg="cyan"), err=True)
309
320
 
310
- result = dispatch(name, task, agent, config.settings)
321
+ result = dispatch_stream(
322
+ name, task, agent, config.settings, on_progress=_on_progress,
323
+ )
324
+ else:
325
+ from .runner import dispatch
326
+ result = dispatch(name, task, agent, config.settings)
311
327
 
312
328
  if result.success:
313
329
  click.echo(result.result)
@@ -328,6 +344,177 @@ def test(name: str, task: str) -> None:
328
344
  raise SystemExit(1)
329
345
 
330
346
 
347
+ @cli.command()
348
+ @click.argument("name")
349
+ def describe(name: str) -> None:
350
+ """Show full configuration for a single agent."""
351
+ config = _load_or_exit()
352
+ if name not in config.agents:
353
+ click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
354
+ raise SystemExit(1)
355
+
356
+ agent = config.agents[name]
357
+ try:
358
+ if agent.directory.is_dir():
359
+ status_label, status_color = "OK", "green"
360
+ else:
361
+ status_label, status_color = "NOT FOUND", "red"
362
+ except OSError:
363
+ status_label, status_color = "UNREADABLE", "red"
364
+ status = click.style(status_label, fg=status_color)
365
+
366
+ def _render_tools(tools: list[str] | None) -> str:
367
+ if tools is None:
368
+ return click.style("(inherit defaults)", fg="cyan")
369
+ if not tools:
370
+ return click.style("(none — explicit override)", fg="yellow")
371
+ return ", ".join(tools)
372
+
373
+ click.echo(f"{click.style(name, bold=True)} [{status}]")
374
+ click.echo(f" directory: {agent.directory}")
375
+ click.echo(f" description: {agent.description}")
376
+ click.echo(f" timeout: {agent.timeout}s")
377
+ if agent.model:
378
+ click.echo(f" model: {agent.model}")
379
+ if agent.max_budget_usd is not None:
380
+ click.echo(f" max_budget_usd: ${agent.max_budget_usd}")
381
+ if agent.permission_mode:
382
+ click.echo(f" permission_mode: {agent.permission_mode}")
383
+ click.echo(f" allowed_tools: {_render_tools(agent.allowed_tools)}")
384
+ click.echo(f" disallowed_tools: {_render_tools(agent.disallowed_tools)}")
385
+
386
+ # Surface project files used by auto_describe so the user can verify
387
+ # what context the dispatched agent will actually inherit.
388
+ try:
389
+ files: list[str] = []
390
+ for fname in ("CLAUDE.md", ".mcp.json", "README.md", "pyproject.toml", "package.json"):
391
+ if (agent.directory / fname).exists():
392
+ files.append(fname)
393
+ if files:
394
+ click.echo(f" project files: {', '.join(files)}")
395
+ except OSError:
396
+ pass
397
+
398
+
399
+ @cli.command()
400
+ def doctor() -> None:
401
+ """Diagnose the agent-dispatch setup and surface common issues."""
402
+ counters = {"issues": 0, "warnings": 0}
403
+
404
+ def section(title: str) -> None:
405
+ click.echo(f"\n{click.style(title, bold=True)}")
406
+
407
+ def ok(msg: str) -> None:
408
+ click.echo(f" [{click.style('OK', fg='green')}] {msg}")
409
+
410
+ def warn(msg: str) -> None:
411
+ counters["warnings"] += 1
412
+ click.echo(f" [{click.style('WARN', fg='yellow')}] {msg}")
413
+
414
+ def fail(msg: str) -> None:
415
+ counters["issues"] += 1
416
+ click.echo(f" [{click.style('FAIL', fg='red')}] {msg}")
417
+
418
+ section("Environment")
419
+ claude_path = shutil.which("claude")
420
+ if claude_path:
421
+ ok(f"claude CLI: {claude_path}")
422
+ else:
423
+ fail("claude CLI not found on PATH")
424
+ click.echo(" Install: https://docs.anthropic.com/en/docs/claude-code")
425
+
426
+ ad_path = shutil.which("agent-dispatch")
427
+ if ad_path:
428
+ ok(f"agent-dispatch CLI: {ad_path}")
429
+ else:
430
+ warn(
431
+ "agent-dispatch not on PATH "
432
+ "(MCP server still works via absolute path)"
433
+ )
434
+
435
+ section("Config")
436
+ cp = config_path()
437
+ config: DispatchConfig | None = None
438
+ if not cp.exists():
439
+ warn(f"Config not found: {cp}")
440
+ click.echo(" Run: agent-dispatch init")
441
+ else:
442
+ try:
443
+ config = load_config()
444
+ n = len(config.agents)
445
+ suffix = "agent" if n == 1 else "agents"
446
+ ok(f"Config: {cp} ({n} {suffix})")
447
+ except ValidationError as e:
448
+ fail(f"Config schema invalid: {cp}")
449
+ click.echo(f" {e}")
450
+ except yaml.YAMLError as e:
451
+ fail(f"Config not valid YAML: {cp}")
452
+ click.echo(f" {e}")
453
+
454
+ section("MCP registration")
455
+ if claude_path is None:
456
+ warn("Skipped (claude CLI missing)")
457
+ else:
458
+ try:
459
+ result = subprocess.run(
460
+ [claude_path, "mcp", "list"],
461
+ capture_output=True, text=True, timeout=10,
462
+ )
463
+ # Match the server name at the start of any line — `claude mcp list`
464
+ # prints `<name>: <command> - <status>`, and we want to avoid false
465
+ # positives from "agent-dispatch" appearing in command paths.
466
+ entry_re = re.compile(r"^agent-dispatch[:\s]", re.MULTILINE)
467
+ if result.returncode == 0 and entry_re.search(result.stdout):
468
+ ok("agent-dispatch is registered with Claude Code")
469
+ else:
470
+ warn("agent-dispatch is not registered with Claude Code")
471
+ click.echo(" Run: agent-dispatch init")
472
+ except subprocess.TimeoutExpired:
473
+ warn("Could not check MCP registration: claude mcp list timed out")
474
+ except (FileNotFoundError, PermissionError, OSError) as e:
475
+ warn(f"Could not check MCP registration: {e}")
476
+
477
+ section("Agents")
478
+ if config is None:
479
+ warn("Skipped (config could not be loaded)")
480
+ elif not config.agents:
481
+ warn("No agents configured. Add one: agent-dispatch add <name> <directory>")
482
+ else:
483
+ for name, agent in config.agents.items():
484
+ try:
485
+ if agent.directory.is_dir():
486
+ extras: list[str] = []
487
+ if (agent.directory / "CLAUDE.md").exists():
488
+ extras.append("CLAUDE.md")
489
+ if (agent.directory / ".mcp.json").exists():
490
+ extras.append(".mcp.json")
491
+ suffix = f" [{', '.join(extras)}]" if extras else ""
492
+ ok(f"{name}: {agent.directory}{suffix}")
493
+ else:
494
+ fail(f"{name}: directory missing - {agent.directory}")
495
+ except OSError as e:
496
+ fail(f"{name}: directory unreadable - {e}")
497
+
498
+ section("Summary")
499
+ issues = counters["issues"]
500
+ warnings = counters["warnings"]
501
+ if issues == 0 and warnings == 0:
502
+ click.echo(click.style("All checks passed.", fg="green"))
503
+ else:
504
+ parts: list[str] = []
505
+ if issues:
506
+ parts.append(click.style(
507
+ f"{issues} issue{'s' if issues != 1 else ''}", fg="red",
508
+ ))
509
+ if warnings:
510
+ parts.append(click.style(
511
+ f"{warnings} warning{'s' if warnings != 1 else ''}", fg="yellow",
512
+ ))
513
+ click.echo(", ".join(parts))
514
+ if issues > 0:
515
+ raise SystemExit(1)
516
+
517
+
331
518
  @cli.command()
332
519
  def serve() -> None:
333
520
  """Start the MCP server (stdio transport)."""
@@ -102,20 +102,35 @@ async def list_agents(ctx: Context | None = None) -> str:
102
102
 
103
103
  agents = []
104
104
  for name, agent in config.agents.items():
105
- healthy = agent.directory.is_dir()
105
+ # is_dir() can raise OSError (PermissionError, network FS hiccup, etc.).
106
+ # Fall back to "UNREADABLE" so a single broken agent doesn't crash the
107
+ # whole listing — per CLAUDE.md's documented response shape.
108
+ try:
109
+ healthy: bool | str = agent.directory.is_dir()
110
+ has_claude_md = (
111
+ (agent.directory / "CLAUDE.md").exists() if healthy else False
112
+ )
113
+ has_mcp_config = (
114
+ (agent.directory / ".mcp.json").exists() if healthy else False
115
+ )
116
+ except OSError:
117
+ healthy = "UNREADABLE"
118
+ has_claude_md = False
119
+ has_mcp_config = False
106
120
  entry: dict = {
107
121
  "name": name,
108
122
  "directory": str(agent.directory),
109
123
  "description": agent.description,
110
124
  "healthy": healthy,
111
- "has_claude_md": (agent.directory / "CLAUDE.md").exists() if healthy else False,
112
- "has_mcp_config": (agent.directory / ".mcp.json").exists() if healthy else False,
125
+ "has_claude_md": has_claude_md,
126
+ "has_mcp_config": has_mcp_config,
113
127
  }
114
128
  if agent.permission_mode:
115
129
  entry["permission_mode"] = agent.permission_mode
116
- if agent.allowed_tools:
130
+ # Include when explicitly set (even []) to distinguish from inheriting defaults
131
+ if agent.allowed_tools is not None:
117
132
  entry["allowed_tools"] = agent.allowed_tools
118
- if agent.disallowed_tools:
133
+ if agent.disallowed_tools is not None:
119
134
  entry["disallowed_tools"] = agent.disallowed_tools
120
135
  agents.append(entry)
121
136
  if ctx:
@@ -150,10 +165,11 @@ async def dispatch(
150
165
  if err := _validate_agent(config, agent):
151
166
  return err
152
167
 
153
- # Check cache
168
+ # Check cache. caller/goal are part of the key because they change the
169
+ # prompt sent to Claude and therefore the response.
154
170
  cache = _get_cache(config)
155
171
  if cache:
156
- cached = cache.get(agent, task, context or None)
172
+ cached = cache.get(agent, task, context or None, caller or None, goal or None)
157
173
  if cached:
158
174
  if ctx:
159
175
  await ctx.info(f"Cache hit for {agent} — returning cached result")
@@ -179,7 +195,7 @@ async def dispatch(
179
195
 
180
196
  # Populate cache
181
197
  if cache:
182
- cache.put(agent, task, result, context or None)
198
+ cache.put(agent, task, result, context or None, caller or None, goal or None)
183
199
 
184
200
  return result.model_dump_json(indent=2, exclude_none=True)
185
201
 
@@ -290,9 +306,9 @@ async def dispatch_parallel(
290
306
  item_caller = item.get("caller") or None
291
307
  item_goal = item.get("goal") or None
292
308
 
293
- # Check cache
309
+ # Check cache (caller/goal are part of the key — see dispatch())
294
310
  if cache:
295
- cached = cache.get(name, task, item_context)
311
+ cached = cache.get(name, task, item_context, item_caller, item_goal)
296
312
  if cached:
297
313
  d = json.loads(cached.model_dump_json(exclude_none=True))
298
314
  d["cached"] = True
@@ -312,7 +328,7 @@ async def dispatch_parallel(
312
328
  )
313
329
 
314
330
  if cache:
315
- cache.put(name, task, result, item_context)
331
+ cache.put(name, task, result, item_context, item_caller, item_goal)
316
332
 
317
333
  return json.loads(result.model_dump_json(exclude_none=True))
318
334
 
@@ -53,6 +53,48 @@ class TestCacheBasics:
53
53
  cache.put("a", "task", _fail_result())
54
54
  assert cache.get("a", "task") is None
55
55
 
56
+ def test_caller_affects_key(self):
57
+ """Different caller → different prompt → must not collide in cache."""
58
+ cache = DispatchCache(ttl=60)
59
+ cache.put("a", "task", _ok_result(text="for-frontend"), caller="frontend")
60
+ cache.put("a", "task", _ok_result(text="for-backend"), caller="backend")
61
+ assert cache.get("a", "task", caller="frontend").result == "for-frontend"
62
+ assert cache.get("a", "task", caller="backend").result == "for-backend"
63
+ # Different caller from what was stored → miss
64
+ assert cache.get("a", "task", caller="other") is None
65
+
66
+ def test_goal_affects_key(self):
67
+ """Different goal → different prompt → must not collide in cache."""
68
+ cache = DispatchCache(ttl=60)
69
+ cache.put("a", "task", _ok_result(text="for-debug"), goal="debug crash")
70
+ cache.put("a", "task", _ok_result(text="for-perf"), goal="optimize perf")
71
+ assert cache.get("a", "task", goal="debug crash").result == "for-debug"
72
+ assert cache.get("a", "task", goal="optimize perf").result == "for-perf"
73
+
74
+ def test_caller_and_goal_combined(self):
75
+ cache = DispatchCache(ttl=60)
76
+ cache.put(
77
+ "a", "task", _ok_result(text="A"),
78
+ caller="frontend", goal="debug",
79
+ )
80
+ # Same caller, different goal → miss
81
+ assert cache.get("a", "task", caller="frontend", goal="optimize") is None
82
+ # Same goal, different caller → miss
83
+ assert cache.get("a", "task", caller="backend", goal="debug") is None
84
+ # Both match → hit
85
+ assert cache.get(
86
+ "a", "task", caller="frontend", goal="debug",
87
+ ).result == "A"
88
+
89
+ def test_caller_none_vs_empty_string_collide(self):
90
+ """caller=None and caller="" canonicalize the same — both mean
91
+ 'no caller specified'. Document the behavior so future changes
92
+ don't silently break the contract."""
93
+ cache = DispatchCache(ttl=60)
94
+ cache.put("a", "task", _ok_result(text="anon"), caller=None)
95
+ assert cache.get("a", "task", caller="") is not None
96
+ assert cache.get("a", "task", caller=None) is not None
97
+
56
98
 
57
99
  class TestCacheTTL:
58
100
  def test_expired_entry_returns_none(self):