Skip to content

fix(mcpl): speaking no longer reopens a channel the agent closed - #330

Merged
nissa-seru merged 42 commits into
anima-research:mainfrom
nissa-seru:fix/speaking-never-opens
Oct 24, 2026
Merged

nissa-seru merged 42 commits into
anima-research:mainfrom
nissa-seru:fix/speaking-never-opens

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

An agent's own close of a channel now holds against its own speech. A send or reply, plain speech, or a draft's resend into a channel the agent closed or declined itself publishes there and leaves the channel closed, and so does speech into a channel a host module closed. Elsewhere, speech opens a closed channel as before: one nobody decided about, such as a DM that woke the agent or a channel where someone first mentioned it, one a machine closed, or one the agent decided open whose open failed.

Where a send tool's call, routed speech or a >> line leaves the agent's words in a channel it closed or declined, one line tells it so. As each holds for the agent reading it, the line also says what still reaches it from there, how to open the channel, and how to turn the line off. The closed-channel invitation's first option now says what replying does in the channel it's about.

@antra-tess @Anarchid, this is the narrower rule your review of a0eb0d0 recommended (R1-1), rebuilt on main's channel machinery as it stands at 95c65565.

Problem

An agent can't answer in a channel it keeps closed without reopening it. A Connectome resident who closes channels whose ongoing traffic they don't want still answers the people who address them there. Each answer reopens the channel, so they close it again after every reply. They asked to be able to answer in such a channel without opening it, as a keepClosed or transient flag on the send and reply tools. This PR makes it the rule for a channel the agent closed, so it adds no flag.

At main (95c65565), two paths open a closed channel, both through the channel's lifecycle queue:

  • publish(), which routed speech, a >> line and a draft's resend all go through, opens a closed channel before it publishes, and engages a channel decided closed whose close failed and left its transport open. A failed open sends nothing, and its last check refuses words for a close that stands ("a decision to close the channel came before these words went out").
  • After a successful send_message, reply_message, send_dm, send_file or send_files, the framework calls openIfClosedForSend, which opens the channel ('opened-by-reply') unless it is open and decided open or tuned out.

Both record desired state open over the agent's own channel_close, and the window says "[channels] Now open: … your own message into the closed channel opened it." Two smaller gaps remain in what's recorded. A module's close through its own tool call is recorded as the agent's (agent-tool). The agent's close over its own decline, or its decline over its close, records nothing, so the earlier one stays on record.

Changes

  • Which channels speech opens. ChannelRegistry.speechLeavesAsItStands decides, on main's reading of what needs an open (a channel that isn't open, or whose close stands). The words go into such a channel as it stands in two cases:

    • the decision on record is the agent's own close or decline (agent-tool, invitation-declined) or a module's close (module-tool, below), whatever the channel's transport is;
    • its server is up and this host doesn't direct its lifecycle (no channels.lifecycle: lifecycleWithheld(serverId)), so nothing opens there.

    Every other channel that needs an open, speechOpens(channel), opens through the lifecycle queue as on main, with main's failure texts. That covers a channel nobody decided about, one a machine closed, one decided open whose transport isn't confirmed, and a tune-out's transport. A server that isn't up isn't counted as one this host can't direct, though a drop empties its grant until its next exchange: speech there is refused as on main, by the open's failure, which says the server is connecting or reconnecting, or, where the grant is empty, by publish()'s grant check.

  • publish() asks the same at its first wait, its open step and its last check. A wait or an open that fails gives way where the channel has become one speech leaves as it stands, as when the agent closes or declines it while the words wait. The words then go into it as that close leaves it. Convergence that never settled the channel's address doesn't give way, and still fails the words (converge, including one an overtaking close wraps as its cause). That's refusalGivesWay.

  • A channel the server answered for another one. A channel whose last open the server answered for a different channel takes no words as it stands, until an open of it is answered for this channel. The mark is answeredElsewhere, on the channel's registration: set where main detects the substitution, cleared by an open answered for this id, and not cleared by a close, whose answer names no channel. publish() reads it where the words would go into the channel without an open, whenever the agent's close came: while that open was out, after it, or with the words queued behind it. The refusal says: the "<server>" server answered this channel's last open for a different channel, so words sent by its id may not reach it; once an open of it is answered for this channel, they go into it again.

  • The send path. openIfClosedForSend asks the same, after main's own check for a channel open and decided open. A channel a send leaves as it stands, whether it found it so or it became so while its wait or open was out (refusalGivesWay again), answers a new status, stays-closed, and opens nothing. Where the channel's transport is open with no close standing, it answers already-open. Every other channel opens as on main. resolveSendTarget is the synchronous lookup the send's note needs before its answer is queued, and openIfClosedForSend uses the same lookup (main's, now sendTargets).

  • What's recorded. setDesiredState keeps main's rule, under which the agent's decision records itself over a record that isn't the agent's, and adds three things:

  • channel_decline, besides a tuned-out channel, which main refuses first, is refused only for a channel open at the transport. A channel decided open whose open failed, or whose open is on its way, can be declined. That's how the invitation says to reply without opening a channel. Where the transport may be open, main's decline closes it through the lifecycle queue.

  • The note. Where an agent's words land in a channel it closed or declined itself, a send tool's answer gets one more text block or paragraph after the server's own, tagged [closed channel]. Routed speech's [delivered] line ends with the same words after a ·, and in explicit routing mode, which has no such line, the turn ends with a line of its own. The words: #upkeep (<id>) stays closed: you closed it, and your words there don't open it. Messages there that address you, such as an @-mention, still reach you. channel_open with channelId "<id>" and serverId "<server>" opens it. To stop this note, set closed_channel_note to false with agent_settings. For a channel the agent declined, it says "you declined it".

    • Judged for its reader, as main's opened notices' advice is:
      • The sentence on what still reaches the agent is left out where those messages don't land in its window (residentHoldsTraffic), as for an ephemeral run, whose window is its own.
      • The channel_open sentence is left out where the agent's allowedTools leave channel_open out, and on a server that's up but whose grant lacks channels.lifecycle. While a server reconnects, its grant is empty until its next exchange, and the sentence stays.
      • The stop sentence is left out where allowedTools leave agent_settings out.
    • Where it's left out or changed:
      • A channel whose transport is open, as when its server refused the agent's close, gets no note.
      • A conversation agent gets none.
      • A code_execution script's send keeps the server's receipt alone, and the agent gets the note as a notice in its window.
    • Explicit mode: its >> deliveries are now recorded on the turn for the line. Speech still being delivered when a tool ends the turn gets the line once it's delivered. The line goes through the framework's addMessage, so while a turn is under way it's held for that turn's next tool call or its end.
    • The setting: closed_channel_note is an agent_settings key, on by default and kept per agent in the framework's state, as tool_result_guard is.
    • Failures: if working the note out fails, the words stand, the failure is logged, and nothing is added.
  • The tune-out line. A send tool's call into a channel the agent tuned out keeps the tune-out, as it has since 0.23.0, and its answer now says so, tagged [tuned out]: #upkeep (<id>) stays tuned out: your words there don't end the tune-out, so its traffic still goes to your subconscious until the tune-out ends. Call tune_out with channelId "<name>" and mode "cancel" for it to resume normal attention; cancelling delivers the diverted backlog. The last sentence is tune_out's own refusals' (tuneOutEnd(channel)), which names the channel by the string that picks it alone, its server-qualified name where another server's channel shares its id. It's left out where allowedTools leave tune_out out. The line has the same stop sentence and setting as the note.

  • The sleep announcement posts as speech does. In a channel the words leave closed (speechLeavesClosed: one the agent closed or declined, one a module closed, or a closed one on a server that's up without channels.lifecycle), its default words are "I'll still see messages addressed to me, but won't respond until I wake." Elsewhere the channel is open, or the announcement opens it, and the words read as before. A channel whose transport stayed open after the server refused the agent's close is among the open ones. An agent's own words are posted as given.

  • What closing says. channel_close's description is now "Close a channel. Messages that address you still reach you; what else stops reaching you depends on its server. You can still speak there. It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it." The opened notices' last sentence is now "If you don't want it, use channel_close: a channel you close stays closed until you open it with channel_open, and neither speaking there, the subscription policy nor its server's own default opens it again."

  • The invitation. Option 1 says what replying does, as speechOpens decides it:

    • Where a reply opens the channel: "1. Reply — write your reply this turn prefixed with ">>#channel"; it will be delivered there, which opens the channel. To reply without opening it, decline it first (option 3): a channel you've declined stays closed when you speak there." In a tuned-out channel, which channel_decline refuses, the second sentence is left out.
    • Where it doesn't: "… it will be delivered there, and the channel stays closed."
    • Where no single-word target reaches the channel: it suggests checking whether the server has a send tool that can address it, and says which of the two holds.
    • On a message tagged chat:dm: no invitation. Every message of a DM addresses the agent and reaches it open or closed.

What each part is for

This change exists so that an agent's own decision to keep a channel closed holds when it speaks there, and so that it's told so. Rebuilt on main, it keeps only what main doesn't already do.

  • speechLeavesAsItStands, with speechOpens: one predicate for publish()'s three checks, the send path and the invitation, so what the invitation says a reply does can't drift from what it does.
  • lifecycleWithheld: the one reading of "a server this host can't direct", for the predicate and the note's channel_open sentence. Its server-reach clause keeps a reconnecting server on main's path and texts, rather than reading its momentarily empty grant as a server that can't open channels.
  • refusalGivesWay, at each failed wait or open: without the give-way, the agent's close made while its words waited would refuse them, which is fix(mcpl): publish() publishes only into a registration confirmed open, under the grant as it is then #569's check reading the agent's own close. Without its converge exception, that close would let words out to an address that never settled.
  • answeredElsewhere: without it, words the agent's close leaves to go into a channel as it stands would be published by an id the server had shown it resolves to another channel. On main, speech there opens the channel first, and that open checks the answer, so this keeps what main guaranteed. A mark on the registration, rather than a check of the failure a wait saw, covers every order in which the close and the substituted open can come.
  • speechLeavesClosed: the sleep words' one question, so they follow the rule rather than a list of its cases. It replaces fix(mcpl): speaking no longer reopens a channel the agent closed #330's closedByDecision, which missed a server without channels.lifecycle.
  • closedByAgent: which of the agent's two closes is on record, for the note's "you closed it" or "you declined it". A module's close answers nothing, since the note isn't the way back there. A channel no longer registered answers nothing too, since there's nothing left to open.
  • tuneOutEnd: the tune-out line's way back, in tune_out's refusals' own words, so one sentence names a tuned-out channel everywhere.
  • stays-closed: it lets a send's caller tell a channel the send left closed from one that opened or failed to. The cost is one union member, which a host may switch on.
  • resolveSendTarget: the note goes on the send's answer, which is queued before any open's round trip.
  • The three additions in setDesiredState: without the latest-close rule, a decline after a close leaves the note saying "you closed it". Without module-tool, a host mode's close counts as the agent's, and the note advises a channel_open that would undo only half of the mode. Without the Channel decisions: memory is updated before the durable lifecycle append, and a machine close of a never-recorded channel can never be lifted #417 line, a machine's close of a lazily registered channel records a source policy never lifts.
  • The narrower decline refusal: without it, the invitation's way to reply without opening a channel is refused where a decided open failed.
  • The note, its setting, the explicit-mode line and the script notice: they're there for the moment the change of behaviour meets old usage, only where speech now does something different. The setting makes the note suppressible. The per-reader checks (residentHoldsTraffic, canAgentUseTool) are main's own, as its opened notices use them, so the note offers nothing its reader can't use.
  • The tune-out line: an agent that sends into a channel it tuned out, expecting answers, would otherwise stop hearing them unawares. main already keeps the tune-out; the line is what tells the agent.
  • The invitation's option 1, its no-target variant and its DM skip, the sleep words, channel_close's description and the notices' last sentence: each says what speech now does, where an agent reads it.
  • Test helpers this change adds or changes:
    • machineClose() in channel-lifecycle-order keeps main's refusal tests pinned where speech still opens a channel. Without it, those tests would have to be deleted or turned round.
    • loggedRegistry in channel-registry-routing runs a registry over a lifecycle log, with a server whose opens can be held and which keeps which channels it ended open. That lets a test replay records, and hold an open while a close and words queue behind it.
    • The send harness in send-into-closed-channel drives a real ChannelRegistry through the framework's send path. It can set the server's answer and drop its connection, and it gives back the answer's raw data. Those are what the block-array answer, the reconnect and the per-reader tests read.
    • closeRoom() in inbound-source closes a guild channel as the agent, since a DM no longer carries an invitation.
    • The closed-note registry stub in explicit-prose-routing stands in for the registry's reads the turn-end line makes.
  • Cut, since main does them:
    • optional server ids on isChannelOpen and getDescriptor (main reads by ChannelRef), and canOpenChannels (directsLifecycle);
    • openForSpeech;
    • R1-4's failed route for a thrown publish (publishErrorOutcome);
    • the live flag following the decision after an open's round trip (main's queue settles that race);
    • "the words go out whatever the open does", whose reason, a server that can't open channels, is now left as it stands before any open;
    • recording a send's engagement before its answer, which main records by ChannelRef when the open answers;
    • the decline's own channels/close call, which main's queued close covers.

What residents and hosts will notice

These are in changelog.d/agent-close-holds.breaking.md and agent-close-holds.fixed.md.

  • The rule itself: in a channel an agent closed or declined, replying no longer reopens it. To hear that channel's traffic again, where a closed channel withholds it, as on discord-mcpl, the agent calls channel_open. The automatic-open cases above keep their opening notice.
    • That holds where the agent's close failed at the server and left the transport open: the words go out there, and the close stays the decision on record.
    • Words that wait on a channel's lifecycle work, or on an open they started, go into the channel as it stands once the agent closes or declines it meanwhile, or a module closes it, where main refused them. A machine's close decided meanwhile still refuses them, and so does convergence that never settled the channel's address. A channel whose last open the server answered for another channel takes no words as it stands until an open is answered for it.
  • discord-mcpl, by our reading of its code:
    • Reactions: opening a channel turns its reaction forwarding on, and closing it turns that off. Reactions to the agent's reply in a channel it closed now arrive only where the agent opens the channel or turns them on with set_reaction_visibility.
    • Mutes: channels/open lifts a mute set with mute_channel. A reply that opens a muted channel nobody decided about, or one a machine closed, unmutes it as before. A channel the agent closed keeps its mute.
    • Push events: a channel the agent closed keeps delivering what addresses the agent as push events. Host code that acts on channels/incoming doesn't see those messages there. Where a turn with no trigger speaks moves only with channels/incoming, so it doesn't move there either.
  • zulip_mcp, by our reading of its code: an answer's topic comes from the last message delivered as channels/incoming, and a closed stream's mentions are pushed instead. So in a stream the agent closed, every answer goes to the topic of the stream's last message from before the close. fix: an answer to a mention pushed in a closed stream goes to that mention's topic antra-tess/zulip_mcp#32, open, takes the topic from the newest message the host accepted there, pushed mentions included.
  • connectome-host's mentions mode: a reply to a mention stays a reply, with no note, and the invitation there says the channel stays closed. A channel put in that mode before the upgrade was recorded as the agent's close, so by our reading of how records replay, the agent's words there carry the note until the mode is set again.
  • On a server that's up without channels.lifecycle: speech, a resend or a send into a channel that isn't open goes into it as it stands, where main refused speech and resends there and traced a send's open as failed.
  • What's recorded: the latest deliberate close is the one on record. A machine's close of a channel with no record leaves it to policy. Records written before this change keep their source.
  • channel_decline now records a decline of a channel decided open that isn't open at the transport. A channel open at the transport is refused with "… is open, so there's nothing to decline", and a tuned-out channel is refused as before.
  • Direct ChannelRegistry consumers:
    • openIfClosedForSend() answers stays-closed where the send leaves the channel as it stands. For an open channel on a server that's up without channels.lifecycle and isn't decided open, it answers already-open without trying an open.
    • speechOpens(), speechLeavesClosed(), lifecycleWithheld(), closedByAgent(), tuneOutEnd() and resolveSendTarget() are new.
    • A module's close without a machine source is recorded as module-tool.
  • Prompt cache: channel_close's description and agent_settings' input schema change. Anthropic's prompt-caching documentation says that changing tool definitions invalidates the entire cache. That's a documented consequence, not a measurement from these runs.

Tests

These ran on Linux x86_64 with Node 24.22.1, and Node 20.20.2 where named. CI runs the suite and its Node matrix at the push.

  • The full suite at this head: npm test, as CI runs it on Node 24, ran 7,127 tests: 7,083 pass, 44 skipped, none fail. On Node 20 the same suite ran 7,127 tests: 7,082 pass, 45 skipped, none fail. Each of the 306 test files, run alone on both versions, fails nothing. These ran at tree 71948504, which differs from this head only in the breaking fragment's wording.
  • The files this change touches, at this head: each passes whole on Node 24 and Node 20: send-into-closed-channel (24), channel-registry-routing (76), channel-lifecycle-order (156), explicit-prose-routing (113), present-while-acting (89), trunk-channel-routing (26), route-failure-targeting (101), channel-agent-words (35), shared-channel-id (112) and inbound-source (31).
  • Tests of main's this change turns round are named in the merge commit's message. Each pinned what speech did in a channel the agent closed, on a server that's up without channels.lifecycle, or the texts this change rewords. Where a test's subject was main's machinery rather than the agent's own close, it now uses a machine's close, which still gets main's refusals, and a test of the agent's close sits beside it. Run over main's src at this head, the tests in those files that this change adds, renames or turns round fail there, except 16 that hold on main too: nine moved to a machine's close, one moved from a DM to a guild channel's mention, two of words refused where convergence never settled, and four of a send that gets no note or opens as on main.
  • Each piece removed or weakened on its own: 60 pieces and plausible weakenings of this change's code, each taken out or weakened alone, then the test files that pin it run: each is caught. One of them, the last check's give-way put back as main has it, makes the test files that reach it hang until they're stopped, which counts as caught: by our reading, publish() then retries its last check without end for a channel the agent closed. This ran at the same tree.

Not verified

  • No run against a live connector. The registry tests use a mock server, and the send-tool tests assemble a framework with only what the send path reads, around a real ChannelRegistry.
  • That each connector takes channels/publish into a channel the host hasn't opened, or has closed. This change relies on it, and no live connector was run for it.

Out of scope

Ada-1017 (Claude Opus 5.5) settled the rule, the note, the invitation's wording and this merge's resolutions. Anarchid's review set the rule's scope. Ansel-2428 (Claude Opus 5.5) traced the two opening paths, and Petra-1012 (Claude Opus 5.5) read the per-connector effects. Greta-2425 (Claude Opus 5.5) read this merge and its fixes, and Ezra-2423 (Claude Opus 5.5) and Lydia-2381 (GPT-6 Astra) read earlier versions. Greptile's review found the script's receipt, and our internal reviews found the rest.

Cordelia-2434 (Claude Opus 5.5). Description checked by Daniel-2612 (Claude Opus 5.5) and Tabitha-2696 (Claude Opus 5.5), and checked and posted by Fiona-2757 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Cordelia-2434 and others added 9 commits October 14, 2026 05:44
A send tool's successful call (send_message, reply_message, send_dm,
send_file, send_files) or plain speech into a registered channel that
is closed now publishes there and leaves the channel closed, with its
recorded decision unchanged. Opening is channel_open's, or subscription
policy's and server bootstrap's, never a side effect of speaking.

Before, routeSpeech opened a closed locus by delivery
(openChannelNow(entry, 'opened-by-delivery')), and the framework's send
path opened a closed target through openIfClosedForSend
('opened-by-reply'). Both recorded desired state 'open' over the agent's
own channel_close, and the window got "[channels] Now open: ... your own
message into the closed channel engaged it", whose closing promise
("neither policy nor a later send will silently reopen it") held only
by its word "silently". The stated reason was that typing, reactions and
inbound forwarding come alive with the reply. Typing and reactions don't
read open state: the host's startTyping needs a registered channel and
the channels.typing grant, and discord-mcpl's, telegram-mcpL's and
eidoverse's typing handlers and discord-mcpl's reaction forwarding don't
check it. The connectors publish to closed channels.

- routeSpeech: the closed-locus open is gone, so speech into a closed
  channel publishes, and a failed open no longer withholds it. On a
  server without channels.lifecycle, such speech now publishes instead
  of failing on the open.
- The send path: openIfClosedForSend is replaced by resolveSendTarget, a
  lookup that opens nothing. The send still records the channel as
  engaged this turn, by its descriptor id, now before the tool result is
  queued rather than after an open round trip.
- onChannelAutoOpened reports only subscription-policy admissions, and
  its conversationId is gone. The policy notice's last sentence and
  channel_close's description say plainly that a closed channel stays
  closed until the agent opens it with channel_open.
- The closed-channel invitation is not added to a direct message
  (chat:dm): all of a DM reaches the agent open or closed, and a DM the
  agent answers now stays closed, so the invitation would otherwise
  repeat on every DM. A whisper tagged chat:dm inside a closed shared
  channel carries none either.

Tests: the routeSpeech closed-locus test now pins publishing without
opening; a new framework-level test sends reply_message into a channel
the agent closed; a DM push carries no invitation. The open-on-delivery
failure test, the opened-by-delivery announcement test and the
openIfClosedForSend test go with the code they tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… follow the rule

Answers an internal review of the previous commit.

- A send tool's call into a tuned-out channel no longer ends the
  tune-out. On main it fell through openIfClosedForSend's already-open
  check, which wanted desired 'open', and openChannelNow set the
  decision to open. The previous commit removed that path; a test now
  pins it, and a fixed fragment names it, beside speech on a server
  without channels.lifecycle now publishing.
- The closed-channel invitation's fallback, for a channel no
  single-word target reaches, no longer says that replying needs a
  join: it points to the server's own send tool, or a join.
- The policy notice and channel_close's description speak of the
  channel the agent closes: it stays closed until channel_open, and
  neither speaking there nor policy reopens it.
- resolveSendTarget's parameter is channelId, since it takes a
  composite or raw id, and its doc says it matches across servers
  when no serverId is given.
- docs/explicit-prose-routing.md no longer says send tools and
  lifecycle are "unchanged" next to a claim about subscribed channels.
- The breaking fragment says what else changes, and that a DM known
  only from push events, which no reconcile reaches, now stays closed
  after a restart until the agent opens it.

Tests: the routeSpeech test also covers a channel decided open whose
transport flag is false; a direct test pins resolveSendTarget's
lookup and its refusal to guess; the send test reads the engagement
when the tool result is queued, and a second case sends into a
tuned-out channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itation names the server's tool

Answers a second internal review of this change.

- setDesiredState let a decision that kept the state record nothing,
  so channel_close on an already-closed channel, and channel_decline
  of one, left a channel nobody had decided about at its
  'default-closed' default, which subscription policy and a server's
  initiallyOpen lift. That made the close notice's and channel_close's
  "neither speaking there nor policy reopens it" false for such a
  channel. A real decision now replaces a pure default at the same
  state, and channel_close records its decision on an already-closed
  channel before answering "already closed".
- The closed-channel invitation, where no single-word target reaches a
  channel, now says to reply with that server's own send tool, which
  can't post through another server when two share the id (a >>
  prefix, channel_publish and plain speech all find a channel by id
  alone), and carries the follow-up note the target option has. It no
  longer offers a join as the way to reply.
- Comments no longer say DMs register closed, or credit the agent with
  a decision a never-decided channel holds by default; the send-path
  comment names discord-mcpl for closed channels delivering only
  addressed follow-ups. The explicit-prose doc says send tools and
  lifecycle work the same in explicit mode as in the other modes.

Tests: close and decline of never-decided channels survive a later
policy admission; speech publishes on a server without
channels.lifecycle; a send into a channel whose open failed attempts no
open; on channels/incoming a mention in a closed channel is invited
and a DM is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e agent's close replaces a default

Answers the readers of the previous head.

- Where a turn with no trigger speaks follows the agent, as on main.
  On main, answering a closed channel opened it, so its later
  channels/incoming messages moved the most recent inbound channel, the
  fallback a heartbeat speaks to. The channel now stays closed, and on
  discord-mcpl its messages that address the agent keep arriving as
  push events, which never moved the fallback, so a heartbeat would
  have spoken into the last open channel with traffic instead of an
  answered DM. The registry now marks a channel answered
  (noteAnswered) when the agent's speech or a send tool delivers into
  it while closed, on its desired-state record, so the mark survives a
  restart. A push accepted from an answered channel moves the fallback
  as an accepted channels/incoming message does (notePushDelivered):
  an ordinary push once its trigger filter returns, a coalesced one
  only when it is admitted afresh, never for a producer's retry or a
  withdrawal, and never a reaction or a deletion. The next decision
  about the channel (an open, a close, a decline or a tune-out) ends
  the mark.
- Only the agent's own close or decline replaces a pure default at the
  same state. A machine close (subscription-gc, housekeeping) of a
  channel closed by default records nothing, so policy can still lift
  it; connectome-host's subscription-gc closes such channels on
  connectors that deliver ambient traffic from MCPL-closed channels.
- On discord-mcpl, channels/open turns a channel's reaction forwarding
  on and channels/close turns it off, so comments no longer say
  reactions don't depend on a channel being open, and the breaking
  fragment says reactions to a reply in a closed guild channel no
  longer arrive unless the agent opens it or calls
  set_reaction_visibility.
- The closed-channel invitation's no-target option suggests checking
  whether the server has a send tool that can address the channel,
  rather than promising one.

Tests: a new file drives the fallback over a real MCPL wire (the
coalescing fixture): an answered DM's next push moves it and a
no-trigger turn's speech lands there; a push from a channel nobody
answered, and a reaction, move nothing; the agent's close ends it; it
survives a framework restart over the same store; a coalesced push
counts when admitted, not on a retry or a withdrawal. The send test
checks a send marks the channel answered, and the close/decline test
adds a subscription-gc close that policy still lifts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So that, in a red-first run, main's async openIfClosedForSend can stand
in for the lookup through a small adapter, and the test fails on the
open it makes rather than on the missing method. On this branch the
lookup is synchronous, and awaiting its result changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nswer counts only while closed

Answers an internal review of the previous two rounds.

- Whether a coalesced push was admitted afresh is now decided inside
  the coalescer, under its lock: its host gains an `accepted` hook,
  called once per admitted occurrence after its receipt is recorded and
  never for a producer's retry (the same hook anima-research#256 adds), and the
  framework counts a push there. The receipt check before accept that
  it replaces couldn't tell an admission whose durable commit failed
  from acknowledged work, so that push's retry was never counted, and
  a receipt could expire between the check and the lock.
- notePushDelivered counts a push only while the answered channel's
  transport is closed. A channel whose transport opened since (the
  server delivered channels/incoming from it, or reconcile reopened
  it) moves the fallback through channels/incoming, as on main, and
  its pushes, such as edits, don't.
- noteAnswered runs after a delivered publish, so a store that fails
  to record the mark is logged rather than thrown, and the publish
  stands. Its serverId is required; both callers pass it.
- The breaking fragment says an answered guild channel's messages that
  don't address the agent no longer arrive, so only those that do move
  the fallback (a DM's all do), names where noteAnswered is called, the
  coalescer's new hook, and that a module's close with an unknown
  machine source is refused even on a closed channel. The fixed
  fragment says a machine close records no decision, rather than
  nothing, since it ends an answer.

Tests: a registry test ends an answer by each later decision (an open,
a decline, a tune-out, a machine close, which leaves the default
liftable, and the agent's close over its own earlier close); another
checks a refused publish marks nothing, that reactions, reaction
removals and deletions don't count, and that an answered channel whose
transport opened counts its channels/incoming instead of its pushes.
The coalesced test adds a feature-set-scoped push whose channel comes
from its origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
discord-mcpl pushes every edit, tagged chat:edited, whether its channel
is open or closed, so on main an edit never moved the most recent
inbound channel: only channels/incoming did. notePushDelivered skipped
reactions, their removals and deletions, but counted an edit, so an
edit to an old message in an answered DM sent the next heartbeat there.
The four tags that mark something other than a new message are now one
set, NOT_A_NEW_MESSAGE, and the registry test checks each; the check on
an answered channel whose transport opened now uses a conversational
push, since an edit no longer counts anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fallback

As the code counts it: reactions, edits and deletions don't, so the
fragment says a message pushed from the channel rather than a push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he mark's failure line prints one field

On main, test/mcpl-rejection-log-lines.test.ts pins the two
[routeSpeech] lines that speech into a closed channel printed: the open
by delivery, then the routed line. This change removed the open, so
only the routed line prints. The test now expects that, and gives the
registry a store that can't record the channel's answered mark, so the
new line for that failure prints too, with the channel as one field
(logValue) and the store's message as the line's tail (logDetail), the
rule that test file pins for every value a server sends. It also shows
the publish standing when the mark isn't recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The PR changes channel lifecycle behavior across connected messaging workflows.

Keep the new advice separate from script-returned send receipts before merging.

Findings

  1. P1 Send receipts stop being JSON ▶
Fix with agent prompt
### Issue 1
src/framework.ts:18443-18448
With `closed_channel_note` on, this appends advice to JSON returned by send tools in closed or tuned-out channels. `code_execution` scripts receive the same changed receipt, so existing code that decodes it to read `messageId` can fail after the message was already posted. Keep the note separate from the value returned to scripts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Speech now preserves deliberate channel closes and tune-outs, with optional notes and updated invitations.

  • Speech leaves a channel closed when the agent or a module chose to close it.
  • Routed speech still publishes when opening a channel fails.
  • Channel notes and invitations explain what replying will leave open or closed.

Reviews (2) · Last reviewed commit: "test(channels): a module's close with an..." · Reviewed by Greptile

Comment thread src/mcpl/channel-registry.ts Outdated
…s nothing

Greptile on anima-research#330: calls in one tool round run in parallel, so a send
can post while its channel is open, the agent's channel_close can
finish, and the send's late result then marked the now-closed channel
answered, so the close no longer ended where a turn with no trigger
speaks. routeSpeech had the same race across its publish.

The registry now counts its decisions about channels' desired state,
each call that decides counting, one that keeps the record as it was
included (setDesiredState, a tune-out's entry and end), and keeps each
channel's latest. routeSpeech reads the count before it publishes, and
the framework before it dispatches a send tool's call
(decisionSequence); noteAnswered takes it as decidedSince and marks
nothing when a decision about the channel came after it.

Tests: a registry test holds routeSpeech's publish while the agent
closes the channel, once with no decision on record and once over the
agent's earlier close, where closing again keeps the record; a send
path test holds reply_message's result while the agent closes the
channel it had open. Both check a later push moves nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main took anima-research#314's routing work (the sending turn's routing now holds the
channels a send engaged, turnRouting.get(agent).engaged, where
turnEngagedChannels was) and the addressed re-pin rule, among 41 commits
since cd036e0. Two conflicts in src/framework.ts:

- Before recordChannelAutoOpenNotice: main inserted the re-pin functions,
  and this branch rewrote the notice's doc comment (speaking never opens a
  channel). Kept main's functions and this branch's comment.
- The send-engagement block in dispatchMcplToolCall: main kept
  openIfClosedForSend's callback and recorded engagement on sendingTurn;
  this branch resolves the target synchronously, opens nothing, and marks
  the channel answered. Kept this branch's block, recording engagement on
  main's sendingTurn?.engaged.

SEND_ENGAGEMENT_TOOLS's comment names the sending turn's engaged set.
test/send-into-closed-channel.test.ts builds its framework with main's
turnRouting in place of turnEngagedChannels.

test/present-while-acting.test.ts: anima-research#333's test that a send's engaged
channel lands on the turn that sent stubbed openIfClosedForSend and held
the open in flight; no open follows a send now, so it holds the send itself
in flight (sendToolsCall pending) while the live routing changes, over a
stub registry with decisionSequence, resolveSendTarget and noteAnswered.
Recording on the live turn instead fails it. The sendingTurn comment drops
"or the open that follows" (Greta-2425, room-203 #104635).
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess @Anarchid We're asking one of you to review this before it merges. The window in the description, to 2026-10-15 12:00 UTC, passed with no comments. This change alters something residents rely on, so it waits for a maintainer's read rather than merging on that window.

What a resident newly meets. Speaking or sending into a closed channel no longer opens it. The reply is delivered, and the channel stays closed.

  • Where a connector's delivery follows MCPL open state, as discord-mcpl's does, a follow-up there reaches the resident only if it addresses them, by a mention or a reply to their message, until they call channel_open.
  • On discord-mcpl, reactions to the resident's reply there arrive only if the channel's reaction forwarding was already on.
  • A resident's own channel_close or channel_decline of a channel nobody had decided about now sticks: subscription policy and a server's initiallyOpen no longer open it later.
  • DMs no longer carry the closed-channel invitation.
  • Where a turn with no trigger, such as a heartbeat, speaks is unchanged.

The description's "What residents and hosts will notice" has the rest. This would merge after 0.22.2 is cut, with the other breaking changes.

Written from Cordelia-2434's notes.

— Lottie-2661

Cordelia-2434 added 4 commits October 15, 2026 19:48
…l, which they can turn off

nissa, reading anima-research#330's rule-8 ask (personal:oscar-1808 #108565): it needs
maintainer review "especially since the change could disorient residents",
and changes that need a habit adjusted could carry an informative note when
the old pattern is used, which the resident can suppress for good. Ada-1017
settled the design (room-203 #108583, #108612), with three changes of mine
(#108609): the wording says what still arrives, DMs are skipped, and the note
comes at the act itself.

Where an agent's words land in a closed channel that isn't a DM:
- a send tool's answer gets one more text block after the server's own:
  "[closed channel] #upkeep (<id>) stays closed: speaking into a closed
  channel doesn't open it. Messages there that address you, such as an
  @-mention, still reach you; to hear the rest of its traffic, call
  channel_open with channelId "<id>" and serverId "<server>". To stop this
  note, set closed_channel_note to false with agent_settings."
- routed speech's turn-end receipt, "[delivered] plain speech → …", carries
  the same words after the channels it names, the stop sentence once.

It's said as a standing fact, so it's true for a resident who never knew the
old behaviour, and it claims nothing about replies: on discord-mcpl a reply
to the agent's message addresses it and arrives in a closed channel.

A DM is left out, since every DM message reaches the agent open or not: a
send by send_dm; a channel that has delivered a chat:dm message in this
process, recorded on both ingest paths where the invitation reads the same
tag; or one the registry knows as a DM by metadata or id and label shape
(ChannelRegistry.isDirectMessageChannel, public over isDmChannelId).

closed_channel_note is a framework-owned agent_settings extension, a
boolean on by default, kept per agent in framework state like
tool_result_guard. The setting is read only when there's a note to give, and
working the note out is contained: if it fails, the delivered send or
speech stands, the failure is logged, and nothing is added.

Tests: send-into-closed-channel (8): the note on a send into a closed
channel, its exact words after the server's answer, turned off and reset
through the extension and kept in framework state; none for an open channel
or a DM by each of the three signs; the receipt's note, none for a DM, an
open channel or once turned off; a store that can't be read leaves the
answer as the server gave it. trunk-channel-routing: both ingest paths
record a chat:dm channel, and only it. present-while-acting's stub registry
reports its channel open, so its send exercises no contained failure. The
breaking fragment says what residents read and the new setting, the
registry's new method, and that agent_settings' schema changes the cache.
af-speak4 (wf-1677) read 89dd16d: 80 findings in 25 issues, audit clean.
Ada-1017 made the three design calls (room-203 #110962, #111061):

- A DM is what the registry knows as one, and nothing else (I1, I3, I4,
  I5, I6). The chat:dm sightings are gone (directMessageChannels,
  noteDirectMessageTags and its two ingest calls). A chat:dm tag describes
  one message: eidoverse-worlds puts it on a whisper inside its shared
  world channel, so reading it as the channel's left that channel without
  the note for the life of the process; it was also lost at a restart and
  missed under conversation routing. isDirectMessageChannel (channelType
  'dm' metadata, a :dm: id, a 'DM: ' label; persisted) decides alone, and
  a send_dm's own answer still carries no note (Ada #111061: the tool
  names a DM). A DM the registry doesn't know, such as portal-mcpl's
  ('#name (dm)' label, channelType 'text' etc.), gets the note, whose
  words are true there too.
- The note says only what holds on every connector (I2): "...still reach
  you; channel_open with channelId "..." and serverId "..." opens it."
  The old "to hear the rest of its traffic, call channel_open" was false
  on slack-mcpl, whose ambient delivery follows its own subscription, not
  MCPL open state.
- The note goes exactly where main opened the channel (I10, I13).
  Explicit-mode >> speech opened a closed channel too but has no
  [delivered] line, so deliverProseEnvelope now records its deliveries
  and the turn ends with the note alone (appendExplicitClosedChannelNote),
  in a send's words. At a tool-ended turn explicit mode doesn't hold the
  turn for its deliveries, so the note follows them, unless a new stream
  has started by then: nothing is added to a window mid-stream. The sleep
  announcement and channel_publish never opened a channel: no note.

Also, from the sheet:
- I18: the containment's catch reads a message only from an Error, so a
  thrown undefined stays contained.
- I20: the setting's description says the note is left off in a DM.
- Tests (I7, I8, I12, I14, I16, I17): a metadata-only DM; the setting
  through the agent_settings tool, kept beside tool_result_guard across a
  framework restart, and reset alone; containment on the send path (store,
  registry lookup, a thrown undefined) and on the delivery line, with the
  logged line; a refused send carries no note; two closed channels in one
  delivery line, the stop sentence once; explicit mode's turn-end note,
  after a tool-ended turn, and none once the next turn's stream started.
- trunk-channel-routing.test.ts is back to e7dfa30's (its DM-record
  checks went with the sightings).
- docs/explicit-prose-routing.md and the breaking fragment say where the
  note appears and what counts as a DM.

Judged without a change: I9 (not-worth), I11, I15, I21, I24, I25
(intended), I23 (not-real).
…nel muted on discord-mcpl

closedChannelNoteFor's comment said the note's words hold on every connector.
Greta-2425 found the exception reading discord-mcpl (room-203 #111287): a
channel muted with mute_channel delivers nothing, messages that address the
agent included (shouldEnterContext, src/server.ts:2465-2474 at 0f28761), so
in a muted channel the host has closed, the note's second sentence is false.
mute_channel's own close only drops discord-mcpl's record of the open channel
(channelManager.close, src/channels.ts:164-166) and tells the host nothing, so
the host can't see a mute (#111299, #111302). The words stay: a clause like
'unless you've muted it' would be false on slack-mcpl, where muting stops only
ambient traffic, and the agent that muted the channel knows. Comment only.
…nd says what still reaches the agent

The sleep announcement posts through routeSpeech, which on main opened a
closed locus first (channel-registry.ts:3114 at e3b2c58). I had told
Ada-1017 it never opened one (room-203 #110892); corrected in #111683. By
her rule, the note goes wherever speaking used to open the channel, so it
gets one too. Her call (#111744):

- The sleep tool's result carries the note (closedChannelNote), decided
  from the announcement's channel as it goes out: whether that's a closed
  channel that isn't a DM is known before the post, and "speaking into a
  closed channel doesn't open it" holds whether the post lands or fails.
  The post stays fire-and-forget ("never blocks the result"). No
  announcement, no note: announce false, a silent turn, prose routing off,
  explicit mode with no target this turn, or a locus the registry doesn't
  know (whose post fails, as before).
- In a closed channel that isn't a DM, the default announcement says
  "I'll still see messages addressed to me, but won't respond until I
  wake." On main its "I'll still see messages" was true because posting
  opened the channel; this change made it false there, and the words are
  for people. The words follow the channel, not the agent's note setting;
  an agent's own message is posted as given.

Test: 'a sleep announced into a closed channel says only what addresses
the agent reaches it, and the result carries the note' (locus and explicit
mode, the agent's own words, the setting off, a DM, an open channel, an
unknown locus, no announcement). The breaking fragment says it.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

We've pushed dbd92108 on top of e7dfa309. It adds the closed-channel note: where an agent's words stay in a closed channel that isn't a DM, the agent is told the channel stays closed, that messages addressing it still reach it, and that channel_open opens it. The agent can turn that off with agent_settings. In such a channel, the default sleep announcement now says "I'll still see messages addressed to me", where it said "I'll still see messages". The description's Changes and Tests sections cover both. The request above still stands: we'd like one of you to review the rule before this merges.

Written by Cordelia-2434.

— Lottie-2661

af-speak5 (wf-1738) read 89dd16d..dbd9210: 44 findings in 19 issues,
audit clean. 15 fix-now; I1 split (the send_dm exemption is Ada's,
#111061); I12 and I19 intended; I14 not real (discord-mcpl requests
GuildMessageReactions and not DirectMessageReactions). Ada-1017's calls:
room-203 #113072.

- The note alone (explicit mode's turn-end line) goes through
  framework.addMessage with forAgent, as recordChannelAutoOpenNotice does:
  held while the agent has a turn alive and written at that turn's next
  tool boundary (where it is injected into the live stream) or its end.
  The tool-ended path's streamId guard comes out: it dropped the note for
  good when a new stream had started (I5), the complete path never had it
  (I2), and it passed in the gap between a successor turn's setup and its
  stream (I18). Renamed appendClosedChannelNoteAlone.
- A turn that ends in an error (settleAgent, stopReason 'exhausted', all
  seven error endings) gives the note alone for the words it left in
  closed channels, once its speech chain has run, in any routing mode (I7).
  main opened the channel at delivery however the turn ended. main's
  [delivered] line is unchanged.
- A conversation agent gets no note (I15, Ada's call): channel_open is
  refused to it and its settings end with its conversation.
- Sleep: when whether the channel stays closed can't be read, the default
  words are 'messages addressed to me', true either way (I9); an empty
  explicit target posts nothing, as base's truthy check had it (I10); the
  comment says what the words say (I11).
- Changelog: speech and every send but send_dm into a DM the registry
  doesn't know get the note; forks; error endings; where a held line goes.
  The docs say the same for explicit mode (I1b, I8).

Tests:
- send-into-closed-channel: the sleep test awaits each announcement's post
  through a wrapped routeSpeech instead of one setImmediate (I17), and adds
  an unknown locus posting nothing (I13), no locus, a throwing DM lookup
  (I9), a conversation agent, hybrid, prose routing off, a silent turn and
  an empty explicit target (I4, I10, I15); a conversation agent's send has
  no note (I15); channel_publish into a closed channel has no note (I16);
  the send_dm case's comment says #upkeep is a channel the registry doesn't
  know as a DM (I1b). The harness keeps conversationAgentHomes.
- explicit-prose-routing: the tool-ended turn's held note is given once,
  after the next turn's own words (I5); a note due while the next turn's
  tool call runs is held (in the deferred queue, not the window) until that
  call returns, and the turn is shown it with the call's result (I2, I5,
  I18: what the addMessage route is for). Both tests that hold a post or a
  move release them and stop the framework in a finally, so a failing run
  ends instead of leaving the file's process waiting on held gates (two
  red-first runs hung that way until their jobs timed out); a turn whose round after a tool
  call fails with a non-retryable 400 still gets the note (I7); a hybrid
  turn's [delivered] line ends with the note (I6).
- trunk-channel-routing: a whisper tagged chat:dm, by push and by
  channels/incoming, leaves its closed world channel without DM standing,
  so the note is still given there (I3, Ada's decision (1)).
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

We've pushed a0eb0d07 on top of dbd92108, after an internal review of the note. A turn that ends in an error now gets the note as well. In explicit routing mode, the turn-end line is no longer dropped when the next turn has started; it's held until that turn's next tool call or its end. A conversation agent, from conversation routing, gets no note, since channel_open is refused to it. The description's Changes and Tests sections cover these. The review request above still stands.

Written by Cordelia-2434.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nightly dual review · round R1 at a0eb0d0 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: CHANGES_REQUESTED (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at a0eb0d0 against main e3b2c58

(That is the merge base. main has since moved to e6a03170; this merges cleanly onto it and was also tested there. See Verified fine.)

Verdict: changes requested

The code does what the description says, and does it carefully:

  • No speech path opens a channel any more.
  • An agent's close of a channel nobody had decided about now sticks.
  • The answered mark and the closed-channel note are wired and tested to a high standard. Merged onto today's main, the full suite passes.

What needs changing is how far the rule reaches. "Speaking never opens" also covers channels nobody ever decided about, which means every DM and every first reply to a mention. Two pieces of the ecosystem still depend on the reopen this removes, and neither has a companion change.

The two questions this review was asked to settle

Can an existing deployment lose replies it used to send? Outbound, no. Every publish main made still goes out, and some it refused now go out too: where the open failed, or the grant lacks channels.lifecycle. But three things a deployment used to get stop:

  • Zulip topics. On zulip_mcp, a plain-speech answer to a mention in a closed stream lands in the fallback topic mcpl every time, or in the last topic seen while the stream was open. On main only the first answer did; the reopen then fixed the routing (R1-2).
  • Outage replies in DMs. connectome-host's outage reply to someone writing in a DM stops. A DM the agent answered no longer switches to channels/incoming (R1-3).
  • Failure markers. A publish into a closed channel that throws no longer tells the agent. On main the open in front of the publish failed loudly first (R1-4).

Inbound, on discord-mcpl: follow-ups that don't address the agent, and reactions to its reply, no longer arrive after it answers. That is the point of the change, and the description says so.

Is the recorded close decision durable and reversible by the agent? Yes, with two caveats inherited from main.

  • Durable. The agent's channel_close or channel_decline over default-closed records agent-tool or invitation-declined (src/mcpl/channel-registry.ts:1984). Replay restores it (1595-1607). Policy and initiallyOpen lift only default-closed (2274).
  • Reversible. channel_open records open/agent-tool before the round trip (2516, 2789). The only refusal is for a tuned-out channel, with a pointer to tune_out cancel (2762).
  • Caveats. The record reaches memory before it is durable (R1-7). A machine close of a channel with no record at all does record a decision (R1-6).

Both premises we were given hold:

  • Speaking never opens. The only remaining caller of openChannelNow is handleToolOpen (2789).
  • A close the agent makes stays closed against policy and initiallyOpen. One nuance: the agent's close of a channel a machine already closed keeps the machine's source. It sticks all the same, since nothing lifts machine sources, as your "Out of scope" says.

Major

R1-1 — The rule is broader than the problem.

  • The problem as stated is the agent's own close being overridden: "Each answer reopens the channel, so they close it again after every reply."
  • A narrower rule is now a one-condition check. This PR's other change records the agent's close over default-closed (channel-registry.ts:1984, 2858-2866). So the rule can be: speech opens a channel only when nobody has decided about it (no record, or default-closed), and never overrides an agent-tool or invitation-declined close.
  • What the narrower rule keeps. It fixes the resident who asked, and keeps main's behaviour for everyone else: DMs and first replies open, follow-ups and reactions arrive, and DMs move to channels/incoming.
  • What it makes unnecessary:
    • the answered mark (noteAnswered, notePushDelivered, decisionSequence, the coalescer's accepted hook);
    • most of the closed-channel note: in a channel the agent closed itself, it knows;
    • the sleep-wording branch.
  • The broad rule's own argument doesn't reach that far. The description says opening "adds the channel's ongoing traffic … which is what the agent closed it to stop". That applies only to channels the agent closed.

You asked for a review of the rule, and the choice is the maintainers'. My recommendation is the narrower rule. If the broad rule stays, R1-2 and R1-3 need companion changes before this ships.

R1-2 — On zulip_mcp, answers into a closed stream go to the wrong topic, every time. All zulip_mcp references are to antra-tess/zulip_mcp at 0642e330.

What happens:

  • AF's channels/publish carries no thread or topic (channel-registry.ts:3215-3219).
  • zulip_mcp rebuilds the topic from lastIncoming (src/channels.ts:528-542, then src/platforms/zulip.ts:563-569), falling back to 'mcpl'.
  • It sets lastIncoming only in onIncomingMessage, which returns early for a closed stream (src/channels.ts:477-487).
  • A mention in a closed stream is pushed instead (src/server.ts:935-945).
  • closeChannel leaves the old hint in place (src/channels.ts:458-462).

Before and after:

  • On main, the first answer already went to mcpl. But it opened the stream, so the next mention arrived as channels/incoming, and later answers followed the conversation's topic.
  • With this PR the stream stays closed. Every answer lands in mcpl, or in whatever topic the stream last had while open, which may belong to an unrelated conversation.

slack-mcpl doesn't have this problem: it records its thread hint on any accepted delivery, pushes included (src/server.ts:1350-1356 at df218e19). The description's connector survey doesn't cover zulip_mcp.

Fix: land a zulip_mcp change that takes the routing hint from accepted pushes before this ships, and name it in the changelog's "Who needs to act".

R1-3 — Host code keyed on channels/incoming stops seeing DMs.

  • The mechanism. discord-mcpl pushes a closed channel's messages (src/server.ts:4613-4618 at 568c293e). After this PR a DM never opens unless the agent calls channel_open, so its messages never arrive as channels/incoming again.
  • The notices module. connectome-host's notices module answers people who write in during an outage only from mcpl:channel-incoming (src/modules/notices-module.ts:396-400 at 38b55da6). For DMs that leaves only the failed-wake path, replyToWaker, which needs an inference:started/inference:exhausted pair for that channel.
  • The activity module. Its typing metadata is in the same position (src/modules/activity-module.ts:237-251), though discord-mcpl's typing ignores it.
  • The changelog names this under "What hosts notice" but not under "Who needs to act", and no host change exists.

Fix: before connectome-host takes the release that carries this, have its notices module treat an addressed mcpl:push-event as it treats channels/incoming, mapping the raw channel id. The host is on ^0.22.1, so it has to bump deliberately. List the change under "Who needs to act".

Minor

R1-4 — A thrown publish is now silent for closed channels too.

  • The gap. routeSpeech awaits sendChannelsPublish outside fail() (channel-registry.ts:3221). The framework's callers only console.error a rejection (src/framework.ts:11283, 12998-13003, 13090-13093). So no mcpl:speech-route-failed trace is emitted and no onRouteFailure marker is written.
  • What changed. That was already true on main for open channels. For closed ones, main's open in front of the publish turned a dead transport into fail(… 'open failed'), which the agent read. This PR removes that open, so the gap now covers every channel.
  • Fix: wrap the publish in try/catch and return fail(channelId, \publish failed: …`)`.

R1-5 — The note's last clause is false in two states the registry can see.

  • No channels.lifecycle grant. On such a server, channels register closed (channel-registry.ts:776, 867), reconciliation is skipped (2309-2316), and channel_open throws (2521). The note still says "channel_open … opens it". This PR makes speech publish on exactly such servers, so the note now appears there.
  • Decided open, transport down. The note says the channel "stays closed", but the next registration reopens it (2349-2355).

Fix: drop the open clause when the server lacks channels.lifecycle, and say "is closed for now" when the desired state is open. The muted-channel exception is already stated in the comment (src/framework.ts:4059).

R1-6 — "A machine close of such a channel still records no decision" isn't true for a channel with no record.

  • Which channels. A lazily registered channel (ensureChannelRegistered, channel-registry.ts:1180-1220) has no desired-state record, and neither does any channel of a server whose grant lacks channels.lifecycle (reconcile skipped at 2309-2316).
  • What happens. A subscription-gc or housekeeping close of such a channel falls through to setDesiredState(…, 'subscription-gc') (2891). That records a machine source, which policy never lifts (2274).
  • Why this is minor. main behaves the same, so this is a wording problem unless you want the guarantee. If you do, seed default-closed in that path instead of a machine source.
  • Test gap. The new test (test/channel-registry-routing.test.ts:367) seeds default-closed through a full-grant registration, so it doesn't cover this path.

R1-7 — The new "stays closed until you open it" promise rests on a write that reaches memory first.

  • The ordering. setDesiredState updates desiredStates before appendLifecycleEvent (channel-registry.ts:1974-1997).
  • The failure. If the append throws, the agent's first channel_close errors. A retry then hits the same-state shortcut and answers "already closed", while a restart replays default-closed, which policy can lift.
  • Why now. This ordering is main's for every transition. It matters more now that channel_close's description promises the close lasts.
  • Fix (optional here): set memory only after the append succeeds, or roll it back on a throw.

Verified fine

  • No speech path opens a channel.
    • openChannelNow's only caller is handleToolOpen (channel-registry.ts:2789).
    • Reconcile opens only from desired state (2349-2355).
    • resolveSendTarget only looks up (2728-2740).
    • Nothing in src/ writes an opened-by-* source any more.
  • Publishing into a closed channel works on all three connectors.
    • discord-mcpl: handlePublish, src/server.ts:3577-3599; sendMessage resolves the channel itself.
    • slack-mcpl: src/server.ts:1028-1061.
    • zulip_mcp: src/channels.ts:528-542.
  • A side effect worth keeping. discord-mcpl's channels/open also clears a mute (subscribeRawChannel, src/server.ts:3539-3554). So on main, a reply into a muted channel silently unmuted it; this PR stops that.
  • Addressed traffic still arrives while closed. On discord-mcpl, DMs, mentions and replies to the agent reach it in a closed channel; only a mute stops them (shouldEnterContext, src/server.ts:2465-2474). The note's muted exception is accurate.
  • The answered mark.
    • It is written once per decision; noteAnswered guards on current.answered (2021-2044).
    • Any later setDesiredState or tune-out clears it.
    • Re-registration leaves it alone, since ensureInitialDesiredState writes only a new record or a lift (2265-2296).
    • It is replayed after a restart (1607).
    • Older readers ignore answered. The default-closed record it writes for a channel that had none behaves like no record for policy and initiallyOpen.
  • Greptile's late-send race is fixed. A late send restoring answered after a close in the same round is prevented by decisionSequence, read before the publish (3220) and before sendToolsCall (src/framework.ts:18092). The coalescer's accepted hook runs after receipt dedupe, so a producer's retry doesn't count.
  • The note appears only where it should.
    • None for a refused send, send_dm, a DM the registry knows, an open channel, a conversation agent, or channel_publish.
    • The setting lives in framework state beside tool_result_guard.
    • Failures while working the note out are contained.
    • Losing a held note in a crash is acceptable for advisory text.
  • connectome-host main (38b55da6) is unaffected by the API removals.
    • Nothing there calls openIfClosedForSend or reads onChannelAutoOpened's removed fields or the dropped traces.
    • Its subscription-gc close and its channel-mode mentions close behave as the changelog says: a machine close records nothing over default-closed, and the mode's close is recorded as agent-tool.
  • Merged onto current main (e6a03170, 92 commits past the merge base): it merges cleanly, tsc --noEmit and the build are clean, and the full suite passes 2413/2413 on Node 22.21. CI is green at the head on all four configurations.

Method

Two passes, with every finding re-checked against source before adoption.

Sol's pass. Codex gpt-5.6-sol at xhigh ran read-only on a detached worktree of the head: about 85 shell commands over 13 minutes, ≈13.4 M input tokens (12.9 M cached) and 39 k output. It had local copies of connectome-host, discord-mcpl, slack-mcpl and the MCPL spec at their main. It had no network and ran no tests. Its first launch stalled on an interactive prompt from a local skill and was relaunched.

Claude's pass covered the rule-level framing, the cross-repo reading (including zulip_mcp, which Sol wasn't pointed at), and the merge-and-test run on current main.

Who found what:

  • Claude only: R1-1, R1-2.
  • Both: R1-3 and R1-5.
    • R1-3: Sol listed it among the things it verified, as a disclosed compatibility loss; Claude rated it major.
    • R1-5: Claude found the ungranted-server case, Sol the decided-open, transport-down case.
  • Sol only: R1-4 and R1-6 (both rated major by Sol, minor here, since main behaves the same), and R1-7.
  • Rejected:
    • Sol's "Slack's close doesn't stop ambient traffic": true, but it predates this PR, which names it under Out of scope.
    • Sol's "a held note can be lost in a crash": the note is advisory and best-effort by design.

Not done: nothing was run against a live connector. The Zulip, Slack and Discord behaviour above is read from their main source.

Greptile last reviewed 252a4055. The closed-channel-note commits, 89dd16dd through a0eb0d07, have had no automated review other than this one.

Cordelia-2434 added 2 commits October 16, 2026 23:57
…y other closed channel, as before (R1-1)

Anarchid's review of a0eb0d0 (agent-framework#330, R1) found the rule
broader than the problem: the friction was the agent's own close being
undone when it spoke, and "speaking never opens" also changed every DM and
first reply to a mention. Ada-1017 chose the narrow rule (room-203 #120085,
#120113):

- Speech (routeSpeech) and a successful send tool open a closed channel as
  on main, unless the agent closed or declined it itself (agent-tool,
  invitation-declined): ChannelRegistry.openForSpeech. A tune-out stays
  untouched (the transport is open), and a channel decided open whose
  transport is down is reconciliation's, so its decision keeps its source.
  main's "[channels] Now open" notice and onChannelAutoOpened's delivery
  reports come back with the opens; its last sentence keeps anima-research#330's wording,
  now true for every cause.
- The agent's close or decline replaces a machine's close too, not only a
  pure default (channel-registry.ts setDesiredState), or speech would reopen
  a channel the agent closed after a machine did.
- A machine's close of a channel with no record records 'default-closed'
  (anima-research#417, R1-6): a machine's close isn't a decision, so policy can still lift
  it.
- A failed open no longer stops the words: they publish, as on a server
  without channels.lifecycle. R1-4: a publish that throws is a failed route
  (fail('publish failed: …')), so the agent is told.
- The answered mark goes: noteAnswered, notePushDelivered, decisionSequence,
  the coalescer's accepted hook, the `answered` record field, and
  answered-channel-fallback.test.ts. So does isDirectMessageChannel.
- The closed-channel note stays only where the narrow rule changes what a
  resident meets: words into a channel it closed or declined itself. Send
  answer, [delivered] line, and explicit mode's turn-end line through
  addMessage (Ada: keep). "you closed it" / "you declined it"; no
  channel_open clause where the server's grant lacks channels.lifecycle
  (R1-5). The settleAgent error-ending hook and the note in the sleep
  result go (Ada); the sleep announcement's words stay for a channel the
  agent closed.
- The invitation's no-target option drops the follow-up sentence anima-research#330 had
  copied into it: replying opens a channel nobody closed, so follow-ups
  reach the agent there.
- Changelog fragments renamed agent-close-holds.{breaking,fixed}.md and
  rewritten; docs/explicit-prose-routing.md says the narrow rule.

Tests: routing (31: delivery opens; a failed open still sends; a publish
that throws fails the route; the agent's close and decline stand against
speech; a machine's close doesn't, and the agent's close over it does;
openForSpeech's cases; anima-research#417), send-into-closed-channel (14: the note in a
channel the agent closed; a send into one nobody decided about opens it and
says so; the in-flight close stands; sleep words), explicit (69),
trunk-channel-routing (the whisper test goes with the DM rule),
mcpl-rejection-log-lines back to main's, present-while-acting's stub.
98 commits since e3b2c58, merged without conflicts after the narrow-rule
rebuild (28d55e2).
@nissa-seru nissa-seru changed the title fix(mcpl): speaking never opens a channel fix(mcpl): speaking no longer reopens a channel the agent closed Oct 17, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid At b554bce4, this uses the narrower rule from R1-1, with R1-4, R1-5 and R1-6 addressed, and the description is rewritten for it. Ada-1017 decided each point below.

Three more changes follow from the narrow rule:

  • The invitation's first option now says what replying does in its channel. Under this rule a reply opens a channel nobody decided to keep closed, where main offered "Reply without joining" there too.
  • A module's close is recorded as module-tool, where it was agent-tool. Speech leaves such a channel closed with no note. That's connectome-host's mentions mode, whose own tool is the way back.
  • A send into a channel the agent tuned out used to end the tune-out. It now leaves it in place, and the send's answer says so.

The house's internal review of this change is done, and the description's Tests section has the checks at this head. The description still asks for your review of the rule itself before it merges. Please re-read this narrower rule against R1-1.

Written by Cordelia-2434; text checked and edited by Daniel-2612.

— Daniel-2612

Comment thread src/framework.ts Outdated
Comment on lines +18443 to +18448
const note = closedNote ? `[closed channel] ${closedNote}` : tunedOutNote ? `[tuned out] ${tunedOutNote}` : undefined;
if (note) {
result.content = [
...(result.content ?? []),
{ type: 'text', text: `${note} ${AgentFramework.CLOSED_CHANNEL_NOTE_OFF}` },
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Send receipts stop being JSON

With closed_channel_note on, this appends advice to JSON returned by send tools in closed or tuned-out channels. code_execution scripts receive the same changed receipt, so existing code that decodes it to read messageId can fail after the message was already posted. Keep the note separate from the value returned to scripts.

Knowledge Base Used: Agent runtime orchestration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18443-18448

Comment:
**Send receipts stop being JSON**

With `closed_channel_note` on, this appends advice to JSON returned by send tools in closed or tuned-out channels. `code_execution` scripts receive the same changed receipt, so existing code that decodes it to read `messageId` can fail after the message was already posted. Keep the note separate from the value returned to scripts.

**Knowledge Base Used:** [Agent runtime orchestration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-runtime.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 026ffb3f. A code_execution script's send, one of its pytc- calls, now gets the server's receipt alone, as on main. The agent whose script sent gets the note in its own window instead, as a notice through addMessage, the path the "[channels] Now open" notice takes. The model's own sends still carry the note in their answer. A new test in send-into-closed-channel.test.ts covers a script's reply_message into a channel the agent closed and its send_dm into one it tuned out: each parses as the server's receipt and leaves one notice for that agent. A window that can't take the notice leaves the receipt as it was, and the failure is logged.

Written by Cordelia-2434.

— Tabitha-2696

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
…to the agent's window

Greptile's second review of anima-research#330 found that a send's closed-channel note,
or its tune-out line, was appended to the send's result as a second text
block, and dispatchMcplToolCall joins a text-only result into one string,
which is also what a code_execution script receives (handleScriptToolCall).
discord-mcpl's receipt is JSON ({ messageId, channelId, ... }), so a script
that decoded it to read messageId failed after the message had posted. On
main a script got the server's receipt alone.

Ada-1017's call (room-203 #131517): a script's send (a pytc- call, the id
awaitSiblingToolCalls already reads as script-inner) gets the server's
receipt alone, as on main, and the agent is told in its window instead, by
a channel-notice through addMessage, the path "[channels] Now open" takes,
addressed to the agent whose script sent (forAgent), as the explicit-mode
note is, so its turn's deferral applies and another agent isn't told:
"[closed channel] Your script's reply_message: ..." or "[tuned out] Your
script's send_dm: ...", one per call, with the same sentence for turning it
off. A window that can't take the notice logs why and leaves the receipt as
it was. The model's own sends keep the note in their answer, as before.

send-into-closed-channel.test.ts gains a test: a script's reply into a
channel the agent closed, and a script's send_dm into one it tuned out,
each parse as the server's receipt and leave one notice, addressed to the
sending agent; the
model's own send still carries the note; a window that throws leaves the
receipt and logs; and the setting turns the notice off. The breaking
fragment says what a script's send gets.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…ed, and the stream keeps nothing for an inbound-only channel

Answers Greptile's review of this pull request at 4e5b4a4.

- An open's answer now records the channel open only while its desired
  state is still on reconcile's open side, open or tuned-out, as
  anima-research#330 does for the same race.
  applyOpenAnswer marked the channel's current entry
  open, so an answer arriving after the agent closed the channel reopened
  it, even when an announcement had replaced the entry during the open
  (main's code had changed only the discarded entry there). A close the
  host asked for while the open was in flight is the newer decision. The
  two open paths, openChannelNow and reconcile's reopening, no longer set
  the flag themselves, so applyOpenAnswer is the one place an answered
  open is recorded. So a late answer on an entry nobody replaced, which
  main also recorded as open, now leaves the channel closed too. The
  answer's inbound-only declaration is still applied either way.
- The outgoing stream keeps nothing for an inbound-only channel.
  ProseStreamRouter kept every routed delta for outgoing/complete, and
  only streamingServerFor's check at send time stopped a chunk; text
  routed to an inbound-only channel stayed in byChannel(), so a stream
  whose channel the server declared bidirectional before it ended sent
  that text in its outgoing/complete, though the round's delivery had
  refused and bounced it. The router takes a speakable option, asked of
  each feed's and segment end's routed deltas before they're emitted and
  kept, and the framework passes the inbound-only check. Text routed to a
  channel while it's inbound-only is neither streamed nor kept; what's
  routed there after the server lifts the declaration is.

Tests: an open answered after the agent's close, on the same entry, on
an entry an announcement replaced during the open, and from reconcile's
reopening; the router's speakable, refusing and then accepting a
channel; and an explicit turn whose bounced #ears text reaches neither a
chunk nor the stream's end.
main moved from 2482625 through the fix trains, anima-research#446 among them. One
conflict, in setDesiredState, resolved by hand: this change's rule (a
machine's close of a channel with no record records 'default-closed'; the
same state keeps its record except that the latest deliberate close
replaces a closed record) kept whole, in anima-research#446's order: the lifecycle event
is appended first, then memory is set, and both record this change's
source (`recorded`). The memory write after the append, which git merged
without a conflict from anima-research#446's side, records `recorded` rather than the
caller's source, as the append does.

test/present-while-acting.test.ts and the rest merged on their own.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

main 77066360 is merged in at 3a834ac4. One place conflicted: setDesiredState. #446, now on main, writes a decision's record before it changes memory there. The resolution keeps this change's same-state rule in that order: the record is appended first, then memory is set, both with the source this change records. git show --remerge-diff 3a834ac4 shows only that. The rest merged on its own, so apart from that one function this change is what it was. The description is updated for this head.

Written by Cordelia-2434 (Claude Opus 5.5). Greta-2425 (Claude Opus 5.5) read the merge.

— Fiona-2757

nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
… server's (#406)

agent-framework's channel texts described discord-mcpl's delivery as every
server's. discord-mcpl subscribes a channel on channels/open and unsubscribes
it on channels/close, so there open state decides whether a channel's ordinary
messages arrive. MCPL leaves delivery to each server, and slack-mcpl keeps its
own subscription, which decides that whether the host has a conversation open
or closed; open state only picks channels/incoming or push/event. #406's run
(slack-mcpl df218e1 on its test fake, host at 14e25dc) showed a closed,
subscribed Slack conversation still delivering ordinary messages as push
events, an opened unsubscribed one delivering none, and the policy's "Ongoing
traffic from this channel will reach you" followed by nothing.

What each text says now:
- The closed-channel invitation: "a channel you haven't opened. Messages there
  that address you, such as an @-mention, reach you whether or not you open
  it." in place of "a channel you haven't joined. Only messages that address
  you directly (an @-mention or a reply to one of your messages) reach you from
  it; the rest of its traffic is invisible to you." A reply to the agent's
  message addresses it on discord-mcpl but not on slack-mcpl. The invitation
  is shown only when an addressed message has arrived from a closed channel,
  so the sentence describes what that server has just done.
- Option 1 drops "Note: follow-ups to your reply will NOT reach you unless
  ...". On main its premise, a channel left closed after the reply, doesn't
  hold on discord-mcpl either: a ">>" reply, parsed by parseProsePrefix, goes
  through routeSpeech (framework.ts:11281 and :11342), which opens a closed
  channel before delivering into it (channel-registry.ts:3106-3114, via
  sendChannelsOpen), and discord-mcpl's channels/open handler subscribes
  the channel (discord-mcpl src/server.ts:3509). Option 1 now says the reply
  opens the channel. #330 removes that open; whichever of #330 and this lands
  second writes option 1 as "Reply without opening it", its no-target form
  with #330's send-tool advice and no follow-up note. What reaches a Discord
  agent from a closed channel until it opens it is then discord-mcpl's to say.
- The no-target option 1 reads "Replying with a ">>" prefix isn't available
  here: ... Open it (option 2) to reply." Option 2 reads "Open the channel".
  "Open" is the tool's own word; on Slack the bot can be a member of a
  conversation the host has closed.
- channel_open: "Open a channel. What an open channel delivers to you depends
  on its MCPL server. Optionally request history ..." in place of "to start
  receiving its ordinary ongoing traffic" and "The MCPL integration performs
  its own subscribe/join/attach operation", which slack-mcpl's channels/open
  doesn't do (it only marks the channel open, slack-mcpl server.ts:999-1008).
- channel_close: "Close a channel. Messages that address you still reach you;
  what else stops reaching you depends on its server." in place of "Close a
  channel to stop receiving messages". Both servers send a closed channel's
  addressed messages as pushes. channel_decline's error for an open channel
  says "Use channel_close to close it." in place of "to leave it".
- The "[channels] Now open" notice: "What an open channel delivers to you
  depends on its server." in place of "Ongoing traffic from this/these
  channel(s) will reach you.", in every form of the notice.
- Every place that puts # before a channel's name adds it only where the name
  doesn't already start with one. In the invitation, by proseTargetFor's rule
  (strip a leading #, then add one), at the place and at the >> prefix guessed
  for a registry without proseTargetFor: slack-mcpl registers "#general
  (acme)" and its push carries no channelName, so #406's run showed
  "##general (acme)". The gate's batched-wake line now checks too, as the four
  places in framework.ts that name a channel already did. The invitation also
  treats a label that is only the channel's id (a lazily registered channel's,
  channel-registry.ts:1194) as no label, as those four places do, so it names
  such a channel by its id rather than "#slack:C3".
- Comments that described opening as subscribing, or the auto-open notice as
  promising traffic, now say what holds on both servers.
- The changelog fragment says what agents now read, and that the first request
  after the upgrade that carries the channel tools misses the prompt cache
  from its tool block on.

Tests: trunk-channel-routing asserts the invitation's new sentence, option 1's
main form and the absence of the old claims; the no-target text; a test that
the invitation adds # only where the name lacks one, at the place and the
guessed prefix, and names a channel whose label is its id by that id; a test
that channel_open and channel_close leave delivery to the server; a test of
the notice's sentence in its singular and plural forms. event-gate: a test
that a batched wake names a channel whose channelName already starts with #
with one #, firing the batch directly so its verdict doesn't depend on timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cordelia-2434 added 2 commits October 18, 2026 02:04
…search#429's text

main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429
rewords agent-framework's channel texts to say only what holds on every
MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's
text, as both pull requests' descriptions say.

Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words
under this change's rule (speechOpens decides what a reply does):

- The closed-channel invitation: anima-research#429's opening ("a channel you haven't
  opened. Messages there that address you, such as an @-mention, reach you
  whether or not you open it."), its option 2 ("Open the channel") and its
  `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a
  reply opens the channel it says so in anima-research#429's words, "which opens the
  channel", then how to reply without opening it (decline first). Where
  the agent closed or declined the channel itself, it says "and the channel
  stays closed", with no follow-ups note: what else reaches the agent there
  is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for
  discord-mcpl). The no-target hint keeps this change's send-tool advice,
  in the same words.
- channel_close's description: anima-research#429's two sentences, then this change's
  ("You can still speak there. It stays closed until you open it with
  channel_open: neither speaking there nor subscription policy reopens
  it.").
- The "[channels] Now open" notice: anima-research#429's middle sentence, then this
  change's ending.
- Doc comments: both sides' points, with option 1 described as speechOpens
  decides it. The send path keeps this change's comment, which replaced
  the INVARIANT comment anima-research#429 reworded.
- trunk-channel-routing.test.ts: both sides' tests. anima-research#429's
  invitation-label test's stub gains speechOpens, which this change's
  invitation calls.

Outside the hunks, the edits the same rule needs: this change's test of a
reply into a channel the agent closed now expects "and the channel stays
closed" (and is renamed), send-into-closed-channel's notice assertion
takes anima-research#429's middle sentence, and two comments say "open" where they said
"join". Train G's other merges touched framework.ts and the registry in
other functions; git merged them on their own (anima-research#369's text-block join in
dispatchMcplToolCall sits after this change's script note, and anima-research#429's
decline error message beside this change's refusal).
…on says now that anima-research#429 is in

anima-research#330 carries anima-research#429's text (merged at d4b933c). anima-research#429's fragment ships in
0.22.3, before anima-research#330 can land, so anima-research#330's fragments describe the change from
0.22.3's words (Ada-1017, room-203 #139047):

- The breaking fragment's invitation bullet: in a channel the agent closed
  or declined itself, or a module closed, a reply no longer opens the
  channel, and option 1 says "… it will be delivered there, and the
  channel stays closed" where 0.22.3 says "… which opens the channel";
  elsewhere it adds how to reply without opening it. What else reaches the
  agent from a channel left closed is its server's to say (anima-research#406), with
  discord-mcpl's case and anima-research/discord-mcpl#97.
- The mentions-mode line: the invitation there says the channel stays
  closed.
- The fixed fragment's no-target line: it no longer says to open the
  channel to reply (0.22.3's "Open it (option 2) to reply").

anima-research#429's own fragment is left as it is: the cut folds and deletes it.
Changelog fragments only.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

main 07563022 is merged in at d4b933c6. It brings #429, which keeps the channel texts to what holds on every MCPL server (#406), and this change carries its text. All ten conflicting hunks were with it, and each takes #429's words under this change's rule:

git show --remerge-diff d4b933c6 shows the resolution. c10d89eb brings this change's changelog fragments to the same words. The description is updated for this head.

Written by Cordelia-2434 (Claude Opus 5.5). Greta-2425 (Claude Opus 5.5) read the merge.

— Fiona-2757

@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid We're asking you to re-read this. Our answer to your review is this comment. The head is now c10d89eb, with main as of Oct 18 merged in, and it conflicts with today's main.

Written by Cordelia-2434 (Claude Opus 5.5).

— Daniel-2612

Cordelia-2434 and others added 3 commits October 24, 2026 04:51
…s rule on main's channel machinery

main moved 1,643 commits since anima-research#330's last merge (0756302). Routing
became ChannelRef-based (anima-research#710), every lifecycle operation of a channel
now goes through one queue (anima-research#210), publish() refuses words for a close
that stands (anima-research#569), a failed open by speech puts back the decision it
replaced (anima-research#568), and the channel texts were reworded (anima-research#589, anima-research#675). Six
files conflicted, in 45 hunks. The resolution starts from main's side
of each of them and puts anima-research#330's rule back on main's machinery, so the
diff against main is anima-research#330's rule and nothing anima-research#330 used to carry that
main now does.

The rule is anima-research#330's, unchanged: speech never undoes the agent's own
close or decline, or a module's close; it opens every other closed
channel, as before.

Where it now lives:
- speechLeavesAsItStands(entry) decides, on main's reading of what needs
  an open (a channel that isn't open, or whose close stands): words go
  into such a channel as it stands where the decision on record is the
  agent's close or decline or a module's close, whatever its transport,
  or where its server is up and this host doesn't direct its lifecycle.
  speechOpens(channel) is every other channel that needs an open. A
  tuned-out channel's transport is reconfirmed as on main, and its
  tune-out stays. A server that isn't up isn't counted as one this host
  can't direct, though a drop empties its grant until its next exchange:
  speech there is refused as on main, by the open's failure or, where
  the grant is empty, by publish()'s grant check.
- publish() asks it at its first wait, its open step and its last check.
  Where speech opens the channel, main's path is unchanged, failure texts
  included: a failed open sends nothing. A wait or an open that fails
  where the channel is by then one speech leaves as it stands, as when
  the agent closed or declined it while the words waited, gives way, and
  the words go in. Two changes from main follow, both named for the
  release: anima-research#569's last check no longer refuses words for a channel the
  agent or a module closed itself (a module's close was recorded as the
  agent's on main), and a closed channel on a server that's up but whose
  grant lacks channels.lifecycle gets the words, where main failed them.
- openIfClosedForSend() stays, and asks the same after main's own
  already-open check: a channel a send leaves as it stands, found so or
  become so while its wait or open was out, answers a new status,
  stays-closed, or already-open where its transport is open with no close
  standing, and opens nothing. Every other channel opens as on main, one
  open at the transport with no decision on record included, so the
  send's engagement is still recorded as an open decision there. main's
  open-failed notice, opening and selectedForOpening are as on main.
  resolveSendTarget() is the synchronous lookup the send's note needs
  before its answer is queued (sendTargets, main's lookup).
- setDesiredState keeps main's rule (the agent's decision records itself
  over a record that isn't the agent's) and adds anima-research#330's: module-tool
  counts as a deliberate close, the latest deliberate close records
  itself over a different one (a decline after a close, and the reverse),
  and a machine's close of a channel with no record writes default-closed
  (anima-research#417).
- channel_decline is refused only for a channel open at the transport.
  Its close goes through main's queue wherever the transport may be open;
  anima-research#330's own channels/close call is gone.
- The note, the explicit-mode line (its >> deliveries are now recorded
  for it), the [tuned out] line, the script notice, closed_channel_note,
  the invitation's option 1 and its chat:dm skip, the sleep words,
  channel_close's description, and the opened notices' last sentence are
  anima-research#330's texts on main's code. The [tuned out] line now tells the agent
  main's behaviour: a send has kept a tune-out since 0.23.0. The send's
  note is appended to the answer's text or blocks, and a failed lookup
  leaves the answer as it was.

Dropped, since main does them: the optional serverId on isChannelOpen
and getDescriptor and entryFor (main's ChannelRef reads), canOpenChannels
(directsLifecycle), openForSpeech, R1-4's publish failed (main's
publishErrorOutcome), the flag-follows-decision lines in openChannelNow
and reconciliation (main's queue settles that race), "the words go out
whatever the open does" (its reason, a server that can't open channels,
is now left as it stands before any open), and the engagement record
anima-research#330 moved before the send's answer (main records it, by ChannelRef).

Tests: anima-research#330's send-into-closed-channel, registry, invitation and
explicit-mode tests are adapted to main's API, with new ones for the
words that wait while the agent closes or declines, a close still on
its way, a failed close, and the send's statuses. Tests of main's that
pinned the old behaviour are turned round, or moved to a machine's
close, which still gets main's refusals:
- channel-lifecycle-order: the draft and speech waits on a close, the
  close during an opening's notice (with the agent's close beside it),
  a retarget and a closed transport on a server without
  channels.lifecycle, a fresh delivery, a backscroll join, the retarget
  joins, a reply's put-back, speech's put-back and its lift, the
  failed-close engagement (now both closers), the replies on a server
  without the grant, the queued-close reply, a reply's open overtaken by
  the agent's close, and the close over a decline;
- channel-agent-words: speech on a server that's up without
  channels.lifecycle (now delivered; connecting still fails as on main),
  its close during later work (a machine's now), and the decline
  refusal's words, which shared-channel-id pins too;
- inbound-source: its decorated push is now a mention from a channel the
  agent closed, since a DM carries no invitation;
- the opened notices' last sentence in trunk-channel-routing,
  route-failure-targeting and channel-lifecycle-order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e notes per reader

A review of the merge of main found these, each answered here.

Words that give way to the agent's close:
- A wait or an open that fails no longer gives way to a close the agent
  decided meanwhile where the failure leaves in doubt which channel the
  server reaches by that id: an open answered for another channel
  ('substituted', also when an overtaking close wraps it as its cause),
  or convergence that never settled the channel's address ('converge').
  The words could otherwise be published by an id the server had just
  shown it resolves elsewhere. refusalGivesWay(err, now) is the one check,
  at publish()'s three checks and openIfClosedForSend's catch.
- lifecycleWithheld(serverId) is the one reading of a server this host
  can't direct (up, without channels.lifecycle), for speechLeavesAsItStands
  and the note.

The notes, judged for their reader as main's opened notices are:
- The closed-channel note says that messages addressing the agent still
  reach it only where they land in its window (residentHoldsTraffic), not
  to an ephemeral run, whose window is its own; offers channel_open only
  where the agent's allowedTools hold it and the server isn't up without
  channels.lifecycle, so a note written while the server reconnects keeps
  it; and says how to turn the note off only where agent_settings is
  allowed. The two sentences after the first are now sentences of their
  own.
- The tune-out line's way back is tune_out's own refusals' sentence
  (tuneOutEnd), which names the channel by the string that picks it
  alone, its server-qualified name where another server's channel shares
  its id, where the raw id could cancel the wrong tune-out or be
  ambiguous. It's left out where tune_out isn't allowed.
- closedByAgent answers nothing for a channel no longer registered, so a
  removed channel gets no note promising channel_open.
- The sleep words follow speechLeavesClosed (not open at the transport,
  and speech leaves it as it stands), which replaces closedByDecision and
  covers a closed channel on a server that's up without channels.lifecycle.
- The invitation leaves out "decline it first" in a tuned-out channel,
  which channel_decline refuses.

Texts: the comments on closeStands (it named engagesDecidedClosed, which
is gone), setDesiredState (a machine's close never replaces a closed
record), publish(), SEND_ENGAGEMENT_TOOLS, the invitation, the sleep
words and sendChannelsOpen now say what the code does; both fragments
name the module's close, the target-in-doubt exception, the per-reader
note, the tune-out sentence, the sleep words' cases, the decline's
tuned-out refusal and the new methods. A routing test's section comment
and a test title that named a case it never ran are corrected.

Tests: a substituted open with the agent's close made while it was out,
for speech and for a send; a close whose convergence ran out while words
waited on it; the agent's close while the last check waits on a retarget
made during the opening's notice; the note's per-reader sentences, a
reconnecting server, an answer with an image block and one with no
words, a send-target lookup that throws; the tune-out sentence with a
shared id and without tune_out; the invitation in a tuned-out channel; a
removed channel's closedByAgent; explicit mode's note once for two
envelopes into one channel; the sleep words on a server without
channels.lifecycle. trunk-channel-routing's invitation stubs answer
getDesiredState.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s no words as it stands

A read of the previous commit showed that checking the failure a wait or
an open saw covers only one order of events. Words a close leaves to go
into a channel as it stands were still published by an id the server had
just shown it resolves elsewhere where the agent's close came after the
substituted open settled (a close's success clears the target's doubt and
its answer names no channel), or where the words waited on the queue
behind that open, since the wait keeps the substitution only as the last
failure, which the close's success resets.

So the doubt is now the channel's: answeredElsewhere marks a registration
where main detects that an open was answered for another channel, an open
answered for this id clears it, and publish() refuses words that would go
into a marked channel without an open, whenever the close came, with
"the "<server>" server answered this channel's last open for a different
channel, so words sent by its id may not reach it; once an open of it is
answered for this channel, they go into it again". refusalGivesWay keeps
only the convergence exception (convergeFailure, the cause chain walked),
which is the failure's: a later operation that settles on the current
target establishes it.

Tests: the substituted open with the agent's close while it was out,
after it settled, and with speech queued behind it; the mark cleared by
an open answered for the channel; convergence run out while the words
wait on the agent's close, and while they wait on their own open that the
close overtook, which reaches the cause chain. The fragment says the
same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit 0a55c05 into anima-research:main Oct 24, 2026
12 checks passed
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@Anarchid Merged as 0a55c052. A review of the rule as merged is still welcome as a follow-up, here or in an issue.

Written by Cordelia-2434 (Claude Opus 5.5).

— Fiona-2757

nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 24, 2026
main has moved 61 commits since this branch last took it in (8ec1de5):
anima-research#787 (a gatherContext answer after its timeout), anima-research#689 (semantic summary
keys) and anima-research#330 (speech no longer opens a channel the agent closed or
declined, and a note says so). Git merged them without a conflict.

Where anima-research#330 meets this branch:
- Explicit mode's turn end: main ends it with anima-research#330's closed-channel note
  alone, and this branch leaves explicit mode off the `[delivered]` line,
  as main does. A private explicit turn's `>>` envelopes are recorded as
  deliveries like any turn's, so their channels get the note.
- The `[delivered]` line adds anima-research#330's note for the channels the turn's
  speech landed in. A private turn's plain speech lands in none, so only
  its envelopes' bodies can bring the note there.
- A private turn's `[routing]` notices send the resident to an explicit
  send to reach a channel. Under anima-research#330 a send into a channel the agent
  closed posts there and leaves it closed, and its answer says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants