diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index fa4bc34..0ba73fa 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -16,6 +16,9 @@ python -m pip install -e ".[dev,mcp]"
python -m pytest -q
```
+Tests live in `tests/`. Run only the MCP wire tests with
+`python -m pytest -q tests/test_mcp*.py`.
+
Tests isolate the default and named-bag directories as well as
`POSTBAG_LEDGER`, and use fake doors. They must never scan the user's bags
or reach a real session. Core tests use the standard library and pytest.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd18be4..9e45737 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,7 +55,7 @@ jobs:
- name: Install MCP and test dependencies
run: python -m pip install -e '.[dev,mcp]'
- name: Run MCP subprocess wire tests
- run: python -m pytest -q test_mcp*.py
+ run: python -m pytest -q tests/test_mcp*.py
- name: Check MCP entry point
run: postbag-mcp --version
@@ -82,7 +82,9 @@ jobs:
import tarfile
with tarfile.open(next(Path("dist").glob("*.tar.gz"))) as archive:
packaged = {name.split("/", 1)[1] for name in archive.getnames() if "/" in name}
- missing = {path.name for path in Path(".").glob("test_*.py")} - packaged
+ tests = {path.as_posix() for path in Path("tests").glob("test_*.py")}
+ assert tests, "No test modules found under tests/"
+ missing = tests - packaged
assert not missing, f"Source archive is missing test modules: {sorted(missing)}"
PYTESTARCHIVE
- name: Install wheel and source archive in fresh environments
@@ -115,7 +117,7 @@ jobs:
wheels=(dist/*.whl)
"$RUNNER_TEMP/wheel-mcp/bin/python" -m pip install "${wheels[0]}[mcp]" pytest
mkdir "$RUNNER_TEMP/wheel-mcp-tests"
- cp test_mcp*.py "$RUNNER_TEMP/wheel-mcp-tests/"
+ cp tests/test_mcp*.py "$RUNNER_TEMP/wheel-mcp-tests/"
cd "$RUNNER_TEMP/wheel-mcp-tests"
"$RUNNER_TEMP/wheel-mcp/bin/postbag-mcp" --version
"$RUNNER_TEMP/wheel-mcp/bin/python" -I -c 'import pathlib, sys, postbag_mcp; assert pathlib.Path(postbag_mcp.__file__).is_relative_to(sys.prefix)'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 585f800..e65cb5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to postbag are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
uses [Semantic Versioning](https://semver.org/).
+## [2.2.1] - 2026-10-02
+
+Documentation and repository layout release. No runtime change beyond the
+version number.
+
+### Changed
+- The README is a short product page: install and connect for both hosts,
+ the two-prompt setup, the five tools, and what to know before use. Release
+ evidence and migration notes stay in this file, `docs/mcp.md` and
+ `docs/native-compatibility.md`.
+- The demo shows scripted calls to two real `postbag mcp` servers, one with
+ a Codex identity and one with a Claude Code identity, through fake native
+ doors, including the envelope each door receives. Its source is
+ `docs/demo_mcp.py`. The CLI demo environment script is removed.
+- Tests live in `tests/`. CI, the installed-wheel check and the source
+ distribution follow the new paths.
+
## [2.2.0] - 2026-10-01
The new `postbag mcp` launcher lets MCP installers run Postbag by its
@@ -386,7 +403,8 @@ Four commits from "bridge" to "postbag" on the day the idea was born:
the ledger became the only state, `open` became human-only, and every
refusal learned to say stop.
-[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.0...HEAD
+[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.1...HEAD
+[2.2.1]: https://github.com/parasxos/postbag/releases/tag/v2.2.1
[2.2.0]: https://github.com/parasxos/postbag/releases/tag/v2.2.0
[2.1.0]: https://github.com/parasxos/postbag/releases/tag/v2.1.0
[2.0.0]: https://github.com/parasxos/postbag/releases/tag/v2.0.0
diff --git a/Dockerfile b/Dockerfile
index 17d7edf..fb679fb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,6 @@
# letters through a host session's native door, which a container does not have,
# so native delivery from this image is unsupported and unverified.
FROM python:3.12-slim
-ARG POSTBAG_VERSION=2.2.0
+ARG POSTBAG_VERSION=2.2.1
RUN pip install --no-cache-dir "postbag[mcp]==${POSTBAG_VERSION}"
ENTRYPOINT ["postbag-mcp"]
diff --git a/MANIFEST.in b/MANIFEST.in
index 3cac073..b25ccee 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,14 +1,7 @@
include postbag
include CONCEPT.md CHANGELOG.md .github/SECURITY.md .github/CONTRIBUTING.md
-include test_postbag.py test_hardening.py test_transports.py
-include test_names.py test_presentation.py
-include test_bags.py test_bag_context.py
-include test_inventory.py
-include test_inventory_terminal.py
-include test_resume.py
-include test_mcp*.py test_review_core.py test_core_2.py
-include test_leave.py
-include docs/demo.tape docs/demo-env.sh docs/readme-research.md
+recursive-include tests test_*.py
+include docs/demo.tape docs/demo_mcp.py docs/readme-research.md
include docs/native-compatibility.md
include docs/mcp.md
include server.json llms-install.md
diff --git a/README.md b/README.md
index b1f56fe..1c9fcf0 100644
--- a/README.md
+++ b/README.md
@@ -1,249 +1,113 @@
-

+

# postbag
**Two agents, one bag of letters.**
-Let two existing Claude Code or Codex sessions review each other's work,
-split a task, or exchange a second opinion. They run on the same machine,
-receive letters through their native inboxes and share one recorded history.
+Your Claude Code and Codex sessions write to each other. Ask one to review
+the other's diff, split a task, or get a second opinion, without copying
+text between windows.
[](https://github.com/parasxos/postbag/actions/workflows/ci.yml)
[](https://pypi.org/project/postbag/)
-[](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md)
-[](#install)
-[](https://github.com/parasxos/postbag/blob/v2.2.0/LICENSE)
-
-```sh
-pipx install 'postbag[mcp]'
-```
+[](https://glama.ai/mcp/servers/parasxos/postbag)
+[](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE)
-Five MCP tools and a CLI. Postbag adds no delivery daemon, polling, hooks or
-remote relay. It carries text and records submitted letters. The agents and their
-hosts decide when to reply and when to stop. Code and other work products
-stay in your repository.
+
-
+Scripted calls to two real postbag MCP servers, with fake native doors.
+[Demo source](https://github.com/parasxos/postbag/blob/v2.2.1/docs/demo_mcp.py).
-*Real CLI commands with fake inboxes and a temporary home. This is a local
-demonstration, not a recording of live agents.
-[Demo source](https://github.com/parasxos/postbag/blob/v2.2.0/docs/demo.tape).*
+## What it does
-See [agent installation instructions](https://github.com/parasxos/postbag/blob/v2.2.0/llms-install.md).
+- **Native delivery.** A letter arrives in the recipient as a new user turn,
+ through its own input: Claude Code's inbox or `codex queue`. postbag adds
+ no delivery daemon, polling or hooks.
+- **One shared record.** Every join, letter and leave goes into one
+ append-only ledger per bag, which both agents and you can read.
+- **Set up by the agents.** Joining a bag creates it. Nothing to run in a
+ separate terminal.
+- **The session is the sender.** Identity comes from the host, never from
+ tool arguments.
## Install
```sh
pipx install 'postbag[mcp]'
-postbag --version
-postbag-mcp --version
```
-Python 3.10 or later, on macOS or Linux. Use `pipx install postbag` for the
-CLI alone, which uses only the standard library. Upgrade both peers' CLI and
-MCP installations together and reconnect their MCP servers. Existing bags
-need no migration. Once a bag contains a leave record, all its readers need
-2.1 or later. See [migration details](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md#upgrade-existing-sessions).
-
-Each vendor in use brings its own door. A Claude Code session exports
-`CLAUDE_CODE_MESSAGING_SOCKET` and `CLAUDE_CODE_MESSAGING_TOKEN` to the
-commands it runs. A Codex session exports `CODEX_THREAD_ID` (older versions
-use `CODEX_SESSION_ID`) and has a
-`codex` binary with the `queue` command (0.149 or later). Set `POSTBAG_CODEX`
-if it is not in the ChatGPT app or on `PATH`. Two Claude sessions need no
-Codex binary, two Codex sessions no Claude socket.
-
-### MCP tools
-
-The optional MCP interface lets agents call `postbag_join`, `postbag_leave`, `postbag_send`,
-`postbag_read`, and `postbag_bags` directly. Sender identity comes from the
-host. The ledger and native delivery are the same as the CLI. Joining creates
-a missing bag. There is no letter budget or human-only command.
+Python 3.10 or later, on macOS or Linux. Then register the server once in
+each host.
-Install the optional tools with:
+**Claude Code**
```sh
-pipx install 'postbag[mcp]'
-```
-
-Or let [uv](https://docs.astral.sh/uv/getting-started/installation/) run the
-pinned package in an isolated environment:
-
-```sh
-uvx --with 'postbag[mcp]==2.2.0' postbag@2.2.0 mcp
+claude mcp add --transport stdio --scope user postbag -- "$(command -v postbag-mcp)"
```
-This starts a stdio MCP server for a host to manage. It waits for protocol
-input, rather than opening an interactive terminal prompt. `postbag mcp`
-and `postbag-mcp` serve the same five tools. The launcher takes no `--bag`
-or operational arguments. Each tool call selects its own bag.
-
-Register the absolute path to `postbag-mcp` as a local stdio MCP
-server in each host. [Setup, upgrades, and compatibility](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md).
-The MCP SDK is required only for this interface.
-
-The supported peers are existing Claude Code and Codex sessions. A generic
-MCP client can inspect bags, but registering and sending require a supported
-host's native session identity. Install Postbag on the same machine as both
-sessions. A directory's Docker check can inspect the tool catalog without
-providing access to those sessions.
-
-The installed 2.2 candidate `c56d92d` passed native acceptance through
-`postbag mcp` on macOS with Claude Code 2.1.287 and Codex 0.159.2. Five
-letters were observed at their recipients, including replies in both
-directions. Leaving blocked a later send, deliberate rejoining restored
-delivery, and no reply followed a final letter during a 30.0992-second
-observation window. See [launcher evidence and limits](https://github.com/parasxos/postbag/blob/v2.2.0/docs/native-compatibility.md).
-
-The installed 2.0 candidate `8b87b4f` passed native acceptance on macOS with
-Claude Code 2.1.286 and Codex 0.158.0-alpha.2.1. The check observed two-way
-receipt, no Postbag reply during 30.0668 seconds after a final letter, and a later
-deliberately initiated ordinary round trip. This observation does not
-guarantee that other model conversations will stop. See
-[candidate evidence and limits](https://github.com/parasxos/postbag/blob/v2.1.0/docs/native-compatibility.md).
-
-Historical 1.x native delivery checks ran on macOS with Claude Code 2.1.285 and
-Codex 0.157.1 / desktop 0.158.0-alpha.2.1, including the installed 1.3.0 MCP
-package, default server discovery, two-way receipt and spent-budget refusal.
-The 1.4 checks are also retained in [native compatibility](https://github.com/parasxos/postbag/blob/v2.1.0/docs/native-compatibility.md).
-Native Linux delivery is unverified. Windows is unsupported.
-
-## Quick start
-
-1. Open two sessions on the same machine. Ask each to join under a name:
- `postbag --bag default join claude ada` and `postbag --bag default join claude bob`,
- or `postbag --bag default join codex bob` for Codex. Same-vendor pairs need distinct names.
- The first valid join creates the bag.
-2. Ask ada to send the first letter:
-
- ```sh
- postbag --bag default send @bob "Review my last commit. Reply with the top three findings."
- ```
-
- If accepted, bob receives: "Letter 1 from @ada to @bob via postbag
- (bag default).", the body, and the
- reply instructions: use `postbag_send` if MCP tools are available, or
- `postbag --bag default send @ada -` with the reply on stdin. The footer asks
- for replies that advance the task and discourages courtesy acknowledgements
- and unsolicited delivery checks.
-3. Read the bag from anywhere with `postbag --bag default read`. Its first
- line names the bag, its registered peers and its recorded letter count.
-4. To send a letter that asks for no reply, use
- `postbag --bag default send --final @bob "The review is complete."`.
- Its footer says not to reply to that letter, even if its body asks for one.
- This is guidance to the recipient. It does not close the bag or prevent a
- later deliberately initiated send.
-5. To stop participating in this bag, ask the session to run
- `postbag --bag default leave`, or call `postbag_leave` with `bag="default"`.
- This releases its name without ending the session or deleting history.
- Its door can no longer send or be addressed in that bag until it joins again.
- Queued letters and a send already holding the bag lock can still arrive.
- Rejoin only when you deliberately ask the session to resume.
-
-If you intend to resume participation after a restart, rejoin the same bag
-under the same name. A reply reaches
-whoever holds the name when it runs, and a displaced door's next `send` refuses.
-
-## Bags
-
-A bag is one ledger, and it has a name. `default` is `~/.postbag/ledger.jsonl`.
-For a second conversation, each session joins another bag with the
-same flag, `postbag --bag acceptance join claude ada` and
-`postbag --bag acceptance join claude bob`, and ada sends with
-`postbag --bag acceptance send @bob "..."`.
-
-`--bag` goes before the verb and takes a name, kept in
-`~/.postbag/bags/.jsonl`, or an absolute path of printable characters.
-`join` creates a missing default, named or path-selected bag. `send`, `leave`
-and `read` refuse a missing bag without creating files or directories. A join
-refused for its arguments or identity also creates nothing. Once creation
-starts, an I/O failure can leave a directory or partial file for inspection.
-Outputs identify their bags, and every command inside a
-letter or a refusal carries `--bag`, `--bag default` included, so a reply
-lands where the letter came from whatever the recipient's shell has set.
-Without `--bag`, `POSTBAG_LEDGER` selects a ledger by path.
-
-Run `postbag bags` for a count of paths found, recorded letter counts, last letter
-times and registered names with vendors. It lists default, named and selected
-custom bags. Unselected external paths are omitted. Busy or unreadable bags
-are unavailable. The summary counts bags with letters, bags without letters,
-and unavailable bags. Registered names do not imply live sessions.
-Terminals adapt to width and sort bags by last letter, with brief local times.
-Pipes keep the plain table and full ISO timestamps.
-
-`postbag bags --resume` adds Claude resume commands for conversations recorded
-at `join`, with one reminder for missing IDs. Rejoin after `/clear` or switching
-conversations. Resume needs saved history and opens a new process, not the old terminal.
-
-## How it works
-
-`join` writes the session's door into the ledger under a name: Claude Code's
-messaging socket and token, or Codex's thread id. `send` holds a file lock
-while it knocks on that door and appends the letter. Completed sends get
-distinct record numbers. MCP calls refuse a busy ledger without waiting.
-Letter numbers count all recorded letters in the bag.
-Record numbers also count joins, leaves and historical `open` rows. CLI `read N`
-returns the last N records. MCP `before` and `next_before` address immutable
-record numbers. Old `open` rows keep their
-recorded limits as history and no longer control sending.
-Before using `leave`, upgrade all readers of the bag to 2.1 or later and
-reconnect their MCP servers. Once a leave is recorded, 2.0 readers refuse
-that bag. Older records need no migration. Deleting leave rows is not a
-repair because it would restore withdrawn registrations.
-Two sessions are the supported use, three or more is experimental.
-A bag is one ledger, the only state. No delivery daemon,
-polling, hooks, or bag index. The optional MCP process is started by its host
-and uses the same CLI operations in isolated workers.
-[CONCEPT.md](https://github.com/parasxos/postbag/blob/v2.2.0/CONCEPT.md) is the specification.
-
-## Tests
-
-For a full development test run, install both test dependencies and the MCP extra:
+**Codex**, in `~/.codex/config.toml`, with the path that `command -v postbag-mcp` prints:
-```sh
-python -m pip install -e '.[dev,mcp]'
-python -m pytest -q
+```toml
+[mcp_servers.postbag]
+command = "/Users/you/.local/bin/postbag-mcp"
+tool_timeout_sec = 60
```
-Without the `mcp` extra, the wire tests are skipped. Tests use private fixtures
-and fake native doors. See [Contributing](https://github.com/parasxos/postbag/blob/v2.2.0/.github/CONTRIBUTING.md).
-
-## Security and limits
-
-- A ledger holds every Claude session token and every letter in its bag.
- New files use `0600` and new state directories `0700`. Existing file modes
- are preserved. Mutations refuse files that grant group or other access or
- lack owner read and write permissions. `read` and
- `bags` hide door credentials. Keep raw files out of git and logs.
-- An accepted letter is a user turn. Trust both sessions with the task.
- postbag itself sends nothing off the machine. Vendor sessions forward
- the letter to their model services like any prompt.
-- A name is an address, not authentication, and so is a bag. Sender routing
- uses the vendor's session variables or trusted host metadata. Another
- process running as the same OS user can supply those fields.
-- Claude's inbound policy may hold or refuse a letter, including in
- bypass-permissions sessions. CLI sends need permission to write the ledger
- and contact the recipient. MCP tools run with the server process permissions,
- outside the command sandbox. Use host tool approvals for per-letter consent.
-- Leaving removes a registration in one bag. It does not revoke the native
- inbox or registrations in other bags. Claude subagents sharing an inbox
- share a peer, so one subagent leaving withdraws the parent's name too.
-- Postbag has no letter limit or rate limit. The footer and `--final` are
- model instructions, not protection against loops or prompt injection.
- Closing a Codex client does not revoke its saved thread's queue. Making a
- ledger unwritable prevents new write opens. Mutations also check the mode
- after taking the lock, but an operation past that check may finish. Read
- access can remain, and queued letters are not recalled. MCP body, page and
- result depth caps remain, as do native timeouts.
-- "Delivered" means submitted through the door, not accepted or read. postbag
- does not wait for delivery notices or retry. A crash before recording leaves
- a submitted letter in doubt. Timeouts and failed native commands can also
- leave submission uncertain. Check both the bag and the recipient before
- sending again. An absent ledger record is not proof of failed delivery.
-
-postbag is a small bridge for two existing sessions. [Tools that do more](https://github.com/parasxos/postbag/blob/v2.2.0/docs/readme-research.md) · [Concept](https://github.com/parasxos/postbag/blob/v2.2.0/CONCEPT.md) · [Security](https://github.com/parasxos/postbag/blob/v2.2.0/.github/SECURITY.md) · [Changelog](https://github.com/parasxos/postbag/blob/v2.2.0/CHANGELOG.md) · [Contributing](https://github.com/parasxos/postbag/blob/v2.2.0/.github/CONTRIBUTING.md) · [MIT](https://github.com/parasxos/postbag/blob/v2.2.0/LICENSE)
+Reconnect or restart each host's MCP server, then check that the five
+postbag tools are listed, for example with `/mcp` in Claude Code.
+Codex delivery uses the `codex` binary's `queue` command, 0.149 or later.
+Set `POSTBAG_CODEX` if it is neither on `PATH` nor in the ChatGPT app.
+To run without installing, as listed in the official MCP registry:
+`uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp`.
+Agents can follow [llms-install.md](https://github.com/parasxos/postbag/blob/v2.2.1/llms-install.md).
+
+## Use it
+
+Open a Codex session and a Claude Code session in the same repository.
+
+1. In Codex: *"Join the postbag bag review as bob and wait for letters."*
+2. In Claude Code: *"Join bag review as ada and ask bob to review my last commit."*
+
+Each letter ends with how to reply, so neither agent needs instructions.
+Watch the conversation with `postbag --bag review read`. When the work is
+done, ada can send a final letter, which asks for no reply, and either side
+can leave the bag.
+
+## The five tools
+
+| Tool | What it does |
+|---|---|
+| `postbag_join` | Registers this session under a name in a bag, creating the bag if needed. |
+| `postbag_send` | Sends a letter to a name in the bag. `final: true` asks for no reply. |
+| `postbag_read` | Pages through the bag's history. |
+| `postbag_bags` | Lists local bags with their letter counts and registered names. |
+| `postbag_leave` | Withdraws this session from the bag. History stays. |
+
+The CLI has the same verbs: `postbag join`, `send`, `read`, `bags` and `leave`.
+
+## Good to know
+
+- **No brake.** postbag sets no letter limit. Your hosts' tool approvals and
+ the agents' judgement decide how long a conversation runs.
+- **A letter is a user turn.** Connect only sessions you trust with the
+ task. The ledger holds each Claude session's inbox token. It is created
+ with mode `0600`, so keep it out of git and logs.
+- **Same machine, same version.** Two sessions are the supported use. After
+ an upgrade, upgrade both and reconnect both MCP servers.
+- **Restarted session.** If a session comes back with a new identity and
+ should keep corresponding, ask it to join the bag again under the same name.
+- **Submitted, not read.** A send returns once the recipient's door took the
+ letter, not when the agent read it.
+
+[Concept](https://github.com/parasxos/postbag/blob/v2.2.1/CONCEPT.md) ·
+[MCP setup and limits](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) ·
+[Security](https://github.com/parasxos/postbag/blob/v2.2.1/.github/SECURITY.md) ·
+[Changelog](https://github.com/parasxos/postbag/blob/v2.2.1/CHANGELOG.md) ·
+[Contributing](https://github.com/parasxos/postbag/blob/v2.2.1/.github/CONTRIBUTING.md) ·
+[Tools that do more](https://github.com/parasxos/postbag/blob/v2.2.1/docs/readme-research.md) ·
+[MIT](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE)
diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif
index 55f4707..3b4fe40 100644
Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ
diff --git a/docs/demo-env.sh b/docs/demo-env.sh
deleted file mode 100644
index eb76116..0000000
--- a/docs/demo-env.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-# Fake doors for the README demo. Source this in a disposable shell, as vhs
-# provides, then run postbag. It replaces HOME and unsets the session
-# variables, so do not source it in an interactive shell you keep using.
-# Nothing here touches a real session or your own bags: HOME is a temporary
-# directory, so the default bag lives there, and each Claude door is a
-# throwaway socket that accepts a connection and drops it.
-unset CLAUDE_CODE_MESSAGING_SOCKET CLAUDE_CODE_MESSAGING_TOKEN CLAUDE_CODE_SESSION_ID CODEX_SESSION_ID CODEX_THREAD_ID POSTBAG_LEDGER
-PS1='$ '
-PATH="$PWD:$PATH" # run the checkout being documented
-DEMO=$(mktemp -d)
-trap 'kill $LISTENERS 2>/dev/null; rm -rf "$DEMO"' EXIT
-export HOME="$DEMO"
-LISTENERS=
-for door in ada bob; do
-python3 - "$DEMO/$door.sock" <<'PY' &
-import socket, sys
-s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen()
-while True:
- c, _ = s.accept(); c.recv(65536); c.close()
-PY
-LISTENERS="$LISTENERS $!"
-done
-sleep 0.3
-in-ada() { CLAUDE_CODE_MESSAGING_SOCKET="$DEMO/ada.sock" CLAUDE_CODE_MESSAGING_TOKEN=demo-ada "$@"; }
-in-bob() { CLAUDE_CODE_MESSAGING_SOCKET="$DEMO/bob.sock" CLAUDE_CODE_MESSAGING_TOKEN=demo-bob "$@"; }
diff --git a/docs/demo.tape b/docs/demo.tape
index 3758267..97edc29 100644
--- a/docs/demo.tape
+++ b/docs/demo.tape
@@ -1,30 +1,16 @@
# vhs docs/demo.tape (https://github.com/charmbracelet/vhs)
+# Real postbag MCP tool calls between a Claude Code identity and a Codex
+# identity, through fake native doors. See docs/demo_mcp.py.
Output docs/assets/demo.gif
Set Shell bash
-Set FontSize 18
-Set Width 1180
-Set Height 700
-Set Padding 18
+Set FontSize 16
+Set Width 1260
+Set Height 800
+Set Padding 20
Set Theme "Catppuccin Mocha"
-Set TypingSpeed 40ms
Hide
-Type "source docs/demo-env.sh; clear"
+Type "clear; ${PYTHON:-python3} docs/demo_mcp.py"
Enter
-Sleep 1.5s
Show
-
-Type "# postbag demo: real commands against fake doors, no live session" Enter Sleep 800ms
-
-Type "# in the first Claude Code session" Enter
-Type "in-ada postbag join claude ada" Sleep 300ms Enter Sleep 900ms
-Type "# in the second Claude Code session" Enter
-Type "in-bob postbag join claude bob" Sleep 300ms Enter Sleep 900ms
-Type "# ada writes, bob answers, ada marks the last letter final" Enter
-Type `in-ada postbag send @bob "Review parser.py, top three findings please."` Sleep 300ms Enter Sleep 1400ms
-Type `in-bob postbag send @ada "Empty input, swallowed date errors, no field bound."` Sleep 300ms Enter Sleep 1400ms
-Type `in-ada postbag send --final @bob "Fixed all three, merging."` Sleep 300ms Enter Sleep 1400ms
-Type "# bob withdraws from the bag" Enter
-Type "in-bob postbag leave" Sleep 300ms Enter Sleep 900ms
-Type "postbag read" Sleep 300ms Enter
-Sleep 4s
+Sleep 36s
diff --git a/docs/demo_mcp.py b/docs/demo_mcp.py
new file mode 100644
index 0000000..e16eef7
--- /dev/null
+++ b/docs/demo_mcp.py
@@ -0,0 +1,243 @@
+"""Render the README demo: scripted calls to two real postbag MCP servers.
+
+One `postbag mcp` server runs with a Claude Code identity (ada), the other
+with a Codex identity (bob). Their native doors are fakes: a throwaway Unix
+socket stands in for Claude Code's inbox and a small script stands in for
+`codex queue`, so the demo shows the exact envelope each door receives.
+No model authors these calls, and nothing touches a real session or your
+own bags.
+
+ vhs docs/demo.tape # records docs/assets/demo.gif
+ python docs/demo_mcp.py # the same transcript in your terminal
+"""
+import json
+import os
+import pathlib
+import queue
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+THREAD = "019a0000-0000-7000-8000-00000000b0b0" # bob's Codex thread, fixed for a stable recording
+DEADLINE = 30
+
+ADA, BOB, DIM, BOLD, OK, END = "\033[38;5;215m", "\033[38;5;114m", "\033[2m", "\033[1m", "\033[38;5;151m", "\033[0m"
+
+
+class Server:
+ """One agent's postbag MCP server, spoken to with plain JSON-RPC lines."""
+
+ def __init__(self, env, errors, meta=None):
+ self.meta = meta or {}
+ self.next_id = 1
+ self.errors = errors
+ self.lines = queue.Queue()
+ with errors.open("w") as sink:
+ self.process = subprocess.Popen([sys.executable, str(ROOT / "postbag.py"), "mcp"], env=env,
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=sink, text=True)
+ # A reader thread hands over complete lines, so each request waits on one deadline in total.
+ self.reader = threading.Thread(target=self.read_lines, daemon=True)
+ self.reader.start()
+ try:
+ self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
+ "clientInfo": {"name": "postbag-demo", "version": "1"}})
+ self.write({"jsonrpc": "2.0", "method": "notifications/initialized"})
+ except BaseException:
+ self.close()
+ sys.stderr.write(errors.read_text())
+ raise
+
+ def read_lines(self):
+ for line in self.process.stdout:
+ self.lines.put(line)
+ self.lines.put(None)
+
+ def write(self, message):
+ self.process.stdin.write(json.dumps(message) + "\n")
+ self.process.stdin.flush()
+
+ def request(self, method, params):
+ ident, self.next_id = self.next_id, self.next_id + 1
+ self.write({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
+ deadline = time.monotonic() + DEADLINE
+ while True:
+ try:
+ line = self.lines.get(timeout=max(0.0, deadline - time.monotonic()))
+ except queue.Empty:
+ raise TimeoutError(f"no answer to {method} within {DEADLINE} s") from None
+ if line is None:
+ raise RuntimeError(f"the server exited during {method}")
+ message = json.loads(line)
+ if message.get("id") == ident:
+ return message["result"]
+
+ def call(self, tool, **arguments):
+ result = self.request("tools/call", {"name": tool, "arguments": arguments, "_meta": self.meta})
+ outcome = result["structuredContent"]
+ # Only calls that really succeeded are shown; anything else stops the recording.
+ if result.get("isError") or outcome["ok"] is not True:
+ raise RuntimeError(f"{tool} failed: {outcome}")
+ if tool == "postbag_send" and outcome["submission_state"] != "submitted":
+ raise RuntimeError(f"{tool} was not submitted: {outcome}")
+ return outcome
+
+ def close(self):
+ try:
+ self.process.stdin.close()
+ except OSError:
+ pass # the child already exited and closed its end
+ try:
+ self.process.wait(timeout=DEADLINE)
+ except subprocess.TimeoutExpired:
+ self.process.kill()
+ self.process.wait()
+ self.reader.join(timeout=DEADLINE)
+ self.process.stdout.close()
+
+
+class Doors:
+ """Fake native doors that keep what each recipient would be handed."""
+
+ def __init__(self, temp):
+ self.to_ada, self.to_bob = [], []
+ self.socket_path = str(temp / "ada.sock")
+ self.queue_log = temp / "bob.jsonl"
+ self.queue = temp / "codex"
+ self.queue.write_text(
+ f"#!{sys.executable}\nimport json, sys\n"
+ "args = sys.argv[1:]\n"
+ f"with open({str(self.queue_log)!r}, 'a') as out:\n"
+ " out.write(json.dumps(args[args.index('--message') + 1]) + '\\n')\n")
+ self.queue.chmod(0o700)
+ listener = socket.socket(socket.AF_UNIX)
+ listener.bind(self.socket_path)
+ listener.listen()
+ threading.Thread(target=self.inbox, args=(listener,), daemon=True).start()
+
+ def inbox(self, listener):
+ while True:
+ connection, _ = listener.accept()
+ with connection:
+ data = b""
+ while chunk := connection.recv(65536):
+ data += chunk
+ for line in data.decode().splitlines():
+ message = json.loads(line)
+ if message.get("type") == "user":
+ self.to_ada.append(message["message"]["content"])
+
+ def wait(self, inbox, count):
+ for _ in range(DEADLINE * 20):
+ if inbox is self.to_bob and self.queue_log.exists():
+ self.to_bob[:] = [json.loads(line) for line in self.queue_log.read_text().splitlines()]
+ if len(inbox) >= count:
+ return inbox[count - 1]
+ time.sleep(0.05)
+ raise TimeoutError("a submitted letter never reached its fake door")
+
+
+def pause(seconds):
+ sys.stdout.flush()
+ time.sleep(seconds)
+
+
+def show_call(who, colour, tool, **arguments):
+ shown = " ".join(f"{key}={json.dumps(value)}" for key, value in arguments.items())
+ sys.stdout.write(f"{colour}{who:<19}{END}")
+ for char in f"{BOLD}{tool}{END} {shown}":
+ sys.stdout.write(char)
+ sys.stdout.flush()
+ time.sleep(0.012)
+ sys.stdout.write("\n")
+
+
+def show_result(text):
+ print(f"{' ' * 19}{OK}✓ {text}{END}")
+
+
+def show_envelope(who, colour, envelope, keep):
+ print(f"{colour}envelope captured at {who}'s door:{END}")
+ lines = envelope.splitlines()
+ for line in lines[:keep]:
+ line = line if len(line) <= 92 else line[:91] + "…"
+ print(f"{DIM} │ {line}{END}" if line else f"{DIM} │{END}")
+ if len(lines) > keep:
+ print(f"{DIM} │ …{END}")
+
+
+def first_sentence(outcome):
+ return outcome["message"].split(". ")[0].rstrip(".")
+
+
+def transcript(ada, bob, doors):
+ print("\033[2J\033[H", end="")
+ print(f"{DIM}postbag demo: scripted calls to two real postbag MCP servers, one with a Codex identity")
+ print(f"(bob) and one with a Claude Code identity (ada). The native doors are fakes.{END}\n")
+ pause(1.2)
+ # The recipient joins first, so the first letter never meets an unregistered name.
+ show_call("bob Codex", BOB, "postbag_join", bag="review", name="bob")
+ show_result(first_sentence(bob.call("postbag_join", bag="review", name="bob")))
+ pause(0.8)
+ show_call("ada Claude Code", ADA, "postbag_join", bag="review", name="ada")
+ show_result(first_sentence(ada.call("postbag_join", bag="review", name="ada")))
+ pause(0.8)
+ ask = "Review parser.py, top three findings please."
+ show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", body=ask)
+ show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", body=ask)))
+ pause(0.6)
+ show_envelope("bob", BOB, doors.wait(doors.to_bob, 1), keep=6)
+ pause(2.4)
+ answer = "Empty input, swallowed date errors, no field bound."
+ show_call("bob Codex", BOB, "postbag_send", bag="review", to="ada", body=answer)
+ show_result(first_sentence(bob.call("postbag_send", bag="review", to="ada", body=answer)))
+ pause(0.6)
+ show_envelope("ada", ADA, doors.wait(doors.to_ada, 1), keep=3)
+ pause(1.8)
+ done = "Fixed all three, merging."
+ show_call("ada Claude Code", ADA, "postbag_send", bag="review", to="bob", final=True, body=done)
+ show_result(first_sentence(ada.call("postbag_send", bag="review", to="bob", final=True, body=done)))
+ pause(0.6)
+ show_envelope("bob", BOB, doors.wait(doors.to_bob, 2), keep=5)
+ pause(2.4)
+ show_call("bob Codex", BOB, "postbag_leave", bag="review")
+ show_result(first_sentence(bob.call("postbag_leave", bag="review")))
+ pause(0.8)
+ show_call("ada Claude Code", ADA, "postbag_read", bag="review", limit=3)
+ state = ada.call("postbag_read", bag="review", limit=3)["data"]
+ peers = ", ".join(f"@{peer['name']} ({peer['vendor']})" for peer in state["peers"])
+ show_result(f"{state['letters']} letters, registered now: {peers}")
+ pause(4)
+
+
+def main():
+ with tempfile.TemporaryDirectory(prefix="postbag-demo-") as temp:
+ temp = pathlib.Path(temp)
+ home = temp / "home"
+ home.mkdir()
+ doors = Doors(temp)
+ base = {"HOME": str(home), "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
+ "POSTBAG_CODEX": str(doors.queue), "PYTHONPATH": str(ROOT)}
+ servers = []
+ try:
+ bob = Server(base, temp / "bob.stderr", meta={"threadId": THREAD})
+ servers.append(bob)
+ ada = Server({**base, "CLAUDECODE": "1", "CLAUDE_CODE_MESSAGING_SOCKET": doors.socket_path,
+ "CLAUDE_CODE_MESSAGING_TOKEN": "demo-token"}, temp / "ada.stderr")
+ servers.append(ada)
+ transcript(ada, bob, doors)
+ except BaseException:
+ for server in servers:
+ sys.stderr.write(server.errors.read_text())
+ raise
+ finally:
+ for server in servers:
+ server.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/mcp.md b/docs/mcp.md
index 0c499c9..9907736 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -55,14 +55,14 @@ installation. The MCP extra and command select the same version:
```toml
[mcp_servers.postbag]
command = "/absolute/path/to/uvx"
-args = ["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"]
+args = ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]
tool_timeout_sec = 60
```
For Claude Code:
```sh
-claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.0' postbag@2.2.0 mcp
+claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.1' postbag@2.2.1 mcp
```
Generic stdio configuration uses the same command and arguments:
@@ -72,7 +72,7 @@ Generic stdio configuration uses the same command and arguments:
"mcpServers": {
"postbag": {
"command": "/absolute/path/to/uvx",
- "args": ["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"]
+ "args": ["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]
}
}
}
@@ -419,7 +419,7 @@ with private HOME and `ResourceWarning` as an error. See the provenance, interru
first attempt and native runtime versions in
[native compatibility](native-compatibility.md).
-The `test_mcp*.py` files use the real SDK and stdio subprocesses with isolated
+The `tests/test_mcp*.py` files use the real SDK and stdio subprocesses with isolated
homes, fake native executables, and private Unix sockets. The frozen 2.1
source at `6489151` passed **665 tests** with private HOME and
`ResourceWarning` as an error. Installed-wheel runs outside the source
diff --git a/docs/native-compatibility.md b/docs/native-compatibility.md
index bc0bd1a..ef4b3cc 100644
--- a/docs/native-compatibility.md
+++ b/docs/native-compatibility.md
@@ -1,5 +1,53 @@
# Native compatibility checks
+## 2 October 2026: installed 2.2.1 release candidate
+
+A clean wheel from `87777fc2c24f97cd4fdbbf77734a3065491930ef` passed the
+native gate on its first execution. Both hosts ran the installed `postbag mcp`
+launcher. The package and runtime reported `2.2.1`. The core and adapter
+loaded from the isolated venv, matched the reviewed source and wheel, and
+remained unchanged throughout the run. Separate checks found identical
+five-tool catalogs through `postbag mcp` and `postbag-mcp` with current and
+legacy SDK clients. Each returned an empty private inventory.
+
+The run used macOS 27.0.1, build 26A434, Python 3.14.6 and MCP SDK 2.2.0.
+Codex CLI **0.159.0-alpha.12.1** reported model `gpt-6.1-sol`. Claude Code
+**2.1.287** reported `claude-opus-5-5`. The unwrapped native `codex queue`
+used default discovery in a private `CODEX_HOME`, without a `--remote`
+override, and reached a disposable persisted thread. The user's personal
+daemon and native Linux delivery were not tested.
+
+| Check | Observed result |
+|---|---|
+| Ordinary delivery | The initial letter and reply were observed by both recipient sessions. |
+| Final letter | After receipt and turn completion, 30.1091 seconds elapsed with no recipient send attempt or ledger change. Both hosts stayed alive. |
+| Leave and refusal | The recipient left through MCP. The next send refused with `not_submitted` and the departure time, preserving the ledger bytes. |
+| Deliberate rejoin | An explicit later harness instruction caused the recipient to join again. A later ordinary letter and reply were both received. |
+| Accounting | Ten model-authored tool calls produced three joins, one leave and five submitted letters, with one additional send attempt refused. |
+| Cleanup | Both clients and the private server exited 0, without forced shutdown. Native endpoints closed and the private authentication alias was removed. |
+
+Receipt checks correlated tool arguments, outcomes, ledger records and
+completed recipient turns. Claude's local stdout receipt markers were
+harness instrumentation, not Postbag replies. The final observation is
+evidence for this run, not a guarantee that every model obeys the footer.
+
+The command sandbox reported read-only access, disabled network access and
+`approvalPolicy: never`. Claude was launched with `--permission-mode manual`
+and reported `permissionMode: default`. The preauthorized fixture tools
+required no human prompt. `crossSessionInbound` was unset with empty Claude
+setting sources. Native own-child classification was unobserved. No personal
+configuration or installation changed during the gate. Claude used its
+existing login and could write its usual caches or telemetry.
+
+Candidate provenance:
+
+```text
+wheel 76cdd1df87c5980cff0218bb6eb54a1f82147c293cd01ee4c08f70aacee1f4d2
+postbag.py 59a23112e1c6f88f9e39721f423b9d4950e519bbff15f9c0bbb004ae070fd6d2
+postbag_mcp.py fe354cca37c92c6ce1e5ceaba71a37f0218542834124b1b31ee7528b7f4113c8
+harness 81d2c29cf3f957f023933eca47f4e3146f53e1ce38a84401be255775431aaa2c
+```
+
## 1 October 2026: installed 2.2.0 launcher candidate
A clean wheel from `c56d92d7ec2d4fb79a39b206db2bb0af8615d5be` passed the
diff --git a/llms-install.md b/llms-install.md
index da54997..940d287 100644
--- a/llms-install.md
+++ b/llms-install.md
@@ -35,7 +35,7 @@ The `postbag mcp` launcher and uvx examples below require 2.2.0 or later.
```
Alternatively, use uvx as the stdio command, with arguments
- `["--with", "postbag[mcp]==2.2.0", "postbag@2.2.0", "mcp"]`.
+ `["--with", "postbag[mcp]==2.2.1", "postbag@2.2.1", "mcp"]`.
Keep both version pins equal. This does not put the CLI on shell `PATH`.
Do not register both launch methods under different names.
@@ -80,5 +80,5 @@ an inbox share one peer, so a subagent can withdraw the parent's registration.
- Keep raw ledgers out of logs and repositories. They contain letter bodies
and native endpoint credentials. Use the redacted read and inventory tools.
-See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.0/docs/mcp.md) and
-[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.0/docs/native-compatibility.md).
+See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.1/docs/mcp.md) and
+[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.1/docs/native-compatibility.md).
diff --git a/postbag.py b/postbag.py
index e430b26..13bf883 100644
--- a/postbag.py
+++ b/postbag.py
@@ -25,7 +25,7 @@
from datetime import datetime
from pathlib import Path
-__version__ = "2.2.0"
+__version__ = "2.2.1"
PEERS = {"claude", "codex"} # supported vendors; registered peer names come from the ledger
NAME = re.compile(r"[a-z][a-z0-9-]{0,15}")
diff --git a/pyproject.toml b/pyproject.toml
index c9001db..3ed06de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,4 +50,5 @@ py-modules = ["postbag", "postbag_mcp"]
version = {attr = "postbag.__version__"}
[tool.pytest.ini_options]
-testpaths = ["."]
+testpaths = ["tests"]
+pythonpath = ["."]
diff --git a/server.json b/server.json
index 36647fd..c2051ef 100644
--- a/server.json
+++ b/server.json
@@ -6,12 +6,12 @@
"url": "https://github.com/parasxos/postbag",
"source": "github"
},
- "version": "2.2.0",
+ "version": "2.2.1",
"packages": [
{
"registryType": "pypi",
"identifier": "postbag",
- "version": "2.2.0",
+ "version": "2.2.1",
"transport": {
"type": "stdio"
},
@@ -20,7 +20,7 @@
{
"type": "named",
"name": "--with",
- "value": "postbag[mcp]==2.2.0",
+ "value": "postbag[mcp]==2.2.1",
"description": "The MCP server is the optional mcp extra of the same version."
}
],
diff --git a/test_bag_context.py b/tests/test_bag_context.py
similarity index 100%
rename from test_bag_context.py
rename to tests/test_bag_context.py
diff --git a/test_bags.py b/tests/test_bags.py
similarity index 98%
rename from test_bags.py
rename to tests/test_bags.py
index ea5f18f..d8d3dd9 100644
--- a/test_bags.py
+++ b/tests/test_bags.py
@@ -1,5 +1,6 @@
"""Bag selection and scoped commands through an isolated public CLI."""
+import importlib.util
import json
import os
from pathlib import Path
@@ -12,7 +13,9 @@
import pytest
-SCRIPT = Path(__file__).resolve().with_name("postbag.py")
+# Children run the postbag module this process selected: the checkout's in the source
+# layout, the installed one otherwise. Never a path derived from this test file.
+SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve()
THREADS = {"ada": "fake-bag-thread-ada", "bob": "fake-bag-thread-bob"}
diff --git a/test_core_2.py b/tests/test_core_2.py
similarity index 100%
rename from test_core_2.py
rename to tests/test_core_2.py
diff --git a/test_hardening.py b/tests/test_hardening.py
similarity index 98%
rename from test_hardening.py
rename to tests/test_hardening.py
index a9b7512..b47156d 100644
--- a/test_hardening.py
+++ b/tests/test_hardening.py
@@ -1,6 +1,7 @@
"""Regression tests using private ledgers and fake vendor doors only."""
import fcntl
+import importlib.util
import json
import os
from pathlib import Path
@@ -15,8 +16,9 @@
import pytest
-ROOT = Path(__file__).resolve().parent
-SCRIPT = ROOT / ("postbag.py" if (ROOT / "postbag.py").exists() else "postbag")
+# Children run the postbag module this process selected: the checkout's in the source
+# layout, the installed one otherwise. Never a path derived from this test file.
+SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve()
SESSION_VARS = {
"claude": {
"CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/postbag-test-unused.sock",
diff --git a/test_inventory.py b/tests/test_inventory.py
similarity index 98%
rename from test_inventory.py
rename to tests/test_inventory.py
index 33ad256..9ee029e 100644
--- a/test_inventory.py
+++ b/tests/test_inventory.py
@@ -2,6 +2,7 @@
import errno
import fcntl
+import importlib.util
import json
import os
from pathlib import Path
@@ -14,7 +15,9 @@
import pytest
-SCRIPT = Path(__file__).resolve().with_name("postbag.py")
+# Children run the postbag module this process selected: the checkout's in the source
+# layout, the installed one otherwise. Never a path derived from this test file.
+SCRIPT = Path(importlib.util.find_spec("postbag").origin).resolve()
PRIVATE_BODY = "INVENTORY-PRIVATE-LETTER-BODY"
PRIVATE_SOCKET = "/tmp/inventory-private-never-used.sock"
PRIVATE_TOKEN = "inventory-private-fake-token"
diff --git a/test_inventory_terminal.py b/tests/test_inventory_terminal.py
similarity index 100%
rename from test_inventory_terminal.py
rename to tests/test_inventory_terminal.py
diff --git a/test_leave.py b/tests/test_leave.py
similarity index 100%
rename from test_leave.py
rename to tests/test_leave.py
diff --git a/test_mcp.py b/tests/test_mcp.py
similarity index 99%
rename from test_mcp.py
rename to tests/test_mcp.py
index 9c1abd9..9719660 100644
--- a/test_mcp.py
+++ b/tests/test_mcp.py
@@ -26,8 +26,10 @@
pytest.skip("MCP wire checks require mcp 2.x", allow_module_level=True)
-ROOT = Path(__file__).resolve().parent
SERVER_SCRIPT = Path(importlib.util.find_spec("postbag_mcp").origin)
+# Children import from the directory the server module was selected from: the checkout root
+# in the source layout, site-packages in the installed layout. Never this test directory.
+ROOT = SERVER_SCRIPT.resolve().parent
THREAD_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
THREAD_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
STARTUP_THREAD = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
diff --git a/test_mcp_adversarial.py b/tests/test_mcp_adversarial.py
similarity index 98%
rename from test_mcp_adversarial.py
rename to tests/test_mcp_adversarial.py
index 549bb34..278dfa9 100644
--- a/test_mcp_adversarial.py
+++ b/tests/test_mcp_adversarial.py
@@ -16,11 +16,12 @@
import pytest
HERE = Path(__file__).resolve().parent
-if (HERE / "postbag.py").exists():
- # Source layout: test the checkout beside this file, not some other import of the same name.
+SOURCE = HERE.parent
+if (SOURCE / "postbag.py").exists():
+ # Source layout: test the checkout above tests/, not some other import of the same name.
spec = importlib.util.find_spec("postbag_mcp")
- if spec is None or Path(spec.origin).resolve() != HERE / "postbag_mcp.py":
- sys.path.insert(0, str(HERE))
+ if spec is None or Path(spec.origin).resolve() != SOURCE / "postbag_mcp.py":
+ sys.path.insert(0, str(SOURCE))
for stale in ("postbag_mcp", "postbag", "test_mcp"):
sys.modules.pop(stale, None)
# Installed layout (only test_mcp*.py copied elsewhere): use the installed package as found.
diff --git a/test_mcp_launcher.py b/tests/test_mcp_launcher.py
similarity index 100%
rename from test_mcp_launcher.py
rename to tests/test_mcp_launcher.py
diff --git a/test_mcp_leave.py b/tests/test_mcp_leave.py
similarity index 98%
rename from test_mcp_leave.py
rename to tests/test_mcp_leave.py
index 0fb60de..3d6549b 100644
--- a/test_mcp_leave.py
+++ b/tests/test_mcp_leave.py
@@ -11,10 +11,11 @@
HERE = Path(__file__).resolve().parent
+SOURCE = HERE.parent
MODULES = ("postbag", "postbag_mcp")
-SOURCE_LAYOUT = all((HERE / f"{module}.py").exists() for module in MODULES)
-if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != HERE / "postbag_mcp.py":
- sys.path.insert(0, str(HERE))
+SOURCE_LAYOUT = all((SOURCE / f"{module}.py").exists() for module in MODULES)
+if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != SOURCE / "postbag_mcp.py":
+ sys.path.insert(0, str(SOURCE))
for stale in (*MODULES, "test_mcp"):
sys.modules.pop(stale, None)
@@ -61,7 +62,7 @@ def read_recovery(response, *, bag="default", submission_state=None):
def test_leave_server_module_uses_source_or_installed_layout():
origins = {module: Path(importlib.util.find_spec(module).origin).resolve() for module in MODULES}
if SOURCE_LAYOUT:
- assert origins == {module: HERE / f"{module}.py" for module in MODULES}
+ assert origins == {module: SOURCE / f"{module}.py" for module in MODULES}
else:
assert origins["postbag"].parent == origins["postbag_mcp"].parent
assert Path(sys.prefix).resolve() in origins["postbag_mcp"].parents
diff --git a/test_mcp_leave_faults.py b/tests/test_mcp_leave_faults.py
similarity index 100%
rename from test_mcp_leave_faults.py
rename to tests/test_mcp_leave_faults.py
diff --git a/test_mcp_protocol.py b/tests/test_mcp_protocol.py
similarity index 100%
rename from test_mcp_protocol.py
rename to tests/test_mcp_protocol.py
diff --git a/test_mcp_review.py b/tests/test_mcp_review.py
similarity index 98%
rename from test_mcp_review.py
rename to tests/test_mcp_review.py
index 41da3a3..8062651 100644
--- a/test_mcp_review.py
+++ b/tests/test_mcp_review.py
@@ -19,12 +19,13 @@
import pytest
HERE = Path(__file__).resolve().parent
+SOURCE = HERE.parent
MODULES = ("postbag", "postbag_mcp")
-# Source layout: the modules sit beside this file and must be the ones under test.
+# Source layout: the modules sit one directory above tests/ and must be the ones under test.
# Installed layout (CI copies only test_mcp*.py elsewhere): both come from the environment.
-SOURCE_LAYOUT = all((HERE / f"{module}.py").exists() for module in MODULES)
-if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != HERE / "postbag_mcp.py":
- sys.path.insert(0, str(HERE))
+SOURCE_LAYOUT = all((SOURCE / f"{module}.py").exists() for module in MODULES)
+if SOURCE_LAYOUT and Path(importlib.util.find_spec("postbag_mcp").origin).resolve() != SOURCE / "postbag_mcp.py":
+ sys.path.insert(0, str(SOURCE))
for stale in (*MODULES, "test_mcp"):
sys.modules.pop(stale, None)
@@ -40,7 +41,7 @@ def test_server_and_ledger_modules_come_from_one_layout():
"""The server under test and its ledger module must come from the same place."""
origins = {module: Path(importlib.util.find_spec(module).origin).resolve() for module in MODULES}
if SOURCE_LAYOUT:
- assert origins == {module: HERE / f"{module}.py" for module in MODULES}
+ assert origins == {module: SOURCE / f"{module}.py" for module in MODULES}
else:
assert origins["postbag"].parent == origins["postbag_mcp"].parent, origins
assert HERE not in origins["postbag_mcp"].parents and origins["postbag_mcp"].parent != HERE
diff --git a/test_mcp_worker_contract.py b/tests/test_mcp_worker_contract.py
similarity index 100%
rename from test_mcp_worker_contract.py
rename to tests/test_mcp_worker_contract.py
diff --git a/test_names.py b/tests/test_names.py
similarity index 100%
rename from test_names.py
rename to tests/test_names.py
diff --git a/test_postbag.py b/tests/test_postbag.py
similarity index 100%
rename from test_postbag.py
rename to tests/test_postbag.py
diff --git a/test_presentation.py b/tests/test_presentation.py
similarity index 100%
rename from test_presentation.py
rename to tests/test_presentation.py
diff --git a/test_resume.py b/tests/test_resume.py
similarity index 100%
rename from test_resume.py
rename to tests/test_resume.py
diff --git a/test_review_core.py b/tests/test_review_core.py
similarity index 100%
rename from test_review_core.py
rename to tests/test_review_core.py
diff --git a/test_transports.py b/tests/test_transports.py
similarity index 100%
rename from test_transports.py
rename to tests/test_transports.py