Metadata-Version: 2.4
Name: symm-mcp
Version: 0.1.2
Summary: A lightweight, symmetric, asynchronous MCP channel for handing work between independent agents.
Keywords: mcp,model-context-protocol,agents,claude-code,async,handoff
Author: tacticaldoll
License-Expression: Apache-2.0 OR MIT
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development
Requires-Dist: agent-client-protocol>=0.12.1,<0.13
Requires-Dist: mcp>=2.3,<3
Requires-Dist: pydantic>=2.12,<3
Requires-Python: >=3.12
Project-URL: Homepage, https://github.com/tacticaldoll/symm-mcp
Project-URL: Repository, https://github.com/tacticaldoll/symm-mcp
Project-URL: Issues, https://github.com/tacticaldoll/symm-mcp/issues
Project-URL: Changelog, https://github.com/tacticaldoll/symm-mcp/blob/main/CHANGELOG.md
Project-URL: Security, https://github.com/tacticaldoll/symm-mcp/blob/main/SECURITY.md
Description-Content-Type: text/markdown

# symm-mcp

Symm is a lightweight asynchronous MCP channel for handing work between independent agents and
re-entering it with fresh attention.

> The work persists. The viewpoint changes.

Any MCP client can dispatch a task to another agent, keep working, and later observe the
result and record a resolution. Symm does not decide who implements and who reviews; that is
expressed in the prompt.

Symm sits between two protocols: toward its caller it is an **MCP server**, and toward the agents
it launches it is an **[Agent Client Protocol](https://agentclientprotocol.com) (ACP) client**.

```text
+-----------+      +----------------------+      +--------------+
| Dispatch  | ---> | Observe              | ---> | Resolve      |
|spawn_task |      |get_events / get_task |      |resolve_task  |
+-----------+      +----------------------+      +-------+------+
       ^                                                 |
       |        Repeat: next task / new viewpoint        |
       +-------------------------------------------------+
```

## Status

0.1.2, the latest release
([changelog](https://github.com/tacticaldoll/symm-mcp/blob/main/CHANGELOG.md)), published on
PyPI as [`symm-mcp`](https://pypi.org/project/symm-mcp/). The package is classified as Alpha.

## How a Task Flows

`spawn_task` returns as soon as the agent process is running. ACP session setup and the prompt
continue after that; the caller can do other work and later read only events it has not seen yet.

```text
Caller                     Symm server                Agent
  | spawn_task(...)              |                       |
  |----------------------------->| start process group   |
  |<-- task_id, status=running --|                       |
  |                              | initialize, new or    |
  |                              | load session, settings|
  |                              |---- prompt over ACP ->|
  |                              |<- updates/permission -|
  | get_events(after_seq=0)      |                       |
  |----------------------------->|                       |
  |<-- events 1..N, cursor=N ----|                       |
  | get_events(after_seq=N)      |                       |
  |----------------------------->|                       |
  |                              |<-- turn ends ---------|
  |<-- new events, succeeded ----|                       |
  | resolve_task(...)            |                       |
  |----------------------------->|                       |
  |<-- task with resolution -----|                       |
```

The next task may swap the agent and caller roles.

## Two Independent Axes

Execution status says what happened to the process. Resolution says what a caller concluded
about the result. They never change each other: `succeeded` does not mean `accepted`, and a
resolution can be revised later.

```text
Execution status (owned by Symm)

created --> starting --> running --success--> succeeded
  |           |             |-- failure --> failed
  |           |             +-- cancel/shutdown --> cancelled*
  |           +-- start failure -----------------> failed
  |           +----------------------------------> cancelled
  +----------------------------------------------> cancelled

* If process-group cleanup cannot be confirmed, the task ends as failed.

Resolution (recorded by callers, finished tasks only)

unresolved --resolve_task--> judged
judged --resolve_task--> judged
judged = accepted | rejected | needs_followup | superseded
```

For a clean completion, `succeeded` means exit code 0 (`agy`) or stop reason
`end_turn` (ACP agents).

## Symmetric by Design

There is no built-in implementer or reviewer role. The same five tools cover every direction
work can travel:

```text
Implement, then verify yourself
  You -- implement X --> Agent
  You <-- result ------ Agent

Implement yourself, ask for review
  You -- review workspace --> Agent
  You <-- findings ------- Agent

Chain or fan out
  Agent A -- implement --> Agent B -- find flaws --> Agent C
      |-- review 1 --> Reviewer 1
      +-- review 2 --> Reviewer 2
```

`initiator_id` and `executor_id` are descriptive labels only. They never grant ownership or
authority.

## Tools

| Tool | What it does |
|---|---|
| `spawn_task` | Dispatch a prompt to a registered agent. Returns as soon as the agent process is running. |
| `get_task` | Current state: execution `status`, `exit_code`, `session_id`, resolution, timestamps. |
| `get_events` | Events after `after_seq` plus a `cursor` to pass next time, and the current `status`. |
| `resolve_task` | Record `accepted`, `rejected`, `needs_followup`, or `superseded` for a finished task. No side effects; may be revised. |
| `cancel_task` | Stop a running agent's process group; failed cleanup ends the task as failed. A finished task is unchanged. |

Registered agents. Symm is the ACP client for six agents. The Claude Code and Codex adapters
launch the user-installed `claude-agent-acp` and `codex-acp` executables. Copilot, Cline, Cursor,
and opencode are launched through their ACP commands. Antigravity has no ACP mode and runs as a
CLI.

`model` and `effort` reach each agent through the interface it accepts. Claude Code requests
`model` and `effort` as ACP session settings. Codex requests `model` and maps `effort` to the
ACP setting `reasoning_effort`. Symm checks ACP settings against values advertised by the agent
before sending the prompt. Cursor and opencode accept `model` only, as the ACP setting `model`.
Cline takes `model` as an ACP session setting and passes `effort` as `--thinking=<effort>`.
Copilot takes `model` and `effort` as joined launch flags (`--model=<model>` and
`--reasoning-effort=<effort>`), which Copilot checks. Antigravity passes `model` as
`--model <model>` and `effort` as `--effort=<effort>`.

| Agent | Executable Symm launches | `options` |
|---|---|---|
| `claude_code` | `claude-agent-acp` | `allow`, `model`, `effort` |
| `codex` | `codex-acp` | `allow`, `model`, `effort` |
| `copilot` | `copilot --acp` | `allow`, `model`, `effort` |
| `cline` | `cline --acp` | `allow`, `model`, `effort` |
| `cursor` | `cursor-agent acp` | `allow`, `model` |
| `opencode` | `opencode acp` | `allow`, `model` |
| `antigravity` | `agy --print=<prompt>` | `model`, `effort`, `mode`, `sandbox`, `dangerously_skip_permissions`, `print_timeout` |

### Permissions

Symm does not widen an agent's permissions on its own.

- **ACP agents ask Symm, and Symm answers.** The `allow` option lists ACP tool kinds approved
  for this task: `read`, `edit`, `delete`, `move`, `search`, `execute`, `think`,
  `fetch`, and `other`. For an allowed kind, Symm chooses a one-time allow option;
  otherwise it chooses a one-time reject option. It never chooses an option that applies
  always. Mode-switch requests are never granted. If the one-time option it needs is not
  offered, Symm cancels the request. A decision Symm cannot record is cancelled, and the
  task fails. Each prompt-turn decision is recorded as a `permission` event. A request
  outside the prompt turn is cancelled without an event.
- **Permission settings are pinned.** Before the prompt, Symm sets Claude Code `mode=default`,
  Codex `mode=read-only`, Copilot `allow_all=off`, and Cline `auto_approve=false`. Cursor is
  pinned to `mode=agent` only when `allow` contains `edit`; otherwise its mode is `plan`. The
  opencode adapter is pinned to `mode=build` and configured to ask before edits, shell commands,
  and web fetches. Callers cannot set these pinned settings. If an ACP agent does not offer a
  setting or value Symm must pin, the task fails before the prompt and records an `error` event
  naming it. The opencode adapter's ask rules are supplied through `OPENCODE_CONFIG_CONTENT`:
  Symm preserves the user's other inline configuration, overrides those permission entries, and
  rejects a value that is not a JSON object.
- **Cursor has a mode-specific limitation.** In `agent` mode Cursor creates and edits files
  without asking, so Symm selects it only when `allow` includes `edit`. Cursor labels file
  deletions as `edit`, so allowing `edit` also allows deletion. In `plan` mode Cursor writes no
  files and runs no commands, so an `execute` grant cannot be used without `edit`.
- **Antigravity has no ACP permission exchange.** Symm cannot pin its settings and adds no
  permission flag by default, so its headless default applies and unapproved actions are denied.
  `effort` accepts `low`, `medium`, `high`, `xhigh`, or `max` and is passed as
  `--effort=<effort>`. A caller can set `mode` to `accept-edits` or `plan`, enable terminal
  restrictions with `sandbox=true`, or set `dangerously_skip_permissions=true` to auto-approve
  every tool request. `print_timeout` accepts a positive integer followed by `s`, `m`, or `h`;
  its default is five minutes. Its prompt is one `--print=<prompt>` argument, limited to 100,000
  bytes and rejected if it contains a NUL character.
- **Launch-only choices are checked by the agent.** Copilot effort accepts `none`, `minimal`,
  `low`, `medium`, `high`, `xhigh`, or `max`. Cline effort accepts `none`, `low`, `medium`,
  `high`, or `xhigh`. Launch-option model values must be safe tokens that cannot be read as
  options. The CLI checks launch-only choices; a value it rejects fails the task. ACP model and
  effort settings are checked against the agent's advertised values before the prompt. Boolean
  options accept only JSON `true` or `false`.

### Events

Every created task records `task_created`; each status transition is a `status_changed` event.
When the agent process starts, Symm records `process_started`; after supervision finishes, it
records `process_exited`. A start failure records `error` without a process start or exit.
Depending on what happens, a task can also record `task_cancelled` and `task_resolved`. The
full event type set is:
`task_created`, `process_started`, `stdout`, `stderr`, `status_changed`, `process_exited`,
`task_resolved`, `task_cancelled`, `error`, `session_started`, `agent_message`,
`agent_thought`, `tool_call`, `tool_call_update`, `plan`, `permission`, and `agent_update`.

For ACP agents, `process_exited` also carries the turn's `stop_reason`; only
`end_turn` counts as `succeeded`.

For ACP agents, `session_started` records the agent session id, stores it in the task's
`session_id`, and makes it available to `spawn_task` for a later resume. All six ACP adapters
support resume; Antigravity does not. A resumed task starts a new agent process and loads the
existing session in the same workspace. It records only updates from the new prompt turn, not
history replayed while loading. It uses only the `model` and `effort` choices supplied to that
`spawn_task` call; Symm does not reapply choices from an earlier task. An agent that cannot load
sessions fails before the prompt. If it rejects or does not recognize an id, the `error` event
names the id and says it may not exist for that agent or workspace.

Each ACP request before the prompt (initialize, new or load session, and setting changes) has a
120-second timeout. If the agent does not answer, the task fails before the prompt and the
`error` event names the unanswered request, such as `load_session`.
An invalid or unavailable model/effort session setting fails before the prompt and
records an `error` naming the setting; it also names the value when the agent offers
that setting but not that value.

ACP message and thought chunks are coalesced and recorded at least once per second. `tool_call`
and `tool_call_update` omit content blocks; raw output whose JSON encoding exceeds 4,000
characters is truncated to a preview. CLI stdout and stderr are recorded as output events. ACP
stdout carries the protocol and is not recorded as output; ACP stderr lines are recorded.

Invalid requests and lookup or transition failures are MCP tool errors whose text has the form
`<code>: <message>`. Codes are `unknown_agent`, `invalid_request`, `task_not_found`, and
`invalid_transition`. Failed starts return a failed task with an `error` event. ACP session
and supervision failures may also record `error`; a CLI's nonzero exit is shown by its
`process_exited` event and failed status.

## Use

Requires [uv](https://docs.astral.sh/uv/), plus the CLI of each agent you use on `PATH`
(`agy`, `claude-agent-acp`, `cline`, `codex-acp`, `copilot`, `cursor-agent`, `opencode`).
Symm does not install, update, or document the installation of agents: install and authenticate
each one following its vendor's instructions
([ADR 0005](https://github.com/tacticaldoll/symm-mcp/blob/main/docs/adr/0005-agents-are-installed-by-the-user.md)).
Symm runs the first match for each executable name in the absolute entries of the server's own
`PATH`; which copy and version that is, is up to you.

Register Symm with an MCP client, for example Claude Code:

```bash
claude mcp add symm -- uvx symm-mcp@0.1.2
```

Or in a client's JSON configuration:

```json
{
  "mcpServers": {
    "symm": {
      "command": "uvx",
      "args": ["symm-mcp@0.1.2"]
    }
  }
}
```

A typical loop, as the calling agent sees it:

1. `spawn_task(agent="claude_code", prompt="Review the authentication changes in this
   workspace and list concrete flaws.", workspace="/path/to/repo")` returns a `task_id` while
   the reviewer starts working.
2. Continue with other work.
3. `get_events(task_id, after_seq=<cursor>)` to follow progress; `get_task(task_id)` once
   `status` is terminal.
4. `resolve_task(task_id, resolution="needs_followup", note="two findings to fix")`, then
   dispatch the next task, possibly with the reviewer and implementer roles swapped.

Agents run in the given workspace with the server's environment. Pass `workspace` explicitly: the
default is the server's working directory, which your MCP client chose and may be your home
directory. Symm does not copy, isolate, or clean workspaces.

## Architecture

```text
MCP client
   | stdio
   v
+------------------------ symm-mcp process -------------------------+
| MCP server (five tools, errors, signals)                          |
|    |                                                              |
|    v                                                              |
| TaskService (orchestration, per-task locks)                       |
|    +--> Task store (JSON strings in memory)                       |
|    +--> Agent registry (adapter option allowlists)                |
|    +--> TaskSupervisor (process groups, argv without a shell)     |
|              |                                                    |
|              +-- launches agy and ACP agents in shared workspace  |
|              +-- reads agy stdout/stderr and ACP stderr, exits    |
|              +-- runs ACP session client for ACP agents           |
|                    (sessions, settings, permissions)              |
|                         |                     ^                   |
+-------------------------|---------------------|-------------------+
                          | ACP over stdio      | session updates,
                          v                     | permission requests
                 ACP agent processes (own process groups)
```

Logical state (tasks, events, resolutions) lives in the store and is always serializable.
Runtime state (processes, pipes, process groups) lives only in the supervisor. The MCP layer
stays a thin transport. These boundaries are part of the project contract in
[PROJECT.md](https://github.com/tacticaldoll/symm-mcp/blob/main/PROJECT.md).

## v0.1 Scope and Limitations

- **stdio only, process-local task ledger.** Each MCP client launches its own Symm server, and
  each server sees only the tasks it dispatched. `stdio + in-memory = process-local task ledger`.

  ```text
  Agent A
     | stdio
     v
  Symm for A [ledger: task 1]
     | task 1
     v
  Agent B
     | stdio
     v
  Symm for B [ledger: task 2]
     | task 2
     v
  Agent C
  ```

  Agent A sees task 1 but not task 2, which B dispatched through its own server. A shared
  ledger needs an HTTP gateway with a shared store, which is deferred.
- **In memory only, by design.** Task state is lost when the server process exits, and Symm does
  not persist it. For ACP agents, the agent keeps its own session; to continue later, even from
  a new client or server process, keep the task's `session_id` and pass it to `spawn_task`.
  `spawn_task` usually returns before an ACP session exists, so read `session_id` with
  `get_task` or from the `session_started` event. Resume with the same agent and workspace. The
  ledger and earlier task's events do not resume with it.
- **No total output cap or retention policy.** Stdout and stderr lines longer than 1 MiB are split
  into pieces no larger than 1 MiB. There is no cap on total output; output remains in the
  process-local ledger, so long, high-output tasks grow server memory.
- **Tools only.** No MCP resources or subscriptions yet; observe by polling `get_events`
  with its cursor.
- **Bounded process cleanup.** Before reporting completion, Symm tries to end every remaining
  member of the agent's process group. When canceling a running ACP task, it first asks the agent
  to cancel its prompt turn, then uses SIGTERM and, if needed, SIGKILL with bounded waits. If
  Symm cannot confirm the group is empty, the task ends as `failed` and the supervisor stops
  managing it; a process can remain alive. On client disconnect or handled SIGTERM, SIGINT, or
  SIGHUP, the server applies the same bounded cleanup before it exits. A task requested after
  shutdown begins fails rather than starting. A server killed with SIGKILL or one that crashes
  cannot run its shutdown handling, so its agents may survive.
- **Your environment, your permissions.** Symm does not install agent software or widen
  permissions by itself. Dependencies, secrets, sandboxing, and workspace cleanliness are the
  caller's responsibility; callers can request only the permission changes in adapter options.
- **POSIX only.** Process groups and signals; Windows is not supported.

Rationale:
[ADR 0003](https://github.com/tacticaldoll/symm-mcp/blob/main/docs/adr/0003-v0-1-runtime-boundaries.md) and
[ADR 0007](https://github.com/tacticaldoll/symm-mcp/blob/main/docs/adr/0007-the-ledger-is-not-persisted.md).
Security assumptions and how to report a vulnerability:
[SECURITY.md](https://github.com/tacticaldoll/symm-mcp/blob/main/SECURITY.md).

## Run Locally

From a checkout:

```bash
uv run symm-mcp
```

The command speaks MCP over stdio, so it is meant to be launched by an MCP client rather than
used interactively.

## Development

This project uses OpenSpec; read
[AGENTS.md](https://github.com/tacticaldoll/symm-mcp/blob/main/AGENTS.md)
before changing anything. The Definition of Done:

```bash
uv sync
uv run python -m compileall -q src tests
uv run python -m pytest
uv run ruff check .
uv run ruff format --check .
./scripts/changelog-guard.sh
```

## License

Licensed under either of
[Apache-2.0](https://github.com/tacticaldoll/symm-mcp/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/tacticaldoll/symm-mcp/blob/main/LICENSE-MIT), at your option.
