agent-peers 0.1.0 → 1.0.0-rc.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-rc.1 — 2026-10-17
4
+
5
+ Release candidate for 1.0.0, published to the npm `next` tag (`npm install -g agent-peers@next`). From 1.0.0, the [stability policy](docs/stability.md) defines what semver covers. One delivery protocol for Codex, Hermes and Claude Code peers. See [the reference](docs/reference.md#send_peer-result).
6
+
7
+ ### Breaking
8
+
9
+ - `send_peer` returns one result on both hosts: `{status, reason, message_id, in_reply_to, to, name, generation, from}`. Codex's `structuredContent.receipt` is now `status`. Hermes's `address` is now `from`, and Hermes failures return the full result with `error` instead of `{error}` alone. There are no aliases.
10
+ - A send to a Claude Code session reports `unconfirmed` instead of "Delivered", because Claude sends no receipt for an accepted message. A write that times out is `failed`/`write-timeout`.
11
+ - Peer names resolve exactly. Unique-substring abbreviations are gone; an exact name without its `claude:` or `codex:` prefix still works when only one kind has it. `not-found` lists exact names to choose from.
12
+ - Names containing `/` are reserved for explicit claims (`<kind>:<scope>/<claim>`) and fail `reserved-name`; `@ * ? ,` and whitespace fail `invalid-request`. Claude session names and Codex labels with other characters are listed with `-` in their place.
13
+ - Inboxes dedup by sender and message id only. Repeated identical bodies are delivered.
14
+ - A Hermes conversation's inboxes accept messages only from peers that conversation has sent to; others get `dropped`/`not-a-reply-target`. A session that lost its Hermes address could otherwise pick another conversation's inbox from the socket directory and post into an unrelated chat (#28).
15
+
16
+ ### Added
17
+
18
+ - Receipts from agent-peers inboxes in Claude Code's own format. Every sender gets `dropped` with a reason; agent-peers 1.0 senders opt in to `queued` and `accepted` through an `agent_peers` line extension that Claude Code and 0.x ignore (#8).
19
+ - `send_peer` takes `in_reply_to`, shown on the recipient's frame; Claude recipients get `(In reply to <id>.)` instead (#10).
20
+ - A `generation` on every listed session. A record whose PID now belongs to another process is not listed, and a send to a name or address that a different process has taken over fails `not-running` instead of reaching it (#12).
21
+ - A negative receipt that arrives after `send_peer` returned is appended to the Codex session's history without starting a turn. Hermes logs it only (#18).
22
+ - Codex `list_peers` reports the caller as `self` (#14).
23
+
24
+ ### Changed
25
+
26
+ - One Codex MCP process serves up to eight root sessions, each with its own inbox, instead of rebinding to the latest (#19). `codex-peer` is split into binding, inbox and tool modules (#16).
27
+ - The Hermes bridge handles requests concurrently, keeping order per conversation (#17).
28
+ - agent-peers never answers an app-server request (approvals, user input, dynamic tools, token refresh, attestation). These fan out to every client and the first answer wins, so an answer could override the client that owns the request. Each is logged by method (#25).
29
+ - Tested on Claude Code 2.1.302 and Codex 0.162.1. CI's Hermes integration job runs against the deployed fork build `0d7c749` (Hermes v0.22.2 plus a minimal host patch).
30
+ - Receipts are unauthenticated between same-user processes; a receipt must name the inbox the message was written to, which rejects stray ones (see [SECURITY.md](SECURITY.md)).
31
+
32
+ ## 0.3.0 — 2026-10-16
33
+
34
+ First npm release since 0.1.0. Versions 0.1.1 and 0.2.0 were released on GitHub only, and their changes are included here. From this version on, npm releases are published from CI with provenance.
35
+
36
+ - `agent-peers whoami [--json]` reports the calling Claude Code session's name and address, found through process ancestry rather than guessed from the working directory.
37
+ - `agent-peers list` probes each peer socket and marks refusing sockets `stale` / `reachable: false` without deleting records; `--no-probe` skips this.
38
+ - Peer discovery scans Claude session records once per listing instead of once per peer (about 110 ms to 5 ms for 120 peers).
39
+ - A delivery to a peer that closes its connection mid-write is reported as failed instead of delivered.
40
+ - Releases publish from a single CI job on version tags, after the tagged commit has passed CI; CI runs five jobs instead of ten.
41
+
42
+ ## 0.2.0 — 2026-10-16
43
+
44
+ GitHub release only; not published to npm.
45
+
46
+ - `agent-peers doctor` asks the Codex app-server one read-only question instead of trusting socket presence, and reports a stale daemon link or a server that does not answer, with the start command.
47
+ - Startup binding backs off to every 10 minutes after its quick retries and logs each failure reason once, plus one `unreachable` event and stderr line, instead of every minute forever.
48
+ - Documented headless Codex workers on a private app-server; the unreachable-thread error now names the app-server in use and the alternatives.
49
+ - Hermes frames carry the message `id` like Codex frames; Hermes sender names are sanitized for any route ID; the dependency recovery command uses `npm install` when the package has no lockfile.
50
+ - The Hermes bridge drops an inbound message whose re-encoded notification would exceed the host's line limit instead of restarting the bridge.
51
+ - The Claude compatibility footer is sent once per sender and recipient instead of on every message.
52
+ - `send_peer` results carry a message ID that matches the `id` on the recipient's frame.
53
+ - `@modelcontextprotocol/sdk` 1.32.1.
54
+ - Startup-binding scans log `mcpServerStatus/list` instead of an unknown method. The internal observatory design draft is no longer packaged.
55
+
56
+ - Shorter coordination guidance, incoming peer frames, Claude-only compatibility footers and delivery receipts. Message bodies, permission boundaries, reply routing and receipt metadata remain intact; private reply inboxes no longer receive Claude-specific footers.
57
+ - Codex sessions open their inbox when they start instead of on their first agent-peers tool call, and are named after their working folder (`codex:<folder>-<id>`); `AGENT_PEERS_NAME` overrides the folder. A subagent's tool call no longer closes its root's inbox.
58
+ - Opt-in, bounded local JSONL diagnostics for Codex, Hermes, app-server requests and socket framing, with content exclusion and a read-only usage summary.
59
+ - Native-only targets now explain the exact native messaging route when mistakenly passed to `send_peer`; idle subagents advertise `followup_task`.
60
+ - Codex discovery limits concurrent thread reads so sessions with more than 256 loaded agents remain discoverable.
61
+ - Explicit negative delivery receipts are marked as MCP tool errors.
62
+ - Read-only diagnostics accept Codex's owned daemon-socket symlink while rejecting unsafe link and target directories.
63
+
64
+ ## 0.1.1 — 2026-10-03
65
+
66
+ - Clearer quick start for independent Claude Code and Codex sessions, with separate setup instructions and a practical coordination example.
67
+ - More descriptive npm search metadata and an MCP Registry ownership marker for a future listing.
68
+ - Empty discovery now explains how to open a Codex inbox instead of claiming no agents are running.
69
+ - Unsupported Hermes conversations link to the required host and setup instead of suggesting a stock Hermes update.
70
+
71
+ Documentation, package metadata, and diagnostic wording only; transport behavior is unchanged from 0.1.0.
72
+
3
73
  ## 0.1.0 — 2026-10-03
4
74
 
5
75
  First public release.
package/CLAUDE-snippet.md CHANGED
@@ -1,3 +1,3 @@
1
1
  Codex CLI sessions do not appear in ListAgents. Run `agent-peers list` to see them with their `uds:` addresses, and message one with SendMessage using that address as `to`; replies arrive like other cross-session messages.
2
2
 
3
- Before messaging, weigh its value against token cost and distraction; keep it concise and avoid repetitive or low-value chatter. Prefer coordination around scope, decisions, and handoffs. Routine progress updates can usually wait; use your judgment about when a discussion or reply would help.
3
+ Keep coordination useful and brief. Send what's new; link existing detail rather than repeating it. Reply when it adds value.
package/CONTRIBUTING.md CHANGED
@@ -21,9 +21,11 @@ Portable Python checks need Python 3.11–3.13:
21
21
  ```sh
22
22
  python3 -m venv .venv
23
23
  .venv/bin/python -m pip install 'pytest>=8,<10' 'pytest-asyncio>=0.24,<2'
24
- .venv/bin/python -m pytest -q
24
+ npm run test:python
25
25
  ```
26
26
 
27
+ `npm run test:python` uses `$AGENT_PEERS_PYTHON`, else `.venv/bin/python`, else `python3`, and fails with setup instructions when pytest or pytest-asyncio is missing. `npm publish` runs it through `prepublishOnly`, together with `check`, `test` and `test:package`.
28
+
27
29
  For host integration, use the exact fork revision in [compatibility](docs/compatibility.md), install its test prerequisites, and run its `scripts/run_tests.sh` with the absolute path to this repository's `test/test_hermes_host.py` with `-m hermes_integration`. CI does this against a separate checkout. Tests cover real discovery, profile isolation, native dispatcher result serialization, and Discord thread returns.
28
30
 
29
31
  ## Live checks
@@ -45,11 +47,15 @@ Explain the concrete behavior change and include the checks you ran. Add a regre
45
47
 
46
48
  Treat installed integrations as production infrastructure. Develop and test in a separate checkout, with isolated discovery registries. Never edit a checkout referenced by live MCP commands, global npm links, or Hermes plugin symlinks. Deploy a tested snapshot through the host's supported lifecycle; preserve agent conversations and verify real replies after a transport update.
47
49
 
48
- 1. Synchronize package and plugin versions and update the changelog.
49
- 2. Run local checks, the CI matrix, and live checks for advertised integrations.
50
- 3. Audit `npm pack --dry-run` and repository history for unintended files or secrets.
51
- 4. Publish the reviewed commit to npm and tag the same revision on GitHub.
52
- 5. Attach the packed artifact and SHA-256 checksum; verify a fresh install from npm.
53
- 6. Record compatibility evidence. Do not claim an untested agent build is supported.
50
+ ### Releasing
51
+
52
+ GitHub Actions ([`release.yml`](.github/workflows/release.yml)) publishes to npm when a `v*.*.*` tag is pushed. Do not run `npm publish` locally.
53
+
54
+ 1. On a branch, set the same version in `package.json`, `package-lock.json`, `plugin.yaml` and `hermes-plugin/plugin.yaml`, and add a `## <version> — <date>` section to `CHANGELOG.md`. That section becomes the GitHub release notes.
55
+ 2. Run the local checks and the live checks for advertised integrations. Audit `npm pack --dry-run` and the history for unintended files or secrets.
56
+ 3. Merge the pull request, wait for CI to pass on the resulting `main` commit, then tag that commit and push the tag: `git tag v<version> <commit> && git push origin v<version>`.
57
+ 4. The release job requires a successful CI run for the tagged commit and does not re-run the matrix. If there is none (CI skipped a docs-only commit, or a newer push cancelled it), run `gh workflow run ci.yml --ref v<version>`, wait for it to pass, and re-run the release job.
58
+ 5. The job checks the tag against the package and plugin versions, runs `npm run test:package`, publishes with npm provenance through trusted publishing (no npm token), waits until `npm view agent-peers@<version> version` answers, and creates the GitHub release from the changelog section unless one already exists. Versions with a prerelease suffix go to the `next` dist-tag and become GitHub prereleases. Re-running the job after a partial failure skips what already happened.
59
+ 6. Verify a fresh install from npm and record compatibility evidence. Do not claim an untested agent build is supported.
54
60
 
55
61
  A Hermes catalog entry is separate from a GitHub/npm release and requires upstream review. Do not submit one while required host APIs are unavailable upstream.
package/README.md CHANGED
@@ -4,73 +4,67 @@
4
4
  [![npm](https://img.shields.io/npm/v/agent-peers)](https://www.npmjs.com/package/agent-peers)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
- Let **Claude Code, Codex, and Hermes conversations message each other** on one machine. Discover a session, send a message, and receive its reply in the conversation that asked. No broker, task board, or polling loop.
7
+ **Agent-to-agent messaging for Claude Code and Codex.** Independent sessions working in different tools and projects can ask each other questions, coordinate changes, and return answers to the conversation that asked. Each keeps its own context and workflow.
8
8
 
9
- [Hermes plugin](docs/hermes.md) · [Compatibility](docs/compatibility.md) · [Protocol and configuration](docs/reference.md)
9
+ Ask Codex:
10
10
 
11
- ## Install
11
+ > Find the Claude session working on the API. Ask which response shape it chose, then use its answer for the client.
12
12
 
13
- Requires **Node.js 22+** on Linux or macOS. Agent integration compatibility is version-sensitive; see the [tested configurations](docs/compatibility.md).
13
+ [Get started](#get-started) · [Hermes plugin](#hermes-experimental) · [Compatibility](docs/compatibility.md) · [Reference](docs/reference.md)
14
+
15
+ ## Why agent-peers
16
+
17
+ - **Coordinate independent sessions.** Claude ↔ Codex, Claude ↔ Claude, and Codex ↔ Codex, across repositories.
18
+ - **Replies arrive in context.** Agents receive messages while idle or busy, through their host's normal conversation handling.
19
+ - **Local transport.** Direct Unix sockets on the same machine and OS user. No extra service to run or account to configure.
20
+
21
+ Backed by [live Claude/Codex round trips, Hermes routing tests, and Linux/macOS CI](docs/release-verification.md).
22
+
23
+ ## Get started
24
+
25
+ Requires **Node.js 22+**, Linux or macOS, and [compatible agent versions](docs/compatibility.md#tested-agent-builds).
14
26
 
15
27
  ```sh
16
- npm install -g agent-peers@0.1.0
17
- codex mcp add agent-peers -- codex-peer
28
+ npm install -g agent-peers@0.3.0
18
29
  ```
19
30
 
20
- Start a new Codex session. Ask it to call `list_peers` once to open its inbox. Codex must use its shared app-server; sessions started with `-c` overrides or `--no-daemon` are unsupported.
31
+ ### Codex (MCP server)
21
32
 
22
- Claude Code already provides messaging. Add this to its instructions:
33
+ ```sh
34
+ codex mcp add agent-peers -- codex-peer
35
+ ```
23
36
 
24
- > Run `agent-peers list` to discover Codex and Claude sessions. Use `SendMessage` with a listed `uds:` address as `to`. Replies arrive in this conversation. Keep coordination concise and relevant to the user's task.
37
+ Start a new Codex session; its inbox opens within seconds, without a tool call, and it is listed as `codex:<folder>-<id>`. It can use `send_peer(to, message)` to contact a listed peer and receive replies automatically. Keep the server name `agent-peers`: under another name, the inbox opens only on the session's first `list_peers` or `send_peer`.
25
38
 
26
- ## Use
39
+ A Codex session is reachable on the app-server agent-peers uses: Codex's shared daemon by default, or a [private app-server](docs/compatibility.md#headless-codex-on-a-private-app-server) for headless workers. `codex exec`, `-c` overrides and `--no-daemon` run an embedded server that peers cannot reach.
27
40
 
28
- Ask your agent:
41
+ ### Claude Code
29
42
 
30
- > Find the agent working on the API, ask which response shape it chose, and use its answer for the client.
43
+ Uses Claude's native `SendMessage`; no MCP server is needed. Add this to your agent instructions:
31
44
 
32
- Or inspect discovery yourself:
45
+ > Run `agent-peers list` to discover peers. Use `SendMessage` with a listed `uds:` address as `to`. Replies arrive in this conversation. Keep coordination useful and brief. Send what's new; link existing detail rather than repeating it. Reply when it adds value.
46
+
47
+ Inspect available peers or check your installation:
33
48
 
34
49
  ```sh
35
50
  agent-peers list
36
- agent-peers list --json
37
51
  agent-peers doctor
38
52
  ```
39
53
 
40
- | Tool | Purpose |
41
- | --- | --- |
42
- | `list_peers()` | Discover sessions and their exact messaging targets. Codex also identifies reachable native subagents. |
43
- | `send_peer(to, message)` | Send to a listed name, inbox address, or an incoming message's reply address. |
44
-
45
- Messages are framed as coming from another agent, with a reply address. They carry no user approval. A successful send means the recipient's inbox accepted the message, not that its model read or answered it.
54
+ For local development diagnostics, start the integration with `AGENT_PEERS_DEBUG=1` and inspect retained activity with `agent-peers logs --json`. Logs contain bounded metadata, timings, correlation IDs and failure categories; message bodies and raw errors are excluded. See [logging configuration and limits](docs/reference.md#local-diagnostic-logging). Existing processes need a normal restart to inherit the setting.
46
55
 
47
- ## Hermes (experimental)
56
+ ### Hermes (experimental)
48
57
 
49
- Install as a native plugin; the standalone tools remain independent of Hermes:
58
+ A native plugin lets Hermes contact Claude and Codex, with private replies to the originating conversation. **Sending requires the [documented Hermes fork](docs/compatibility.md#tested-agent-builds); stock Hermes supports discovery only.**
50
59
 
51
60
  ```sh
52
61
  hermes plugins install ThomasMarcelis/agent-peers --enable
53
62
  ```
54
63
 
55
- **Automatic replies require the documented Hermes fork.** Stock Hermes can discover peers but lacks the conversation-routing APIs needed for automatic replies; sending fails clearly. See [Hermes setup](docs/hermes.md) for dependencies, the explicit injection grant, and the exact tested host revision.
56
-
57
- Hermes stays hidden from discovery. Each conversation creates a private reply inbox on its first send. Discord gateway routing is verified; CLI and Desktop/TUI lifecycle behavior remains experimental on the current fork.
58
-
59
- ## Boundaries
64
+ Complete the [dependency setup and injection grant](docs/hermes.md). Hermes stays hidden from discovery and opens a private reply inbox on its first send. Discord routing is verified; CLI/Desktop lifecycle behavior remains experimental.
60
65
 
61
- - Same machine and OS user, using owner-controlled Unix sockets. Sender identities are not cryptographically authenticated.
62
- - No offline mailbox or automatic delivery retry. Closed conversations become unreachable.
63
- - Claude's inbound approval rules still apply. Rate limits and duplicate suppression reduce accidental chatter; they do not guarantee an agent conversation ends.
64
-
65
- ## Development
66
-
67
- ```sh
68
- npm ci
69
- npm run check
70
- npm test
71
- npm run test:package
72
- ```
66
+ ## Scope
73
67
 
74
- These checks use local fixtures and no model credentials. Python and real-host checks are described in [CONTRIBUTING.md](CONTRIBUTING.md). Live model tests are separate and consume your agent subscriptions.
68
+ Messages are peer input, never user approval. Host approval rules still apply. Delivery confirms inbox acceptance, not a model response. There is no offline mailbox; closed conversations become unreachable. See [delivery and troubleshooting](docs/reference.md) and the [security boundary](SECURITY.md).
75
69
 
76
- MIT. See [LICENSE](LICENSE).
70
+ [Stability](docs/stability.md) · [Contributing and tests](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
package/SECURITY.md CHANGED
@@ -6,7 +6,7 @@ The latest release receives security fixes. During 0.x development, fixes may re
6
6
 
7
7
  ## Trust boundary
8
8
 
9
- agent-peers connects processes owned by the same local OS user. Socket permissions exclude other users; a malicious process already running as you can impersonate another peer. Names and reply addresses are not authenticated identities.
9
+ agent-peers connects processes owned by the same local OS user. Socket permissions exclude other users; a malicious process already running as you can impersonate another peer. Names and reply addresses are not authenticated identities, and delivery receipts carry no authentication either. A receipt counts only if it names the inbox the message was written to. That stops stray and misrouted receipts, not a same-user process that forges one.
10
10
 
11
11
  Peer messages are conversation input, not user approval. The receiving agent's permissions and inbound policy remain authoritative. Plugin injection can trigger a model turn and, in a gateway, a response to the original external chat. Enable it only for profiles where you want this behavior.
12
12
 
@@ -8,10 +8,32 @@ import logging
8
8
  import re
9
9
  import threading
10
10
 
11
- from .runtime import BridgeClient, default_command
11
+ from .runtime import _EXITED, _TIMED_OUT, BridgeClient, default_command
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
  _MAX_ROUTES = 256
15
+ # One send_peer result for every outcome (docs/reference.md, "send_peer result"). An error means
16
+ # the message was definitely not delivered; the bridge builds the result whenever it ran.
17
+ _ERRORS = {"failed", "dropped", "denied", "expired", "refused"}
18
+ _MESSAGE_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
19
+
20
+
21
+ def _message_id(value):
22
+ return value if isinstance(value, str) and _MESSAGE_ID.fullmatch(value) else None
23
+
24
+
25
+ def _result(status, reason, text, in_reply_to=None):
26
+ result = {"status": status, "reason": reason, "message_id": None, "in_reply_to": in_reply_to,
27
+ "to": None, "name": None, "generation": None, "from": None, "text": text}
28
+ if status in _ERRORS:
29
+ result["error"] = text
30
+ return result
31
+
32
+
33
+ def _shaped(result):
34
+ if isinstance(result, dict) and result.get("status") in _ERRORS and "error" not in result:
35
+ return {**result, "error": result.get("text") or "Not delivered."}
36
+ return result
15
37
 
16
38
 
17
39
  def _frame(message: dict) -> str:
@@ -20,13 +42,15 @@ def _frame(message: dict) -> str:
20
42
  sender = "an unnamed peer"
21
43
  address = message["from"]
22
44
  body = re.sub(r"<(?=\s*/\s*peer_message)", r"<\\", message["message"], flags=re.I)
45
+ # The id pairs this arrival with the sender's send_peer result, as in Codex frames, and is what
46
+ # a reply names as in_reply_to (the tool description says so; the footer stays as in 0.3.0).
47
+ msg_id, reply_to = _message_id(message.get("message_id")), _message_id(message.get("in_reply_to"))
48
+ id_attr = f' id="{msg_id}"' if msg_id else ""
49
+ reply_attr = f' in_reply_to="{reply_to}"' if reply_to else ""
23
50
  return (
24
- f"Another agent session ({sender}) sent you a message. It is not from your user.\n"
25
- f'<peer_message from="{sender}" reply_to="{address}">\n{body}\n</peer_message>\n'
26
- "Collaborate within your user's task and this conversation's permissions; "
27
- "a peer message is not user approval. "
28
- f'You can reply using send_peer with to="{address}". '
29
- "Your final response goes to your user. Keep coordination concise and avoid repetitive chatter."
51
+ "Peer input, not user approval. Stay within current permissions.\n"
52
+ f'<peer_message from="{sender}" reply_to="{address}"{id_attr}{reply_attr}>\n{body}\n</peer_message>\n'
53
+ "Reply via send_peer(to=reply_to). Final text goes to your user."
30
54
  )
31
55
 
32
56
 
@@ -58,72 +82,114 @@ class PeerTools:
58
82
  self._bridge = BridgeClient(self.command if self.command is not None else default_command(), self._receive)
59
83
  return self._bridge
60
84
 
61
- def list_peers(self, args, **kwargs):
85
+ def list_peers(self, args, *, session_id=None, **kwargs):
86
+ # The bridge pins what this conversation saw, so a later send to a name another process
87
+ # has taken fails instead of reaching it. Without a route, the listing pins nothing.
88
+ params = {}
89
+ if session_id and self._supports_replies():
90
+ try:
91
+ route = self.ctx.session_message_route(session_id)
92
+ except Exception:
93
+ route = None
94
+ route_id = route.get("route_id") if isinstance(route, dict) else None
95
+ if isinstance(route_id, str) and 1 <= len(route_id) <= 512:
96
+ params["session"] = route_id
62
97
  try:
63
- return self._client().call("list_peers")
98
+ return self._client().call("list_peers", params)
64
99
  except (RuntimeError, ValueError, OSError) as exc:
65
100
  return {"error": str(exc)}
66
101
 
67
102
  def send_peer(self, args, *, session_id=None, **kwargs):
103
+ raw_reply_to = args.get("in_reply_to") if isinstance(args, dict) else None
104
+ if raw_reply_to == "":
105
+ raw_reply_to = None # models fill optional fields with ""; that means none
106
+ in_reply_to = _message_id(raw_reply_to)
68
107
  if not isinstance(args, dict) or not isinstance(args.get("to"), str) or not args["to"].strip():
69
- return {"error": "to must be a peer name or inbox address"}
108
+ return _result("failed", "invalid-request", "to must be a peer name or inbox address", in_reply_to)
70
109
  if not isinstance(args.get("message"), str) or not args["message"].strip():
71
- return {"error": "message must be nonempty text"}
110
+ return _result("failed", "invalid-request", "message must be nonempty text", in_reply_to)
111
+ if raw_reply_to is not None and in_reply_to is None:
112
+ return _result("failed", "invalid-request",
113
+ "in_reply_to must be the id of a message you received (letters, digits, - and _)")
72
114
  if not session_id or not self._supports_replies():
73
- return {"error": "This Hermes runtime has no conversation-addressed reply route; update Hermes and open a supported conversation."}
115
+ return _result("failed", "unsupported-host", (
116
+ "No conversation-addressed reply route is available. "
117
+ "Use a supported conversation on the documented Hermes fork: "
118
+ "https://github.com/ThomasMarcelis/agent-peers/blob/main/docs/hermes.md"
119
+ ), in_reply_to)
74
120
  with self._lock:
75
121
  if self._closed:
76
- return {"error": "agent-peers plugin has been unloaded"}
122
+ return _result("failed", "bridge-unavailable", "agent-peers plugin has been unloaded", in_reply_to)
77
123
  # Hold our lock through lookup + registration: a host retirement
78
124
  # either makes lookup fail or marks this exact in-flight state closed.
79
125
  try:
80
126
  route = self.ctx.session_message_route(session_id)
81
127
  except Exception:
82
- return {"error": "Hermes could not resolve this conversation; no message was sent."}
128
+ return _result("failed", "conversation-closed", "Hermes could not resolve this conversation; no message was sent.", in_reply_to)
83
129
  if not route:
84
- return {"error": "This conversation is no longer reachable for automatic peer replies."}
130
+ return _result("failed", "conversation-closed", "This conversation is no longer reachable for automatic peer replies.", in_reply_to)
85
131
  route_id = route.get("route_id") if isinstance(route, dict) else None
86
132
  if not isinstance(route_id, str) or not 1 <= len(route_id) <= 512:
87
- return {"error": "Hermes returned an invalid conversation route; no message was sent."}
133
+ return _result("failed", "conversation-closed", "Hermes returned an invalid conversation route; no message was sent.", in_reply_to)
88
134
  if route_id not in self._sessions and len(self._sessions) >= _MAX_ROUTES:
89
- return {"error": "agent-peers has too many active conversations; close one before sending."}
135
+ return _result("failed", "inbox-unavailable", "agent-peers has too many active conversations; close one before sending.", in_reply_to)
90
136
  state = self._sessions.setdefault(route_id, _RouteState(session_id))
91
137
  state.session_id = session_id
92
138
  try:
93
139
  # Binding and closing are serialized by the bridge. If a route closes during
94
140
  # the send, immediately retire the endpoint, never repoint it to another chat.
95
- result = self._client().call("send_peer", {
141
+ params = {
96
142
  "session": route_id,
97
- "name": "hermes:" + re.sub(r"[^A-Za-z0-9:_-]", "-", self.profile)[:40] + ":" + route_id[-8:],
143
+ "name": "hermes:" + re.sub(r"[^A-Za-z0-9:_-]", "-", self.profile)[:40] + ":" + re.sub(r"[^A-Za-z0-9_-]", "-", route_id[-8:]),
98
144
  "to": args["to"], "message": args["message"],
99
- })
145
+ }
146
+ if in_reply_to:
147
+ params["in_reply_to"] = in_reply_to
148
+ result = self._client().call("send_peer", params)
100
149
  with self._lock:
101
150
  retired = state.closed
102
151
  if retired:
103
152
  self._client().call("close_session", {"session": route_id})
104
- return result
153
+ return _shaped(result)
105
154
  except (RuntimeError, ValueError, OSError) as exc:
106
- return {"error": str(exc)}
155
+ # A lost or timed-out bridge may already have written the line: delivery is unknown.
156
+ if str(exc) in (_EXITED, _TIMED_OUT):
157
+ return _result("unconfirmed", "bridge-lost", str(exc), in_reply_to)
158
+ return _result("failed", "too-large" if isinstance(exc, ValueError) else "bridge-unavailable", str(exc), in_reply_to)
107
159
 
108
160
  def _receive(self, message):
109
161
  if not isinstance(message, dict):
110
162
  return
163
+ route_id, message_id = message.get("session"), message.get("message_id")
111
164
  with self._lock:
112
- route_id = message.get("session")
113
165
  if not isinstance(route_id, str) or self._closed:
114
166
  return
115
167
  state = self._sessions.get(route_id)
116
168
  session_id = state.session_id if state and not state.closed else None
117
- if not session_id or not all(isinstance(message.get(key), str) for key in ("message_id", "from", "message")):
169
+ if not session_id:
170
+ return self._report(route_id, message_id, False, "inbox-closed")
171
+ if not all(isinstance(message.get(key), str) for key in ("message_id", "from", "message")):
118
172
  return
119
173
  if not re.fullmatch(r"uds:[A-Za-z0-9%:_/.\\-]{1,300}", message["from"]):
120
174
  return
121
- receipt = self.ctx.inject_session_message(
122
- session_id, _frame(message), message_id=message["message_id"], busy_mode="steer",
123
- expected_route_id=message["session"],
124
- )
125
- if not isinstance(receipt, dict) or not receipt.get("accepted"):
175
+ try:
176
+ receipt = self.ctx.inject_session_message(
177
+ session_id, _frame(message), message_id=message["message_id"], busy_mode="steer",
178
+ expected_route_id=message["session"],
179
+ )
180
+ except Exception:
181
+ receipt = None
182
+ accepted = isinstance(receipt, dict) and receipt.get("accepted") is True
183
+ if not accepted:
126
184
  logger.warning("agent-peers reply was not accepted: %s", receipt.get("status", "unknown") if isinstance(receipt, dict) else "invalid_receipt")
185
+ self._report(route_id, message_id, accepted)
186
+
187
+ def _report(self, route_id, message_id, accepted, reason=None):
188
+ # Tells the bridge what the host did, so a sender that asked for receipts hears it.
189
+ bridge = self._bridge
190
+ if bridge is not None and isinstance(message_id, str):
191
+ bridge.notify("delivery_result", {"session": route_id, "message_id": message_id, "accepted": accepted,
192
+ **({"reason": reason} if reason else {})})
127
193
 
128
194
  def route_closed(self, *, route_id, **kwargs):
129
195
  with self._lock:
@@ -170,11 +236,13 @@ def register(ctx):
170
236
  ctx.register_tool(
171
237
  name="send_peer", toolset="agent-peers", handler=send_peer,
172
238
  schema={"name": "send_peer", "description": (
173
- "Send a concise message to a local Claude Code or Codex session using a listed name, "
174
- "inbox address, or incoming reply_to. Replies arrive automatically in this conversation. "
175
- "Weigh value against token cost and distraction; avoid repetitive or low-value chatter."
239
+ "Send to a listed peer name, inbox address, or incoming reply_to. "
240
+ "Set in_reply_to to the id of the message you answer. "
241
+ "Keep coordination useful and brief. Send what's new; link existing detail rather than repeating it. "
242
+ "Reply when it adds value."
176
243
  ), "parameters": {"type": "object", "properties": {
177
244
  "to": {"type": "string"}, "message": {"type": "string", "minLength": 1},
245
+ "in_reply_to": {"type": "string", "maxLength": 128},
178
246
  }, "required": ["to", "message"], "additionalProperties": False}},
179
247
  )
180
248
  if peers._supports_replies():
@@ -34,10 +34,11 @@ def context_thread(target, *, name, args=()):
34
34
 
35
35
 
36
36
  def bridge_environment():
37
- # Discovery paths only; provider credentials and Node preload hooks stay out.
37
+ # Discovery and opt-in diagnostics only; credentials and Node preload hooks stay out.
38
38
  return {key: os.environ[key] for key in (
39
39
  "PATH", "HOME", "XDG_RUNTIME_DIR", "CLAUDE_CONFIG_DIR", "CODEX_HOME",
40
40
  "AGENT_PEERS_HOME", "AGENT_PEERS_CODEX_APP_SERVER",
41
+ "AGENT_PEERS_DEBUG", "AGENT_PEERS_LOG",
41
42
  ) if key in os.environ}
42
43
 
43
44
 
@@ -84,7 +85,9 @@ def default_command(*, root=ROOT, hermes_home=None):
84
85
  except (OSError, subprocess.TimeoutExpired):
85
86
  raise RuntimeError("agent-peers bridge dependency check failed or timed out") from None
86
87
  if probe.returncode:
87
- command = shlex.join(["npm", "--prefix", str(root), "ci", "--omit=dev", "--ignore-scripts"])
88
+ # npm tarballs ship no lockfile, so `npm ci` only works for git installs.
89
+ install = "ci" if (root / "package-lock.json").is_file() else "install"
90
+ command = shlex.join(["npm", "--prefix", str(root), install, "--omit=dev", "--ignore-scripts"])
88
91
  raise RuntimeError(f"agent-peers bridge dependencies are unavailable; run: {command}")
89
92
  return [node, str(bridge), "hermes-bridge"]
90
93
 
@@ -195,6 +198,31 @@ class BridgeClient:
195
198
  with self._lock:
196
199
  self._pending.pop(request_id, None)
197
200
 
201
+ def notify(self, method: str, params: dict) -> bool:
202
+ """Write one id-less line that gets no response; skipped when the bridge is busy or stopped."""
203
+ data = (json.dumps({"method": method, "params": params}, ensure_ascii=False) + "\n").encode("utf-8")
204
+ # The reader thread calls this too, so never wait long for a writer holding the lock.
205
+ if len(data) > MAX_LINE or not self._write_lock.acquire(timeout=1):
206
+ return False
207
+ try:
208
+ with self._lock:
209
+ process = self._process
210
+ if self._stopped or process is None or process.poll() is not None:
211
+ return False
212
+ try:
213
+ self._write(process, data, time.monotonic() + 1)
214
+ except (TimeoutError, OSError, ValueError):
215
+ # A partially written frame cannot be reused safely.
216
+ try:
217
+ process.terminate()
218
+ except OSError:
219
+ pass
220
+ self._fail_pending(process, _EXITED)
221
+ return False
222
+ return True
223
+ finally:
224
+ self._write_lock.release()
225
+
198
226
  def _read(self, process):
199
227
  try:
200
228
  while not self._stopped:
@@ -212,10 +240,14 @@ class BridgeClient:
212
240
  if not isinstance(result, dict):
213
241
  continue
214
242
  if result.get("method") == "peer_message":
243
+ params = result.get("params", {})
215
244
  try:
216
- self._messages.put_nowait(result.get("params", {}))
245
+ self._messages.put_nowait(params)
217
246
  except Full:
218
247
  logger.warning("agent-peers incoming queue is full; dropped peer message")
248
+ if isinstance(params, dict):
249
+ self.notify("delivery_result", {"session": params.get("session"), "message_id": params.get("message_id"),
250
+ "accepted": False, "reason": "queue-full"})
219
251
  continue
220
252
  request_id = result.get("id")
221
253
  if type(request_id) is not int:
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { allPeers } from "../src/wire.mjs";
2
+ import { allPeers, probePeers, whoami } from "../src/wire.mjs";
3
3
  import { formatSessionPeer } from "../src/discovery.mjs";
4
4
  import { VERSION } from "../src/version.mjs";
5
5
 
@@ -7,8 +7,10 @@ const usage = `agent-peers ${VERSION} — local agent messaging
7
7
 
8
8
  Usage: agent-peers <command> [options]
9
9
 
10
- list [--json] Discover running Claude Code and Codex inboxes
11
- doctor [--json] Check local prerequisites without changing configuration
10
+ list [--json] [--no-probe] Discover running Claude Code and Codex inboxes; rows that refuse connections are marked stale
11
+ whoami [--json] Print the calling session's name and address (found through process ancestry)
12
+ doctor [--json] Check local prerequisites and whether the Codex app-server answers
13
+ logs [--json] Summarize retained local diagnostic events
12
14
  hermes-bridge Start the plugin-managed JSON-lines subprocess
13
15
  --version Print the installed version
14
16
  --help Show this help
@@ -33,8 +35,9 @@ async function main() {
33
35
  console.log(VERSION);
34
36
  return;
35
37
  }
36
- if (!["list", "doctor", "hermes-bridge"].includes(cmd) || args.length > 1 ||
37
- (args.length && (args[0] !== "--json" || cmd === "hermes-bridge"))) {
38
+ const allowed = cmd === "list" ? ["--json", "--no-probe"] : ["doctor", "logs", "whoami"].includes(cmd) ? ["--json"] : [];
39
+ if (!["list", "doctor", "logs", "whoami", "hermes-bridge"].includes(cmd) || args.length > allowed.length ||
40
+ new Set(args).size !== args.length || args.some((a) => !allowed.includes(a))) {
38
41
  throw new SyntaxError("unknown command or option; run agent-peers --help");
39
42
  }
40
43
  if (cmd === "hermes-bridge") {
@@ -44,7 +47,7 @@ async function main() {
44
47
  }
45
48
  if (cmd === "doctor") {
46
49
  const { diagnose } = await import("../src/diagnostics.mjs");
47
- const result = diagnose();
50
+ const result = await diagnose();
48
51
  if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
49
52
  else {
50
53
  console.log(`agent-peers ${VERSION}`);
@@ -53,10 +56,36 @@ async function main() {
53
56
  if (!result.ok) process.exitCode = 1;
54
57
  return;
55
58
  }
56
- const peers = allPeers();
59
+ if (cmd === "logs") {
60
+ const { summarizeLogs } = await import("../src/logging.mjs");
61
+ const result = summarizeLogs();
62
+ if (args.includes("--json")) console.log(JSON.stringify(result, null, 2));
63
+ else {
64
+ console.log(`Logging ${result.config.enabled ? "enabled" : "disabled"}: ${result.config.location}`);
65
+ console.log(`${result.events} events in ${result.files} files (${result.bytes} bytes); ${result.corruptLines} malformed lines; ${result.skippedFiles} skipped files; ${result.truncatedFiles} files read partially.`);
66
+ if (result.firstEventAt) console.log(`Observed: ${result.firstEventAt} to ${result.lastEventAt}`);
67
+ for (const [method, tool] of Object.entries(result.tools)) console.log(`${method}: ${tool.calls} completed calls; ${JSON.stringify(tool.outcomes)}`);
68
+ console.log(`Delivery: ${result.delivery.inboxWrites} inbox writes; ${result.delivery.receipts} receipts; ${result.delivery.appServerInputAccepted} app-server inputs accepted; ${result.delivery.emittedToHost} emitted to host.`);
69
+ console.log(`Operations: ${result.operations.started} started; ${result.operations.completed} completed; ${result.operations.incomplete} incomplete.`);
70
+ if (Object.keys(result.reasons).length) console.log(`Reasons: ${JSON.stringify(result.reasons)}`);
71
+ console.log(result.scope);
72
+ for (const warning of result.warnings) console.log(`WARNING ${warning}`);
73
+ }
74
+ return;
75
+ }
76
+ if (cmd === "whoami") {
77
+ const me = whoami();
78
+ if (!me) throw new Error("no Claude Code or Codex session found in this process's ancestry; run whoami from inside a session");
79
+ const { name, address, kind, cwd, pid, generation } = me;
80
+ if (args.includes("--json")) console.log(JSON.stringify({ name, address, kind, cwd, pid, generation }, null, 2));
81
+ else console.log(`${name}\n${address}`);
82
+ return;
83
+ }
84
+ const found = allPeers();
85
+ const peers = args.includes("--no-probe") ? found : await probePeers(found);
57
86
  if (args.includes("--json")) console.log(JSON.stringify({ peers }, null, 2));
58
87
  else {
59
- if (!peers.length) console.log("No agent sessions are running.");
88
+ if (!peers.length) console.log("No peer inboxes found. A Codex session opens its inbox a few seconds after it starts; if one is missing, call list_peers in it.");
60
89
  for (const peer of peers) console.log(formatSessionPeer(peer));
61
90
  if (peers.length) console.log("Codex native reachability depends on your session; call list_peers there for native targets.");
62
91
  }