Metadata-Version: 2.4
Name: symm-mcp
Version: 0.3.0
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://gitlab.com/116lab/ensemble/symm/symm-mcp
Project-URL: Repository, https://gitlab.com/116lab/ensemble/symm/symm-mcp
Project-URL: Issues, https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/work_items
Project-URL: Changelog, https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/blob/main/CHANGELOG.md
Project-URL: Security, https://gitlab.com/116lab/ensemble/symm/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 |      |wait_tasks / get_task |      |resolve_task  |
+-----------+      +----------------------+      +-------+------+
       ^                                                 |
       |        Repeat: next task / new viewpoint        |
       +-------------------------------------------------+
```

## Status

0.3.0, the latest release
([changelog](https://gitlab.com/116lab/ensemble/symm/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 -|
  | wait_tasks([task_id])        |                       |
  |----------------------------->|                       |
  |                              |<-- turn ends ---------|
  |<-- completed, terminal ------|                       |
  |    (status, final_message,   |                       |
  |     permissions_refused)     |                       |
  :                              :                       :
  : optional, when the outcome is not enough:            :
  | get_task(task_id)            |                       |
  |----------------------------->|                       |
  |<-- usage, session_id, ... ---|                       |
  | get_events(after_seq=cursor) |                       |
  |----------------------------->|                       |
  |<-- page, cursor, has_more ---| repeat until          |
  |                              | has_more is false     |
  :                              :                       :
  | resolve_task(...)            |                       |
  |----------------------------->|                       |
  |<-- task with resolution -----|                       |
```

`wait_tasks` and `get_task` both return the turn's `final_message` (its trailing `agent_message`
text, capped at the last 4,000 characters, with `final_message_truncated` saying whether the cap
cut it) and `permissions_refused`, so the usual path reads the outcome there. `get_task` alone also
returns `usage`. `get_events` pages through the full record when the outcome is not enough, and can
be read while the task runs.

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 the agent's prompt turn ended with stop reason
`end_turn`.
This does not show that the agent did useful work: check for `agent_message` or `tool_call`
events, or verify the work, before accepting it.

## Symmetric by Design

There is no built-in implementer or reviewer role. The same thirteen 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 |
|---|---|
| `describe_agent` | Reads an agent's advertised model and effort offers, how each choice is delivered, and the settings Symm pins. Creates no task. |
| `list_sessions` | Lists the sessions an agent can resume from a workspace, one page at a time, with a cursor for the next. Creates no task. |
| `delete_session` | Deletes one of an agent's sessions, which cannot be undone, and returns the agent, the resolved workspace, the session id and `deleted: true`. Creates no task. |
| `spawn_task` | Dispatches a prompt to a registered agent and returns the task summary plus the task's `notify_path` as soon as the agent process is running. With `keep_idle`, `keep_turns` or `on_agent` it also returns `kept_agent` (see Keeping an agent). |
| `get_task` | Returns a task's current state: status, error, resolution and its outcome (`final_message`, `permissions_refused`, `usage`). |
| `get_events` | Returns one bounded page of a task's events after `after_seq`, with `cursor`, `has_more` and `status`. |
| `wait_tasks` | Waits for any of the given tasks to end and returns the finished ones with `final_message` and `permissions_refused`, and a `reason`. |
| `list_tasks` | Lists this server's tasks, newest first, as summaries; recovers task ids a caller lost. |
| `resolve_task` | Records `accepted`, `rejected`, `needs_followup` or `superseded` for a finished task and returns the task summary plus its resolution note, actor and time. It has no other effect and may be revised. |
| `cancel_task` | Stops a running task's agent process group and returns the task summary. A finished task is unchanged. |
| `forget_task` | Frees a finished task the caller has already resolved, with its event history and completion marker, and returns `{task_id, forgotten: true}`. Creates no task and cannot be undone. |
| `extend_agent` | Renews a kept agent's lease and returns `{handle, expires_at, turns_left}`. Creates no task. |
| `release_agent` | Ends a kept agent and returns `{handle, agent, session_id, released: true}` once its end is known. Creates no task. |

### Tool results and limits

What the table leaves out, tool by tool. Error codes are listed under Events.

- **`describe_agent`.** Each model or effort offer is `{current, values}`; launch flags have a null
  `current`, and effort values describe the default model. It also reports `delivery` (per choice,
  `session_setting` or `launch_flag`), `pins` (the settings Symm fixes) and `pins_with_edit` (the
  pins when `allow` includes `edit`). Process startup and ACP setup share a 30-second deadline;
  ending the process group afterward is a separate, bounded supervisor cleanup attempt. No task
  is created, though the agent may keep the session it created for the description in its history.
- **`list_sessions`.** Returns `{agent, workspace, sessions, next_cursor}`; each session keeps
  `session_id`, `cwd`, `title`, `updated_at` and `additional_directories` only when the agent
  reports them, never `_meta`. It passes the resolved workspace as the `session/list` cwd filter
  and a `cursor` through; the page size is the agent's. It has the same 30-second deadline and
  bounded cleanup as `describe_agent`. An agent that did not advertise session list fails with
  `unsupported:`.
- **`delete_session`.** Sends ACP `session/delete` for `session_id`, under the same 30-second
  deadline and bounded cleanup as `describe_agent`. An agent that did not advertise session delete
  fails with `unsupported:` before anything is sent; a session a live task or a kept agent of this
  server holds, or that another delete is deleting, fails with `session_in_use:`, as does a resume
  that arrives while the delete runs. `describe_agent` and `list_sessions` never delete anything.
- **`spawn_task`.** `keep_idle` (1 to 600 seconds) and `keep_turns` (1 to 20) ask Symm to keep the
  agent alive after a successful turn (see Keeping an agent). A ninth keep request fails with
  `agent_limit:` and creates no task. `on_agent` takes a kept agent's handle and runs the task's
  turn on that agent instead of starting one; it is refused, creating no task, with `agent_gone:`,
  `agent_busy:` or `invalid_request:`. With `on_agent`, `keep_idle` and `keep_turns` renew the
  lease instead of asking to keep an agent.
- **`get_task`.** Returns the task summary (execution `status`, `exit_code`, `error`, `session_id`,
  `resolution`, timestamps) plus `started_at`, `resolution_note`, `resolved_by` and `resolved_at`.
  `error` is the latest error message, usually why the task failed; one recorded after the task
  ended does not replace it. `include_prompt` returns the prompt as well. `permissions_refused` is
  a count of the task's recorded permission requests that were rejected or cancelled, never more
  than were recorded; it is informational and never changes `status`. `usage` is the turn's last
  context report and its token report, as the agent reported them, and null before a turn.
  `final_message` is the turn's trailing consecutive `agent_message` events, capped at the last
  4,000 characters, and `final_message_truncated` says whether the cap cut it. A task that asked to
  keep its agent, or runs on a kept agent with `on_agent`, also carries `kept_agent`.
- **`get_events`.** A page holds 50 events by default (up to 500 with `limit`) and a soft
  8,000-character budget (`max_chars` of at least 1,000). `agent_thought` events and the
  `rawInput`/`rawOutput` previews of `tool_call` and `tool_call_update` payloads are excluded
  unless asked: `include_thoughts` returns thoughts, `include_raw` keeps the raw previews.
  `event_types` narrows what is returned.
- **`wait_tasks`.** Takes up to 50 distinct task ids and waits at most 30 seconds. Each finished
  task is its summary plus `permissions_refused`, `final_message` and `final_message_truncated` as
  in `get_task`; `usage` is read with `get_task`. `reason` is `terminal`, `timeout` or `shutdown`.
- **`list_tasks`.** Pages 20 tasks by default (up to 100 with `limit`) and returns `next`, which
  `after` takes for older tasks.
- **`cancel_task`.** A stop that leaves the process group not confirmed empty ends the task as
  `failed`, not `cancelled`, and keeps both the cancellation and the error in its events.
- **`forget_task`.** Afterwards `get_task`, `get_events` and a second `forget_task` fail with
  `task_not_found:`, `list_tasks` no longer lists the task, and an `after` cursor naming it still
  pages. A task that is still running, not resolved, or whose kept agent is still alive fails with
  `invalid_transition:`; an unknown id fails with `task_not_found:`. Nothing is created or changed
  by a refusal.
- **`extend_agent`.** `keep_idle` (1 to 600 seconds) replaces the idle bound and `keep_turns` (1 to
  20) replaces the turns left; one left out keeps its current value, and the idle clock starts over
  from the idle bound either way. A bound out of range fails with `invalid_request:`, a handle that
  names no kept agent with `agent_gone:`, and an agent running a turn with `agent_busy:`.
- **`release_agent`.** Sends ACP `session/close` first when the agent advertised it, then ends the
  process group. A handle that names no kept agent fails with `agent_gone:`, one running a turn
  with `agent_busy:`, and a group that cannot be confirmed empty with `invalid_request:` after the
  handle is gone.

### Keeping an agent

A task ends its agent when its turn ends. `spawn_task` with `keep_idle` or `keep_turns` asks Symm
to keep the agent process alive instead, under a lease, when the turn ends `succeeded` with no
unkept guarantee; the other bound takes its default of 300 seconds or 1 turn. The agent's handle
(`agent_` and 12 hexadecimal digits) comes back in `kept_agent`, which `get_task` shows live: its
`state`, `expires_at` and `turns_left`. The turn records `agent_kept` in place of `process_exited`.
Any other turn ends its agent as before, and `kept_agent` says why it was not kept; so does a
`cancel_task` that arrives before the keep completes, which ends the agent and leaves a finished
task `succeeded`. At most 8 agents
are kept, and the kept agent holds its session, so a resume of that session is refused with
`session_in_use:` naming the handle. The lease ends, and Symm ends the agent's process group, when
`release_agent` is called, when `keep_idle` passes with no turn, when the agent exits, or when the
server shuts down; a group that cannot be confirmed empty keeps the session held and makes the
server exit 1.

```text
Caller                     Symm server                Agent process
  | spawn_task(keep_idle,        |                       |
  |   keep_turns=2)              |---- prompt, turn 1 -->|
  |<-- task_id, kept_agent ------|                       |
  |    (handle, state=pending)   |<-- turn ends ---------|
  |                              | succeeded: keep it,   |
  |                              | record agent_kept     |
  |                              | (turns_left=2)        |
  | wait_tasks([task_id])        |                       |
  |----------------------------->|                       |
  |<-- completed ----------------|                       |
  | get_task(task_id)            |                       |
  |----------------------------->|                       |
  |<-- kept_agent: state=kept ---| idle clock runs       |
  |    expires_at, turns_left    |                       |
  | spawn_task(on_agent=handle)  |                       |
  |----------------------------->|---- prompt, turn 2 -->|
  |<-- new task_id --------------|<-- turn ends ---------|
  |                              | same process and      |
  |                              | session, no           |
  |                              | process_started;      |
  |                              | succeeded: kept again |
  |                              | (turns_left=1)        |
  | extend_agent(handle)         |                       |
  |----------------------------->| restart idle clock;   |
  |<-- handle, expires_at, ------| given bounds replace  |
  |    turns_left                | the old ones          |
  | release_agent(handle)        |                       |
  |----------------------------->|---- session/close --->|
  |<-- released: true -----------| end process group     |
  :                              :                       :
  : or Symm ends the group itself when keep_idle passes
  : with no turn, the agent exits, the server shuts down,
  : or a turn leaves no turn to spend or does not succeed
```

`extend_agent` and `release_agent` are refused with `agent_busy:` while a turn runs, so they come
between turns; `release_agent` sends `session/close` only when the agent advertised it.

A later task runs its turn on a kept agent with `spawn_task(on_agent=<handle>)`. The turn uses the
agent's live process and session, so the process is not started again and the task records no
`process_started`, and the handle keeps holding the session. It is still one task per turn: the task
carries the handle and `get_task` shows its `kept_agent`. A turn spends one of the agent's
`turns_left`, and the idle bound pauses while the agent runs it; when the turn succeeds the agent is
kept again with a fresh `keep_idle`, and its task records `agent_kept`. When no turn is left, or the
turn does not succeed, or `cancel_task` stops it, the agent's group is ended after the turn and the
task records `process_exited`. A turn's model and effort are sent before its prompt and stay with
the session until a later turn changes them; its `allow` applies to that turn only. What Symm can
only pass when it starts the process, such as Cline's `effort`, the workspace and the agent must be
the kept agent's: a launch option left out keeps the process's value, and one with a different value
is refused with `invalid_request:` naming it. So is a model or effort the agent does not offer,
checked before the turn is claimed, so a typo costs neither a task nor a turn. An agent that is
running a turn, or is still finishing the turn that kept it after a wait of at most the grace period, is `agent_busy:` and a task that waits for it should `wait_tasks` and retry; Symm
does not queue. `on_agent` cannot be combined with `session_id`. `keep_idle` and `keep_turns` given with it renew
the lease from the keep that follows the turn, as `extend_agent` does while the agent is idle:
`keep_idle` is the idle bound, `keep_turns` the turns left after this turn, a bound left out keeps
its value, and a turn that does not keep the agent renews nothing. A value out of range is refused
with `invalid_request:` before the claim.

Registered agents. Symm is the Agent Client Protocol (ACP) client for six agents, and drives
every agent over ACP. 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.

The caller's `model` and `effort` choices reach each agent through the interface it accepts. Claude
Code uses ACP session settings `model` and `effort`. Codex and Copilot use `model` and map `effort`
to the session setting `reasoning_effort`. Symm checks session settings against the agent's ACP
offer before sending the prompt. Cursor and opencode accept only `model` as a session setting.
Cline takes `model` as a session setting and passes `effort` as the launch flag
`--thinking=<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` |

### Permissions

Symm never widens an agent's permissions on its own: it approves only request kinds in `allow`,
never chooses an "always" option, and pins the permission settings the caller cannot set. Actions
an agent takes without sending Symm a permission request are outside what Symm observes or
enforces. Broader enforcement, such as sandboxing, belongs to the caller or an external policy
layer.

- **Symm answers agent-sent permission requests.** 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 its `agent` mode and then `allow_all=off`, and Cline
  `auto_approve=false`. Copilot's `autopilot` mode turns allow-all on and continues the turn on
  its own, and a resumed Copilot session keeps its saved mode. 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. An empty value, or one of only JSON whitespace, counts
  as unset.
- **Cursor has a mode-specific limitation.** Symm pins Cursor to `plan` when `allow` lacks `edit`;
  in `plan` mode Cursor itself writes no files and runs no commands. 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.
- **Launch flags are checked against no offer.** Cline's `effort` launch flag accepts `none`,
  `low`, `medium`, `high`, or `xhigh`; Symm refuses any other value with `invalid_request:` and
  creates no task. Model and effort session settings are checked against the agent's offer before
  the prompt.

### 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`, or `agent_kept` when the agent is kept after its turn. 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 event types a task records today are:
`task_created`, `process_started`, `stderr`, `status_changed`, `process_exited`,
`task_resolved`, `task_cancelled`, `agent_kept`, `error`, `session_started`, `agent_message`,
`agent_thought`, `tool_call`, `tool_call_update`, `plan`, `permission`, `agent_request`,
and `agent_update`.
New event types may be added in any release. Consumers must ignore event types they do not know.

`process_exited` carries the agent's `exit_code` and the turn's `stop_reason`; with no requested
stop or cleanup failure, only `end_turn` counts as `succeeded`, regardless of process exit code.
A requested stop ends as `cancelled` even when the agent reports `end_turn`.

`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. `list_sessions` shows the sessions an agent
can resume from a workspace, and `delete_session` deletes one. All six adapters support resume. 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. An agent's stdout carries the protocol and is not recorded;
its stderr lines are recorded as `stderr` events.

Symm's own request, lookup, transition, and session-use failures are MCP tool errors whose text
has the form `<code>: <message>`. Codes are `unknown_agent`, `unsupported` (the agent did not
advertise the capability the call needs), `invalid_request`, `task_not_found`,
`invalid_transition`, `session_in_use`, `agent_limit`, `agent_gone`, and `agent_busy`. The MCP SDK rejects arguments that do not fit a
tool's input schema, including wrong types, missing required arguments, and unsupported values
such as a `resolution` outside its allowed values, before Symm handles the call. These validation
errors use the SDK's error text and have no Symm error code. Failed
starts return a failed task with an `error` event. ACP session and supervision failures may also
record `error`; an agent that exits before ending its turn fails the task.

## Use

Requires [uv](https://docs.astral.sh/uv/), plus the CLI of each agent you use on `PATH`
(`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
([intent/agent-adapters.md](https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/blob/main/intent/agent-adapters.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.3.0
```

Or in a client's JSON configuration:

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

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. `wait_tasks([task_id])` until it returns `reason` `terminal` with the task in its `completed`
   list. Each call waits at most 30 seconds. Read the outcome there: `status`, `final_message`
   (check `final_message_truncated`) and `permissions_refused`, the count of the task's permission
   requests that were rejected or cancelled.
4. Only when that is not enough, read the record with `get_events(task_id, after_seq=<cursor>)`,
   passing the returned `cursor` back while `has_more` is true; `event_types` narrows what is
   returned. `get_task` adds `usage`.
5. `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.
6. When the result is no longer needed, `forget_task(task_id)` frees the task and its events. Do
   it after anything waiting on its `notify_path` has woken, because forgetting removes the
   marker.

### Waiting Without Polling

`spawn_task` returns `notify_path`. When a task ends while the server is running, Symm creates an
empty file at that path. If the server shuts down first, it removes the directory and no marker
appears; read the outcome with `get_task`. A caller whose host wakes it when a
background command ends, as Claude Code does, can run this in the background instead of calling
`wait_tasks` again and again:

```bash
until [ -e "$P" ] || [ ! -d "$(dirname "$P")" ]; do sleep 1; done
```

`P` is the `notify_path`. The loop checks before it waits, because a task can end before
`spawn_task` returns. The file only signals that the task ended. Read the outcome with `get_task`.
If the directory is gone, the server has usually exited. Call `get_task` anyway: if the server
still answers, its base directory was moved, and you can fall back to `wait_tasks`. A server
killed with SIGKILL, or one whose file system stalls during shutdown, can leave the directory
behind, so give the loop your own deadline if the wait must end.

Markers live in a directory of their own for each server process, inside a per-user base:
`$XDG_RUNTIME_DIR/symm-mcp`, or `symm-mcp-<uid>` in the system temporary directory, where `<uid>`
is the effective uid. Pass
`--notify-dir DIR` to choose another base, or `--no-notify` to turn markers off, after the
package in the registration above, for example `symm-mcp --no-notify`. If the base is a symbolic link, is not yours, or is writable by
others, Symm logs a warning and starts with markers off. `notify_path` is `null` when markers are
off, whether because of `--no-notify` or because Symm refused the base at startup
([intent/completion-markers.md](https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/blob/main/intent/completion-markers.md)).

Agents run in the given workspace with the server's environment plus values their adapter sets;
adapter values override inherited ones. Every agent also receives `SYMM_MCP_DEPTH`, set to the
server's depth plus one, and a server started with `SYMM_MCP_DEPTH` of 1 or more, or a malformed
value, or below a Linux ancestor that carries the nesting marker, lists no tools and says why in
its instructions: an agent Symm launched cannot dispatch further agents through Symm. The
opencode adapter rewrites `OPENCODE_CONFIG_CONTENT` to include its permission configuration. 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 (thirteen tools, errors, signals)                      |
|    |                                                              |
|    v                                                              |
| TaskService (orchestration) -> ledger (per-task locks, writes)    |
|    +--> Task store (JSON strings in memory)                       |
|    +--> Agent registry (adapter option allowlists)                |
|    +--> ProcessPool (process groups, argv without a shell)        |
|              |                                                    |
|              +-- launches ACP agents in shared workspace          |
|              +-- keeps agents alive after a turn, under leases    |
|              |     (an idle clock per lease, 8 kept at most)      |
|              +-- serves describe_agent, list_sessions and         |
|              |     delete_session on short-lived probe processes  |
|              |     (no task, not through the ledger)              |
|              +-- reads agent stderr, observes exits               |
|              +-- runs the ACP session client                      |
|                    (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 process pool. The MCP layer
stays a thin transport. These boundaries are part of the project contract in
[PROJECT.md](https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/blob/main/PROJECT.md).

## 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. 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. If a live task or a kept agent of this
  server already holds that `(agent, session_id)`, `spawn_task` returns `session_in_use:` naming
  that task id or the handle without creating a task. This limit is process-local: another Symm server or the agent's CLI
  can still use the session because the agent owns its session store.
- **No cap on memory, output or retention.** Task records, their event histories and every
  kept-agent handle ever reserved (one small entry each, also one never kept) live in the server's
  memory for as long as the server runs, with no limit on their number, their size or the total
  output, and are lost when the server exits; stderr lines longer than 1 MiB are split into pieces
  no larger than 1 MiB. A server that is never asked to forget grows with every task, and long,
  high-output tasks grow it faster.
  A caller that has read a finished task's result and resolved it can free it with `forget_task`;
  Symm never frees a task on its own, because that would change what `get_task` and `get_events`
  return for old tasks. A forgotten task still costs its id and its place in creation order, so
  that cursors keep working and the id is never reused. Forgetting removes the task's completion
  marker, so forget a task only after anything waiting on its `notify_path` has woken.
- **Tools only.** No MCP resources or subscriptions yet. Wait for completion with `wait_tasks`,
  which returns within 30 seconds, or in the background on the task's completion marker. Read
  progress with `get_events` and its cursor.
- **Bounded process cleanup.** Before reporting completion, Symm tries to end every remaining
  member of the agent's process group, except for an agent the caller asked to keep after a turn
  that succeeded: its group ends with its lease, by the same bounded cleanup. 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 pool 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, and exits with status 1
  on every one of those paths, including a signal, which would otherwise end the process by that
  signal, if any agent's process group could not be confirmed empty, so a host can tell that
  agents may remain. A shutdown that itself fails after end of input also exits with status 1; one
  that fails during a signal still ends the process by that signal unless a group is unconfirmed.
  When every group is confirmed empty and shutdown itself did not fail, end of input exits 0 and a
  signal ends the process by itself. 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 provide an
  operating-system sandbox for agent actions. Dependencies, secrets, sandboxing, and workspace
  cleanliness are the caller's responsibility. Symm pins adapter-controlled permission settings
  and answers the ACP permission requests an agent sends according to `allow`. It declines
  every elicitation request an agent sends during a turn, and cancels one that arrives outside a
  turn.
- **POSIX only.** Process groups and signals; Windows is not supported.

Rationale:
[PROJECT.md](https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/blob/main/PROJECT.md) and the decisions under
[intent/](https://gitlab.com/116lab/ensemble/symm/symm-mcp/-/tree/main/intent).
Security assumptions and how to report a vulnerability:
[SECURITY.md](https://gitlab.com/116lab/ensemble/symm/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 records its decisions as [plerosis](https://gitlab.com/116lab/plerosis/plerosis)
intent under `intent/`; read
[AGENTS.md](https://gitlab.com/116lab/ensemble/symm/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
plerosis check --run
```

## License

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