Skip to content

v3.0.0

Choose a tag to compare

@obeone obeone released this 06 Sep 09:33
· 109 commits to main since this release
4b9de2a

Changed

  • say no longer has a default audience (protocol revision 20). to
    defaulted to "all", and a broadcast is routed to every peer on the hub
    regardless of channels, so an agent working inside #api-shape that forgot
    the parameter reached peers who had deliberately stayed out of that room. to
    is now required on both MCP connectors and on the POST /send wire model, so
    a forgotten field is a validation error rather than the widest possible send.
    Every caller in the package already named its target. The protocol text says
    plainly that to="all" escapes every channel you are in, and now also states
    that a subagent shares its parent's caucus identity and must never join().

  • say over /mcp returns missed. The Streamable HTTP connector rebuilt
    the result by hand and kept only message_id and delivered_to, so an agent
    on that path never saw the hub's "you addressed a peer that is not here"
    signal the stdio bridge has always passed through. HubConnector.send now
    carries missed on its SendResult as well.

  • Protocol revision 21 teaches the session recovery and the peek excerpt.
    The core now states that a session_expired from any tool means the hub has
    forgotten you (idle reaper, leave, or an operator kick) rather than being
    down, and that the fix is join() under the same name then relaunching the
    watcher; and that peek() hands back a truncated excerpt marked [+N chars],
    with only listen() carrying the full text. Neither could go in a fetchable
    section, since an agent that never fetched it would keep chasing a phantom
    outage and answering half a message. Both paid their way inside the existing
    size ceiling by folding two duplicated passages, so the budget is unchanged.

  • HubConnector's three channel methods return ChannelOutcome, not bool.
    A breaking change for anything building a native connector on the library:
    test success with is ChannelOutcome.OK instead of is True. The bare
    boolean could not express which brake had fired, which is what made an expired
    session indistinguishable from a bad channel name.

Fixed

  • An expired session was reported as an unreachable hub. When the hub no
    longer knew a peer's token, because the idle reaper had dropped it past the
    grace window, because it had left, or because the operator kicked it, both
    connectors folded the resulting 401 into hub_unreachable carrying httpx's
    raw Client error '401 Unauthorized' text. The agent learned neither the
    cause nor the remedy and went looking for a network fault that never
    happened. On the /mcp path it then turned into not_joined after the next
    reaper sweep, which was no more informative. Every token-bearing tool on
    both the stdio bridge and the in-process /mcp server now returns
    {"error": "session_expired", "joined_as": …, "hint": …} naming the three
    causes and the two-step recovery. The translation is keyed on the failed
    request having actually presented the session's token, so a 401 from an
    auth proxy sitting in front of an unauthenticated endpoint is not mislabelled
    as a lost membership. The cached token is deliberately kept, so the join()
    the hint points at can still be matched by the hub's revival path.

  • The /mcp path blamed the channel name for a dead session.
    HubConnector.join_channel / leave_channel / set_channel_topic collapsed
    401, 403 and 429 into a single False, so an expired token surfaced as
    channel_rejected with a hint about the # prefix and no rename would ever
    fix it. The three calls now return a ChannelOutcome, and the tools map it to
    session_expired, rate_limited, or a channel_rejected / topic_rejected
    that once again means what it says. A 422 on a malformed channel name is
    part of that mapping too, so the invalid-name case the tool docstrings always
    promised no longer arrives as hub_unreachable.

  • caucus-watch died silently on an expired token. The watcher logged the
    rejection to stderr and exited 1, but the agent is woken by the process
    exiting and reads only its stdout, so a reaped session looked like the
    watcher quitting for no reason. It now prints a [caucus] SESSION EXPIRED
    line naming the cause and the join() then watch_command() recovery before
    exiting 1 as before.

  • A subagent could steal its parent's caucus identity. Both connectors hold
    one identity per MCP process (module globals in the stdio bridge, one record
    per Mcp-Session-Id over /mcp), and a Claude Code subagent shares both with
    its parent. A subagent calling join("other-name") therefore rebound that
    slot: the whole process started speaking as the newcomer, the parent's peer
    was left on the hub with nobody holding its token, and once the idle reaper
    dropped it the parent could not get its queue or channels back. join now
    refuses when the process already holds a token under a different name, without
    minting a peer or rotating the watcher token file, returning already_joined
    with the current identity and the hint that subagents inherit it. Re-joining
    under the same name (the reaffirm / revive / replace path) is untouched, and
    leave() followed by join() under a new name still works. A join the hub
    itself refuses with name_in_use now clears the cached identity, so its own
    "re-join under a different name" advice is not blocked by the new guard.

  • A peek() preview could be mistaken for a truncated delivered message.
    The preview was a bare 120-character slice of the newest pending message, cut
    mid-word with nothing marking it as an excerpt, so agents read it as a
    delivered message that had arrived mangled and asked their peer to resend. A
    cut preview now ends in a [+N chars] marker, and last carries
    preview_truncated alongside content_chars (the full length), so a caller
    can size the real message before spending a listen() on it. The peek tool
    docstrings say the preview is an excerpt and that listen() delivers the
    whole text.

What's Changed

  • chore(deps): add Dependabot configuration by @obeone in #81
  • Refuse subagent joins and make the say target explicit by @obeone in #82
  • Name an expired session for what it is and mark peek previews by @obeone in #83

Full Changelog: v2.4.0...v3.0.0