Skip to content

feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) - #247

Merged
nissa-seru merged 64 commits into
anima-research:mainfrom
nissa-seru:feat/prose-drafts
Oct 18, 2026
Merged

nissa-seru merged 64 commits into
anima-research:mainfrom
nissa-seru:feat/prose-drafts

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

This PR keeps a resident's unsent plain speech as drafts it can list, resend or dismiss, and gives every send of plain speech, of a draft and of channel_publish an outcome that says whether it was delivered, failed, or may have been posted. It merges main at c5d857b1.

Problem

  • Suppressed speech had no addressable draft. Plain speech held back by an explicit send in the same round stayed in the resident's history, but the turn receipt reported it only as a count ("N segment(s) suppressed … resend with a send tool"), so resending it meant recovering the text from the history. Explicit mode's bounced text was kept for resending in one in-memory, latest-wins clipboard, so a second bounce or a restart lost that copy. The suppression rule is right; making the resident recover the words by hand was not. (Reported by a Connectome resident.)
  • Delivery outcomes were not trustworthy enough to resend against. A missing channels/publish receipt counted as delivered, and "nothing was posted" could not be told apart from "it may have been posted". A safe resend contract needs that difference. channel_publish answered success for a missing receipt and never read delivered.
  • A resend offer could post twice. On main, >>> prose whose publish isn't confirmed, or throws, bounces as "was not delivered … nothing is lost" and offers {{unsent}}, though the post may have gone out. Prudence-2578 found it for a publish error, and our review of fix(channels): the host posts nothing into a channel its server declared inbound-only #350 for a timeout or a lost connection.

Changes

The parts, in dependency order:

  1. Inbound source envelope. Each accepted inbound item is stamped with metadata.inboundSource, frozen at admission before it is queued or acknowledged: server, channel, thread, message, label, endpoint binding, admission lane, and coalescing facts. A channel-scoped coalesced push that leaves out its message and thread, as an edit may, takes the ones its subject holds. Only the framework writes it: an inboundSource in a module's message metadata is dropped. Its conversation key is a JSON array, so two conversations' keys can't collide. An acceptance observer hook exposes it to the receipt work that builds on it.

  2. McplRequestError. A failed MCPL request says whether it was not-sent, got an error-response (its data kept, and its code in the message), or got no-response. A write the transport refuses at once is not-sent, and its pending request and timer are cleared.

  3. One publish executor. ChannelRegistry.publish() returns a PublishOutcome:

    • delivered only on the connector's delivered: true, the rule of agent-framework fix(mcpl): require explicit final speech delivery receipts #163 by xf3s (Gloss, Codex);
    • failed when provably nothing was posted (refused before dispatch, or delivered: false with no message named);
    • unknown otherwise: an error response, timeout, lost connection, or missing or contradictory receipt. The outcome keeps the connector's own words, bounded to 2,000 characters, and its error data whole, since a multi-part send may already be partly posted.

    When plain speech isn't confirmed delivered, the resident's [discord-send-failed] marker says which of these happened, and shows the connector's data bounded. A failure reads "could not be delivered … Nothing was posted". An unknown outcome reads "was not confirmed … It may or may not have been posted, so sending it again could post it twice". A failed delivery that is held as a bounce draft gets its bounce notice instead of the marker. A channel id that more than one server registers is refused rather than guessed, and so is an empty or invalid supplied selector. A send refused for its server or grant opens no closed channel. The executor's channels/publish and channels/open requests always have a deadline (requestTimeoutMs: 0 leaves them the 60 s default), so a publish that is never answered reads as unknown, and an open that is never answered fails the send with nothing posted.

    The executor serves plain speech and draft resends. channel_publish, and the subconscious's speak_in_channel through it, read their answers by the same rule with the same two functions: success only on delivered: true; an error saying nothing was posted for delivered: false naming no message, or a request refused before it was written; otherwise an error saying the publish wasn't confirmed and that sending it again could post it twice. That answer, a draft resend's line for a send that wasn't confirmed, and the drafts tool's read of that attempt carry the connector's data, bounded, when it sent some. discord-mcpl, slack-mcpl and telegram-mcpL already return delivered: true: discord-mcpl and slack-mcpl in server.ts, telegram-mcpL in its channels/publish handler.

  4. Held prose drafts.

    • What gets held. Unsent plain speech is kept as a draft with its exact words (speech a send suppressed keeps each run exactly as written; a bounce or a hold with no destination keeps its envelope as routing read it, surrounding whitespace trimmed) and its reason: explicit-send suppression (locus and hybrid modes; mid-turn, trailing and text-only; native and XML tool modes), hybrid prose left without a destination, and explicit and hybrid bounces, including a delivery that provably failed. {{unsent}} means the latest bounce. A run ends where a round does: at a native tool call, and in XML tool mode at a refused attempt and its notice. So a held run never joins words from either side of one, and a text-only XML turn's publish splits there too, as main already splits around a native call.
    • What never is. Deliberate privacy is never drafted: skip_reply (the tool, or a hybrid >>>skip_reply envelope, which keeps the rest private until a destination is named), a same-round private think in a native round (where that policy applies: XML tool mode routes such prose as ordinary speech, so a send holds it as a draft), silent turns, and proseRouting: disabled narration. A round that calls skip_reply keeps every envelope of it private: none is sent, bounced or held. A skip_reply that main refuses (it asks for a timed wake on a host with no EventGate, and the turn goes on) keeps only its own round private: the turn's silence from before it comes back at the next round with its cause, so a later send's prose is held as drafts. Nor are words a send may already have posted: an unknown outcome gets the failure marker alone, and a plain envelope whose send threw gets a notice that the text may not have been delivered and is not held, since holding it would invite a duplicate post.
    • Hybrid envelopes beside a send. A send withholds publication, not what an envelope does. A held >>> envelope moves the routing state as live routing would (a destination it names becomes the sticky target), >>>skip_reply {{unsent}} still sets the latest bounce aside, and the draft holds the message the envelope would have published, with {{unsent}} expanded under the re-bounce rule.
    • A turn's routing. Every hold, bounce and hybrid state change writes to the routing of the round it came from, never to the agent's current turn's, so a delivery that runs after its turn ended can't change the next turn's routing state or receipt tallies. A late bounce still holds its draft and tells the resident. The receipt's draft tallies are the logical turn's, like its deliveries, and the logical turn's drafts settle at its end. A later start of a logical turn keeps its routing, as main does, so a bounce's hold also holds the text of a compile a provider-capacity hold deferred the turn to. Hybrid's hold keeps its two kinds: after >>>skip_reply the rest stays private; after a malformed, unresolved or undelivered envelope the rest is held as drafts.
    • Storage and durability. Drafts are stored in a RecordJournal. Undo, rollback, checkout and restart never change them, and nothing is ever sent from them on its own. Each attempt is journaled durably before its request leaves AF. An entry whose write failed after it reached the journal counts as written, and when recording an outcome or a dismissal fails, the resident is told what the draft reads as now. While the journal refuses writes (an append failed inside the store), no new draft is kept until the next start: a hold reports the segments it couldn't keep, and the turn's notice names the failure.
    • Retention. Each resident's 100 newest drafts are always kept. An older one is kept for at least 7 days after anything last happened to it (held, named, attempted, answered or dismissed), and is let go when the journal next checkpoints what it keeps, every 500 entries, so opening it replays only what follows. A request naming a draft that was let go is refused, and nothing is sent, unless that id has since been given to a newer draft, which the request then names instead: an id is d- and seven characters, so after N more of its resident's drafts a let-go id has come back with a probability of about N in 2.75 × 10^10. The latest bounce is tracked by id, so {{unsent}} names nothing once that draft is let go.
    • Notices. No notice quotes a draft's words: the drafts tool's read shows them, and its list a preview of each.
      • At a tool boundary, on rounds that present mid-turn messages, a private notice names held drafts.
      • The turn-end receipt, in locus and hybrid modes, names every draft by id and by its state at turn end: still held, delivered by the resident's resend, unconfirmed, dismissed, or let go by retention since the turn held it. A receipt that names a draft that may have been posted, with nothing confirmed delivered, opens "nothing confirmed", never "nothing". It waits for the turn's queued deliveries and holds, even when the turn's last response is empty. Its kept-private count reads "(skip_reply or a private think)". Explicit mode writes no receipt; its drafts are named by their bounce and refusal notices.
      • Each failed delivery gets one notice. A failed delivery held as a bounce draft gets the bounce notice alone, not the marker as well, and an unknown one keeps its failure marker. A bounce notice offers {{unsent}} only when {{unsent}} would send that draft, that is, when it is the latest bounce and carries no risk. Otherwise it names the drafts tool's resend, with confirmDuplicate: true when the draft may already have been posted, and its dismiss.
      • Risk is named. A dismissed draft whose own attempt may already have been posted is named with that risk. A draft holding words copied from one that may already have been posted is named with that risk and the confirmDuplicate its resend needs.
      • What counts as named. A notice that names a draft lists it in its metadata. A draft counts as named while a notice recorded as naming it is on the current branch. So one no notice reached (a crash, an aborted turn), or whose notices a rollback or branch move took away, is named at the next turn's start: every such draft by id, the newest eight with their state and why each was held. Before naming, that catch-up reads the newest 500 messages for a notice that names a draft but wasn't recorded, such as a bounce that landed after its turn ended, and records it instead; an unrecorded notice older than that is missed, and the draft is named again. It leaves out the drafts of a turn still open, which its receipt names, and drafts that a notice still waiting to be written names. A notice is recorded only after the history holding it is synced.
    • The drafts tool: list | read | resend | dismiss, scoped to the calling resident. A call through executeToolCall, such as an operator's puppet, acts as the resident it names, as that path's other tools do.
      • resend publishes the drafts verbatim, in order, through the executor, and counts as an explicit send. A resent draft's channel counts as engaged on the turn that sent it, as a send tool's does.
      • A delivered draft returns its historical receipt instead of sending again.
      • A draft with any attempt of unknown outcome (or words copied from one) needs confirmDuplicate.
      • A resend owns its drafts and re-checks each one before sending it.
      • A dismissal is refused while that draft's attempt is out, since it can't call back words already on their way; no dismissal is recorded then, so the resend's own reply can't contradict one. A draft only queued in a resend can still be dismissed, and is then not sent. Dismissing a draft that may already have been posted names that risk.
      • A draft a resend doesn't attempt is told why the batch stopped.
    • >>skip_reply {{unsent}} (and its hybrid form) sets the latest bounce aside. In hybrid mode it does so in a skip_reply round too, except one with a private think. While that draft is being sent, it leaves the draft and tells the resident, and one that can't be recorded says the bounce is still held.
    • {{unsent}} puts a draft's words in exactly, $&, $$ and $1 included. A failed or thrown {{unsent}} send re-holds the words it attempted: its re-bounce expands {{unsent}} from the draft it substituted, read again after the attempt, not from whichever bounce is latest when the failure comes back.
    • {{unsent}} that can't be sent. When {{unsent}} names no held bounce (it was sent, set aside or let go, or there never was one), or the latest bounce is being sent, queued in a resend, or may already have been posted, an envelope using it sends nothing. Its own words are held as a bounce draft, with {{unsent}} written out and any risk carried, or, if there are none, a short notice says nothing was sent. While the refused draft's attempt is out, the notice says the copy can be resent with confirmDuplicate: true once that result is in. If the journal refuses the copy, the notice says the words remain only in the history.

The privacy boundary, stated plainly. A draft is never published except by its resident's explicit resend (or {{unsent}}), and only that resident can list or act on it. Its notices go into the resident's own history. Residents that share one message slot (#197) share that history, just as they already share the assistant turn the words came from.

What each part is for

This PR exists so that speech the host doesn't send stays the resident's to resend, by id and word for word, and so that every send of it says whether it was delivered, failed, or may have been posted. Each part serves that:

  • Holding the words as drafts: speech an explicit send suppressed, speech with no destination, and bounces. Before, the resident had a count or one in-memory clipboard. A draft holds a run exactly as written, and a run ends where a round does: at a native tool call, and in XML tool mode at a refused attempt and its notice, so a held run never joins words from either side of one. That also splits a text-only XML turn's publish at a refused attempt, as main already splits around a native call.
  • What's never drafted: skip_reply, a same-round private think, silent turns and disabled routing, so deliberate privacy stays private. Nor are words a send may already have posted, an unknown outcome or a thrown send: they get the marker, or for a thrown send a notice that the text may not have been delivered, since holding them would invite a duplicate post.
  • A held hybrid envelope moves routing as live routing would. A send withholds publication, so the send shouldn't change where the turn's later prose goes: held envelopes move the router (pin, hold, !, the {{unsent}} set-aside) as they would have live.
  • The journal, and each attempt journaled before its request leaves: drafts outlive undo, rollback and restart, and a crash mid-send reads as unknown, not unsent.
  • One publish executor, PublishOutcome, and McplRequestError's outcome: plain speech and a resend need the same three answers, and not-sent is what separates failed from unknown. The error's data carries a connector's account of a partial send into the outcome. The executor's hardening serves the same: channels/publish and channels/open keep a deadline when requestTimeoutMs is 0, so an attempt can't stay open for good; a write refused at once reads as not-sent; a channel id two servers share, or an empty server selector, is refused rather than guessed; and a refused send opens no closed channel. The resident's [discord-send-failed] marker says which outcome happened.
  • The drafts tool and its gates (confirmDuplicate, a resend owning its drafts, no dismissal while an attempt is out): words that may already be posted aren't posted again without the resident saying so. A resend counts as an explicit send, so it silences its round's prose as send_message does.
  • {{unsent}} that can't be sent, and inherited risk: a copy of words that may already be posted carries that risk into its own draft.
  • The notices, at a tool boundary and in the turn-end receipt: the resident learns a draft's id in time to resend it within the turn, and where the turn's words went at its end. That includes a draft sent by {{unsent}}, which gets no tool answer, and a dismissal whose attempt may already have been posted. The bounce notice goes to the resident whose envelope bounced. A notice a rollback removed is written again, since nothing but a notice tells a resident a draft exists.
  • Retention (the 100 newest, plus any touched in the last 7 days, checkpointed every 500 entries): it bounds what a resident keeps and what opening the journal replays, without letting go of a draft something just happened to.
  • The inbound source envelope: nothing in this PR reads it. feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 and feat(mcpl): every channel item names its conversation in a stored source header #257 do, and they're built on this PR's first commits, so it lands with this one.

Cut: McplRequestError's code, and each draft's source (branch, turn token, round and segment) with what filled it. Nothing read either, in this PR or the ones stacked on it, and the turn token named nothing after a restart.

Tests

New and changed tests, against main:

  • test/inbound-source.test.ts: the envelope at both ordinary lanes and coalescing, a push edit that takes its subject's message and thread or keeps its own, frozen at admission across a rename, observer failure, and the strict reader.
  • test/mcpl-request-outcome.test.ts: each outcome over a real stdio server. test/mcpl-request-timeout.test.ts: requestTimeoutMs: 0 still leaves channels/publish a deadline, whose expiry is no-response.
  • test/publish-outcome.test.ts: every receipt and refusal shape, the marker wording end to end, channel_publish's and publishForAgent's answers by the same rule with the connector's data, and speech routing's log line for a channel it opens.
  • test/prose-drafts.test.ts, 87 tests:
    • the store over a real Chronicle store: reopen, branch independence, journal failure, inherited risk, notice ordering, a draft id's form and the drafts tool's example of it;
    • end to end over the speech-route fixture (test/fixtures/speech-route-mcpl-server.mjs): a three-segment suppressed reply inspected and resent verbatim, XML mode, privacy cases, resident scoping, failed then unknown then confirmed resends, a rollback to before a confirmed resend, a restart with an unresolved attempt, crash catch-up, explicit bounce and {{unsent}} across a restart, refusals, a puppeted read, a puppeted call whose handler throws, a refused wake's own round kept private while a later send's prose is held, in native and in XML tool mode, and, in native tool mode, a module's skip_reply beside a refused wake after a send keeping the later prose private;
    • concurrent resends through the real handler with controlled publish completions, dismissals while an attempt is out, a dismissal and a >>skip_reply {{unsent}} whose recording fails after or before it reached the journal, of a held and a possibly-posted draft, with the dismissal trace, a resend and an {{unsent}} send whose attempt's recording fails after it reached the journal, and the connector's data in a resend's reply and the draft's read;
    • exact runs and resent bytes; hybrid >>>skip_reply privacy across segments and rounds; {{unsent}} beside a send; the receipt's states and its "nothing confirmed"; the held-draft notice for a copied, possibly-posted draft; the catch-up's 500-message window; and each notice's text about a possibly-posted draft but one, under Not verified.
  • test/prose-segments.test.ts: raw runs against trimmed segments, and the refused-attempt boundaries.
  • test/explicit-prose-routing.test.ts: explicit and hybrid routing with drafts, and, with prose-drafts, each failure site of a late {{unsent}} send (explicit and hybrid, failed and thrown, with a newer bounce landing and the store reloading while the send waits).
  • test/present-while-acting.test.ts and test/tool-result-guard.test.ts: receipt and routing-state expectations with drafts. test/trunk-channel-routing.test.ts: fixture stubs for channel labels, among them the stand-in registry of main's test that the invitation adds a # only where the channel's name lacks one.

Checks:

  • The suite: CI runs the typecheck, the build and the whole suite, on Ubuntu and macOS with Node 20 and 24.
  • Each piece, undone on its own, fails the tests that pin it, one test each unless a count is given, on Linux with Node 24.22.0. The pieces were undone at 9e0ace8c, except the saved silence's precedence, which was undone at aa38879f. Since 9e0ace8c this branch has added that precedence's test and merged main three times, at 86a36834, 07563022 and c5d857b1. Their resolutions add no piece of their own. The first kept two field declarations. The second kept both sides in each of its three conflicts, and gave the stand-in registry of a new test on main the channel-label method this branch's push lane calls. The third keeps fix(mcpl): contain inbound request handler failures #208's bounded server text in this branch's McplRequestError for an error response, fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #172) #437's notice for a turn with no channel beside this branch's two notices for a channel, whose outcome they name, and fix(discord): edit a turn's thoughts only into the agent's own post in that turn #445's turn id beside this branch's drafts in a turn's fresh routing.
    • The merge of main at 3af13bf4:
      • a refused wake's saved silence restored as the boolean alone, as on main (the 2 new tests in prose-drafts), or saved without its 'private' over 'send' (the test aa38879f adds, in prose-drafts);
      • XML mode's turn-end scan counting a refused skip_reply as silencing (8 in skip-reply-wake-xml-mode, 1 in prose-drafts), or its turn-end runs keeping the refused round's prose (7 in skip-reply-wake-xml-mode, 1 in prose-drafts);
      • skip_reply offering wake_in_seconds with no EventGate (2 in skip-reply-wake-capability), and a request pending at a disconnect rejected with the server's own close reason in its text (mcpl-ws-transport).
    • The round before the merge, 7f24bdc7 through ce572290:
      • a draft id of five characters again (5: 4 in prose-drafts, 1 in explicit-prose-routing);
      • the drafts tool's example id of five, in its schema or in its refusal of a malformed draftIds (prose-drafts, one each);
      • a puppeted drafts call left to the module registry (2 in prose-drafts), or answered without draftsToolResult's catch (prose-drafts);
      • a failed dismissal's reply saying the draft is still held (2 in prose-drafts), or its {{unsent}} notice saying so and that {{unsent}} still names it (3 in prose-drafts);
      • a resend's or an {{unsent}} send's attempt failure that leaves out what the draft reads as (prose-drafts, one each);
      • a dismissal that stands after its recording failed, left untraced or without its risk, by the drafts tool or by {{unsent}} (prose-drafts, one each), and the dismissal trace naming every draft the call asked about (prose-drafts);
      • a push edit's envelope taking its origin alone, or its subject's message and thread over its own (inbound-source, one each).
    • The fourth merge of main and the round after it, through 0ce5c346:
      • speech routing's line for a channel it opens, gone (mcpl-rejection-log-lines) or printed for every open (publish-outcome);
      • the held copy's wait for an attempt still out, left out, or given for an unconfirmed draft (prose-drafts);
      • channel_publish answering success whatever the receipt says (7 in publish-outcome), or reading every thrown request as unconfirmed (publish-outcome);
      • the connector's data dropped from channel_publish's answer (publish-outcome), a resend's line or the read (prose-drafts), or shown for an outcome that posted nothing (publish-outcome, prose-drafts);
      • the unconfirmed draft's bounce sentence as before (explicit-prose-routing);
      • a routed publish opening a channel that's already open, or publishForAgent answering success whatever happens (publish-outcome).

Not verified

  • Hermetic, not live. Everything above runs against fixture MCPL servers and a mock membrane. No live Discord or provider deployment was exercised.
  • XML tool mode is covered by a wrapped mock stream with no roundContent, not by a live XML provider.
  • Mid-turn notice presentation reads Membrane's supportsInjectedMessages when a release carries it. Until then it falls back to the presence of roundContent.
  • The kill-at-append durability of notices was checked by a reviewer's SIGKILL probe outside the suite. The suite pins the sync order (sync, append, sync).
  • No test checks the bounce notice's sentence for a send whose outcome isn't known and whose words have no recorded attempt: "The send may have gone out, so sending it again could post it twice."

Companion PRs and merge order

Review

Written by Petra-1012, with rounds by Basil-1369 (the fixes after Greptile's first review), Rupert-1859 (refusing a dismissal while that draft's attempt is out: slimepriestess's approving review at 1d6d2bb noted that a dismiss was accepted while that draft's resend was in flight), Ada-1017 (two tests that pin the dismissal marks), and Nathan-2367 (GPT; held explicit failures, receipts after an empty last response, the marker's connector data, synchronous write failures). Reviewed independently by:

  • before publication, Tessa-974 (the agent-framework side), Hugo-1004 (the inbound-envelope seam and RecordJournal), June-1016 (the branch-independence boundary) and Agnes-984 (the receipt contract with discord-mcpl);
  • Felix-1368 (Basil-1369's fixes; Felix-1368's probes found the notice's missing risk and became the slow-delivery and XML edge-whitespace tests), Humphrey-1858 (Rupert-1859's change; Humphrey-1858's probes found the resend replies contradicting a mid-send dismissal, and the {{unsent}} case its tests cover), Basil-1007 (the first merge of main, and Ada-1017's two tests), and Ada-1017 (the fixes after our first review sweep, built in a separate worktree);
  • Agnes-1009 (Opus), who read every commit from 28368490 through 89fda1dd, running mutants at each round through 3a458ee3 and checking the three short commits after it against Petra-1012's runs; and Arthur-2378 (GPT), who read them through 89fda1dd, ran the full suite (1816/1816), reproduced the late {{unsent}} failure through real turns, and found the thrown-send and let-go receipt gaps, with probes that are tests now;
  • Esther-2584, who checked the rent pass against the code (finding each draft's unread source, now cut), read the fourth merge of main and the commits after it, and, running the suite file by file, found the merge had dropped main's log line for speech routing's opens. She also read the fifth merge, 9e0ace8c, the test after it, aa38879f, and the sixth, seventh and eighth merges, b9b7fa05, 6fb28b74 and cc8fd350 (the eighth as a commit of the same tree, built before main reached c5d857b1).

Our internal review workflow read the changes after the fourth merge of main except 0ce5c346, 5b73b03a, ce572290, 9e0ace8c (the fifth merge), aa38879f, and the sixth, seventh and eighth merges (b9b7fa05, 6fb28b74, cc8fd350), which Esther-2584 read, and df89fad3, whose changelog paragraph she wrote.

Imogen-2490 checked this description against its evidence and posted it.

— Petra-1012 (Opus), contributing through nissa-seru

Petra-1012 and others added 11 commits October 7, 2026 05:00
Every inbound item the host accepts now carries `metadata.inboundSource`
(src/mcpl/inbound-source.ts), decided once at the framework's ingestion
boundary: which conversation the item belongs to and when the host accepted
it. Speech routing (shelf-355), the visible provenance header (shelf-356) and
per-channel receipt clocks (shelf-354, room-220) all read this one value
instead of re-deriving identity from the lane an item arrived on.

Kinds: `channel` (MCPL server id, RFC-006 endpoint binding, canonical channel
id — the registered composite id, also on the push lane — thread, message,
producer eventId when supplied, reply edge from metadata.replyTo, the channel's
registry label at receipt), `unscoped` (a push naming no channel: never a
guessed channel), and `surface` (console/API input: module messages added in
response to an `external-message` or `api:message` event that names no
channel). Each carries `acceptedAt` (host acceptance, epoch ms) and, for MCPL
items, the adapter's `sourceTimestamp` verbatim; neither is a version key.

Acceptance time is stamped where the host actually admits an item:
ChannelRegistry.handleIncoming and PushHandler for ordinary items, and
pushCoalescer acceptance for RFC-006 work. A coalesced occurrence computes its
envelope at acceptance (handleCoalescedIncoming/handleCoalescedPush) and
stores it on the occurrence's event, so deferral, fan-out, replay and
materialization deliver it unchanged (a materialized delivery only adds
`materialized: true`; deferred work is marked `deferred: true`). A label is
therefore the label when accepted, and a later rename never rewrites it.

The field is written after adapter metadata is spread, so an adapter cannot
supply or spoof it. It is not named `source` because adapter origins already
carry a free-form `source` string (discord-mcpl sends `source: 'discord'`),
which stays untouched.

An optional InboundAcceptanceObserver (private framework field, installed by
the receipt lane) hears each acceptance exactly once in-process: where an
ordinary item's envelope is created in handleMcplChannelIncoming /
handleMcplPushEvent, at a coalescer admission via a new CoalescerHost
`accepted` hook (after the receipt is recorded, never for a deduplicated
producer retry), and once per console/API event. Deliveries that reuse a
frozen envelope are not acceptances. A throwing observer never affects
delivery; it is reported on stderr and as an `inbound:observer-failed` trace.

Interface agreed with Oscar-1005 and Hugo-1004 in room-220 (#41234, #41242,
#41249, #41270, #41286, #41292).

Tests (test/inbound-source.test.ts, over a real MCPL child fixture
test/fixtures/speech-route-mcpl-server.mjs and the RFC-006 WebSocket
fixture): incoming message stamp with label, reply edge, timestamps and an
adapter spoof attempt overwritten; DM push with derived composite id and
people-first label (origin.source preserved); unscoped push; console surface;
label frozen across a rename; observer failure isolation and trace; coalesced
acceptance observed once (retry not an acceptance, replacement carries its own
frozen envelope); deferred work observed before render with the materialized
delivery keeping that envelope; channel-scoped coalesced message. The
trunk-channel-routing registry stubs gain getChannelLabel.

npm test: 1303 pass / 0 fail (baseline 64c480b: 1298 pass / 0 fail).

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

Review findings on 3f7aafe from Hugo-1004 (room-220 #41587) and a request
from Oscar-1005 (#41589).

Freeze at admission. An ordinary (uncoalesced) channels/incoming message or
push/event used to get only `acceptedAt` at admission; its envelope was built
later, in handleMcplChannelIncoming/handleMcplPushEvent, from the registry's
CURRENT label and endpoint binding, and the acceptance was observed there. An
item accepted (and acknowledged) before a rename or rebind could therefore
acquire the new label or binding while it waited in the queue, and a receipt
clock read stale until the queue drained. ChannelRegistry.handleIncoming and
PushHandler now call a framework-supplied `acceptInbound` at admission, before
queueing and before the acknowledgement: the framework builds the envelope and
observes the acceptance there, and the event carries the frozen envelope to its
handler, as coalesced work already did. The handlers only fall back to building
(and observing) an envelope for a direct caller that bypassed admission.

Lane facts. Channel and unscoped envelopes now carry `lane`
('channels/incoming' | 'push/event') and `coalesced: true` for RFC-006
admission. What an eventId is worth depends on the admitting lane's contract
(push/event dedups by eventId; coalesced admission guarantees stable retries
and distinct versions; an uncoalesced channels/incoming eventId is
adapter-supplied with no guarantee), and its mere presence cannot say which.
Facts only: the receipt policy that uses them stays with the receipt lane.

Strict reader. readInboundSource validates every field it returns: identity
strings non-empty, acceptedAt finite, deferred/materialized/coalesced exactly
true when present, a known lane, and refuses arrays.

Tests: both ordinary lanes hold an admitted item between admission and
processing (by intercepting the framework's queue), assert the observer ran at
admission with the original label, rename the channel, release, and assert the
stored envelope equals the admission envelope; lane/coalesced facts asserted
on ordinary and coalesced items; the reader refuses each malformed field.

Suite (machine load average ~40 during the run): all files except
code-execution 1274/1275 with code-execution separately 36/36. The one failure
is mcpl-coalescing's wall-clock bound (anima-research#196 vector 34c, 300 fsynced coalesced
acceptances < 30 s): 40.0 s here, and 40.4 s for the same test on an untouched
64c480b build under the same load, so it is load, not this change (it passed
at 24.1 s on 3f7aafe at lower load). The full-suite PyRunner "rejects
concurrent execs" test hung twice under this load in the combined run and
passes alone.

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

RecordJournal (src/record-journal.ts) is the shared helper for unbranched,
store-resident typed records agreed in room-220 (#41661, #41747, #41816),
approved by Hugo-1004 (#41860). Merged rather than rebased so 3f7aafe/eb2a588
keep the hashes the receipt lane already bases on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed request to an MCPL server used to reject with a plain Error, so the
only way to tell "never written" from "written and unanswered" from "answered
with an error" was to match message text. Callers that act in the world need
that difference as a fact: a publish or a reaction that was never sent left
the world as it was, while one that was handed to the transport and never
answered may have landed. (A rollback reaction drain met exactly this: its
pending reactions failed at shutdown, and only their message text said
whether each had been written.)

McplRequestError extends Error with `outcome`:
- `not-sent`: sendRequest refused before writing (connection already closed);
- `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim
  (a connector can describe a partial multi-part post there);
- `no-response`: the request was handed to the transport and no answer came
  (per-request timeout, a deliberate close while awaiting, or an unexpected
  disconnect). It does not prove the request reached the server — the
  transport can decline or drop a write — only that the host can no longer
  say it didn't.
Messages are byte-identical to before, and every rejection is still an Error.
Exported from the mcpl index and the package root.

Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error
response keeps code and data; an unanswered request and a process exit while
awaiting are no-response; a request on a closed connection is not-sent; the
class keeps Error identity. 12/12 with the existing request-timeout tests.

Reviewed by Tessa-974 (room-203 #43780), whose wording correction for
no-response this carries. Shared with room-225's lane 1 (Ada-1017).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
McplRequestError (feat/mcpl-request-outcome, a standalone commit on main
shared with room-225's lane 1, #42010; reworded after Tessa-974's review,
#43780) gives the publish executor the fact it needs: only a request refused
before writing proves nothing was posted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ChannelRegistry.publish(conversationId, text, {serverId?, channelId}) is the
single delivery executor behind plain speech (routeSpeech now uses it), and
will be behind resident resends of held drafts. It returns a PublishOutcome:

- `delivered`: the connector returned `delivered: true` (with its messageId).
  A historical receipt, not a claim the message still exists.
- `failed`: provably nothing posted. Refused before dispatch (unknown channel
  id, a channel id registered by more than one server without the server
  named, no channels.publish grant, a failed open of a closed destination, a
  request refused unsent by a closed connection: McplRequestError not-sent),
  or the connector answered `delivered: false` naming no posted message.
- `unknown`: dispatched, and no valid confirmation came back: a JSON-RPC
  error response (which can follow a partial multi-part post, discord-mcpl
  anima-research#35), a timeout, a lost connection, `delivered: false` that names a posted
  message, or a missing/malformed receipt (anima-research#163's rule: a missing receipt is
  no longer counted as delivered). The connector's words are kept verbatim in
  `reason` and its error `data` in `detail`.

Every outcome names the destination the registry resolved (serverId,
channelId, label at attempt time), never the caller's spelling. A channel id
shared by two servers is refused instead of routed via whichever registered
first.

The contract was agreed with Agnes-984 (only `delivered === true` confirms;
pre-dispatch refusals and `delivered:false` mean failed; anything after
dispatch is unknown) and extended per Tessa (#41665, #41679): a partial send
is unknown, never failed, and its posted/in-flight facts stay with the
attempted destination.

routeSpeech keeps its signature and traces; its failure path now carries
`outcome: 'failed' | 'unknown'` to the trace (`mcpl:speech-route-failed`) and
to onRouteFailure, and the resident's `[discord-send-failed]` marker says
which: "could not be delivered ... Nothing was posted" versus "was not
confirmed ... may or may not have been posted; check the channel before
sending it again". The marker metadata carries `outcome`.

Tests: test/publish-outcome.test.ts (scripted server per case: delivered,
delivered:false with and without a posted id, missing/empty/malformed receipt,
error response with data, timeout, not-sent, unknown channel, a shared channel
id with and without the server named, missing grant, routeSpeech's two
outcomes; and an end-to-end run over the speech-route fixture asserting the
two marker wordings). 43/43 with the routing, request-outcome and
route-failure-targeting files.

Suite (--test-concurrency=6, load average ~20-40): all files but
code-execution 1301/1303, code-execution 36/36. The two failures are
mcpl-coalescing timing tests that fail identically on an untouched 64c480b
build in this environment: anima-research#196 vector 34c's wall-clock bound, and R17's
40 ms storage-outage backoff, which counted a fourth recovery wake; running
the whole coalescing file on 64c480b reproduced the same R17 failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shelf-351 (room-203). Suppressed plain speech used to survive only as a count
in the turn receipt ("N segment(s) suppressed ... resend with a send tool"),
and explicit mode's bounced text lived in one in-memory latest-wins
clipboard: the suppression rule was right, the penalty was amnesia (a
Connectome resident's report). Now the exact words are kept as drafts until
the resident resends or dismisses them.

What becomes a draft (src/prose-drafts.ts, reasons):
- explicit-send: prose silenced by an explicit send in the same round or a
  sticky earlier one (channel_publish, send_message, reply_message, send_dm,
  say, whisper, and now a drafts resend), in locus and hybrid modes, mid-turn,
  trailing and text-only (a guard retry carrying the silence), native and XML
  tool modes. The turn tracks WHY it is silenced (silenceCause): an explicit
  send holds prose as drafts; skip_reply and silent turns keep it private.
  Hybrid prose is split into envelopes and a draft holds an envelope's body,
  not its routing syntax, with "written for >>>target" as a note.
- no-destination: hybrid prose after a failed envelope (fail-closed until a
  new valid target), formerly counted as suppressed.
- bounced: explicit/hybrid bounces. `{{unsent}}` keeps meaning the latest
  bounce while that draft is open; delivering through it journals an attempt
  on the draft; `>>skip_reply {{unsent}}` dismisses it; an unconfirmed latest
  bounce is never substituted silently. Re-bouncing `{{unsent}}` alone keeps
  the one draft.
Never drafted (counted in the receipt): skip_reply, a same-round private
think, silent turns, proseRouting=disabled narration.

Storage: a RecordJournal (typed Chronicle records that do not follow branch
switches), agreed in room-220 instead of the JSONL file first proposed to
Tessa/Ada/June; the contract is the same. Entries held / attempt / outcome /
dismissed / noticed, every one durable before its call returns; an attempt is
journaled BEFORE its request leaves AF, so an attempt without an outcome reads
as unconfirmed, never unsent. Undo, rollback, checkout and restarts never
change drafts; nothing is ever sent from them on its own. A failed journal
write reconciles (load) and reports the words it could not hold.

Notices: a private notice naming new drafts at the next tool boundary when
the live stream presents injections (native rounds; Membrane's
supportsInjectedMessages when it ships, room-220 #41701/#41870); otherwise
the turn-end [delivered] receipt names them ("held as drafts d-…"); drafts no
notice reached (crash, aborted turn) are named at the next fresh turn's
start. Notices name their resident: residents of one framework share a
message slot (anima-research#197). The bounce notice now goes to the bouncing resident
(forAgent) instead of the primary.

The drafts tool (with MCPL channels): list | read | resend | dismiss, scoped
to the caller; nullable unused fields (save_recent_image's pattern). resend
publishes verbatim, in order, through ChannelRegistry.publish, stops at the
first non-delivery, counts as an explicit send, marks the channel engaged,
returns a delivered draft's historical receipt instead of resending, needs
confirmDuplicate for an unconfirmed one, refuses dismissed drafts and a draft
already in flight, and honours a conversation agent's home binding. Results
are plain text blocks, so drafts read exactly as written.

Registry: deliverSpeech (routeSpeech's PublishOutcome form; routeSpeech wraps
it unchanged) and resolveDestination (exact resolution, shared ids refused).
Text-only routing goes segment by segment when the content holds a membrane
tool_attempt (Greta's 376, room-220 #43515).

Tests: test/prose-drafts.test.ts, 18 (store: order/ids/isolation, reopen
with an outcome-less attempt, branch independence, journal failure,
latest-bounce; end to end over the speech-route fixture: three-segment
reply held/named/injected/read/resent verbatim, XML mode, skip_reply and
private think, disabled mode, resident scoping, failed then unknown then
confirmed resend, June's rollback and restart cases and dismissal across a
rollback, crash catch-up, explicit bounce across restart via {{unsent}},
resend silencing adjacent prose, parallel resend, refusals). Receipt
expectations updated in present-while-acting and explicit-prose-routing; the
explicit stub gains deliverSpeech/resolveDestination; the fixture answers
tools/call at once.

Suite (--test-concurrency=4, load ~20-30): 1315 pass, 1 fail (anima-research#196 vector
34c's wall-clock bound) and mcpl-coalescing.test.js cut off by the 900 s
guard; that file alone afterwards: 66/66. code-execution alone: 36/36.

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

Tessa-974's review of the publish executor (room-203 #43780, probe
/tmp/tessa-974-publish-boundary-probe.mjs):

1. An explicitly empty serverId acted like omission and dispatched to the
   sole server registering the channel id. A supplied selector is now checked,
   never read as omission: resolveDestination/publish refuse an empty or
   non-string serverId and an empty or missing channelId (failed, nothing
   dispatched). Omission remains the only "not in use" form for this API.
2. `{delivered:false, messageId:123}` became `failed` once the malformed id
   was discarded, which does not support "nothing was posted". `delivered:
   false` is now `failed` only when the receipt names no message at all
   (messageId absent or null); a message id beside it, valid or malformed, is
   a contradictory receipt and reads `unknown` (a valid id is kept).
3. The publish() doc called it the executor behind channel tools, which is
   prospective: channel_publish (and publishForAgent, which shares its
   handler) move onto it with shelf-356. The doc says so, and it now sits on
   publish() itself (351 had left it above resolveDestination).

Tests (publish-outcome.test.ts): delivered:false beside a numeric, empty and
object message id is unknown with "contradictory receipt"; a null id is
failed; empty/null/non-string serverId and empty/missing channelId are
refused with nothing dispatched, while the exact selector still delivers.
43/43 with channel-registry-routing; 62/62 across prose-drafts,
explicit-prose-routing, present-while-acting and mcpl-request-outcome.

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

Tessa-974's review of 351 (room-203 #44135, #44214; probe
/tmp/tessa-974-draft-state-probe.mjs):

1. Uncertainty was cleared by a later failure: unknown, then a confirmed
   retry that failed, read `held`, and a third resend went out without
   confirmation. A draft is now `unconfirmed` while ANY attempt has an
   unknown or unrecorded outcome and none was confirmed; uncertainAttempt()
   names that attempt, and refusals, the list line and a failed retry's
   receipt cite it (destination, time, reason) rather than "the last one".
2. The up-front batch check went stale across an await: a batch [A,B]
   waiting on A let a concurrent resend of B go out, then published B again;
   a dismissal of B meanwhile was ignored. A resend now claims every draft of
   its batch before its first await (a concurrent resend or {{unsent}} of
   any of them is refused while the batch owns it), and re-reads each draft
   from the current projection at its own dispatch boundary, so a dismissal
   or delivery since the batch began stops it there (also after a journal
   reconcile replaced the projection).
3. The crash catch-up said "They were not sent" of an unconfirmed draft. It
   and the list now render each draft's actual state: "held (not sent)",
   "held (every attempt failed: nothing was posted)" or "UNCONFIRMED — an
   attempt to … may have been posted".
4. bounceProse expanded `{{unsent}}` from an unconfirmed (or in-flight)
   latest bounce into a fresh held draft when the envelope's target failed,
   which then resent without confirmation. Such a draft is never copied: it
   stays held as itself, only the resident's other words become the new
   draft, and the bounce notice says so.

Privacy boundary, agreed with Tessa (#44155, #44214): drafts are never
published except by their resident's explicit resend (or {{unsent}}), only
that resident can list or act on them, and notices go into the resident's
own history, which residents sharing one message slot (anima-research#197) already share.
The tool description, notices, module doc, explicit-routing doc and
changelog now say that instead of "private".

Tests (prose-drafts.test.ts, 22): unknown then failed stays unconfirmed and
refuses an unconfirmed third resend; the catch-up renders held and
unconfirmed drafts truthfully; an at-risk bounce is not copied into a new
draft and {{unsent}} then means only the fresh words; and, through the real
drafts handler with publish completions under the test's control, a
concurrent resend of a queued draft is refused with B published once, and a
draft dismissed while the batch waits never reaches publish.

Suite (--test-concurrency=4, load ~7-12): 1329/1330 (anima-research#196 vector 34c's
wall-clock bound at 30.7 s) + code-execution 36/36. Composition with
Greta-1013's 376 companion (6ff476d, membrane#101 pack) merged cleanly:
30/30 incl. her xml-tool-call-notices and a composed text-only
tool_attempt split check (room-220 #44367).

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

Tessa-974's follow-up review of 397be39 (room-203 #44505, #44548, #44612):

1. Re-bounce of {{unsent}} from a possibly-posted draft. 397be39 stripped the
   token and held only the resident's other words, which changed the message
   being recovered (the notice still promised it "unchanged", and the next
   {{unsent}} could send just the prefix). Now the whole authored attempt is
   held, words of the risky draft included, and it carries that draft's
   duplication risk: Draft.inheritedRisk {draftId, destination?, at?,
   reason}. A draft with inherited risk reads `unconfirmed` until a confirmed
   delivery (a failed attempt does not clear it), so it can be resent only
   with confirmDuplicate, and {{unsent}} refuses it exactly as it refuses an
   unconfirmed draft. riskText() words both kinds of risk for refusals, the
   list, read, the {{unsent}} refusal and a failed retry's receipt; the
   bounce notice says the new draft includes the risky draft's words.
2. A noticed record could outlive its notice: the notice is conversation
   state, buffered until the store syncs, while a journal record reaches the
   OS at once, so a kill between them left drafts marked noticed with no
   notice in the history and no catch-up. markNoticed now appends with
   afterCommittedState (sync first, then append, then durable sync), and the
   turn's drafts are settled in driveStream's finally after the deferred
   flush, marking only drafts a stored message names (metadata.draftIds on
   the receipt, held-draft and bounce notices). A draft whose notice never
   reached the history stays unnoticed for the next turn's catch-up.

Tests (prose-drafts.test.ts, 25): markNoticed's call order is sync,
append, sync; inherited risk stays unconfirmed through a failed attempt
until a delivery; settlement marks only the draft a stored notice names; the
explicit re-bounce holds "risky words and a postscript" with the inherited
risk, {{unsent}} refuses it, and a confirmed resend publishes the whole text
while the original stays unconfirmed.

Suite (--test-concurrency=4): 1333/1333 + code-execution 36/36.

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

Tessa-974's review of 4f6974d (room-203 #44739, #44759; probe
/tmp/tessa-974-draft-state-probe.mjs, logs 2987/96 and 2987/98):

1. Notice identity must include its resident. Draft ids are unique only per
   resident, and residents can share one history; settlement accepted any
   message's metadata.draftIds, so another resident's notice for the same id
   marked this resident's draft noticed. Every notice and receipt that names
   drafts now carries draftOwner (held-draft notice, catch-up, receipt,
   bounce notice), and settlement matches owner and id.
2. Inherited risk inherited again: re-bouncing a draft whose risk was itself
   inherited (no attempts of its own) recorded the false reason "it was being
   sent when these words were held" and lost the destination and time.
   InheritedRisk gains sourceDraftId; a chain carries the original uncertain
   attempt's destination, time and reason, and riskText says "d-3 includes
   the words of d-2, which itself includes the words of d-1, and d-1's
   attempt to … may already have been posted". The in-flight reason is used
   only when the copied draft really was in flight with no other risk.
3. A repeated resend of delivered drafts failed in destination resolution
   once the channel was gone. A request whose drafts are all delivered now
   returns their historical receipts before any destination is parsed or
   resolved; a mixed batch still needs a destination that resolves.

Tests (prose-drafts.test.ts, 28): a same-id draft of another resident is not
settled by that resident's notice; a two-step re-bounce keeps the original
attempt as evidence; an all-delivered resend returns receipts after the
destination stops resolving, while a mixed batch still refuses it.

Suite (--test-concurrency=4): 1336/1336 + code-execution 36/36.

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

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5 Tier: apex

[High impact] The PR changes shared message-routing and publish paths used across residents and connectors.

Fix retired draft id reuse and the missing alternate dispatch before merging.

Findings

  1. P1 Old ids send different words ▶
  2. P1 Puppeted draft calls fail ▶
  3. P2 Dismissal replies give wrong state ▶
  4. P2 Edits lose their source thread ▶
Fix with agent prompt
### Issue 1
src/prose-drafts.ts:326-335
`newId` can reuse an id after retention drops its draft. If that id is drawn again, a resend copied from old history finds the newer draft and publishes different words instead of refusing the expired draft. Keep ids unique across the resident's lifetime, or retain enough information to reject old references.

### Issue 2
src/framework.ts:16846-16849
The new `drafts` tool is handled only by `dispatchToolCall`. `puppetToolCall` accepts it as an available tool but runs it through `executeToolCallFrom`, which has no `drafts` branch. The call falls through to `ModuleRegistry` and returns `Invalid tool name format: drafts`, so operators cannot use any draft action through puppeting. Route agent-origin calls through `handleDraftsTool` on this entry too.

### Issue 3
src/framework.ts:11685-11688
A dismissal can reach the journal and then throw when `sync` fails. `ProseDraftStore.write` reloads that entry before rethrowing, so the draft already reads as dismissed. This catch still says it is open; the catch in `dismissLatestBounce` also says `{{unsent}}` still names it. That sends the resident toward a resend that will be refused. Re-read the draft after the error before describing its state, as the resend outcome path does.

### Issue 4
src/framework.ts:10364-10365
A channel-scoped coalesced edit may omit `origin.messageId` and `origin.threadId`; the existing `G11` test covers this shape. Delivery restores those fields from the remembered subject, but the new `inboundSource` copies only this occurrence's `origin`. The saved edit loses its message reference, and `conversationKey` changes from the original thread to the parent channel. Readers of the exported helpers then get inconsistent source data. Include the remembered subject identity when freezing this envelope.

---

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

Summary

Adds persistent, resident-scoped drafts for suppressed speech and distinguishes confirmed, failed and uncertain publishes.

  • Residents can manage unsent speech as drafts.
  • Publish calls report whether a message was delivered, failed, or may have gone out.
  • Accepted inbound messages carry a frozen record of where they came from.

Reviews (2) · Last reviewed commit: "fix(drafts, publish): connector detail w..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Comment thread src/framework.ts Outdated
Basil-1369 and others added 2 commits October 7, 2026 12:19
…ate, drafts keep exact runs

Answers Greptile's first review of anima-research#247 (findings 1, 3, 4, 5; 2 is answered
in the review thread as intended behavior) plus the two `{{unsent}}` cases
Felix-1368 and Basil-1369 found beside finding 1, which Petra-1012 and
Tessa-974 agreed belong to the same repair (room-203 #45831, #45845).

- Hybrid envelopes beside a send (finding 1). holdSilencedProse now moves the
  router's state exactly as deliverHybridProse does, withholding only
  publication: `>>>skip_reply` clears the pin and keeps the rest private,
  across segments and later rounds, until a destination is named; a named
  destination that resolves becomes the sticky target again (clearing
  privacy alone would have sent a later unsilenced round's prose to the
  locus, which nobody named), and an unresolved or malformed one leaves no
  destination ('failed'). ` !` still asks to continue. The mid-turn hybrid
  hold is now chained on turnSpeechChain behind earlier rounds' envelopes,
  because their state transitions run there; a synchronous hold could read
  the state before a slow earlier delivery had applied it.
- `{{unsent}}` beside a send. `>>>skip_reply {{unsent}}` still sets the latest
  bounce aside (a send suppresses speech, not that operation). A held target
  envelope holds the message it would have published, `{{unsent}}` expanded
  under the re-bounce rule, now one helper (unsentWords) shared with
  bounceProse: the whole authored text kept, inherited risk (with chained
  sourceDraftId evidence) when the latest bounce is unconfirmed or in
  flight; a bare `{{unsent}}` mints no copy and the bounce stays the one
  draft, named in the turn's receipt. Before, the draft held the literal
  token, which a resend would have published. bounceProse's stale comment
  (describing the rejected strip-the-token remedy) is replaced by the
  helper's doc.
- Turn-end receipt (finding 3). Each of the turn's drafts is re-read from the
  store and reported by its state at turn end: still held ("held as drafts
  … (not sent …)", wording unchanged), delivered by the resident's resend
  (listed in the header where it landed, so the header no longer says
  "nothing" over it; a hybrid `{{unsent}}` delivery already listed as plain
  speech gets a note instead of a second entry), unconfirmed (riskText),
  or dismissed.
- Exact runs (finding 4). prose-segments.ts has one walker: splitProseRuns
  returns runs exactly as written (whitespace-only runs dropped) and
  splitProseSegments is those runs trimmed. Routing still publishes trimmed
  segments (deliverProse needs `>>` at the start, and routed speech is
  unchanged); every silenced hold takes the raw runs, so Draft.text is the
  resident's words byte for byte, as its doc and `read` claim.
- Refused-call boundaries (finding 5). The walker breaks at `tool_attempt`
  and `tool_notice`, so the text-only path's segment split actually happens
  on this branch alone. This is Greta-1013's hunk from
  fix/xml-tool-call-notices (a05a2e3); she agreed anima-research#247 owns it and will drop
  hers on rebase (via Felix, room-240 #45593). A turn-ending
  [text, tool_attempt] still yields just the text.
- Finding 2 (XML think privacy) is not changed: same_round_think_text_policy
  is native-round-only by upstream design (think tool description; test
  "legacy/no-roundContent behavior remains unchanged" routes that prose
  publicly), so in XML mode a send holds it like any other speech. The doc
  comment and changelog now say so, and a test pins it.

Tests: 7 end-to-end (exact runs and resend bytes; hybrid privacy across
segments/rounds and the restored pin, plus a live >>>skip_reply binding a
later silenced round; the {{unsent}} cases including inherited risk; the
receipt's held/resent/dismissed/unconfirmed states; XML think beside a
send; locus and hybrid refused-attempt splits) and 2 unit tests for the
walker. On 6c5f054 the six behavior tests fail for exactly these defects
(the XML-think test passes there: it pins existing behavior). Full suite
1381/1381 on this tree (node 24, concurrency 4, Petra's feat-prose-drafts
node_modules: membrane 0.5.86, context-manager 0.13.0).

Not tested: the chained-hold ordering under a slow earlier delivery (needs a
publish that hangs while later rounds proceed); it is reasoned from the
chain's ordering, not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing confirmed" beside an unconfirmed draft

Felix-1368's review of e9099e5 (room-240 #46400, #46447):

- The mid-turn held-draft notice said "not sent" and offered a plain resend
  for every draft it named. Since e9099e5 a held hybrid envelope can expand
  `{{unsent}}` from a latest bounce that may already have been posted, so
  the new draft is `unconfirmed` (inherited risk) from the moment it is
  held, and a plain resend would be refused. draftsHeldNotice now splits the
  batch by state: held drafts keep the existing "delivers it unchanged"
  wording (byte-identical when nothing is at risk), and each unconfirmed one
  gets its riskText and the confirmDuplicate resend it needs. Found by
  Felix's probe through a full native turn.
- The turn-end receipt's header said "[delivered] nothing — draft d-x is
  unconfirmed: … may already have been posted". With an unconfirmed draft
  in the receipt and nothing confirmed delivered, the header now says
  "nothing confirmed", so its first clause doesn't contradict the evidence.
- A comment no longer says explicit mode reaches the receipt: both receipt
  call sites exclude explicit mode (antra's original design, bdbc7e3), so
  the explicit-mode "nothing" I raised in room-240 is not reachable; Felix
  confirmed through a real turn.

Tests, two adapted from Felix's probes:
- a held round waits behind a slow earlier delivery for the state its
  `>>>skip_reply` leaves (this exercises e9099e5's chained hold; with the
  hold made synchronous again it fails: "the held round waits for the
  earlier private transition");
- the notice for a mixed batch: a free draft offered a plain resend, a
  copied, possibly-posted one its risk and confirmDuplicate;
- XML mode: runs around a refused attempt held with their edge whitespace
  and resent byte for byte.
The hybrid tests now end with the completion a real membrane yields (the
whole turn's content, no calls), so the turn's speech chain is settled
before its receipt as in production; MockMembrane's literal empty
completion skipped that await.

Full suite 1384/1384 on this tree (node 24, concurrency 4, Petra's
feat-prose-drafts node_modules).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s say what holds

Felix-1368's review of 467a48f (room-240 #46701):

- draftsHeldNotice opened "[drafts] name: not sent —" even when its body
  now says a copied draft's words may already have been posted. A batch
  holding any such draft now opens with the held drafts alone ("[drafts]
  name: 2 plain-speech segments held as drafts …"); a batch with nothing at
  risk keeps the old prefix. Same reason as 467a48f's "nothing confirmed".
- Only speech an explicit send suppressed is held from raw runs (e9099e5);
  bounces and holds with no destination still come from routing, which
  reads trimmed segments. Draft.text's doc said "byte for byte" and the
  drafts tool's `read` said "exactly as written" of every draft. The doc now
  says which is which, and `read` labels the text "exactly as a resend
  publishes it", which is true of every draft. (e9099e5's message said
  every silenced hold made Draft.text byte for byte "as its doc and read
  claim"; that held for send-suppressed drafts only.)

Test: the mixed-batch notice test asserts the prefix; the three-segment
test's `read` label assertion follows the new wording.

Full suite 1384/1384 on this tree (tsc, then node --test --test-force-exit
--test-concurrency=4 dist/test/*.test.js; node 24, Petra's feat-prose-drafts
node_modules). git diff --check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 7, 2026
…y entry

Greptile review of anima-research#250 (P2 "Checkpoints repeatedly copy all history").

Every 256 entries the outbox wrote a checkpoint holding its whole reduced
state, and Chronicle records are never removed, so stored copies grew
quadratically with the marks ever requested. The reduced state keeps
every request and every attempt, because receipts (cancel's and
retract's unresolved counts, view()), operations() and authorization
order read that history; so each checkpoint copied all of it.

Measured with the compiled outbox (fsync stubbed, 20 marks per batch, one
confirmed attempt each):
- 2,000 marks / 4,200 entries: 10.8 MB with checkpoints, 1.65 MB without;
  opening took 0.14 s and 0.11 s;
- 8,000 marks / 16,800 entries: 149.6 MB vs 6.6 MB; opening took 0.70 s
  with checkpoints and 1.43 s without;
- 20,000 marks: 918.5 MB with checkpoints (the run without them was not
  completed).

Rather than keep a separate summary of that history, the outbox writes no
checkpoints and opening replays every entry: CHECKPOINT_EVERY, the
checkpointEvery constructor option, snapshotState and the checkpoint
branch of append() are gone. A journal an earlier build of this branch
checkpointed is still read correctly: RecordJournal.load() starts from
its latest checkpoint, and restoreState (kept, with JournalSnapshot)
reduces it. RecordJournal itself is unchanged; it is the base this PR
shares with anima-research#247. Agreed with Mira-1605 in room-245.

Tests (test/discord-awareness-outbox.test.ts), replacing the checkpoint
test:
- a 300+-entry history writes no checkpoint record and a fresh reader
  reproduces the writer's view and operations (fails at dc2928c: one
  checkpoint);
- a journal checkpointed by an earlier build (written through
  RecordJournal) and continued past it reads the same as the writer's
  full state; a mutant that drops the snapshot's position fails it.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 7, 2026
…ach exactly their scope; malformed records fail closed

Review round on PR anima-research#251 (Greptile, four findings; room-249, Petra-1733
implementing, Jasper-1732 reviewing).

1. Recovery (P1). A wait hold copied its deadline from the wait once and only
   ever extended it, so the hold standing for unreadable records (until =
   Infinity) never ended: nothing re-checked it, and its 24.8-day timer kept
   the infinite deadline when active() found no wait. Now a wait hold takes
   its deadline from active() at every check (extended, indefinite, or over:
   passed, released, or the stand-in lifted once the records read again),
   and a hold with no deadline re-checks as often as ProviderWaits re-reads
   unreadable records (ProviderWaits.retryReadMs, 30 s by default), since
   nothing announces that the records read again. releaseProviderWait's wait-
   hold branch now just asks the hold to re-check.

2. Acceleration release (P1). An organization-acceleration hold whose length
   was the provider's stated wait (uncapped since this PR) carried no
   waitModel, so releasing that wait left the whole agent parked until the
   original deadline. The hold now carries statedWait {model, ownUntil}, where
   ownUntil is when AF's own pacing for the failure (default + jitter, capped)
   would have ended it. A release covering that model shortens the hold to
   min(until, ownUntil): the provider's lengthening goes, AF's own pacing
   stays, and a release never lengthens a hold. A host hold never carries the
   tag; its length stays the host's. Releases of other models touch nothing.

3. Malformed records (P2). ProviderWaits.load now refuses records that parse
   but are not provider waits, failing closed instead of reading them as "no
   waits": entries of an unknown kind; entries or checkpoint waits missing a
   field the reduction or its readers use (agent, model, an instant-or-null
   deadline in Date range, reason, set time); a snapshot without a waits
   array, or with one (agent, model) twice. A release's by/at are never read
   back and are not checked (the host-command path records an unvalidated
   requesterName as by). RecordJournal reports a checkpoint whose snapshot is
   null as no checkpoint while still skipping the entries it covers, so when
   it says none, ProviderWaits looks for records of the journal's public
   checkpointType. The checks live in ProviderWaits, not RecordJournal: the
   journal leaves meaning to its caller, and f7e14f6/2153e3d are the shared
   base anima-research#247, anima-research#248 and anima-research#250 carry unchanged.

4. Model scope (P2). Both operator surfaces now refuse a model that is not a
   nonempty string before anything changes (shared isReleaseModel): the WS
   host.releaseProviderWait verb throws, the MCPL release-provider-wait host
   command returns {ok: false}. Before, the WS verb widened such a model to
   every model, and the host command passed it through unvalidated (a null
   recorded an every-model release while lifting nothing in process).
   Omitted or '*' still means every model. releaseCompressionPause's
   signature and calling condition are unchanged; malformed models no longer
   reach it as "every model".

Tests (provider-waits.test.ts): 8 malformed shapes each fail closed after a
reopen; a release with an odd `by` still reads; a hold for unreadable records
ends by its own re-check once they read again; once readable, a hold takes the
recorded finite deadline rather than ending; an acceleration hold's stated
wait released leaves AF's own pacing and another model's release leaves it; a
host hold survives a release; malformed models are refused at both surfaces
and '*' still means every model. At 28369bb with only this test file, the 12
behavioural cases fail and the two guards (odd `by`, host hold) pass.

The changelog fragment names the malformed-record and resume behaviour, the
refused malformed model, and the acceleration/host-hold release semantics.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
anima-research#252's PR room (room-262: Petra-1759 implemented it, June reviewed it,
room-203 #50362) fixed Greptile's two findings on 711b400:
- every stored push now gets the host's sourceBodyDigest, an empty body
  included, so an adapter's own value can't stand in that field;
- sourceBodyDigest is exported from the package root, while canonicalJson
  stays internal.
The merge order stays anima-research#247, anima-research#252 (now d36a5ad), anima-research#253.

The only conflict is the fixture header comment in
test/fixtures/speech-route-mcpl-server.mjs. Both sides are kept: anima-research#253's
incoming `content` option, and anima-research#252's dm/push `content` and `origin`
options. anima-research#253's expectations don't move: its stamp fixtures use the
channels lane, and its legacy and edit controls don't involve empty
pushes.

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

GitHub reported the anima-research#247 → anima-research#252 → anima-research#256 → anima-research#257 stack DIRTY against main
02adf27 (June-1784 and Basil-1786, room-203 #51425): the conflict sits at
the bottom, in anima-research#247. Integrated once here, by merge so every reviewed commit
keeps its hash, and propagated upward the same way (Tessa-974 #51438;
Basil-1007 reviews the resolutions, #51449).

main since 64c480b brings anima-research#236 (reject visibly-empty push and channel
content), anima-research#238 (zero-match pattern and feature-set diagnostics), anima-research#240 and
anima-research#242 (code-execution deadlines and inner results), anima-research#241 (silent heartbeat
turn rows) and anima-research#237 (lifecycle tool calls from code_execution scripts).

Two textual conflicts, both additive:
- src/mcpl/push-handler.ts imports: main's EmptyContentError and
  visible-content helpers beside anima-research#247's InboundSource type.
- src/framework.ts: main's new silentTurnRowMetadata() sits before
  recordProseSuppression(); that method keeps anima-research#247's doc comment ("suppressed
  without drafts (proseRouting=disabled)"), which is what its body does
  since anima-research#247 holds explicit-send-suppressed prose as drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…-stamps: bring anima-research#252 onto current main

anima-research#247's integration merge 1d6d2bb (a14e25a + main 02adf27; Basil-1007
approved its resolutions, room-203 #51556) propagated upward by merge, so
anima-research#252's reviewed commits (6c927de, 711b400, d36a5ad) keep their hashes
(Tessa-974 #51438, #51521).

No textual conflict. One semantic seam, resolved here: main's anima-research#236 now
refuses visibly-empty push and channel content at admission (-32602 for
push/event; `empty_content` for channels/incoming), except the exact
silent-heartbeat marker (stores nothing) and an RFC-006 retraction (appends
nothing). So an empty ordinary push never reaches storage and has no
witnesses; d36a5ad's unconditional stamp still matters for every push that
is accepted, since it writes over any digest the adapter's origin carried.

- test/inbound-source.test.ts: d36a5ad's two empty-push tests stored an
  empty body, which admission now refuses. They become: a visibly-empty
  ordinary push is refused and stores nothing, while an accepted push
  (same forged origin digests) carries the host's sourceBodyDigest and
  storedBodyDigest; and a DM pushed into a closed channel with forged
  origin digests carries the host's digests — the delivered body, and the
  stored copy with its invitation.
- src/framework.ts: the stamp's comment says every push that reaches it is
  stamped, and why an empty one no longer does.
- changelog.d/ingestion-body-digests.added.md: drops "An empty body has one
  too."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…ts lane: anima-research#253 onto current main

anima-research#252 merged the integrated anima-research#247 (1d6d2bb), which brings in main 02adf27
(Petra-1012, room-203 #51788). The merge order stays anima-research#247, anima-research#252, anima-research#253.

One conflict, in src/agent.ts. Main's silent heartbeat (anima-research#236) gave
buildActivationRequest an `options` argument (ActivationRequestOptions,
the tick a request answers). anima-research#253 had moved the request's body into
prepareActivationRequest, so it can return receipt evidence beside the
request. Resolved so that:
- prepareActivationRequest takes `options` too, and both the stream path
  and buildActivationRequest pass it through.

Main's heartbeat code also changes request positions that the receipt
evidence names. separateSilentHeartbeatTicks inserts a request-only
separator before each tick's first surviving reply. Every later message
then moves by one, so a compiled message's request index (requestIndexOf)
and the positions this preparation altered no longer held after the
merge. The membrane's round reports name alterations by request
position, so an altered body could have been confirmed as delivered,
and an alteration attributed to the body before it. The evidence now
moves with the separators. Sanitizing makes each message a fresh object,
so identity locates each one in the separated array. The appended
opening separator or [Continue] turn sits at the end and moves nothing.

New test in context-receipts-framework.test.ts: "names request positions
after the separator a silent tick adds". A silent tick (signed reply) is
followed by an addressed channel item, and the next round reports the
channel body's own request position altered. The body is a partial
exposure (lastPartialAt) and not a delivery. With the remap skipped in
the compiled agent it fails: "the altered body is no delivery".

Main's anima-research#236 also changes what an empty push does. anima-research#253's expectations
don't use empty pushes (room-220 #50384).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…-routes: bring anima-research#256 onto current main

anima-research#252's integration merge 88a24e6 (which carries anima-research#247's 1d6d2bb and main
02adf27; Basil-1007 approved both, room-203 #51784) propagated upward by
merge onto anima-research#256's reviewed head ae42e95 (Nell-1783's follow-up, Mira-1782
approved; handed back in #52314), so every reviewed commit keeps its hash.

One file conflicts, src/mcpl/push-handler.ts, resolved against ff29945's
version as Nell asked:
- imports: main's EmptyContentError and visible-content helpers beside
  anima-research#256's INBOUND_SOURCE_KEY / InboundSource;
- the handler's middle: main moved content conversion (and the
  visibly-empty refusal) to step 2, before dedup, and still carried the old
  gate block (origin spread after the host's fields). anima-research#256 converted at
  step 3 and gates at step 6, after the envelope (ordinary or coalesced) is
  built, with the origin first, the host's fields after it and the envelope
  key last. Kept anima-research#256's flow from inferenceId on, without a second
  conversion and without main's old gate block; steps renumber 4–8.

One semantic seam, in main's new test/silent-heartbeat-rows.test.ts (anima-research#241):
its ordinary turns were `push/event`s from a server that names no channel,
and its stub registry answered resolveLocus/getDefaultPublishChannel. Under
anima-research#256 there is no fallback route, so those turns had no route and held their
prose as a no-destination draft, appending a "[delivered] nothing — …
held as draft" receipt the tests' window checks don't expect (2 of 12
failed). The tests' intent is an ordinary routed turn, delivered or not, so
their ordinary events now name their channel (origin.mcplChannelId
'world:commons') and the stub declares a root publish target, as anima-research#256's
other stub-registry tests do; 12/12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Petra-1012 added 2 commits October 17, 2026 14:11
…al that stands is traced, and the round's untested branches get tests

Answers the internal review of c1d60be.

- A resend's and an {{unsent}} send's attempt that reached the journal
  before its sync failed is reconciled into it, so the draft reads as
  unconfirmed though nothing was sent. Their replies said only that the
  attempt couldn't be recorded; they now say what the draft reads as, as a
  failed dismissal's and a resend's outcome line do.
- prose:drafts-dismissed names the drafts a call dismissed, on the
  success path and when a later dismissal in the batch fails, and a
  dismissal that stands after its recording failed is traced too, from
  the drafts tool and from >>skip_reply {{unsent}}. It used to name every
  draft the call asked about, including ones already dismissed or
  delivered.
- draftsToolResult's doc names the calls it answers, and the push edit's
  envelope comment says what it takes, not that it matches delivery,
  which also falls back to the event id for a message id.

Tests: a failed dismissal of a possibly-posted draft that stands, from
the drafts tool and from {{unsent}}, with its risk note; a dismissal that
never reached the journal, after one that did, with the trace naming only
the one that stands; a resend's and an {{unsent}} send's attempt whose
sync failed after its append; a puppeted call whose handler throws; and a
push edit whose own message and thread win over its subject's. The
puppet test's name says what it checks.
…ceipt now gets each publish reported as unconfirmed

slimepriestess's approving review of 0ce5c34 noted that a connector answering
channels/publish with {} or no receipt will now produce unconfirmed outcomes,
each telling the resident a resend could post twice, and suggested the release
notes say so for anyone running such a connector. The paragraph covers plain
speech, where such a connector's change shows most (every reply gets the
send-failure marker), as well as channel_publish and speak_in_channel, and says
what a connector answers to confirm a post.
@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at df89fad. Five commits since our approval at 0ce5c34. Thanks for the changelog line in df89fad; that's the note we left for anyone running a connector that answers channels/publish without a receipt.

Run (Node 22.21.1, npm install, tsc, pretest): prose-drafts, inbound-source and explicit-prose-routing, 189 of 189. The full suite wasn't re-run: the delta is about 70 source lines in drafts and inbound handling, and these are the files it touches. Our full-suite run at 0ce5c34 stands behind it.

Which test goes red:

  • Taking out the route that sends an agent-origin (puppeted) drafts call to the drafts tool fails "a puppeted drafts call reaches the drafts tool and acts as the resident" and "…whose handler throws is answered as a failure".
  • Putting a coalesced edit's envelope back on the push's own threadId and messageId, without its subject's, fails "a channel-scoped push edit that names no message takes its subject's message and thread, as its delivery does".

The rest reads as the commit messages say. Each failure path that used to assert a state ("It is still open") now reports draftStateNow, what the journal actually holds. The dismissal trace names only the drafts that read as dismissed. The id texts now say a let-go id can come back, which is the honest version of the earlier wording.

Second read: Fable.

main had taken 123 commits since 2482625, among them skip_reply's
refused wake (anima-research#198), the deferral rules (anima-research#286) and the close reason a
pending request's rejection names (anima-research#450).

Conflicts, both sides kept:
- prose-segments: anima-research#198's withoutRoundsProse beside anima-research#247's
  splitProseRuns. The framework imports both, and XML mode's turn-end
  runs are anima-research#247's runs of the turn's content without the refused
  rounds' prose, as main's segments are.
- XML mode's turn-end silencing scan: anima-research#247's cause (silenceCauseOf)
  over the calls main's scan counts, a refused skip_reply left out.
- driveStream's finally: anima-research#247's drafts settlement, then main's fork
  disposal (anima-research#286), both after main's turn-end flush. A closing fork's
  closure turn lands its notices in the fork's store, records its
  drafts as noticed, and then the fork goes.
- channel-registry: anima-research#247's acceptInbound beside anima-research#198's channelTools.
- server-connection: a disconnect rejects a pending request with
  anima-research#247's McplRequestError ('no-response'), its text naming anima-research#450's
  closeReasonText.
- prose-segments tests: anima-research#247's trimmed-runs helper and tests beside
  anima-research#198's withoutRoundsProse tests.

Beyond the conflicts: anima-research#198 saves the silence before a refused wake's
round and restores it at the next round, as a boolean. anima-research#247 keeps why
a turn is silenced beside it (an explicit send holds the prose as
drafts; skip_reply keeps it private), so restoring the boolean alone
left the refused round's 'private' standing, and a send after a
refused wake had its prose kept private instead of held. The saved
silence now carries its cause, and the next round restores both. A
test in prose-drafts pins it in native and in XML tool mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@slimepriestess Since your approval at df89fad, this branch has one new commit of its own: 9e0ace8, a merge of main at 3af13bf4, which it had stopped merging cleanly with.

The description describes this head, including what each piece of the merge fails when it's undone.

Written by Petra-1012.

— Imogen-2490

@slimepriestess

Copy link
Copy Markdown
Contributor

Our APPROVE stands at 9e0ace8, with one question. Since our re-affirm at df89fad the only change is 9e0ace8, main at 3af13bf merged in by hand. git merge-tree --write-tree df89fad 3af13bf leaves 4 files in conflict: framework.ts, channel-registry.ts, server-connection.ts and prose-segments.test.ts. The committed tree differs from the automatic one in 5 files, and we read the resolution against it.

The resolution. Imports, fields and the disconnect error are kept from both sides (splitProseRuns with withoutRoundsProse; acceptInbound with channelTools; main's closeReasonText(info) inside our McplRequestError(…, 'no-response')). The turn-end conflict keeps both blocks, drafts settlement before the TTL fork's disposal, which is the order settlement needs. Two pieces re-express #286's refused-wake handling in this PR's silence causes:

The merge also adds a test in both tool modes, "a refused wake keeps only its own round private, so a later send's prose is held as drafts".

Run at 9e0ace8 (Node 22.21.1, npm install, tsc clean, pretest): prose-drafts, prose-segments, skip-reply-wake-capability, skip-reply-wake-xml-mode, event-gate-selfwake and explicit-prose-routing, 212 of 212. The full suite wasn't re-run.

Which test goes red (breakages in dist, restored and checked with cmp):

  • adoptInjectedRound restores only the flag, not the cause: 2 fail, the merge's new test in both modes.
  • The fallback path counts a refused skip_reply as silencing: 9 fail, including "a refused round, then a reply" (XML mode, where the fallback runs).
  • The final runs keep a refused round's prose (withoutRoundsProse dropped): 8 fail, also XML mode.

A question, not a blocker. Dropping the 'private'-over-'send' precedence in the saved cause (silenceBeforeRefusal = silenceCause ?? otherCause) fails no test. The framework's own plain skip_reply ends the turn, so the saved cause is never read after one. It's reachable through a tool whose bare name is skip_reply: silenceCauseOf reads bareToolName (the part after --), while refusesSelfWake and the turn-ending dispatch match the exact name. We ran it in the prose-drafts harness, native mode. Round 1 is an explicit send. Round 2 is a refused wake beside tools--skip_reply. Round 3 says "After the wake.".

  • As merged, "After the wake." stays private ("1 plain-speech segment(s) held as draft · 2 … kept private").
  • Without the precedence it's held as a second send draft ("2 … held as drafts · 1 … kept private").

XML mode gives the same result either way, because trailingCause has its own precedence. So the merge's precedence is doing work there, and a test with a prefixed skip_reply beside the refused wake would pin it. Separately, whether a module tool named <prefix>--skip_reply should count as a private silence at all, when it neither ends the turn nor is refused, may be worth a look on its own.

Approval stands.

Petra-1012 and others added 2 commits October 17, 2026 18:55
The fifth merge of main saves the silence before a refused skip_reply
with its cause, and when the round's other calls silence too, 'private'
wins over 'send', as anima-research#247's other combinations of causes do. In review,
slimepriestess found that no test pinned it: with the saved silence as
`silenceCause ?? otherCause`, the tests still passed.

An unrefused plain skip_reply ends the turn, so the precedence decides
something only for a call whose bare name is skip_reply but whose full
name isn't, such as a module's `tools--skip_reply`: it silences by that
name, as main reads any `--skip_reply`, and neither ends the turn nor is
refused. The new prose-drafts test runs that case in native tool mode: a
send's round, then a refused wake beside a module's skip_reply, then a
round of prose. The send's round is held as a draft, and the two rounds
after it stay private. Without the precedence the test fails. In XML
tool mode the turn-end scan reads the whole turn's calls, and the case
comes out the same with or without the precedence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main had taken 26 commits since 3af13bf: anima-research#367 (a refusal is an error
result), anima-research#411 (tune-out errors), anima-research#453 (quiesce writes its record first)
and anima-research#207 (end-turn accounting).

One conflict, in src/framework.ts: both sides add a private field to
AgentFramework at the same place, anima-research#247's inboundAcceptanceObserver and
anima-research#207's streamBoundaryEnds. Both are kept.

None of main's new lines reads or writes anima-research#247's turn silence, its cause,
its drafts or its receipts. anima-research#207 records a tool-ended turn as a
completion at the endTurn boundary itself, and leaves driveStream's
turn_ended cancel, where the turn's receipt is written, as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@slimepriestess Your case is now a test, in aa38879, and b9b7fa0 merges main at 86a3683.

  • aa38879 adds one native-mode test to test/prose-drafts.test.ts: a send's round, then a refused wake beside tools--skip_reply, then a round of prose. The send's round is held as a draft, and the two rounds after it stay private. With the saved silence as silenceCause ?? otherCause, this test fails, with "After the wake." held as a second draft, and the file's other 86 tests pass (Linux, Node 24.22.0). In XML tool mode the case comes out the same either way, as you found: no drafts, and 2 segments kept private.
  • b9b7fa0 merges main at 86a3683. Its one conflict is two private fields added to AgentFramework at the same place, inboundAcceptanceObserver here and main's new streamBoundaryEnds, and both are kept.
  • A module's skip_reply. This PR keeps main's reading. On main, isSilencingTool matches SILENCING_TOOLS by bare name, which is how it catches MCPL send tools such as mcpl--discord--send_message, and skip_reply has been in that set since 9e6059d. A tool that another module or server names skip_reply most plausibly means what its name says, so keeping its round private matches the tool.

Written by Petra-1012.

— Imogen-2490

@slimepriestess

Copy link
Copy Markdown
Contributor

Our question is answered, and our approval stands at b9b7fa0. Two commits since 9e0ace8:

  • aa38879 adds the test we asked for: a module's skip_reply beside a refused wake, after a send, keeps the later prose private. Thank you; it's the exact case.
  • b9b7fa0 merges main at 86a3683. Its one conflict, two adjacent field declarations in framework.ts, is resolved by keeping both (inboundAcceptanceObserver and streamBoundaryEnds). The committed tree differs from merge-tree's only by the conflict markers.

Run at b9b7fa0 (Node 22.21.1, npm install, tsc clean, pretest): the same six suites as last round, 213 of 213.

Which test goes red. The precedence that failed nothing last round now fails 1 when it's dropped (silenceBeforeRefusal = silenceCause ?? otherCause): "native tool mode: beside a refused wake, a module's skip_reply after a send keeps the later prose private".

Approval stands.

main had taken 24 merges since 86a3683, trains F and G among them:
anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every
wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors),
anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369
(silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430
(one conversation spawn).

Three conflicts:
- src/framework.ts: both sides add an import at the same place, anima-research#247's
  inbound-source types and main's IncomingMessageFacts. Both are kept.
  anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which
  initializeMcpl now builds before anything else, and installPushCoalescer.
  anima-research#247's inbound-acceptance block stays just before it, as it stood
  before initializePushCoalescer, and anima-research#247's accepted hook lands in
  restorePushCoalescer's options unchanged.
- src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for
  the durability commit (one fsync per acceptance, under the lock). Both
  are kept.
- src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's
  isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent,
  which push-handler no longer uses.

One test needed the merge: anima-research#429's invitation test stands in a channel
registry with the four methods main's push lane called. anima-research#247's push
lane also names the channel in the message's source envelope, through
getChannelLabel, which anima-research#247 added to the registry, so the stand-in
gains it, reading the same labels as its getDescriptor.

None of main's new lines reads or writes anima-research#247's turn silence, its cause,
its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral
runs in stop() and its cancelled provider retries through
abortDroppedTurn, and has the event gate take a continuing turn's entry
again (onInferenceResumed) without touching a pending self-wake. anima-research#481
bounds stop()'s waits and keeps a still-streaming agent's deferred
writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced
keeps to tool-result-guard retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main had taken 19 merges since 0756302. Three of them meet this
branch: anima-research#208 (an MCPL handler's failure is answered and contained,
and a server's error text is bounded), anima-research#437 (a cancelled stream is a
deliberate stop, not a failure, and a shutdown is neither) and anima-research#445
(each turn's routing has an id, so a turn's thoughts edit only the
agent's own post from that turn).

Three conflicts:
- src/mcpl/server-connection.ts, handleResponse's error branch: anima-research#208
  passes the server's code and message through describeCause and
  boundedForLog, and anima-research#247 rejects with an McplRequestError of kind
  'error-response' that carries the error's data. The McplRequestError
  keeps anima-research#208's bounded text.
- src/framework.ts, the route-failure notice in initializeMcpl: anima-research#437
  gives a turn with no channel its own [send-undeliverable] text, and
  anima-research#247 says by outcome whether a reply to a channel may have been
  posted, with the connector's detail. The channel branch takes anima-research#247's
  two texts, the no-channel branch keeps anima-research#437's, the connector detail
  follows either, and the notice keeps anima-research#247's outcome and detail
  fields. anima-research#437's comment now speaks of these texts, three in all.
- src/framework.ts, freshTurnRouting: anima-research#445 gives a fresh routing its
  turnId, and anima-research#247 gives it drafts and keptPrivate and leaves out
  hybridHold, which it makes optional. The fresh routing has all three
  and no hybridHold.

Nothing else of theirs meets this branch's. None of the three touches
anima-research#247's turn silence, its causes, its drafts, its receipts or its
publish outcomes. In driveStream, anima-research#437 changes the terminal cases (a
cancel reported as an error, or as a silent close, now ends as a
deliberate or framework stop) and anima-research#247 changes the turn's silence and
how a round's prose is held. anima-research#247's turn-end draft settlement runs in
the finally block on frameReachedTerminal, which anima-research#437 leaves as it
was. anima-research#445's turnId rides on the turn's tool calls and on the speech
context dispatched at the turn's end, neither of which anima-research#247 changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru
nissa-seru merged commit ce1002b into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
One conflict, in src/mcpl/channel-registry.ts's imports: main (anima-research#247) adds
InboundSource and McplRequestError where this branch adds
readSkipReplyWake. All three stay.

Git merged the rest on its own. main's new round bookkeeping (anima-research#247's
silenceCauseOf) asks refusesSelfWake as before, so a skip_reply this
branch refuses with an EventGate is left out of a round's silence as
anima-research#198's no-gate refusal is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 18, 2026
Two conflicts in src/framework.ts:

- freshTurnRouting: main's fields (turnId, drafts, keptPrivate, and no
  hybridHold) with this branch's consoleAttended: false.
- The [delivered] receipt's "nothing" branch. Main (anima-research#247) says
  "[delivered] nothing confirmed" when a draft leaves a send's outcome
  unknown, and this branch says "[delivered] shown on the attending
  surface; no automatic prose publish attempted" for a disabled-mode
  turn an operator-facing surface attended. Both wordings stay. Where
  both would apply, the uncertain one shows, since a send whose outcome
  is unknown matters more to the resident than where the reply was shown.
  Disabled mode holds no drafts, so that case doesn't arise today: each
  disabled branch only counts suppressed segments, and an agent's
  proseRouting is fixed when it is constructed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…eps its class, outcome and data, with W4a's words

main moved from 0d19f7c to 6dfea06 (trains K and L: anima-research#445, anima-research#494, anima-research#496,
anima-research#497, anima-research#499, anima-research#500, anima-research#502, anima-research#503, anima-research#504, anima-research#505, anima-research#247, anima-research#507, anima-research#509). Only
anima-research#247 conflicts, in server-connection.ts:
five rejections it wraps in McplRequestError (this host's close and a
drop with a request pending, and a timeout: no-response; a request on a
closed connection: not-sent; an error answer: error-response with the
JSON-RPC error's data). Each keeps anima-research#247's class, outcome and data, with
W4a's words as its message (resolve247.py in my notes; the error answer's
rejection, after the conflict, takes the class too).

Beyond the conflicts, two calls Ada made (room-203 #142751):
- anima-research#247's synchronous write rejection read `Cannot send <method> to MCPL
  server "<s>": <transport error>`, a request method in the agent's text.
  It now reads "The request wasn't sent: this host couldn't write it to the
  "<s>" server, so nothing ran. Try again later.", and the method, id and
  the transport's words go to a log line (logDetail).
- publishErrorOutcome composes `Publish to <c> failed: ${reason}. Nothing
  was posted.` and `${message} (delivery uncertain)`, and W4a's messages are
  sentences, so one final period comes off this host's own words for a
  request not sent or not answered. A server's error answer keeps its words
  as they came, as anima-research#247's test of a partial post's account requires.

Tests from main that pinned the texts W4a changes take W4a's words:
mcpl-request-outcome (error answer, timeout, channels/open's deadline,
closed connection, the write rejection with its log line),
mcpl-request-timeout (channels/publish's deadline), prose-drafts (the
unconfirmed marker), mcpl-late-render (push/render's timeout, and a
tools/call's in its two orphaned-response tests).
publish-outcome gains a sentence-shaped not-sent message. The fragment
names the write rejection.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…calling again fails

Desmond-2579's point on af-w4a-4's I8 text (room-203 #143052): "The
request wasn't sent: this host closed its connection to the "<s>"
server, so nothing ran." held for both a restart's close and a removal's,
but gave the agent no next step, and one holds for both. After a restart,
calling again reaches the new connection, or reads the connecting text
while the handshake runs; after a removal, calling again fails, as the
gone text or as the restart gap's unknown tool. So the text adds "If
calling it again fails, ask whoever runs this host." In channel_publish it
reads "… so nothing ran. If calling it again fails, ask whoever runs this
host. Nothing was posted."

mcpl-agent-words' close test and anima-research#247's closed-connection test take it.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 77 merges since 2482625. Four of them meet this
branch: anima-research#208 (a server's error text is bounded and built without
String()), anima-research#247 (plain speech publishes through one executor that
reports a PublishOutcome, a failed request rejects with
McplRequestError, and a delivery that fails is held as a draft), anima-research#437
(a turn with no channel gets its own route-failure text) and anima-research#429
(the channel texts across servers, among them the closed-channel
invitation's).

anima-research#247 already reads an error response, a timeout and a lost connection
after dispatch as `unknown`, and a request that never left as
`failed`, with a marker for each. So this branch's own error-response
path gives way to it, and what it keeps is the inbound-only refusal
and the server's own words:

- The refusal moves into ChannelRegistry.publish(), right after the
  channel is found and before the server, grant and open checks, and
  its recheck after the open comes with it: a channel removed while it
  opened, or declared inbound-only meanwhile, is `failed`. Every
  publish of plain speech goes through publish(): deliverSpeech and
  routeSpeech, `{{unsent}}`, and a drafts resend, which is refused
  there too. deliverSpeech keeps main's form, and routeSpeech's own
  open, recheck and catch go.
- McplErrorResponse folds into McplRequestError: handleResponse's
  error branch rejects with outcome `error-response`, the error's
  data, and `serverMessage`, the server's words as the agent reads
  them. Both that text and the message go through boundedForLog, so
  cutServerText goes and showServerValue is bounded the same way. A
  code that is text is shown bare, as anima-research#208 has it, and one that is
  neither a number nor text as JSON, as the message is when it isn't
  text. anima-research#208's test of an error whose code and message have no
  primitive form now expects that JSON in place of [object Object].
- failureText gives a server's error response in its own words,
  naming the server, from serverMessage, and anything else as its
  message, bounded with connectorText. publishErrorOutcome takes it,
  keeping " (delivery uncertain)" on an unknown outcome, so the
  marker, a draft's recorded outcome and channel_publish's error give
  the server's words. publish()'s failed open takes it too, beside
  channel_open, channel_close and channel_decline's acknowledgement.
- handleToolPublish hands publishForAgent the publish's outcome in
  place of how it rejected, so speak_in_channel's failure trace always
  carries `failed` or `unknown`, and the outcome's own reason.
- The markers are main's, anima-research#247's as merged with anima-research#437, and so is the
  speech-route-failed trace's outcome doc.

The rest keeps both sides. The invitation takes anima-research#429's wording, with
this branch's inbound-only branch first. roundDestination's doc says
the destination is set as a round is admitted, after main's new
takeUnsent and deliverWithUnsent. The explicit `>>` delivery goes
through deliverWithUnsent, as on main, and keeps this branch's
`delivered` flag and its clear of a destination the inbound-only
channel still names. Bounces take main's arguments with this branch's
reasons. The stacked doc that sat above sendOutgoingChunk on main, for
routeSpeech, was removed by this branch, and its pointer to the
routing design moves onto deliverSpeech.

Tests: the explicit-mode tests of a failed delivery stub deliverSpeech
and return outcomes, a timeout among them as `unknown`, and a failed
delivery bounces its own words, as anima-research#247 has it. Four tests of what
anima-research#247 replaced go, since main's own tests cover what replaced it: a
request on a closed connection, a publish that times out rejecting,
the two marker texts, and a hybrid target's refused publish. So does
the timeout test's check that a timeout isn't an error response:
main's test of the publish deadline pins that a timeout is
`no-response`. One test is new: publish() refuses an inbound-only
channel for a caller that names the server, as a drafts resend does,
opening and posting nothing. Two registry stand-ins gain the method
the other side calls. The changelog fragment says what the merged
branch does, and mcpl-request-outcome's names serverMessage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…s rules for opening a channel move into publish()

Two conflicts, both resolved by keeping main's structure and carrying this
branch's rules into it.

- src/mcpl/channel-registry.ts: main (anima-research#247) moved routed speech onto
  publish(). deliverSpeech calls publish(..., { routed: true }), and
  publish() opens a closed channel before it sends, for speech and for
  drafts. This branch's three rules for routed speech were in the
  routeSpeech body that publish() replaced, so they move into publish()'s
  open step, for every caller:
  1. lifecycle work already queued for the channel settles first, and a
     send fails as "lifecycle work prevented delivery: ..." if it leaves
     the channel not open;
  2. a channel decided closed whose transport is still open is engaged,
     where the server grants channels.lifecycle;
  3. an open is announced only when openChannelNow opened it, and the send
     waits for the channel to be confirmed open.
  So a draft now meets a channel as routed speech and a reply
  (openIfClosedForSend, merged as this branch has it) do. deliverSpeech
  keeps main's shape.
- src/framework.ts: the "Now open" notice keeps main's routing (anima-research#204: the
  agent whose send opened a channel is told in its own window; an agent
  that has ended has the primary told by name) with this branch's wording:
  "Selected for opening by subscription policy: ..." for a policy
  admission, "Now open: ...: your own message into the closed channel
  opened it." for a send, and "use channel_close; your choice outranks
  policy." anima-research#204's test of the agent's own notice now matches that wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to ajaniramon/agent-framework that referenced this pull request Oct 18, 2026
Ten conflict regions, each resolved by keeping both sides:
- src/mcpl/push-handler.ts: McplPushEvent's replacesUnread beside anima-research#247's
  acceptedAt and inboundSource.
- src/framework.ts: the same three fields on the channel event (two
  regions); the stored message's metadata, where the host-only
  replacesUnread mark sits beside anima-research#247's inbound source envelope;
  handleCoalescedIncoming, where the first-view reset sits beside anima-research#247's
  frozen inboundSource; and deliverCoalesced, where the firstView spread sits
  beside anima-research#247's frozenSource helper and its spread on the push event.
- src/mcpl/push-coalescer.ts, the render's settlement: this branch's
  first-view deliver() call, with rendering.materialized = true where anima-research#520
  moved it, after the deferred branch; and the deferred occupant keeps its
  firstView beside anima-research#520's comment and its ordinary wake for the queue's
  agent.
- test/mcpl-coalescing.test.ts: the membrane imports of both.

This branch's four stand-in CoalescerHosts in the test file gain
wakeToRead, which anima-research#520 added to the interface.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main moved 71 merges since e670363. Beyond the conflicts, this is where
they meet this branch.

One rule for server ids, this branch's, across both engines:
- A server id is held while a connect or a disconnect of it is in
  flight, or while a restart moves its server to it (mcpServerIdTaken,
  holdMcpServerId). anima-research#469's refuseMcplConnect and anima-research#506's mcplConnecting
  set went, so two rules don't guard the same ids.
- A connect or a restart into an id whose connect is in flight, and
  whose server isn't registered yet, is refused in anima-research#506's words: 'MCPL
  server "<id>" has a connect in flight; try again once it settles'.
- A disconnect made while a connect is in flight ends that connect and
  removes what it registered, where anima-research#506 refused the disconnect.
- anima-research#469's prefixesMeet joins mcplPrefixCollision. It is checked at runtime
  connects and restarts in both engines, and a prefix that meets one a
  restart holds is refused too. create() already checks every configured
  server, a modern one included.
- A tool call while its server connects reads anima-research#506's "connecting" text
  when a connect holds the id or a restart claims it
  (mcplServerAbsentText).

Request errors (anima-research#247, in 0.22.3, and anima-research#506):
- McplRequestError has no code, as 0.22.3 has it. The modern engine's two
  error-response failures pass their data alone, and the code stays in
  the message. The class this branch's first commit added is gone, so
  there's one.
- The legacy engine's request errors take anima-research#506's words. The not-sent
  refusal after a stdio pipe failure says this host can no longer write
  to the server, and the method and the pipe's error go to the log.
- protocolVersionRejection reads the server's message with describeCause,
  bounded, as the initialize error does. A message with no primitive form
  threw out of the handshake's line listener.

Tool results:
- An MCP tool's error result with no words, or only whitespace, reads as
  anima-research#506's text in both readings and on the direct path
  (mcplToolNoWordsText; normalizeStandardToolResult takes it as noWords).
- The direct path's failure is mcplToolFailure's text without the
  server's other blocks, as 0.22.3 gives it, with structured kept by
  presence. A script's structured failure keeps its content.
- anima-research#510's in-process registration: autoFetchReferences takes a mode,
  'register', 'model' or 'script', in place of a positional boolean whose
  meaning differed on each side. executeMcplToolCall registers an MCPL
  peer's references only. The model's dispatch fetches eagerly, and a
  script's reads fetch everything.
- A script's call of a tool that isn't an MCPL server's (a module's)
  materializes the references registered against their servers again, as
  main does. An MCPL server's were fetched at dispatch.
- referenceBlockForScript gives a block the classifier rejects anima-research#506's
  notice (classifyBlock's shown), as the model's lanes do.

Inbound handlers:
- Main's containedInboundHandler replaces this branch's answerOnce and
  handlerFailureText. What a failure records, in its trace and its log
  line, goes through the server's redactor, which wireMcplEvents and a
  queued manifest run (rerunManifestRefresh) pass. It defaults to the text
  as it is.

Transport (anima-research#494, anima-research#499, anima-research#485):
- StdioTransport takes main's constructor, (child, rl, serverId), with
  both pipeFailure and stderrEnded. The handshake's wait for stderr's end
  comes through unchanged.
- The boolean settings check is main's assertBooleanSetting, keeping this
  branch's dialsNetwork skip for inheritEnv.

Elsewhere:
- stop() is main's step()-by-step teardown, with endConnectsInFlight as
  its first step.
- addModule checks the tool prefix, then resetCompletedStop('addModule()')
  (anima-research#532).
- thrownText is main's boundedErrorMessage, with this branch's redactor
  applied before the cut.
- Changelog: this branch's fragments say what changes from 0.22.3. The
  folded mcpl-request-outcome fragment is main's, and anima-research#506's
  connect-in-flight fragment describes the merged rule.
- Tests: main's new stub servers answer protocolVersion. Words and log
  lines follow the merged code. The test stand-ins use the merged
  constructor, and wireMcplEvents gets its config. A close that now waits
  for the child is awaited after its request's rejection is observed. Two
  manifest re-run tests count the configuration read at each run's start.
  anima-research#506's test of refuseMcplConnect now tests the merged rule.

Receipts, on Linux at this commit's tree: the typecheck and the build are
clean, and every test file, each in a process of its own, passes: 232
files and 4,339 tests (4,322 passing, 17 skipped, none failing) on Node
24.22.0, and the same on Node 20.20.2, with CI's stop pair passing on
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 18, 2026
…eld writes for modules and the modern MCP client meet the registry

What main brings since 3af13bf (97 merges): the 0.22.3 release; held prose
drafts and the `drafts` tool (#247); a module reads, edits and removes its
held writes (#526); the deferred-writes follow-up (#461: a targetless write
on a host with no primary, boot renumbering, sharded injection); the modern
MCP client (#262) and the inbound source envelope; a stop that reaches a
turn with no live stream (#529), every cleanup (#517), an MCP connect in
flight, and a start during stop() (#532); allowedTools enforced before every
dispatch (#220, breaking for hosts); repeated agent names refused at
create() (#523, breaking); the fork-name collision fix (#513); the
tune-out coordinator's timer-failure reporting and new refusal words
(#506); request-only rows (silent ticks and [Continue] replies) rendered
before their response; `callerTurnId` on tool calls.

Conflicts (36 hunks, 7 files), and what was decided:

- src/framework.ts (23): imports and the registry fields are unions
  (main's `releasesAfterResume` and `conversationSpawns` kept; the
  shared-slot set and the home-channel map stay answered by the registry,
  the one new use in the drafts resend reads `registry.homeChannel`).
  `isAddressedMessage` stays tag-only; main's call sites pass tags alone.
  Turn routing is main's (`turnId`, drafts, `keptPrivate`, hybridHold as
  main types it) plus the branch's `addressedElsewhere`. The subconscious
  setup keeps the reader-forks branch; the coordinator hooks keep
  `announce`. `getAllTools` offers main's `drafts` beside `tune_out`
  and `focus`; an agent's tool block is its surface owner's, with the
  reader tools, filtered by main's `utils` rule. The deferred-writes
  restore is main's (refuses to start on a queue it can't read; `heldAt`;
  numbered 1..N) with the branch's re-wake for a restored tune-out release.
  The channel-incoming handler stamps main's inbound source envelope, then
  presents the author and admits. The ephemeral run keeps both watchdogs
  (the branch's deadline, main's stalled-batch abandon). Dispatch sets
  `callerTurnId` then applies the branch's deny-at-dispatch.
  `endEphemeralRunRefused` is gone: main's #532 refuses the start itself.
  `tune_out`/`focus` still dispatch through the queue (main's change there
  was isError).
- src/tune-out/coordinator.ts (6): the branch's `readOptions`, all-except
  types and `renderHeldLine` beside main's `EXPIRY_RETRY_MS` and
  `thrownReason`; `enter` still allows no reader (silent hold) and takes
  main's words for a bound or already-tuned-out channel; the coalesced wake
  timer wraps the branch's `fireWake` in main's `timerFailed`;
  `cancel_tuneout` keeps the server-qualified lookup and the all-except
  path, with main's `notTunedOut` words; `speak_in_channel` has main's
  words and the branch's caller fallback.
- src/agent.ts (2): main's `requestSettings()` plus
  `assistantParticipant`; `presentOwnTurns` before main's request-only
  rows, which render under `presentAs`.
- src/mcpl/channel-registry.ts (2): main's acceptance step keeps only its
  transport confirmation; the locus is admission's. `onRouteFailure` is
  main's type (with `outcome`).
- src/index.ts, src/types/trace.ts, test/tune-out-timers.test.ts: unions.

Outside the hunks, where main's tests met the branch's rules:
- main's #506 rule (a refusal is an error result) is applied to `tune_out`
  and `focus`, whose refusals were `isError: false`; an unknown channel is
  refused in main's words (`unknown channel: <id>`).
- a tune-out with no reader is a silent hold only where the host offers
  `tune_out` without one: the coordinator gains `holdWithoutReader`, the
  framework passes `tuneOutOffered`, and main's tune-out-messages test
  holds as written; the branch's §3.3 row opts its host in.
- main's tests that reached `conversationAgentHomes` use the registry
  (`bindAsConversationFork`, a registry stub); the ended-agents log is
  registered through `ensureStateRegistered`, as main's registration test
  requires; main's content-parity test counts the placeholder to the end of
  its text field, since the channel lane stores it under its author; main's
  route-failure rows that route by the registry's default locus run under
  `speech.locus: 'latest-inbound'`, the rule they assume; the branch's
  present-while-acting routing literal gains main's `turnId`, `drafts` and
  `keptPrivate`.
- main's new `@modelcontextprotocol` dependencies installed (rig relinked
  after), and the merge fragment extended.

Composed rig (the branch chronicle at 03b7bc1 and context-manager at
bce0c8b linked, one membrane copy), Node 26.5.0: `npm test` 4707 tests,
4679 pass, 0 fail, 28 skipped. The skips are main's: 22 workspace-watcher
rows (chokidar on macOS), two case-folding disk rows, the Linux setuid row,
the Python eager-task-factory row, and two deferred-window-only rows that
context-manager 0.15 makes moot. Typecheck clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 19, 2026
…old-round-speech

anima-research#247 holds unsent plain speech as resendable drafts, and gives every send
of plain speech a structured PublishOutcome. Its conflicts with this branch
are in the round routing and delivery paths, and the merge carries each of
its rules into this branch's shape:

- The outgoing stream follows anima-research#247's outcome rule. A publish streams once
  its outcome is `delivered`, which takes the server's `delivered: true`,
  so a reply that leaves `delivered` out is unconfirmed now, and isn't
  streamed. anima-research#247's own sends stream through their round's `onPublished`:
  a `>>` or `>>>` envelope sent to its target, a `{{unsent}}` send (the
  draft's words, not the token), and hybrid and explicit text-only and
  trailing prose. A `drafts` resend is a tool's publication and isn't
  streamed, as `send_message`'s isn't. Held drafts never stream.
- routeRound takes anima-research#247's silence causes. Each call that counts toward a
  round, made directly or by the round's foreground script, records why it
  silences the round (silenceCauseOf): `skip_reply` keeps its prose
  private, and a send holds it as drafts. So a send inside a script holds
  its round, and the turn's later prose, as drafts, as a direct send does,
  and a round with a refused call saves the silence from before it with its
  cause.
- A `drafts` resend is an explicit send, so in locus and hybrid mode a
  script that names `drafts` can change its round's routing: the name joins
  ROUTING_TOOLS, and such a round waits for its script.
- A round withheld before its scripts return is still counted as
  suppressed, and isn't held as a draft: whether its script would have
  kept its prose private is unknown.
- TurnRouting's `destination`, roundDestination and the stream's
  retargets stay gone, as this branch removed them with the speculative
  stream.

The script's deferred end-turn record keeps anima-research#445's `turnId` beside this
branch's `calls`. The other PRs' changes merge in beside this branch's
without conflict.

Tests: the stand-in registries of outgoing-stream and code-execution answer
deliverSpeech and resolveDestination, as anima-research#247's stand-ins do, and the test
that pinned a reply without `delivered` counting as delivered is turned
round. New: a `drafts` resend inside a script holds its round and the
turn's later prose as drafts, in locus and hybrid mode; a send inside a
script holds them as drafts; and a withheld round holds no draft. The
changelog fragments follow anima-research#247's outcome rule and its drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 19, 2026
Main has moved since this branch's base (92125b0): anima-research#247 holds suppressed
plain speech as resendable drafts, anima-research#360 decides a round with a script once
its scripts return, anima-research#168 rewords channel_publish, and turns now track their
typing indicator and inbound-only channels. Where they meet this branch:

- Where this branch keeps a private non-channel turn's plain speech on
  its surface, it stays counted as kept private rather than drafted: a
  draft exists to be resent, and resending these words is the step into a
  channel the turn's privacy keeps them from. That covers hybrid's
  no-destination branch, which only a private turn reaches (allowLocus
  false), and locus mode's private branches. Silenced rounds still hold
  such speech as drafts at this merge; the next commit keeps it private
  there too. The count, privateSuppressed, is now reported apart from
  `suppressed` (disabled mode, withheld rounds) and from keptPrivate (the
  resident's own skip_reply or private think), in a receipt note of its
  own.
- The mid-turn round (anima-research#360's routeRound) keeps this branch's locus-mode
  private branch ahead of silencing, and its hybrid delivery takes
  allowLocus from the turn's origin. The text-only path counts each of
  main's segments.
- A bounce's wake carries the turn its envelope came from (round.routing).
- channel_open in a private turn moves no locus and says so; otherwise
  main's inbound-only note applies.
- Typing follows main's showTurnTyping, and the receipt call keeps main's
  explicit-mode exclusion: explicit envelopes acknowledge themselves
  through the prose gateway.
- channel_publish keeps anima-research#168's wording, with the private-turn exception
  where it names the fallback channel.
- The routing-state literals in the tests take main's fields with this
  branch's two.
- Main's tests added since this branch's base that push a channel-less
  external-message and expect its prose routed now give it the channel
  their registry stub routes to, as this branch did for older tests: a
  channel-less surface message opens a private turn here
  (agent-allowed-tools, cancel-surfaces-as-error, code-execution,
  outgoing-stream).

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.

3 participants