agent-dispatch 0.2.0__tar.gz → 0.2.2__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 (27) hide show
  1. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/PKG-INFO +1 -1
  2. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/pyproject.toml +1 -1
  3. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/__init__.py +1 -1
  4. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/cli.py +48 -19
  5. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/models.py +15 -5
  6. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/runner.py +51 -12
  7. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/server.py +30 -15
  8. agent_dispatch-0.2.2/tests/test_cli.py +415 -0
  9. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/test_models.py +16 -0
  10. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/test_runner.py +93 -0
  11. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/test_server.py +49 -1
  12. agent_dispatch-0.2.0/tests/test_cli.py +0 -203
  13. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/.github/dependabot.yml +0 -0
  14. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/.github/workflows/ci.yml +0 -0
  15. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/.github/workflows/publish.yml +0 -0
  16. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/.gitignore +0 -0
  17. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/LICENSE +0 -0
  18. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/README.md +0 -0
  19. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/SECURITY.md +0 -0
  20. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/agents.example.yaml +0 -0
  21. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/assets/mascot.png +0 -0
  22. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/cache.py +0 -0
  23. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/src/agent_dispatch/config.py +0 -0
  24. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/__init__.py +0 -0
  25. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/conftest.py +0 -0
  26. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/test_cache.py +0 -0
  27. {agent_dispatch-0.2.0 → agent_dispatch-0.2.2}/tests/test_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.2.0"
3
+ version = "0.2.2"
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.0"
3
+ __version__ = "0.2.2"
@@ -8,11 +8,31 @@ import subprocess
8
8
  from pathlib import Path
9
9
 
10
10
  import click
11
+ import yaml
12
+ from pydantic import ValidationError
11
13
 
12
14
  from .config import auto_describe, config_path, load_config, save_config
13
15
  from .models import AgentConfig, DispatchConfig, check_permission_mode, validate_agent_name
14
16
 
15
17
 
18
+ def _load_or_exit() -> DispatchConfig:
19
+ """Load config, exiting with a friendly error on malformed YAML or schema."""
20
+ try:
21
+ return load_config()
22
+ except ValidationError as e:
23
+ click.echo(click.style(
24
+ f"Error: config at {config_path()} has an invalid schema:", fg="red"
25
+ ))
26
+ click.echo(str(e))
27
+ raise SystemExit(1)
28
+ except yaml.YAMLError as e:
29
+ click.echo(click.style(
30
+ f"Error: config at {config_path()} is not valid YAML:", fg="red"
31
+ ))
32
+ click.echo(str(e))
33
+ raise SystemExit(1)
34
+
35
+
16
36
  @click.group()
17
37
  @click.version_option(package_name="agent-dispatch")
18
38
  def cli() -> None:
@@ -107,7 +127,7 @@ def add(
107
127
  click.echo(f"Error: {e}")
108
128
  raise SystemExit(1)
109
129
 
110
- config = load_config()
130
+ config = _load_or_exit()
111
131
  dir_path = Path(directory).resolve()
112
132
 
113
133
  if name in config.agents:
@@ -126,9 +146,9 @@ def add(
126
146
  max_budget_usd=max_budget,
127
147
  permission_mode=permission_mode,
128
148
  allowed_tools=[t.strip() for t in allowed_tools.split(",") if t.strip()]
129
- if allowed_tools else [],
149
+ if allowed_tools else None,
130
150
  disallowed_tools=[t.strip() for t in disallowed_tools.split(",") if t.strip()]
131
- if disallowed_tools else [],
151
+ if disallowed_tools else None,
132
152
  )
133
153
  if warning := check_permission_mode(permission_mode):
134
154
  click.echo(click.style(f"Warning: {warning}", fg="yellow"))
@@ -141,7 +161,7 @@ def add(
141
161
  @click.argument("name")
142
162
  def remove(name: str) -> None:
143
163
  """Remove an agent."""
144
- config = load_config()
164
+ config = _load_or_exit()
145
165
  if name not in config.agents:
146
166
  click.echo(f"Agent '{name}' not found.")
147
167
  raise SystemExit(1)
@@ -154,14 +174,20 @@ def remove(name: str) -> None:
154
174
  @cli.command("list")
155
175
  def list_agents() -> None:
156
176
  """List configured agents with health status."""
157
- config = load_config()
177
+ config = _load_or_exit()
158
178
  if not config.agents:
159
179
  click.echo("No agents configured. Run: agent-dispatch add <name> <directory>")
160
180
  return
161
181
 
162
182
  for name, agent in config.agents.items():
163
- healthy = agent.directory.is_dir()
164
- status = click.style("OK", fg="green") if healthy else click.style("NOT FOUND", fg="red")
183
+ try:
184
+ healthy = agent.directory.is_dir()
185
+ status_label = "OK" if healthy else "NOT FOUND"
186
+ status_color = "green" if healthy else "red"
187
+ except OSError:
188
+ status_label = "UNREADABLE"
189
+ status_color = "red"
190
+ status = click.style(status_label, fg=status_color)
165
191
  click.echo(f" {name} [{status}]")
166
192
  click.echo(f" dir: {agent.directory}")
167
193
  click.echo(f" desc: {agent.description}")
@@ -176,10 +202,12 @@ def list_agents() -> None:
176
202
  click.echo(f" config: {', '.join(extras)}")
177
203
  if agent.permission_mode:
178
204
  click.echo(f" permission_mode: {agent.permission_mode}")
179
- if agent.allowed_tools:
180
- click.echo(f" allowed_tools: {', '.join(agent.allowed_tools)}")
181
- if agent.disallowed_tools:
182
- click.echo(f" disallowed_tools: {', '.join(agent.disallowed_tools)}")
205
+ if agent.allowed_tools is not None:
206
+ rendered = ", ".join(agent.allowed_tools) if agent.allowed_tools else "(none)"
207
+ click.echo(f" allowed_tools: {rendered}")
208
+ if agent.disallowed_tools is not None:
209
+ rendered = ", ".join(agent.disallowed_tools) if agent.disallowed_tools else "(none)"
210
+ click.echo(f" disallowed_tools: {rendered}")
183
211
  click.echo()
184
212
 
185
213
 
@@ -214,7 +242,7 @@ def update(
214
242
  disallowed_tools: str | None,
215
243
  ) -> None:
216
244
  """Update an existing agent's configuration."""
217
- config = load_config()
245
+ config = _load_or_exit()
218
246
  if name not in config.agents:
219
247
  click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
220
248
  raise SystemExit(1)
@@ -229,26 +257,27 @@ def update(
229
257
  agent.timeout = timeout
230
258
  updated.append("timeout")
231
259
  if model is not None:
232
- agent.model = None if model.lower() == "none" else model
260
+ agent.model = None if model.strip().lower() in ("none", "") else model
233
261
  updated.append("model")
234
262
  if max_budget is not None:
235
263
  agent.max_budget_usd = None if max_budget == 0 else max_budget
236
264
  updated.append("max_budget_usd")
237
265
  if permission_mode is not None:
238
- effective = None if permission_mode.lower() == "none" else permission_mode
266
+ stripped = permission_mode.strip()
267
+ effective = None if stripped.lower() in ("none", "") else stripped
239
268
  agent.permission_mode = effective
240
269
  if warning := check_permission_mode(effective):
241
270
  click.echo(click.style(f"Warning: {warning}", fg="yellow"))
242
271
  updated.append("permission_mode")
243
272
  if allowed_tools is not None:
244
- if allowed_tools.lower() == "none":
245
- agent.allowed_tools = []
273
+ if allowed_tools.strip().lower() in ("none", ""):
274
+ agent.allowed_tools = None
246
275
  else:
247
276
  agent.allowed_tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
248
277
  updated.append("allowed_tools")
249
278
  if disallowed_tools is not None:
250
- if disallowed_tools.lower() == "none":
251
- agent.disallowed_tools = []
279
+ if disallowed_tools.strip().lower() in ("none", ""):
280
+ agent.disallowed_tools = None
252
281
  else:
253
282
  agent.disallowed_tools = [
254
283
  t.strip() for t in disallowed_tools.split(",") if t.strip()
@@ -268,7 +297,7 @@ def update(
268
297
  @click.argument("task", default="What project is this? Describe in one sentence.")
269
298
  def test(name: str, task: str) -> None:
270
299
  """Test an agent by dispatching a task."""
271
- config = load_config()
300
+ config = _load_or_exit()
272
301
  if name not in config.agents:
273
302
  click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
274
303
  raise SystemExit(1)
@@ -15,14 +15,24 @@ KNOWN_PERMISSION_MODES = frozenset({
15
15
 
16
16
  def check_permission_mode(mode: str | None) -> str | None:
17
17
  """Return a warning message if mode is unknown, else None."""
18
- if mode and mode not in KNOWN_PERMISSION_MODES:
18
+ if not mode:
19
+ return None
20
+ trimmed = mode.strip()
21
+ if not trimmed:
22
+ return None
23
+ if trimmed not in KNOWN_PERMISSION_MODES:
19
24
  known = ", ".join(sorted(KNOWN_PERMISSION_MODES))
20
- return f"Unknown permission_mode: {mode!r}. Known values: {known}"
25
+ return f"Unknown permission_mode: {trimmed!r}. Known values: {known}"
21
26
  return None
22
27
 
23
28
 
24
29
  class AgentConfig(BaseModel):
25
- """Configuration for a single agent."""
30
+ """Configuration for a single agent.
31
+
32
+ `allowed_tools` / `disallowed_tools` use `None` to mean
33
+ "inherit from settings.default_*" and `[]` to mean "explicitly empty
34
+ (override defaults to no tools)".
35
+ """
26
36
 
27
37
  directory: Path
28
38
  description: str = ""
@@ -30,8 +40,8 @@ class AgentConfig(BaseModel):
30
40
  max_budget_usd: float | None = None
31
41
  model: str | None = None
32
42
  permission_mode: str | None = None
33
- allowed_tools: list[str] = Field(default_factory=list)
34
- disallowed_tools: list[str] = Field(default_factory=list)
43
+ allowed_tools: list[str] | None = None
44
+ disallowed_tools: list[str] | None = None
35
45
 
36
46
  @field_validator("directory", mode="before")
37
47
  @classmethod
@@ -29,9 +29,14 @@ _PERMISSION_PATTERNS = [
29
29
  ]
30
30
 
31
31
 
32
- def _classify_error(error_text: str) -> str:
33
- """Classify an error message into a category."""
34
- lower = error_text.lower()
32
+ def _classify_error(error_text: object) -> str:
33
+ """Classify an error message into a category.
34
+
35
+ Accepts any type and coerces to string — some claude CLI error paths
36
+ produce non-string values (None, dict) that would crash `.lower()`.
37
+ """
38
+ text = str(error_text) if error_text else ""
39
+ lower = text.lower()
35
40
  for pattern in _PERMISSION_PATTERNS:
36
41
  if pattern in lower:
37
42
  return "permission"
@@ -101,11 +106,18 @@ def _build_command(
101
106
  if permission_mode:
102
107
  cmd.extend(["--permission-mode", permission_mode])
103
108
 
104
- allowed = agent.allowed_tools or settings.default_allowed_tools
109
+ # None = inherit from settings; [] = explicitly empty (no inheritance)
110
+ allowed = (
111
+ agent.allowed_tools if agent.allowed_tools is not None
112
+ else settings.default_allowed_tools
113
+ )
105
114
  for tool in allowed:
106
115
  cmd.extend(["--allowedTools", tool])
107
116
 
108
- disallowed = agent.disallowed_tools or settings.default_disallowed_tools
117
+ disallowed = (
118
+ agent.disallowed_tools if agent.disallowed_tools is not None
119
+ else settings.default_disallowed_tools
120
+ )
109
121
  for tool in disallowed:
110
122
  cmd.extend(["--disallowedTools", tool])
111
123
 
@@ -227,22 +239,36 @@ def dispatch(
227
239
  data = json.loads(proc.stdout)
228
240
  except json.JSONDecodeError:
229
241
  # Fallback: treat stdout as plain text
242
+ success = proc.returncode == 0
243
+ text = proc.stdout.strip()
244
+ if success:
245
+ return DispatchResult(agent=agent_name, success=True, result=text)
246
+ error_text = text or f"claude exited with code {proc.returncode} (non-JSON output)"
247
+ error_type = _classify_error(error_text)
248
+ if error_type == "permission":
249
+ error_text += _permission_hint(agent_name)
230
250
  return DispatchResult(
231
251
  agent=agent_name,
232
- success=proc.returncode == 0,
233
- result=proc.stdout.strip(),
252
+ success=False,
253
+ result=text,
254
+ error=error_text,
255
+ error_type=error_type,
234
256
  )
235
257
 
236
258
  is_error = data.get("is_error", False)
237
259
  if is_error:
238
- error_text = data.get("result", "")
260
+ raw_result = data.get("result", "")
261
+ error_text = str(raw_result) if raw_result else (
262
+ f"Agent '{agent_name}' reported an error with no details "
263
+ f"(exit code {proc.returncode})"
264
+ )
239
265
  error_type = _classify_error(error_text)
240
266
  if error_type == "permission":
241
267
  error_text += _permission_hint(agent_name)
242
268
  return DispatchResult(
243
269
  agent=agent_name,
244
270
  success=False,
245
- result=data.get("result", ""),
271
+ result=str(raw_result) if raw_result else "",
246
272
  session_id=data.get("session_id"),
247
273
  cost_usd=data.get("total_cost_usd"),
248
274
  duration_ms=data.get("duration_ms"),
@@ -326,11 +352,21 @@ def dispatch_stream(
326
352
  text=True,
327
353
  env=env,
328
354
  )
329
- except OSError as e:
355
+ except FileNotFoundError as e:
330
356
  return DispatchResult(
331
357
  agent=agent_name, success=False, result="", error=str(e),
332
358
  error_type="not_found",
333
359
  )
360
+ except PermissionError as e:
361
+ return DispatchResult(
362
+ agent=agent_name, success=False, result="", error=str(e),
363
+ error_type="permission",
364
+ )
365
+ except OSError as e:
366
+ return DispatchResult(
367
+ agent=agent_name, success=False, result="", error=str(e),
368
+ error_type="cli_error",
369
+ )
334
370
 
335
371
  # Kill the process if it exceeds the timeout
336
372
  timed_out = threading.Event()
@@ -387,14 +423,17 @@ def dispatch_stream(
387
423
  if result_data:
388
424
  is_error = result_data.get("is_error", False)
389
425
  if is_error:
390
- error_text = result_data.get("result", "")
426
+ raw_result = result_data.get("result", "")
427
+ error_text = str(raw_result) if raw_result else (
428
+ f"Agent '{agent_name}' reported an error with no details"
429
+ )
391
430
  error_type = _classify_error(error_text)
392
431
  if error_type == "permission":
393
432
  error_text += _permission_hint(agent_name)
394
433
  return DispatchResult(
395
434
  agent=agent_name,
396
435
  success=False,
397
- result=result_data.get("result", ""),
436
+ result=str(raw_result) if raw_result else "",
398
437
  session_id=result_data.get("session_id"),
399
438
  cost_usd=result_data.get("total_cost_usd"),
400
439
  duration_ms=result_data.get("duration_ms"),
@@ -113,9 +113,10 @@ async def list_agents(ctx: Context | None = None) -> str:
113
113
  }
114
114
  if agent.permission_mode:
115
115
  entry["permission_mode"] = agent.permission_mode
116
- if agent.allowed_tools:
116
+ # Include when explicitly set (even []) to distinguish from inheriting defaults
117
+ if agent.allowed_tools is not None:
117
118
  entry["allowed_tools"] = agent.allowed_tools
118
- if agent.disallowed_tools:
119
+ if agent.disallowed_tools is not None:
119
120
  entry["disallowed_tools"] = agent.disallowed_tools
120
121
  agents.append(entry)
121
122
  if ctx:
@@ -326,6 +327,7 @@ async def dispatch_parallel(
326
327
  "success": False,
327
328
  "result": "",
328
329
  "error": str(res),
330
+ "error_type": "cli_error",
329
331
  })
330
332
  else:
331
333
  output.append(res)
@@ -421,16 +423,22 @@ async def dispatch_stream(
421
423
  # Forward progress messages while the subprocess runs
422
424
  while not future.done():
423
425
  await asyncio.sleep(0.1)
424
- while not progress_queue.empty():
425
- msg = progress_queue.get_nowait()
426
+ while True:
427
+ try:
428
+ msg = progress_queue.get_nowait()
429
+ except queue.Empty:
430
+ break
426
431
  if ctx:
427
432
  await ctx.info(f"[{agent}] {msg[:300]}")
428
433
 
429
434
  result = await asyncio.wrap_future(future)
430
435
 
431
436
  # Drain any remaining messages
432
- while not progress_queue.empty():
433
- msg = progress_queue.get_nowait()
437
+ while True:
438
+ try:
439
+ msg = progress_queue.get_nowait()
440
+ except queue.Empty:
441
+ break
434
442
  if ctx:
435
443
  await ctx.info(f"[{agent}] {msg[:300]}")
436
444
 
@@ -642,7 +650,7 @@ async def add_agent(
642
650
  directory: Absolute path to the project directory.
643
651
  description: What this agent can do. Leave empty for auto-generation.
644
652
  timeout: Timeout in seconds (0 uses global default of 300).
645
- max_budget_usd: Max cost in USD per dispatch (0 = no limit).
653
+ max_budget_usd: Max cost in USD per dispatch (0 or omitted = no limit).
646
654
  permission_mode: Permission mode for the claude CLI
647
655
  (e.g. default, plan, bypassPermissions). Leave empty for default.
648
656
  allowed_tools: Comma-separated list of allowed tools
@@ -666,8 +674,17 @@ async def add_agent(
666
674
  return json.dumps({"error": f"Agent '{name}' already exists. Remove it first."})
667
675
 
668
676
  desc = description or auto_describe(dir_path)
669
- parsed_allowed = [t.strip() for t in allowed_tools.split(",") if t.strip()] if allowed_tools else []
670
- parsed_disallowed = [t.strip() for t in disallowed_tools.split(",") if t.strip()] if disallowed_tools else []
677
+ parsed_allowed = (
678
+ [t.strip() for t in allowed_tools.split(",") if t.strip()]
679
+ if allowed_tools else None
680
+ )
681
+ parsed_disallowed = (
682
+ [t.strip() for t in disallowed_tools.split(",") if t.strip()]
683
+ if disallowed_tools else None
684
+ )
685
+
686
+ if ctx and (warning := check_permission_mode(permission_mode or None)):
687
+ await ctx.info(f"Warning: {warning}")
671
688
 
672
689
  config.agents[name] = AgentConfig(
673
690
  directory=dir_path,
@@ -681,8 +698,6 @@ async def add_agent(
681
698
  save_config(config)
682
699
 
683
700
  if ctx:
684
- if warning := check_permission_mode(permission_mode or None):
685
- await ctx.info(f"Warning: {warning}")
686
701
  await ctx.info(f"Added agent '{name}' -> {dir_path}")
687
702
 
688
703
  result: dict = {
@@ -746,8 +761,8 @@ async def update_agent(
746
761
  name: Agent name to update.
747
762
  description: New description.
748
763
  timeout: New timeout in seconds (0 = don't change).
749
- max_budget_usd: Max cost in USD per dispatch (0 = don't change,
750
- negative = clear limit).
764
+ max_budget_usd: New max cost in USD per dispatch (0 = don't change;
765
+ pass a negative number to clear the limit).
751
766
  model: Model override. Pass "none" to clear.
752
767
  permission_mode: Permission mode. Pass "none" to clear.
753
768
  allowed_tools: Comma-separated allowed tools. Pass "none" to clear.
@@ -781,13 +796,13 @@ async def update_agent(
781
796
  updated.append("permission_mode")
782
797
  if allowed_tools:
783
798
  if allowed_tools.lower() == "none":
784
- agent.allowed_tools = []
799
+ agent.allowed_tools = None
785
800
  else:
786
801
  agent.allowed_tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
787
802
  updated.append("allowed_tools")
788
803
  if disallowed_tools:
789
804
  if disallowed_tools.lower() == "none":
790
- agent.disallowed_tools = []
805
+ agent.disallowed_tools = None
791
806
  else:
792
807
  agent.disallowed_tools = [
793
808
  t.strip() for t in disallowed_tools.split(",") if t.strip()