diff --git a/CHANGELOG.md b/CHANGELOG.md index e65cb5d..3085161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.2] - 2026-10-02 + +Documentation release. No runtime change beyond the version number. + +### Changed +- The README is a short product page about Codex and Claude Code talking to + each other: a one-line pitch, the demo, install and setup for both hosts, + a two-prompt quick start, the five MCP tools, and three security notes. +- The demo shows two panes, Claude Code and Codex, each with its own MCP + calls and the letters it receives, from scripted calls to two real servers + with stand-in native doors. +- A 1280x640 social preview image is in `docs/assets/social-preview.png`. + ## [2.2.1] - 2026-10-02 Documentation and repository layout release. No runtime change beyond the @@ -403,7 +416,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.1...HEAD +[Unreleased]: https://github.com/parasxos/postbag/compare/v2.2.2...HEAD +[2.2.2]: https://github.com/parasxos/postbag/releases/tag/v2.2.2 [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 diff --git a/Dockerfile b/Dockerfile index fb679fb..b8caef8 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.1 +ARG POSTBAG_VERSION=2.2.2 RUN pip install --no-cache-dir "postbag[mcp]==${POSTBAG_VERSION}" ENTRYPOINT ["postbag-mcp"] diff --git a/README.md b/README.md index 1c9fcf0..54c0a33 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,97 @@
-Postbag logo +Postbag logo # postbag -**Two agents, one bag of letters.** +**Let Codex and Claude Code talk to each other.** -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. +A local MCP server. Ask Codex to review what Claude Code just wrote, or the +other way round. The answer arrives as a new turn. No copying text between +windows. [![ci](https://github.com/parasxos/postbag/actions/workflows/ci.yml/badge.svg)](https://github.com/parasxos/postbag/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/postbag)](https://pypi.org/project/postbag/) [![postbag MCP server](https://glama.ai/mcp/servers/parasxos/postbag/badges/score.svg)](https://glama.ai/mcp/servers/parasxos/postbag) -[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/parasxos/postbag/blob/v2.2.1/LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/parasxos/postbag/blob/v2.2.2/LICENSE)
-![Scripted demo: a Codex identity and a Claude Code identity call two postbag MCP servers to join a bag, exchange a review, mark the last letter final, leave and read the bag](https://raw.githubusercontent.com/parasxos/postbag/v2.2.1/docs/assets/demo.gif) - -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). - -## What it does - -- **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. +![Scripted demo in two panes, Claude Code and Codex. Both join bag review with postbag_join. Claude Code sends a review request with postbag_send, and it arrives in Codex as letter 1. Codex replies with three findings as letter 2, and Claude Code sends final letter 3. postbag_read reports 3 letters.](https://raw.githubusercontent.com/parasxos/postbag/v2.2.2/docs/assets/demo.gif) ## Install +Needs Python 3.10 or later, Claude Code and a recent Codex CLI with +`codex queue`. Run both sessions on one machine with the same postbag version. +Delivery is tested on macOS. + ```sh pipx install 'postbag[mcp]' +claude mcp add --scope user postbag -- "$(command -v postbag-mcp)" +codex mcp add postbag -- "$(command -v postbag-mcp)" ``` -Python 3.10 or later, on macOS or Linux. Then register the server once in -each host. +Start a new session in each app, then run `/mcp`. postbag should list five +tools. For uvx and other setups, see +[MCP setup](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md). -**Claude Code** +Or ask your agent: *Install postbag using +[llms-install.md](https://github.com/parasxos/postbag/blob/v2.2.2/llms-install.md).* -```sh -claude mcp add --transport stdio --scope user postbag -- "$(command -v postbag-mcp)" -``` +## Quick start -**Codex**, in `~/.codex/config.toml`, with the path that `command -v postbag-mcp` prints: +A bag is a named conversation. Both agents join the same one. Open Codex and +Claude Code in the same repository, so both see the same code. -```toml -[mcp_servers.postbag] -command = "/Users/you/.local/bin/postbag-mcp" -tool_timeout_sec = 60 -``` +1. In Codex: + > Join postbag bag "review" as codex. +2. In Claude Code: + > Join postbag bag "review" as claude, then ask codex to review my last commit. -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). +Codex joins first so Claude Code has someone to write to. Approve the postbag +tools when asked. Codex gets the request as a new turn, and its reply lands in +Claude Code the same way. Either side can end with a final letter, which asks +for no reply. -## Use it +## What to use it for -Open a Codex session and a Claude Code session in the same repository. +- **Cross review.** Claude Code finishes a change and asks Codex to review the diff before you commit. +- **Split work.** Codex writes the tests while Claude Code writes the implementation. They compare notes by letter. +- **Second opinion.** When one agent is stuck on a bug, it asks the other for a fresh read. -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."* +## Tools -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 +Your agents call these. You just ask in plain words. | 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_bags` | Lists local bags with 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) +## How it works + +- **Native delivery.** Letters arrive as a new turn in the other session, + through its own input. postbag adds no delivery daemon, polling or hooks. +- **No setup prompts.** Every letter ends with how to reply, or asks for no reply. +- **One shared history.** Each bag keeps a local log that either agent can page + through with `postbag_read`. + +## Security + +- A delivered letter becomes a user turn in the other agent. Connect only + sessions you trust with the task. +- There is no letter limit. Keep tool approvals on to check each send before it goes. +- The ledgers under `~/.postbag/` hold each Claude Code session's inbox token, + with file mode `0600`. Never commit or share them. + +See [SECURITY.md](https://github.com/parasxos/postbag/blob/v2.2.2/.github/SECURITY.md). + +[MCP setup](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md) · +[Concept](https://github.com/parasxos/postbag/blob/v2.2.2/CONCEPT.md) · +[Changelog](https://github.com/parasxos/postbag/blob/v2.2.2/CHANGELOG.md) · +[Contributing](https://github.com/parasxos/postbag/blob/v2.2.2/.github/CONTRIBUTING.md) · +[MIT](https://github.com/parasxos/postbag/blob/v2.2.2/LICENSE) diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif index 3b4fe40..77540b2 100644 Binary files a/docs/assets/demo.gif and b/docs/assets/demo.gif differ diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png new file mode 100644 index 0000000..3451557 Binary files /dev/null and b/docs/assets/social-preview.png differ diff --git a/docs/demo.tape b/docs/demo.tape index 97edc29..d91ca88 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,16 +1,18 @@ # 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. +# Claude Code on the left, Codex on the right: scripted calls to two real +# postbag MCP servers, with stand-in native doors. See docs/demo_mcp.py. Output docs/assets/demo.gif Set Shell bash -Set FontSize 16 -Set Width 1260 -Set Height 800 -Set Padding 20 +Set FontSize 20 +Set Width 1280 +Set Height 730 +Set Padding 24 +Set LetterSpacing 0 Set Theme "Catppuccin Mocha" Hide Type "clear; ${PYTHON:-python3} docs/demo_mcp.py" Enter +Sleep 1.5s Show -Sleep 36s +Sleep 17s diff --git a/docs/demo_mcp.py b/docs/demo_mcp.py index e16eef7..afdf322 100644 --- a/docs/demo_mcp.py +++ b/docs/demo_mcp.py @@ -1,32 +1,32 @@ """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. +Two panes, Claude Code on the left and Codex on the right. Each runs its own +`postbag mcp` server with that host's identity, shows its own tool calls, and +shows the letters its door receives. The doors are stand-ins: a throwaway Unix +socket for Claude Code's inbox and a small script for `codex queue`, so the demo +shows the exact envelope each side would be handed. 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 + python docs/demo_mcp.py # the same demo in a terminal of at least 100x28 """ import json import os import pathlib import queue +import shutil import socket import subprocess import sys import tempfile +import textwrap 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 +THREAD = "019a0000-0000-7000-8000-00000000b0b0" # the 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.""" @@ -101,12 +101,12 @@ def close(self): class Doors: - """Fake native doors that keep what each recipient would be handed.""" + """Fake native doors that keep what each recipient would be handed: an inbox socket for Claude Code, a queue script for Codex.""" def __init__(self, temp): - self.to_ada, self.to_bob = [], [] - self.socket_path = str(temp / "ada.sock") - self.queue_log = temp / "bob.jsonl" + self.to_claude, self.to_codex = [], [] + self.socket_path = str(temp / "claude.sock") + self.queue_log = temp / "codex.jsonl" self.queue = temp / "codex" self.queue.write_text( f"#!{sys.executable}\nimport json, sys\n" @@ -129,89 +129,176 @@ def inbox(self, listener): for line in data.decode().splitlines(): message = json.loads(line) if message.get("type") == "user": - self.to_ada.append(message["message"]["content"]) + self.to_claude.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 inbox is self.to_codex and self.queue_log.exists(): + self.to_codex[:] = [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") +W, G = 47, 6 # pane width and gutter, in columns: 100 columns in all +TOP, FOOT = 4, 27 # first content row, footer row +CSI = "\033[" +END = CSI + "0m" +CLAUDE = CSI + "38;2;217;119;87m" # coral +CODEX = CSI + "38;2;137;180;250m" # blue +OK = CSI + "38;2;166;227;161m" # green +DIM = CSI + "38;2;127;132;156m" # grey +TEXT = CSI + "38;2;205;214;244m" # body text +BOLD = CSI + "1m" -def show_result(text): - print(f"{' ' * 19}{OK}✓ {text}{END}") +def at(row, col): + return f"{CSI}{row};{col}H" -def show_envelope(who, colour, envelope, keep): - print(f"{colour}envelope captured at {who}'s door:{END}") +def flush(seconds=0.0): + sys.stdout.flush() + if seconds: + time.sleep(seconds) + + +class Pane: + def __init__(self, col, colour, title): + self.col, self.colour, self.row = col, colour, TOP + sys.stdout.write(at(1, col) + BOLD + colour + title + END + DIM + " postbag MCP" + END) + sys.stdout.write(at(2, col) + colour + "─" * W + END) + + def line(self, parts, typed=0.0): + """Write one row of (style, text) parts at this pane's next row; typed>0 types it.""" + assert self.row < FOOT - 1, "pane overflow" + assert sum(len(t) for _, t in parts) <= W, parts + sys.stdout.write(at(self.row, self.col)) + for style, text in parts: + sys.stdout.write(style) + if typed: + for char in text: + sys.stdout.write(char) + flush(typed) + else: + sys.stdout.write(text) + sys.stdout.write(END) + self.row += 1 + flush() + + def gap(self, rows=1): + self.row += rows + + def call(self, tool, args, body=None, typed=0.010): + """Show a tool call; return the row it started on (the arrow row for a send).""" + start = self.row + self.line([(BOLD + TEXT, tool), (TEXT, " " + " ".join(f"{k}={v}" for k, v in args))], typed) + if body is not None: + lines = body.splitlines() + for i, text in enumerate(lines): + q0, q1 = ('"' if i == 0 else " "), ('"' if i == len(lines) - 1 else "") + self.line([(TEXT, f" {q0}{text}{q1}")], typed) + return start + + def ok(self, text): + self.line([(OK, "✓ " + text)]) + + +def excerpt(envelope, tail): + """Verbatim lines of the delivered envelope: header split before 'via', body, then one tail line.""" 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) + head = lines[0] + cut = head.index(" via ") + blank = [i for i, text in enumerate(lines) if text == ""] + body = lines[blank[0] + 1: blank[1]] + footer = lines[blank[1] + 1:] + rows = [("head", head[:cut]), ("dim", head[cut + 1:])] + rows += [("text", text) for text in body] + if tail: + line = next(text for text in footer if tail in text) + line = line if line.startswith(tail) else "… " + line[line.index(tail):] + rows += [("dim", part) for part in textwrap.wrap(line, 36 if line.startswith("…") else W - 2)] + elif footer: + rows.append(("dim", "…")) # the elided reply instructions are marked, never silently dropped + return rows + + +def arrive(pane, sender, rows, arrow_row, direction): + """Draw the arrow on the sender's call row, then the letter box in the recipient pane.""" + gutter = W + 1 # first gutter column: panes start at 1 and W+G+1 + arrow = " ───▶ " if direction == ">" else " ◀─── " + frames = [arrow[:i] for i in range(1, 7)] if direction == ">" else [arrow[-i:].rjust(6) for i in range(1, 7)] + for frame in frames: + sys.stdout.write(at(arrow_row, gutter) + sender.colour + frame + END) + flush(0.04) + pane.row = max(pane.row, arrow_row) + styles = {"head": BOLD + sender.colour, "dim": DIM, "text": TEXT} + for kind, text in rows: + pane.line([(sender.colour, "▌ "), (styles[kind], text)]) + flush(0.06) + + +def transcript(claude_server, codex_server, doors): + cols, rows = shutil.get_terminal_size() + if cols < 2 * W + G or rows < FOOT + 1: + raise SystemExit(f"demo needs {2 * W + G}x{FOOT + 1}, terminal is {cols}x{rows}") + sys.stdout.write(CSI + "?25l" + CSI + "2J" + CSI + "H") + left = Pane(1, CLAUDE, "Claude Code") + right = Pane(W + G + 1, CODEX, "Codex") + sys.stdout.write(at(FOOT, 1) + DIM + "Scripted tool calls to two real postbag MCP servers. " + "The native inputs are stand-ins." + END) + flush(1.0) + + # Beat 1: both sessions join the same bag (the recipient's server is called first). + left.call("postbag_join", [("bag", '"review"'), ("name", '"claude"')], typed=0.006) + right.call("postbag_join", [("bag", '"review"'), ("name", '"codex"')], typed=0.006) + codex_server.call("postbag_join", bag="review", name="codex") + claude_server.call("postbag_join", bag="review", name="claude") + left.ok("joined bag review") + right.ok("joined bag review") + left.gap(), right.gap() + flush(0.8) + + # Beat 2: Claude Code asks Codex for a review; it lands in Codex as a user turn. + ask = "Review my last commit. Top three issues?" + row = left.call("postbag_send", [("to", '"codex"')], ask) + claude_server.call("postbag_send", bag="review", to="codex", body=ask) + left.ok("letter 1 submitted") + flush(0.3) + arrive(right, left, excerpt(doors.wait(doors.to_codex, 1), "call postbag_send"), row, ">") + right.gap() + flush(1.8) + + # Beat 3: Codex answers from its own session. + answer = "1. empty input crashes parse()\n2. date errors are swallowed\n3. no size limit on fields" + row = right.call("postbag_send", [("to", '"claude"')], answer, typed=0.007) + codex_server.call("postbag_send", bag="review", to="claude", body=answer) + right.ok("letter 2 submitted") + flush(0.3) + left.gap() + arrive(left, right, excerpt(doors.wait(doors.to_claude, 1), None), row, "<") + left.gap() + flush(1.8) + + # Beat 4: Claude Code closes with a final letter. + done = "Fixed all three. Merging." + row = left.call("postbag_send", [("to", '"codex"'), ("final", "true")], done) + claude_server.call("postbag_send", bag="review", to="codex", final=True, body=done) + left.ok("letter 3 submitted") + flush(0.3) + right.gap() + arrive(right, left, excerpt(doors.wait(doors.to_codex, 2), "Final letter"), row, ">") + flush(1.2) + + # Beat 5: one shared record. + left.gap() + left.call("postbag_read", [("bag", '"review"')], typed=0.006) + state = claude_server.call("postbag_read", bag="review", limit=3)["data"] + names = " and ".join(f"@{p['name']}" for p in state["peers"]) + left.ok(f"{state['letters']} letters, {names} registered") + sys.stdout.write(at(FOOT + 1, 1)) + flush(6) # the tape stops recording during this hold, so no shell prompt is filmed def main(): @@ -221,20 +308,22 @@ def main(): 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)} + "POSTBAG_CODEX": str(doors.queue), "PYTHONPATH": str(ROOT), + "PYTHONDONTWRITEBYTECODE": "1"} 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) + codex = Server(base, temp / "codex.stderr", meta={"threadId": THREAD}) + servers.append(codex) + claude = Server({**base, "CLAUDECODE": "1", "CLAUDE_CODE_MESSAGING_SOCKET": doors.socket_path, + "CLAUDE_CODE_MESSAGING_TOKEN": "demo-token"}, temp / "claude.stderr") + servers.append(claude) + transcript(claude, codex, doors) except BaseException: for server in servers: sys.stderr.write(server.errors.read_text()) raise finally: + sys.stdout.write(CSI + "?25h") for server in servers: server.close() diff --git a/docs/mcp.md b/docs/mcp.md index 9907736..10e2882 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.1", "postbag@2.2.1", "mcp"] +args = ["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "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.1' postbag@2.2.1 mcp +claude mcp add --transport stdio --scope user postbag -- /absolute/path/to/uvx --with 'postbag[mcp]==2.2.2' postbag@2.2.2 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.1", "postbag@2.2.1", "mcp"] + "args": ["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "mcp"] } } } diff --git a/docs/native-compatibility.md b/docs/native-compatibility.md index ef4b3cc..70c6f46 100644 --- a/docs/native-compatibility.md +++ b/docs/native-compatibility.md @@ -1,5 +1,56 @@ # Native compatibility checks +## 2 October 2026: installed 2.2.2 release candidate + +An independently built wheel from a clean archive of +`94ad0853ff1a80014954d0972fce2adc4aa00eb2` passed the full native gate on its +first execution. Both fresh hosts used the installed `postbag mcp` launcher +from one isolated venv. Package and runtime versions were `2.2.2`. Installed +modules matched the source and wheel and stayed unchanged. Both launchers +exposed identical five-tool catalogs with current and legacy SDK clients, +and all four checks 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 `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 `--remote`, and +reached a disposable persisted thread. Personal daemon routing and native +Linux delivery were not tested. + +| Check | Observed result | +|---|---| +| Ordinary delivery | Both sessions received the initial letter and reply. The two incoming reply turns were created by the native queue, separate from direct harness prompts. | +| Final letter | After recipient receipt and turn completion, 30.0605 seconds elapsed with no send attempt or ledger change. Both hosts stayed alive. | +| Leave and refusal | MCP leave withdrew the recipient. The next send returned `refused` and `not_submitted`, included the departure time and preserved the ledger bytes. Its body was not received. | +| Deliberate rejoin | An explicit later harness instruction caused rejoining. A later ordinary letter and reply were both received. | +| Accounting | Ten model-authored calls produced three joins, one leave and five submitted letters. One additional send attempt was refused. Nine records and five letter ordinals were contiguous. | +| Cleanup | Both clients and the private server exited 0 without forced shutdown. Native endpoints closed and the private authentication alias was removed. | + +An independent audit checked 52 assertions against raw tool events, +recipient turns, timestamps, ledger records and installed files. Claude's +inbound envelopes were not exposed by its event stream. Its exact-body +stdout receipt markers followed by completed peer-origin turns corroborated +receipt. Those markers were harness instrumentation, not Postbag replies. +The final window is evidence for this run, not general loop prevention or +an isolated test of the footer. Leave did not test recall of queued letters. + +The command sandbox reported read-only access, disabled network access and +`approvalPolicy: never`. Claude was launched with `--permission-mode manual` +and reported `permissionMode: default`. 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. Claude used its existing login and +could write its usual caches or telemetry. + +Candidate provenance: + +```text +wheel fd98de343c5d4089d27e9b39a80621085e224ffe4b8bebd55ab512cb5706c9d7 +postbag.py 18d76fb0643504e381c4bf880bc9b177b8b2e98beff88c2c0d74132f86cc780c +postbag_mcp.py fe354cca37c92c6ce1e5ceaba71a37f0218542834124b1b31ee7528b7f4113c8 +harness bc30165fca08928a4f959ee22465d0beab5ac8c1ccd82e4676d9f3bcaa9fcc71 +``` + ## 2 October 2026: installed 2.2.1 release candidate A clean wheel from `87777fc2c24f97cd4fdbbf77734a3065491930ef` passed the diff --git a/llms-install.md b/llms-install.md index 940d287..62f003c 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.1", "postbag@2.2.1", "mcp"]`. + `["--with", "postbag[mcp]==2.2.2", "postbag@2.2.2", "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.1/docs/mcp.md) and -[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.1/docs/native-compatibility.md). +See [MCP setup and outcomes](https://github.com/parasxos/postbag/blob/v2.2.2/docs/mcp.md) and +[native compatibility evidence](https://github.com/parasxos/postbag/blob/v2.2.2/docs/native-compatibility.md). diff --git a/postbag.py b/postbag.py index 13bf883..49c8c76 100644 --- a/postbag.py +++ b/postbag.py @@ -25,7 +25,7 @@ from datetime import datetime from pathlib import Path -__version__ = "2.2.1" +__version__ = "2.2.2" 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/server.json b/server.json index c2051ef..a5a9c0b 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/parasxos/postbag", "source": "github" }, - "version": "2.2.1", + "version": "2.2.2", "packages": [ { "registryType": "pypi", "identifier": "postbag", - "version": "2.2.1", + "version": "2.2.2", "transport": { "type": "stdio" }, @@ -20,7 +20,7 @@ { "type": "named", "name": "--with", - "value": "postbag[mcp]==2.2.1", + "value": "postbag[mcp]==2.2.2", "description": "The MCP server is the optional mcp extra of the same version." } ],