agent-dispatch 0.1.0__tar.gz → 0.2.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 (27) hide show
  1. agent_dispatch-0.2.0/.github/dependabot.yml +10 -0
  2. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/.github/workflows/ci.yml +2 -2
  3. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/.github/workflows/publish.yml +3 -3
  4. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/PKG-INFO +54 -5
  5. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/README.md +53 -4
  6. agent_dispatch-0.2.0/SECURITY.md +22 -0
  7. agent_dispatch-0.2.0/agents.example.yaml +47 -0
  8. agent_dispatch-0.2.0/assets/mascot.png +0 -0
  9. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/pyproject.toml +1 -1
  10. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/__init__.py +1 -1
  11. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/cli.py +132 -1
  12. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/config.py +2 -2
  13. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/models.py +16 -0
  14. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/runner.py +111 -15
  15. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/server.py +167 -33
  16. agent_dispatch-0.2.0/tests/test_cli.py +203 -0
  17. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/test_models.py +39 -0
  18. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/test_runner.py +118 -0
  19. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/test_server.py +197 -0
  20. agent_dispatch-0.1.0/agents.example.yaml +0 -24
  21. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/.gitignore +0 -0
  22. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/LICENSE +0 -0
  23. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/src/agent_dispatch/cache.py +0 -0
  24. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/__init__.py +0 -0
  25. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/conftest.py +0 -0
  26. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/test_cache.py +0 -0
  27. {agent_dispatch-0.1.0 → agent_dispatch-0.2.0}/tests/test_config.py +0 -0
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ - package-ecosystem: github-actions
8
+ directory: /
9
+ schedule:
10
+ interval: weekly
@@ -14,9 +14,9 @@ jobs:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
18
18
 
19
- - uses: actions/setup-python@v5
19
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
20
20
  with:
21
21
  python-version: ${{ matrix.python-version }}
22
22
 
@@ -11,9 +11,9 @@ jobs:
11
11
  permissions:
12
12
  id-token: write
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
15
15
 
16
- - uses: actions/setup-python@v5
16
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
17
17
  with:
18
18
  python-version: "3.12"
19
19
 
@@ -23,4 +23,4 @@ jobs:
23
23
  python -m build
24
24
 
25
25
  - name: Publish to PyPI
26
- uses: pypa/gh-action-pypi-publish@release/v1
26
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -30,8 +30,17 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  # agent-dispatch
32
32
 
33
+ [![PyPI](https://img.shields.io/pypi/v/agent-dispatch)](https://pypi.org/project/agent-dispatch/)
34
+ [![CI](https://github.com/ginkida/agent-dispatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ginkida/agent-dispatch/actions/workflows/ci.yml)
35
+ [![Python](https://img.shields.io/pypi/pyversions/agent-dispatch)](https://pypi.org/project/agent-dispatch/)
36
+ [![License](https://img.shields.io/github/license/ginkida/agent-dispatch)](LICENSE)
37
+
33
38
  **MCP server that lets Claude Code agents delegate tasks to agents in other project directories.**
34
39
 
40
+ <p align="center">
41
+ <img src="assets/mascot.png" alt="agent-dispatch mascot" width="600">
42
+ </p>
43
+
35
44
  Each agent runs as a separate `claude -p` session in its own project directory — inheriting that project's MCP servers, CLAUDE.md, and tools. The calling agent just gets the result back.
36
45
 
37
46
  Works with OAuth, API key, and Claude subscription authentication.
@@ -50,6 +59,9 @@ agent-dispatch add backend ~/projects/backend
50
59
 
51
60
  # Test it works
52
61
  agent-dispatch test infra
62
+
63
+ # If agents hit permission errors, grant tool access:
64
+ agent-dispatch update infra --permission-mode bypassPermissions
53
65
  ```
54
66
 
55
67
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -70,7 +82,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
70
82
  Lists all configured agents. **Call this first** to see what's available.
71
83
 
72
84
  ```json
73
- // Response
85
+ // Response (permission fields shown only when configured)
74
86
  [
75
87
  {
76
88
  "name": "infra",
@@ -78,7 +90,9 @@ Lists all configured agents. **Call this first** to see what's available.
78
90
  "description": "Infrastructure agent. MCP: portainer. Stack: Python, Docker",
79
91
  "healthy": true,
80
92
  "has_claude_md": true,
81
- "has_mcp_config": true
93
+ "has_mcp_config": true,
94
+ "permission_mode": "bypassPermissions",
95
+ "allowed_tools": ["Bash", "Read", "Grep"]
82
96
  }
83
97
  ]
84
98
  ```
@@ -96,7 +110,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
96
110
  | `goal` | string | no | Broader objective — helps the agent make better trade-offs |
97
111
 
98
112
  ```json
99
- // Response
113
+ // Response (success)
100
114
  {
101
115
  "agent": "infra",
102
116
  "success": true,
@@ -106,8 +120,19 @@ One-shot task delegation. Results are cached — identical requests within TTL r
106
120
  "duration_ms": 5000,
107
121
  "num_turns": 2
108
122
  }
123
+
124
+ // Response (failure — error_type helps you handle programmatically)
125
+ {
126
+ "agent": "infra",
127
+ "success": false,
128
+ "result": "",
129
+ "error": "Tool_use is not allowed in this permission mode\n\nHint: ...",
130
+ "error_type": "permission"
131
+ }
109
132
  ```
110
133
 
134
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
135
+
111
136
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
112
137
 
113
138
  ```markdown
@@ -231,6 +256,24 @@ Register a new project directory as an agent. Description is auto-generated from
231
256
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
232
257
  | `directory` | string | yes | Absolute path to project directory |
233
258
  | `description` | string | no | What this agent can do — auto-generated if empty |
259
+ | `timeout` | int | no | Timeout in seconds (0 = use global default) |
260
+ | `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
261
+ | `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
262
+ | `disallowed_tools` | string | no | Comma-separated disallowed tools |
263
+
264
+ ### `update_agent`
265
+
266
+ Update an existing agent's configuration. Only non-empty fields are changed. Pass `"none"` to clear a field.
267
+
268
+ | Parameter | Type | Required | Description |
269
+ |-----------|------|----------|-------------|
270
+ | `name` | string | yes | Agent name to update |
271
+ | `description` | string | no | New description |
272
+ | `timeout` | int | no | New timeout (0 = don't change) |
273
+ | `model` | string | no | Model override. `"none"` to clear |
274
+ | `permission_mode` | string | no | Permission mode. `"none"` to clear |
275
+ | `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
276
+ | `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
234
277
 
235
278
  ### `remove_agent`
236
279
 
@@ -284,6 +327,11 @@ agents:
284
327
 
285
328
  settings:
286
329
  default_timeout: 300
330
+ # default_permission_mode: bypassPermissions # inherited by all agents
331
+ # default_allowed_tools: # inherited when agent has none
332
+ # - Bash
333
+ # - Read
334
+ # - Edit
287
335
  max_dispatch_depth: 3 # recursion protection
288
336
  max_concurrency: 5 # max parallel claude -p processes
289
337
  cache:
@@ -338,8 +386,9 @@ agent-dispatch MCP server
338
386
  |---------|-------------|
339
387
  | `agent-dispatch init` | Create config + register MCP server with Claude Code |
340
388
  | `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
389
+ | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
341
390
  | `agent-dispatch remove <name>` | Remove an agent |
342
- | `agent-dispatch list` | List agents with health status |
391
+ | `agent-dispatch list` | List agents with health status and permissions |
343
392
  | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
344
393
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
345
394
 
@@ -1,7 +1,16 @@
1
1
  # agent-dispatch
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/agent-dispatch)](https://pypi.org/project/agent-dispatch/)
4
+ [![CI](https://github.com/ginkida/agent-dispatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ginkida/agent-dispatch/actions/workflows/ci.yml)
5
+ [![Python](https://img.shields.io/pypi/pyversions/agent-dispatch)](https://pypi.org/project/agent-dispatch/)
6
+ [![License](https://img.shields.io/github/license/ginkida/agent-dispatch)](LICENSE)
7
+
3
8
  **MCP server that lets Claude Code agents delegate tasks to agents in other project directories.**
4
9
 
10
+ <p align="center">
11
+ <img src="assets/mascot.png" alt="agent-dispatch mascot" width="600">
12
+ </p>
13
+
5
14
  Each agent runs as a separate `claude -p` session in its own project directory — inheriting that project's MCP servers, CLAUDE.md, and tools. The calling agent just gets the result back.
6
15
 
7
16
  Works with OAuth, API key, and Claude subscription authentication.
@@ -20,6 +29,9 @@ agent-dispatch add backend ~/projects/backend
20
29
 
21
30
  # Test it works
22
31
  agent-dispatch test infra
32
+
33
+ # If agents hit permission errors, grant tool access:
34
+ agent-dispatch update infra --permission-mode bypassPermissions
23
35
  ```
24
36
 
25
37
  Done. Every Claude Code session now has access to all dispatch tools.
@@ -40,7 +52,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
40
52
  Lists all configured agents. **Call this first** to see what's available.
41
53
 
42
54
  ```json
43
- // Response
55
+ // Response (permission fields shown only when configured)
44
56
  [
45
57
  {
46
58
  "name": "infra",
@@ -48,7 +60,9 @@ Lists all configured agents. **Call this first** to see what's available.
48
60
  "description": "Infrastructure agent. MCP: portainer. Stack: Python, Docker",
49
61
  "healthy": true,
50
62
  "has_claude_md": true,
51
- "has_mcp_config": true
63
+ "has_mcp_config": true,
64
+ "permission_mode": "bypassPermissions",
65
+ "allowed_tools": ["Bash", "Read", "Grep"]
52
66
  }
53
67
  ]
54
68
  ```
@@ -66,7 +80,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
66
80
  | `goal` | string | no | Broader objective — helps the agent make better trade-offs |
67
81
 
68
82
  ```json
69
- // Response
83
+ // Response (success)
70
84
  {
71
85
  "agent": "infra",
72
86
  "success": true,
@@ -76,8 +90,19 @@ One-shot task delegation. Results are cached — identical requests within TTL r
76
90
  "duration_ms": 5000,
77
91
  "num_turns": 2
78
92
  }
93
+
94
+ // Response (failure — error_type helps you handle programmatically)
95
+ {
96
+ "agent": "infra",
97
+ "success": false,
98
+ "result": "",
99
+ "error": "Tool_use is not allowed in this permission mode\n\nHint: ...",
100
+ "error_type": "permission"
101
+ }
79
102
  ```
80
103
 
104
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
105
+
81
106
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
82
107
 
83
108
  ```markdown
@@ -201,6 +226,24 @@ Register a new project directory as an agent. Description is auto-generated from
201
226
  | `name` | string | yes | Agent name (letters, digits, hyphens, underscores) |
202
227
  | `directory` | string | yes | Absolute path to project directory |
203
228
  | `description` | string | no | What this agent can do — auto-generated if empty |
229
+ | `timeout` | int | no | Timeout in seconds (0 = use global default) |
230
+ | `permission_mode` | string | no | Permission mode (e.g. `default`, `plan`, `bypassPermissions`) |
231
+ | `allowed_tools` | string | no | Comma-separated allowed tools (e.g. `"Bash,Read,Edit"`) |
232
+ | `disallowed_tools` | string | no | Comma-separated disallowed tools |
233
+
234
+ ### `update_agent`
235
+
236
+ Update an existing agent's configuration. Only non-empty fields are changed. Pass `"none"` to clear a field.
237
+
238
+ | Parameter | Type | Required | Description |
239
+ |-----------|------|----------|-------------|
240
+ | `name` | string | yes | Agent name to update |
241
+ | `description` | string | no | New description |
242
+ | `timeout` | int | no | New timeout (0 = don't change) |
243
+ | `model` | string | no | Model override. `"none"` to clear |
244
+ | `permission_mode` | string | no | Permission mode. `"none"` to clear |
245
+ | `allowed_tools` | string | no | Comma-separated. `"none"` to clear |
246
+ | `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
204
247
 
205
248
  ### `remove_agent`
206
249
 
@@ -254,6 +297,11 @@ agents:
254
297
 
255
298
  settings:
256
299
  default_timeout: 300
300
+ # default_permission_mode: bypassPermissions # inherited by all agents
301
+ # default_allowed_tools: # inherited when agent has none
302
+ # - Bash
303
+ # - Read
304
+ # - Edit
257
305
  max_dispatch_depth: 3 # recursion protection
258
306
  max_concurrency: 5 # max parallel claude -p processes
259
307
  cache:
@@ -308,8 +356,9 @@ agent-dispatch MCP server
308
356
  |---------|-------------|
309
357
  | `agent-dispatch init` | Create config + register MCP server with Claude Code |
310
358
  | `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
359
+ | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
311
360
  | `agent-dispatch remove <name>` | Remove an agent |
312
- | `agent-dispatch list` | List agents with health status |
361
+ | `agent-dispatch list` | List agents with health status and permissions |
313
362
  | `agent-dispatch test <name> [task]` | Test an agent with a dispatch |
314
363
  | `agent-dispatch serve` | Start MCP server (stdio, used by Claude Code) |
315
364
 
@@ -0,0 +1,22 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security vulnerability, please report it via [GitHub Security Advisories](https://github.com/ginkida/agent-dispatch/security/advisories/new).
6
+
7
+ **Do not** open a public issue for security vulnerabilities.
8
+
9
+ ## Scope
10
+
11
+ `agent-dispatch` runs `claude -p` subprocesses in configured directories. Security-relevant areas:
12
+
13
+ - **Command injection** — task/context strings are passed as CLI arguments, not shell-evaluated
14
+ - **Directory traversal** — agent directories are resolved to absolute paths via `Path.resolve()`
15
+ - **Recursion** — `AGENT_DISPATCH_DEPTH` env var prevents infinite dispatch loops
16
+ - **Cost** — `max_budget_usd` limits spending per dispatch
17
+
18
+ ## Supported Versions
19
+
20
+ | Version | Supported |
21
+ |---------|-----------|
22
+ | 0.1.x | Yes |
@@ -0,0 +1,47 @@
1
+ agents:
2
+ # Infrastructure agent — has Portainer MCP for container management
3
+ infra:
4
+ directory: ~/projects/infra
5
+ description: "Infrastructure agent. MCP servers: portainer. Can check container logs, restart services, inspect Docker state."
6
+ timeout: 300
7
+
8
+ # Backend agent — source code, tests, database
9
+ backend:
10
+ directory: ~/projects/backend
11
+ description: "Backend API agent. Python, FastAPI, PostgreSQL. Can modify code, run tests, check migrations."
12
+ timeout: 300
13
+ model: null # use default model
14
+
15
+ # Agent with permission controls — useful when you want to restrict tool access
16
+ staging-db:
17
+ directory: ~/projects/staging-db
18
+ description: "Read-only database agent. Can query staging DB but not modify data."
19
+ timeout: 120
20
+ permission_mode: bypassPermissions # skip permission prompts (agent runs non-interactively)
21
+ allowed_tools: # only these tools are available
22
+ - Read
23
+ - Grep
24
+ - Bash
25
+ # disallowed_tools: # alternatively, block specific tools
26
+ # - Write
27
+ # - Edit
28
+
29
+ # Frontend agent
30
+ # frontend:
31
+ # directory: ~/projects/frontend
32
+ # description: "Frontend React app. Can modify components, run build, check TypeScript errors."
33
+ # timeout: 180
34
+
35
+ settings:
36
+ default_timeout: 300
37
+ max_dispatch_depth: 3 # recursion protection: A -> B -> A
38
+ max_concurrency: 5 # max parallel claude -p processes
39
+ # default_max_budget_usd: 1.0 # cost limit per dispatch (all agents)
40
+ # default_permission_mode: bypassPermissions # inherited by agents without override
41
+ # default_allowed_tools: # inherited by agents without override
42
+ # - Bash
43
+ # - Read
44
+ # - Edit
45
+ cache:
46
+ enabled: true
47
+ ttl: 300 # seconds; identical (agent, task, context) requests are cached
Binary file
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.1.0"
3
+ version = "0.2.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.1.0"
3
+ __version__ = "0.2.0"
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  import click
11
11
 
12
12
  from .config import auto_describe, config_path, load_config, save_config
13
- from .models import AgentConfig, DispatchConfig, validate_agent_name
13
+ from .models import AgentConfig, DispatchConfig, check_permission_mode, validate_agent_name
14
14
 
15
15
 
16
16
  @click.group()
@@ -76,12 +76,29 @@ def init() -> None:
76
76
  @click.option("-d", "--description", default=None, help="Agent description. Auto-generated if omitted.")
77
77
  @click.option("--timeout", default=300, help="Timeout in seconds (default: 300).")
78
78
  @click.option("--model", default=None, help="Model override for this agent.")
79
+ @click.option("--max-budget", default=None, type=float, help="Max cost in USD per dispatch.")
80
+ @click.option(
81
+ "--permission-mode", default=None,
82
+ help="Permission mode for claude CLI (e.g. default, plan, bypassPermissions).",
83
+ )
84
+ @click.option(
85
+ "--allowed-tools", default=None,
86
+ help="Comma-separated list of allowed tools (e.g. Bash,Read,Edit).",
87
+ )
88
+ @click.option(
89
+ "--disallowed-tools", default=None,
90
+ help="Comma-separated list of disallowed tools.",
91
+ )
79
92
  def add(
80
93
  name: str,
81
94
  directory: str,
82
95
  description: str | None,
83
96
  timeout: int,
84
97
  model: str | None,
98
+ max_budget: float | None,
99
+ permission_mode: str | None,
100
+ allowed_tools: str | None,
101
+ disallowed_tools: str | None,
85
102
  ) -> None:
86
103
  """Add an agent. Auto-generates description from project files if omitted."""
87
104
  try:
@@ -106,7 +123,16 @@ def add(
106
123
  description=description,
107
124
  timeout=timeout,
108
125
  model=model,
126
+ max_budget_usd=max_budget,
127
+ permission_mode=permission_mode,
128
+ allowed_tools=[t.strip() for t in allowed_tools.split(",") if t.strip()]
129
+ if allowed_tools else [],
130
+ disallowed_tools=[t.strip() for t in disallowed_tools.split(",") if t.strip()]
131
+ if disallowed_tools else [],
109
132
  )
133
+ if warning := check_permission_mode(permission_mode):
134
+ click.echo(click.style(f"Warning: {warning}", fg="yellow"))
135
+
110
136
  save_config(config)
111
137
  click.echo(f"Added agent '{name}' -> {dir_path}")
112
138
 
@@ -139,9 +165,104 @@ def list_agents() -> None:
139
165
  click.echo(f" {name} [{status}]")
140
166
  click.echo(f" dir: {agent.directory}")
141
167
  click.echo(f" desc: {agent.description}")
168
+ extras: list[str] = []
169
+ if agent.timeout != 300:
170
+ extras.append(f"timeout={agent.timeout}s")
171
+ if agent.model:
172
+ extras.append(f"model={agent.model}")
173
+ if agent.max_budget_usd:
174
+ extras.append(f"budget=${agent.max_budget_usd}")
175
+ if extras:
176
+ click.echo(f" config: {', '.join(extras)}")
177
+ if agent.permission_mode:
178
+ 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)}")
142
183
  click.echo()
143
184
 
144
185
 
186
+ @cli.command()
187
+ @click.argument("name")
188
+ @click.option("-d", "--description", default=None, help="New description.")
189
+ @click.option("--timeout", default=None, type=int, help="Timeout in seconds.")
190
+ @click.option("--model", default=None, help="Model override.")
191
+ @click.option("--max-budget", default=None, type=float, help="Max cost in USD. Use 0 to clear.")
192
+ @click.option(
193
+ "--permission-mode", default=None,
194
+ help="Permission mode (default, plan, bypassPermissions). Use 'none' to clear.",
195
+ )
196
+ @click.option(
197
+ "--allowed-tools", default=None,
198
+ help="Comma-separated allowed tools. Use 'none' to clear.",
199
+ )
200
+ @click.option(
201
+ "--disallowed-tools", default=None,
202
+ help="Comma-separated disallowed tools. Use 'none' to clear.",
203
+ )
204
+ @click.pass_context
205
+ def update(
206
+ ctx: click.Context,
207
+ name: str,
208
+ description: str | None,
209
+ timeout: int | None,
210
+ model: str | None,
211
+ max_budget: float | None,
212
+ permission_mode: str | None,
213
+ allowed_tools: str | None,
214
+ disallowed_tools: str | None,
215
+ ) -> None:
216
+ """Update an existing agent's configuration."""
217
+ config = load_config()
218
+ if name not in config.agents:
219
+ click.echo(f"Agent '{name}' not found. Run 'agent-dispatch list' to see agents.")
220
+ raise SystemExit(1)
221
+
222
+ agent = config.agents[name]
223
+ updated: list[str] = []
224
+
225
+ if description is not None:
226
+ agent.description = description
227
+ updated.append("description")
228
+ if timeout is not None:
229
+ agent.timeout = timeout
230
+ updated.append("timeout")
231
+ if model is not None:
232
+ agent.model = None if model.lower() == "none" else model
233
+ updated.append("model")
234
+ if max_budget is not None:
235
+ agent.max_budget_usd = None if max_budget == 0 else max_budget
236
+ updated.append("max_budget_usd")
237
+ if permission_mode is not None:
238
+ effective = None if permission_mode.lower() == "none" else permission_mode
239
+ agent.permission_mode = effective
240
+ if warning := check_permission_mode(effective):
241
+ click.echo(click.style(f"Warning: {warning}", fg="yellow"))
242
+ updated.append("permission_mode")
243
+ if allowed_tools is not None:
244
+ if allowed_tools.lower() == "none":
245
+ agent.allowed_tools = []
246
+ else:
247
+ agent.allowed_tools = [t.strip() for t in allowed_tools.split(",") if t.strip()]
248
+ updated.append("allowed_tools")
249
+ if disallowed_tools is not None:
250
+ if disallowed_tools.lower() == "none":
251
+ agent.disallowed_tools = []
252
+ else:
253
+ agent.disallowed_tools = [
254
+ t.strip() for t in disallowed_tools.split(",") if t.strip()
255
+ ]
256
+ updated.append("disallowed_tools")
257
+
258
+ if not updated:
259
+ click.echo("Nothing to update. Pass at least one option (see --help).")
260
+ raise SystemExit(1)
261
+
262
+ save_config(config)
263
+ click.echo(f"Updated agent '{name}': {', '.join(updated)}")
264
+
265
+
145
266
  @cli.command()
146
267
  @click.argument("name")
147
268
  @click.argument("task", default="What project is this? Describe in one sentence.")
@@ -167,6 +288,16 @@ def test(name: str, task: str) -> None:
167
288
  click.echo(f"\n--- Cost: ${result.cost_usd:.4f} | Turns: {result.num_turns}")
168
289
  else:
169
290
  click.echo(click.style(f"Error: {result.error}", fg="red"))
291
+ if result.error_type == "permission":
292
+ click.echo()
293
+ click.echo(click.style("Diagnosis: permission error", fg="yellow"))
294
+ click.echo("The agent was denied a tool or action. To fix:")
295
+ click.echo(f" agent-dispatch update {name} --permission-mode bypassPermissions")
296
+ click.echo(f" agent-dispatch update {name} --allowed-tools Bash,Read,Edit,Write")
297
+ elif result.error_type == "timeout":
298
+ click.echo()
299
+ click.echo(click.style("Diagnosis: timeout", fg="yellow"))
300
+ click.echo(f" agent-dispatch update {name} --timeout 600")
170
301
  raise SystemExit(1)
171
302
 
172
303
 
@@ -56,7 +56,7 @@ def _collect_mcp_servers(directory: Path) -> list[str]:
56
56
  data = json.loads(path.read_text(encoding="utf-8"))
57
57
  servers.extend(data.get("mcpServers", {}).keys())
58
58
  except (json.JSONDecodeError, KeyError):
59
- pass
59
+ logger.debug("Failed to parse MCP config: %s", path)
60
60
  return list(dict.fromkeys(servers)) # deduplicate, preserve order
61
61
 
62
62
 
@@ -115,7 +115,7 @@ def auto_describe(directory: Path) -> str:
115
115
  if pkg.get("description"):
116
116
  parts.append(pkg["description"])
117
117
  except (json.JSONDecodeError, KeyError):
118
- pass
118
+ logger.debug("Failed to parse package.json: %s", pkg_json)
119
119
 
120
120
  # MCP servers — critical for understanding what tools this agent has
121
121
  servers = _collect_mcp_servers(directory)
@@ -8,6 +8,18 @@ from pydantic import BaseModel, Field, field_validator
8
8
 
9
9
  _AGENT_NAME_PATTERN = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$"
10
10
 
11
+ KNOWN_PERMISSION_MODES = frozenset({
12
+ "default", "plan", "bypassPermissions",
13
+ })
14
+
15
+
16
+ def check_permission_mode(mode: str | None) -> str | None:
17
+ """Return a warning message if mode is unknown, else None."""
18
+ if mode and mode not in KNOWN_PERMISSION_MODES:
19
+ known = ", ".join(sorted(KNOWN_PERMISSION_MODES))
20
+ return f"Unknown permission_mode: {mode!r}. Known values: {known}"
21
+ return None
22
+
11
23
 
12
24
  class AgentConfig(BaseModel):
13
25
  """Configuration for a single agent."""
@@ -46,6 +58,9 @@ class Settings(BaseModel):
46
58
 
47
59
  default_timeout: int = 300
48
60
  default_max_budget_usd: float | None = None
61
+ default_permission_mode: str | None = None
62
+ default_allowed_tools: list[str] = Field(default_factory=list)
63
+ default_disallowed_tools: list[str] = Field(default_factory=list)
49
64
  max_dispatch_depth: int = Field(default=3, ge=1)
50
65
  max_concurrency: int = Field(default=5, ge=1)
51
66
  cache: CacheSettings = Field(default_factory=CacheSettings)
@@ -82,3 +97,4 @@ class DispatchResult(BaseModel):
82
97
  duration_ms: int | None = None
83
98
  num_turns: int | None = None
84
99
  error: str | None = None
100
+ error_type: str | None = None # permission, timeout, recursion, not_found, cli_error