Repository navigation
fix(mcpl): no error a host's call gets carries a configured credential - #578
Conversation
A server's words reached a host in errors the framework built from them, and a library's error reached it as raised: - the legacy initialize error, a refusal of the protocol revision and its data, the revision a legacy server answered with, and both engines' error answers kept the server's words, cut to 200 code points with control characters replaced, and trimmed. The framework redacted its records afterwards, so a credential the cut split kept its start, one holding a control character survived changed, and one ending in a space survived whole, in those records and in an agent's tool result. The caller of a connection got the words, an error answer's data, a refusal's list of revisions and a tools/list cursor's errors unredacted. - a failed legacy handshake's stderrTail was the end of the server's stderr as written, cut before anything redacted it. - a modern connection's failed connect rethrew the SDK's error, which can quote the url in its message, and under Bun carries the url in its path property. - a legacy stdio server that couldn't be started was refused with Node's error as its cause, the child's command line in its spawnargs, and an argument Node refused at once quoted in its words. - McplUnreapedLaunchError printed its connection's configuration, credentials included, wherever the error was inspected. - the record of an answer that came after its deadline was bounded, never redacted. Each error now redacts the server's words with its configuration where it is built, then trims and bounds them, and redacts the strings in an error answer's data (redactedData), to a depth of 64 levels, keeping every entry under a name of its own; with a configuration that can't be read, the data is withheld whole. Each such error takes one build of its redactor (ServerTextRedactor.now), so it reads the configuration at most once. A failed handshake's stderrTail is the stream's last 2,048 characters, redacted as the transport's window of its last 16,384 has them: the redactor can start at a place in a text (Redaction's from) and replaces a credential that reaches across that place whole, so a credential of up to 14,336 characters that reaches into the tail is matched whole. The raw stderrTail getter reads the same window. A library's error is handed on as its name and its redacted words alone (maskedError): a modern connect's failure, and a legacy stdio spawn failure's cause, which keeps its errno code, whether Node refuses the launch at once, when the words stop before Node's quote of the refused value, or reports it on a tick after. An error answer's code is redacted in its log line unless it's a number (loggedCode), and McplUnreapedLaunchError.connection isn't enumerable. The late answer's record is redacted, then bounded. redactServerText's scope sentence, the README and PublishOutcome.detail's doc say so: a JSON-RPC answer's result passes as it is, no error a host's own call gets carries a credential the redactor replaces, a close's or a cleanup's failure carries the transports' own words, and a connection's events are the records' inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README, the scope comment and the changelog fragment said a library's error is handed on as its name and redacted words alone, but a stdio launch failure's cause also keeps Node's code, and its words stop before Node's quote of a value it refused at once. They now say so. The first commit's message calls that code an errno code, which it is for a failure Node reports on a tick after, such as ENOENT; for a value Node refuses at once it's Node's own, ERR_INVALID_ARG_VALUE. The stderr tail's 14,336-character bound read as the tail's length; it bounds the credentials replaced whole, and now sits beside them. README's list of what the redactor doesn't derive names a stdio server's command as well as its arguments. The comment on the launch's env read says both startup and a reconnect act on the framework's own error. The launch test pins the cause's name for a value Node refuses at once (TypeError), and that a failure Node reports on a tick after keeps its words whole, redacted, where they hold " Received". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change reaches host-facing errors and records within the MCPL connection workflow. Fix the repeated key-name search before merging; one error response can stall every agent. Findings
Fix with agent prompt### Issue 1
src/mcpl/protocol-family.ts:251
`redactedData` restarts its suffix search at `2` for every key that redacts to the same name. Different combinations of raw and URL-encoded copies of one token can produce thousands of such keys. With 16,384 keys in 2.2 MB of JSON, this requires 134 million checks; a standalone copy took about 40 seconds.
Both engines run this before returning the error, so every agent and timer in the host stops meanwhile. Track the next suffix for each redacted name instead of restarting the search.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryExtends credential filtering to host-facing errors in both MCP engines.
Acknowledged scope from
Reviews (1) · Last reviewed commit: "docs(mcpl): say what a launch failure's ..." · Reviewed by Greptile |
| let name = names[n]; | ||
| if (name !== key) { | ||
| const base = name; | ||
| for (let k = 2; taken.has(name); k++) name = `${base} (${k})`; |
There was a problem hiding this comment.
redactedData restarts its suffix search at 2 for every key that redacts to the same name. Different combinations of raw and URL-encoded copies of one token can produce thousands of such keys. With 16,384 keys in 2.2 MB of JSON, this requires 134 million checks; a standalone copy took about 40 seconds.
Both engines run this before returning the error, so every agent and timer in the host stops meanwhile. Track the next suffix for each redacted name instead of restarting the search.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/protocol-family.ts
Line: 251
Comment:
**Error data stalls every agent**
`redactedData` restarts its suffix search at `2` for every key that redacts to the same name. Different combinations of raw and URL-encoded copies of one token can produce thousands of such keys. With 16,384 keys in 2.2 MB of JSON, this requires 134 million checks; a standalone copy took about 40 seconds.
Both engines run this before returning the error, so every agent and timer in the host stops meanwhile. Track the next suffix for each redacted name instead of restarting the search.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Addressed in 9dc00cfb. redactedData keeps each redacted name's next number to try, so keys that redact alike are numbered in one pass. Every number below the kept one was taken and stays taken, so each key gets the same name as before.
A run on Linux with Node 24.22.0, with 16,384 keys that a stub redaction maps to one name, took 11.6 s at 0ec0935 and 25 ms with the fix. The new test in test/mcp-recorded-text.test.ts numbers that many keys, checks every name, and checks that the numbering takes under two seconds. At 9dc00cf the whole test takes 65 ms. With the old search put back, the numbering takes 11,152 ms and the test fails.
Written by Rupert-2634.
— Lottie-2661
redactedData gave a redacted key that came out the same as another key the first number its name had free, searching from 2 again for each such key. So a server's error data holding many keys that redact alike took time that grows with the square of their count: 16,384 of them took 11.6 s on Linux with Node 24.22.0, and both engines do this before an error reaches its caller. Each name's next number to try is now kept, since every number below it was taken and stays taken, so the names come out the same and the same keys take 25 ms. A test numbers 16,384 keys that a redaction maps to one name and checks the names and that it takes under two seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#578 (no error a host's call gets carries a configured credential) and this branch both edit three regions; each is resolved to keep both: - README.md's redaction paragraph: anima-research#578's text, with this branch's clause that a WebSocket dial "with `tokenTransport: 'header'` sets none" in the query. - src/mcpl/protocol-family.ts: serverTextRedactor takes this branch's `tokenTransport` in its config and returns anima-research#578's ServerTextRedactor. - src/mcpl/transport.ts: the import from protocol-family.js takes both sides' names (headerCarries and tokenTransportProblem from this branch; maskedError, serverTextRedactor and Redaction from anima-research#578). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#578 changed failed() in the legacy handshake, where it stood inside the initialize answer's promise, so the tail it puts on an error is redacted: transport.stderrTailRedacted(serverTextRedactor(config)), with a comment on why. This branch had moved failed() out of that promise, so the pipe check after the handshake's await can carry the tail too. The resolution keeps failed() where this branch put it, with anima-research#578's body and comment, so the pipe check's error carries the redacted tail like the handshake's other failures. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ge entry for bounded jobs, the batched-wake hold What main brings since 9c6964c: 0.23.0 and 0.23.1 — #360 (hold-round speech), #250 (surgery marks opt-in), #463 (the workspace edit planner), #168 (non-conversational marker tags: a reaction, edit or deletion never retargets the publish locus, apart from an edit the coalescer marks as the agent's first view), #558 (every ephemeral agent's usage in one shared entry), #570 (the batched-wake tests hold the gate's batch), #569, #565, #566, #571, #572, #575, #576, #578 and the 0.23.x merge-backs. Conflicts in five files, resolved by composition: - src/agent.ts: the request strips the framework's token-estimate stamp (main) and then presents the agent's own turns under its name (ours). - src/framework.ts: `ephemeralCandidates` keeps this branch's record (config, spec, resume) beside main's `ephemeralAgents` WeakSet, and createEphemeralAgent adds to both; `isAddressedMessage` stays tag-only (main's second argument is unread) with main's `readsDeferredWrite` spread; the re-pin rule's doc is main's with the `speech.locus` sentence; the turn-start flush is main's `flushForStart`, which gains this branch's `releaseLandedMail` so held mail whose message lands is released. - src/mcpl/channel-registry.ts: the three Dendrite options (`latestInboundLocus`, `onToolClose`, `focusChannelResolver`) beside main's `onRouteFailure` doc; `markAccepted` takes main's `firstView` but records no publish locus — admission does, through `noteDelivered`, and the framework's three `noteDelivered` sites now apply main's marker rule (`isMarkerTagged(tags, firstView)`), so a reaction, edit or deletion is never the latest delivered inbound either; `channel_publish`'s default comment says the same. - test/batched-wake-locus.test.ts: this branch's `batchedWakeRig` keeps its shape and gains main's `holdBatches`, reading the rig's framework; every test that pairs two sends holds the batch, the two ambient-only tests included. - test/present-while-acting.test.ts: `createFramework` takes `speech`, `maxStreamTokens` and main's `proseRouting`; main's new re-pin tests (traces and typing after a re-pin, the budget restart, the inbound-only arrival) run under `LEGACY`, the latest-inbound rule the re-pin belongs to, beside this branch's default-rule test. Three seams the suite found, all at the registry's reply anchor, which main sets where it accepts a message and this branch sets where admission delivers one: - #168's marker rule now lives in `noteDelivered` (tags and the coalescer's first-view mark are passed by the framework's three delivery sites), so the registry's unit tests exercise it as the framework calls it. - `buildChannelContext` reported no `incoming` when the speech fallback was null, and no context at all when nothing was open and no outgoing channel resolved — under `speech.locus: 'delivery'` that hid the reply anchor whenever the latest inbound wasn't a speech fallback. The anchor now depends only on `takesGlobalFallback` (an ephemeral run has none), and a context with an anchor is returned whatever else it lacks. - A coalesced acceptance's anchor is committed when the acceptance is acknowledged, not when the coalescer delivers: an acceptance whose commit fails after its delivery moves no anchor, and the retry the receipt answers commits the one the first attempt parked (`parkedAnchors`, keyed by server and event id, bounded). #157's tests pinned both halves. Test adaptations beyond the conflicts: #157's two re-pin tests and outgoing-stream's re-pin test run under the legacy locus rule, as the others do; two compiled tests main deleted (`mcpl-awareness-barrier`, `prose-stream-router`) were still in `dist/test` and failing from there — removed, since tsc doesn't clean outputs whose sources are gone. Two lines git doesn't show, named by nissa-seru's review of #546: - #558 reads `config.subconscious.name` for the shared usage entry's exemptions; a forks reader has no agent of its own, so the name is read only when `reader !== 'forks'` (the union has none otherwise). - resumeAgent makes its agent with `new Agent`, so it adds the agent to `ephemeralAgents` itself: a resumed job's usage counts in the shared entry, not under its own name. Composed rig: npm test 5408 tests, 5378 pass, 0 fail, 30 skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Main has moved since the last merge (815bfe3): anima-research#579 merges 0.23.1 back, anima-research#578 keeps credentials out of host errors, anima-research#581 projects mid-turn images, anima-research#584 turns Greptile's status comments off, anima-research#561 adds a WebSocket auth header, anima-research#586 defines dispositions for stubs, anima-research#574 reports an MCP pipe that fails while connecting, anima-research#583 holds module rows from residents, and anima-research#585 sends a stream's chunks and complete to each publish's destination, its server and its channel. Git merges both sides on its own. One test file needs a change: anima-research#585's three tests with the real channel registry start their turn from an external-message that names no channel, which this branch makes a private turn, so none of its prose is published. Their trigger now names the channel they stream to, as this branch's other outgoing-stream tests do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No error a host's own call to agent-framework gets, whether thrown, rejected with or in an error answer's data, now carries a credential the framework replaces in its records, whoever wrote its words. A server's words are redacted where an error is built from them, then bounded, and the strings in an error answer's data are redacted. A library's error is handed on as its name and its redacted words, with a failed stdio launch's
code. A failed legacy handshake's stderr tail goes to the host redacted, and the record of an answer that came after its deadline is redacted as an on-time answer's is.Problem
A server's configuration holds credentials: a url's userinfo, credential-like path segments and query values, a static or provided token, a stdio server's credential-named env values.
redactServerTextreplaces them, and the framework runs it over its own records: log lines, traces and alerts. Its doc ended "JSON-RPC answers, and the error a host's own connect raises, pass as they are". Errors a host's call got, and through a host an agent, could carry them:boundedForLog). The framework's records then redacted that cut text, and so did the text an agent's tool call gets back. Onmain6345d27d, a stdio server whose error words quote its own credential-named env value:…xxxKq7vR2mXp9LwT4s…, and so does the agent's result fromexecuteToolCall;Kq7vR2mXp9?LwT4sB8nYd.initializewith, and a legacy tools/list's cursor in its errors.stderrTail, the end of the server's stderr, went on the error as written. It was also cut to its last 2,048 characters before anything redacted it, so a credential at the cut kept a fragment the records' redaction couldn't match.path, credential-like segment and query included. A wrapper can keep the raw error as itscause.McplUnreapedLaunchErrorprinted its connection, configuration and credentials included, wherever the error was inspected.mcpl:orphaned-responseormcpl:coalescing, bounded only. Onmain, a late tools/call error quoting the credential is recorded whole,[-32000] Kq7vR2mXp9LwT4sB8nYd and more words.Changes
serverTextRedactor(src/mcpl/protocol-family.ts) returns aServerTextRedactor. Itsnow()gives one build for one error, and a redaction can start at a place in the text (from), replacing a credential that reaches across that place whole.redactedData: an error answer's data with every string in it redacted, keys included, through arrays and plain objects to a depth of 64. Data nested deeper becomesTOO_DEEP_TEXT. A number whose digits are a credential goes as its redacted text, and keys that redact alike keep every entry, the later ones numbered. With a configuration that can't be read, the data as a whole is withheld.maskedError: a library's error as its name and its redacted words, with nothing else carried over.src/mcpl/server-connection.ts) redacts the server's words with its configuration, then bounds them: in the initialize error, a refusal of the protocol revision and the revisions it lists, the revision a server answeredinitializewith, an error answer, and a tools/list cursor in its errors. An error answer's words are trimmed after they're redacted. It redacts the strings in an error answer's data and in a revision refusal's, and each such error takes one build of the redactor. A failed handshake'sstderrTailis the stream's last 2,048 characters, redacted as the transport's window of its last 16,384 has them (stderrTailRedactedinsrc/mcpl/transport.ts), and the rawstderrTailgetter reads the same window. A stdio child that can't be started, whether Node refuses the launch at once or reports it on a tick after, is refused with its words redacted and its cause masked, itscodekept; for a launch Node refuses at once, the words stop before Node's quote of the refused value. An error answer's code is redacted in its log line unless it's a number (loggedCode).McplUnreapedLaunchError.connectionisn't enumerable.src/mcpl/modern-connection.ts) does the same in its error answers, its refusal of the protocol revision and the revisions it lists, and an HTTP refusal's data, which keeps the host's ownhttpStatus. A failed first connect is thrown asmaskedErrorof what the open threw. The engine's own protocol refusal, already built from redacted words, is thrown as it is.src/framework.ts) redacts a late answer's record before bounding it.redactServerText's doc, README's redaction paragraph andPublishOutcome.detail's doc say the new scope. 0.23.0's changelog says a caller's text keeps an error answer's message unredacted, and this change's fragment says what changed from that. A JSON-RPC answer's result passes as it is. No error a host's own call gets carries a credential the redactor replaces. A close's or a cleanup's failure carries the transports' own words, and a connection's events are the records' inputs.src/mcpl/protocol-family.tsandsrc/mcpl/transport.ts, so the two conflict in those files.What each part is for
This change keeps the credentials the framework replaces in its records out of the errors a host's call gets too, and out of the records whole, whoever wrote the words.
fromreplaces a credential that reaches across that place whole. So any credential up to 14,336 characters long that reaches into the tail is matched, newlines and all, a private key's PEM included. A redaction that shortens the window can't move the tail's edge back to where the window's own cut split a credential. The window costs 16,384 characters of memory for each stdio connection, it's scanned only when a failed handshake's error is built, and it's the transport's one buffer, which the raw getter reads too.redactedDatacovers the strings a server puts in an error answer's data. A host hands the data on: the channel registry gives a failed publish's data to the resident to judge, so its entries are kept, each under a name of its own. Dropping the data instead would lose what it says about a partial post. A number whose digits hold a credential goes as its redacted text, since a server can send a numeric key as a JSON number. Its depth limit keeps the walk off the stack's end: Node 24.22.0 parses JSON nested 10,000 deep, a recursive walk of it overflows, and in the legacy engine the walk runs after the call's timer is cleared, where a throw would leave the call unsettled. Data nested past 64 levels is withheld; legitimate error data doesn't nest that deep, and the limit leaves the stack room its callers' frames need.maskedErroris for every error a modern connect's open throws except the engine's own protocol refusal, since any of them can be the SDK's or the transport's. Their other properties, such as Bun's fetchpathor a wrapper'scause, can carry the url, and nothing reads them. The framework reads onlyMcplProtocolVersionErrorandMcplUnreapedLaunchErrorfrom a failed connect, and both are built here.redactedDatacalls it for every string in an error's data.now()) reads the configuration once for all of an error's texts. A framework-launched stdio child'senvis a getter that reads the Discord awareness ledger, so while the ledger can't be read, each error reads it once rather than once per string, as fix(mcp): a request JSON can't write reads not-sent, an HTTP failure is reported once, and an unreaped launch is held first #566's test pins for the legacy error answer.redactedisn't renamed and no entry is lost or moved. Each name's next number to try is kept, so many keys that redact alike are numbered in one pass.httpStatusis this host's own number, so it's added after the server's data is redacted, and a configuration that can't be read doesn't withhold it.spawnargs, which can repeat a credential the configuration holds, so the words are redacted and the cause is masked. Node quotes an argument or an env value it refuses at once, cut to 128 characters, so a credential across that cut couldn't be matched: the words stop before the quote, still saying which value Node refused and why. It keeps Node'scode, which says what failed:ENOENTfor a missing command, orERR_INVALID_ARG_VALUEfor a value Node refuses at once. The child's environment is read before that, so a configuration whoseenvcan't be read still throws the framework's own error, which the framework acts on: its startup ends on it, and a reconnect reports it.loggedCodeprints an error answer's code: a number as it is, as JSON-RPC says a code is, so the operator keeps it even when the configuration can't be read, and any other JSON value a server sends there redacted, since the server wrote it.connectionkeeps an inspectedMcplUnreapedLaunchErrorfrom printing the configuration. The framework still reads it, to retry the reap.Not taken: redacting a failed cleanup's words in the framework's runtime connect. Those words are the stdio transport's own: a child's pid, its server's id and the wait it was given. By our reading of SDK 2.3.1's
dist, its HTTP transport doesn't throw on close, so that redaction would guard words no server or library writes.What a host notices
redactedwhere they quoted a credential the records replace. A configured url's other query pairs,name=value, are replaced whole there too, as the records already replace them. Words with neither read as before.supportedandreturnedkeep them whole, redacted.redacted (2)), and a key the redaction leaves alone keeps its name. A number whose digits hold a credential, as JavaScript reads the number, becomes a string, its digits with the credential replaced; a number with more significant digits than a double holds has lost its last ones before that. Data nested past 64 levels reads[withheld: nested too deep to redact]. An HTTP refusal'shttpStatusstays this host's number.stderrTailis the same end of the server's stderr as before, with every credential of up to 14,336 characters that reaches into it replaced whole, and its…still says its start was cut. A child that wrote nothing has none. Theconnect-failedandreconnect-failedevents carry that tail, so they carry it redacted too.Errorwith the SDK's or the transport's name and redacted words. It carries nocauseand none of the original's other properties. AnMcplProtocolVersionErroris thrown as before.causethat keeps Node's error's name, its redacted words and itscode. When Node refuses an argument or an env value at once, the words say which value and why and leave the value out. Itsspawnargs,path,syscallanderrnoare gone. A modern stdio server's failed launch is its failed first connect, thrown asmaskedError, as above.McplUnreapedLaunchError.connectionis still there to read, and is left out ofObject.keys, and ofutil.inspectunlessshowHiddenis set.[withheld: the server's configuration couldn't be read to redact this]), as the records already withhold them, and an error answer's data is that text in place of the data. An HTTP refusal's data keeps only itshttpStatus. A framework-launched stdio child's configuration can't be read while the Discord awareness ledger can't be.isError) is a result, which passes as it is.Tests
The full check, red-first and the removals ran on Linux at this PR's first commit,
f85bee1f, the change onmaine01a2337. The second commit,0ec09356, changes one test oftest/mcp-recorded-text.test.ts, two comments, README and the changelog fragment. At it, that file,test/mcpl-transport-spawn.test.tsandtest/mcpl-stderr-tail.test.tspass on Node 24.22.0, with 44, 4 and 21 tests, and the typecheck passes. The third commit,9dc00cfb, numbers keys that redact alike in one pass and adds a test of it. At it,test/mcp-recorded-text.test.ts,test/mcpl-agent-words.test.tsandtest/mcp-modern-http.test.tspass on Node 24.22.0, with 45, 20 and 12 tests, the new test taking 65 ms in all, and the typecheck passes. With the numbering searching each name's numbers from 2 again, the numbering takes 11,152 ms and the new test fails.npm testruns them:main's CI ate01a2337, Ubuntu and Node 24: 5,245 tests, 5,217 pass, 28 skipped. The first commit adds 31 tests, 30 intest/mcp-recorded-text.test.tsand 1 intest/mcp-modern-http.test.ts, and the third adds 1 more intest/mcp-recorded-text.test.ts. CI's build-and-test leg has no Bun, so it skips one test that this run, with Bun installed, ran.main'ssrc/mcpl/server-connection.ts,modern-connection.ts,transport.tsandsrc/framework.ts. It keeps this PR's tests, and itsprotocol-family.tsandlog-value.ts, which hold the new helpers the tests import.main's transport also gets the window's size constant, since a test sizes a stream by it.mcp-recorded-text, 3 each inmcp-modern-httpandmcpl-agent-words, and 1 each inmcp-modern-outcomes,mcpl-transport-closeandmcpl-stderr-tail.main's code doesn't call, and the pieces they pin fail them when removed. One is the record of an initialize error quoting a credential that ends in a space, whichmain's records already leave out. The last is a launch that wrote nothing to stderr, which onmainhas no tail to withhold.main6345d27d.Not verified
TypeErrorcarrying the url in its ownpath, its credential-like path segment and query included, and no url in its message. The test of a modern connect's failure throws such aTypeErroritself,pathandcodeincluded; it doesn't fetch under Bun.src/mcpl/transport.ts, where the stdio reap failure's words are built, and the SDK's HTTP transport'sclose().Out of scope
WebSocketTransportbuilds the error, by fix(mcpl): mask the url and credentials in a WebSocket client's error #563.error,connect-failed,reconnect-failedandorphaned-response, carry what the connection saw, as inputs to the framework's records, which redact them.fetch_referenceresult and a log line, not as an error a host's call gets. A failed fetch's words, Node's or Bun's, name the server's own host or address and the reason. The fetcher checks a reference's uri before it dials: it refuses one that doesn't parse, carries a user name or password, uses a scheme other than http: or https:, or points to another origin than the server's (originCheckinsrc/mcpl/reference-fetcher.ts). A digest mismatch quotes the first 20 characters of the digest the server claimed,sha256:and 13 base64url characters: the server's own content, as a result is.changelog.d/host-errors-carry-no-credentials.fixed.mdWritten by Rupert-2634 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) decided. Gladys-2413 (Claude Opus 5.5) found the cut before redaction and the late answer's record in a review of #561. Five runs of our internal review read it, the last on the code as it is here apart from the third commit's four lines and the second's comments, and Kestrel-2409 and Hilda-2580 (Claude Opus 5.5) reviewed it; their points are in it. Each found, by probe, that a tail cut from the redacted window could reach back to the window's own cut, and Kestrel-2409 found that Node cuts its quote of a refused argument at 128 characters. Kestrel-2409 approved this change as it is here. Hilda-2580 approved it before its last three rounds, a fix to the spawn failure, one of tests and text, and the key numbering's single pass, all of which Kestrel-2409 read.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.