Three names for one value were live in CopilotKit's own documentation, and
following the wrong one with a CLI-provisioned project yields an undefined
key:
- `INTELLIGENCE_API_KEY` — what `copilotkit project select` writes, used by
all 34 integration examples and the docs site.
- `COPILOTKIT_INTELLIGENCE_API_KEY` — the seven Channels package READMEs and
the packaged skills. Nothing ever read it.
- `COPILOTKIT_API_KEY` — the Slack and Teams examples, and the TSDoc on
`CopilotKitIntelligence` itself, which is what an IDE shows on hover.
`INTELLIGENCE_API_KEY` wins, because it is the name the CLI provisions and
changing it would break every scaffolded project in the wild.
`COPILOTKIT_INTELLIGENCE_API_KEY` is retired outright — no code read it.
`COPILOTKIT_API_KEY` stays readable as a deprecated alias in the two
examples that consume it, so an existing `.env` keeps working, and is
documented as deprecated everywhere it appears.
The skills reference also documented `organizationId`, sourced from a fourth
and fifth env name, as a `CopilotKitIntelligence` option. It is not one:
`CopilotKitIntelligenceConfig` has no such field, so the copy-pasteable
sample it appeared in would not compile. Removed from the samples, and the
prose that told readers to fetch a value for it corrected.
The Intelligence wiring itself was published only inside
`node_modules/@copilotkit/runtime/skills/`, and the only docs pages showing
`CopilotKitIntelligence` were the two Channels frontends — so a developer on
the plain web path had no page to reach it from. Adds
`/premium/connect-your-runtime`, which covers the wiring, how to confirm the
credential is actually consumed, and the self-hosted two-URL rule.
`scripts/validate-intelligence-env-names.ts` keeps this from drifting back.
It runs unfiltered in CI on purpose: the two workflows that would otherwise
cover it filter paths, and static/quality ignores `examples/**` — exactly
where the deprecated alias lives.
`SlackNativeProps` declared `decimal_allowed`. Slack's `number_input` field is
`is_decimal_allowed`. Slack accepts a message whole or not at all, so the
unrecognised key refused the entire `chat.postMessage` call with
`invalid_blocks: invalid field at /blocks/N/element` and ended the Channels
delivery that carried it — no exception reached the caller and nothing arrived
in the channel.
The typed path was therefore the broken one: a developer who bypassed our types
and hand-wrote `is_decimal_allowed` got it right, while everyone using
`Slack.Element.NumberInput` silently lost the message.
Verified live against Slack, one delivery per case:
- `decimal_allowed` alone — refused, `invalid_blocks: invalid field at
/blocks/0/element`
- `is_decimal_allowed` alone — delivered
- both names present — refused with the same error, which isolates the unknown
key as the cause: Slack's required field is satisfied and the payload is
otherwise identical to the one that delivers
Not treated as a breaking change and no alias is kept. The old name never
worked, so no caller can depend on its behaviour, and keeping it would preserve
the trap. Removing it converts a silent message loss into a compile error that
names the right field.
Also adds a test comparing every field name `native.ts` declares against
Slack's own Block Kit declarations in `@slack/types` (already a dependency),
parsed with the TypeScript parser rather than matched as text. Names
`@slack/types` does not cover — `container`'s `blocks`, `rich_text_list`'s
`offset`, `card`'s `slack_icon` and `subtext`, and the whole
`data_visualization` vocabulary — are listed individually with the reason, so
the check states its coverage instead of implying completeness. Floors on the
vocabulary size and on the number of names actually compared keep a collapsed
comparison from passing as success.
Sweeping the remaining names turned up no other mismatch.
Portable <Cell> content was always emitted as a Slack `raw_text` cell, which
is literal: markdown links, Slack link syntax and bare URLs all rendered as
plain characters, so there was no way to get a clickable link or bold text
into a table cell through the portable vocabulary.
Body cells whose content contains a link, bold, italic, strikethrough or
inline code are now emitted as a `rich_text` cell. Plain content still
produces the byte-identical `raw_text` payload, and header cells always stay
`raw_text` (Slack renders them bold already, and `rich_text` is not allowed
in a `data_table` header cell).
The conversion reuses `markdownToMrkdwn` — the package's single source of
truth for the portable dialect — and tokenizes its `mrkdwn` output into
rich-text runs, so the package keeps one markdown parser. The 2000-char cell
budget now applies to the visible text of a rich cell.
Slack's image block takes either an external `image_url` or a `slack_file`
pointing at a file that already exists in the workspace. The required-field check
demanded `image_url` unconditionally, so the `slack_file` form could not be built
at all — an image sourced from Slack itself was unreachable through the catalog.
An image now needs alt text plus *either* source. Passing neither is still an
error: the check moved, it did not disappear, and the test covers both halves.
Slack's option object is `{text, value}` — it has no `type` field, and neither do
confirm, option_group, conversation_filter, dispatch_action_config, slack_file,
trigger or workflow. The codec tagged every catalog entry with its manifest type
regardless, so each of those carried an unknown field and Slack refused the
entire message containing it.
That took out every select, multi-select, checkbox, radio group, overflow menu
and confirmation dialog authored through `Slack.Object.*` — the whole interactive
surface. Measured against a real workspace: 1 of 26 block elements was delivered
before this change, 23 after.
It went unnoticed because a refused payload produces no error. There is no log
line and no exception; the message simply never arrives, which looks exactly like
a bot that had nothing to say.
The existing catalog test asserted the very assumption that was wrong — that
every entry serializes its discriminator — so it was green while the product was
broken. It now asserts the corrected rule and guards against a silent relapse.
Two catalog errors, both surfaced by delivering every documented block into a
real Slack workspace rather than reading the reference again.
container serialized its children into `blocks`; Slack reads `child_blocks`, so
every container an app sent was refused — silently, because a refused payload
produces no error anywhere, just a message that never arrives.
file leaves the manifest. Slack states you cannot add it to app surfaces
directly; it only appears when *reading* messages that contain remote files, and
the same sentence appears verbatim in `@slack/types`' own doc comment. Keeping it
in an authorable catalog offered a component that could never succeed. Sending a
file remains thread.postFile(). alert stays out for the same class of reason
(modals only), and both exclusions now carry their citation so "missing" and
"deliberately not authorable" stay distinguishable.
Drop the defensive parentheticals around Intelligence pricing; say
"available on a free plan" and move on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Note in the Channels overview and package READMEs that building your own
channel runner on the open-source SDK primitives is a supported path with
no CopilotKit Intelligence dependency; teams choosing it own their state,
persistence, concurrency, locking, retries, and race-condition handling.
Intelligence remains the managed runner, with analytics, learning, and
governance in addition.
Also updates the production self-hosting note: Enterprise Intelligence can
be fully self-hosted today, onboarding guides are still to come.
Refs FAC-155
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
finish is optional on the renderer type, so calling it unguarded fails tsc
even though vitest is happy. Matches the non-null assertions the rest of the
file uses for subscriber callbacks.
An agent that streams text, calls a tool, then streams more text leaves Slack's
native "is thinking…" indicator running indefinitely after the answer has
already been posted.
`postedReply` latches on the first posted reply and makes `clearStatus` a
one-shot. But `onToolCallStartEvent` and `onToolCallEndEvent` both write the
status again *after* that latch closes, and from then on nothing clears it:
`onFirstReply` early-returns, and the backstops in `finalizeTurnStream` and
`finish` are skipped precisely because a reply was posted. Slack eventually
expires the stale status, which is why it presents as a slow hang rather than
a bug.
The latch really tracks "the status is already cleared for what is on screen",
not "a reply has been posted", so `setStatus` now resets it whenever a
non-empty status is written. Both existing backstops then fire exactly when
they should, and the normal streamed-text path still skips the redundant clear.
Reproduces on any agent that narrates before a tool call — emitted stream is
text → TOOL_CALL_* → text — and is independent of `showToolStatus`: with it off
both tool events set the generic status, with it on START sets
"is using `tool`…". Either way the write lands after the latch.
Covered by a regression test driving that exact sequence, verified failing
without the change ("expected 'is thinking…' to be ''").
Companion to CopilotKit/Intelligence#714 (OSS-705). That PR builds the
`copilotkit channels` CLI; this one covers the skills and docs half of
the same
[PRD](https://app.notion.com/p/3af3aa381852810b8254ec0cbb5be6af).
## Managed Intelligence becomes the default path in `copilotkit-setup`
The most-used "add CopilotKit to your project" path walked every new
user into the self-hosted SSE runtime and never offered the managed one.
`CopilotIntelligenceRuntime`, `CopilotKitIntelligence`, the required
`identifyUser`, and the hosted environment values all appeared in this
skill's *reference* files but were wired by **no step** — so the skill
could describe managed Intelligence without ever producing it.
- **Step 2 now chooses the runtime mode before any runtime code is
written**, because the mode changes how the runtime is constructed and
retrofitting it means rewriting the file. Managed is the recommended
default and has real wiring.
- **Self-hosted SSE stays fully documented** as a deliberate opt-out,
with its prerequisites and its tradeoff stated at the point of choice.
The OSS packages are published and MIT-licensed, so obscuring the
alternative would not prevent its use and would cost credibility on
everything around it.
- **Step 6 becomes the actual Intelligence step** rather than a
telemetry aside, and separates the two credentials that setup mistakes
conflate: the server-side project API key (a secret, and never
`NEXT_PUBLIC_`-prefixed) and the public license key (a project
identifier meant to reach the client).
- **Fixes a command that does not exist.** Both the skill and
`references/telemetry-setup.md` instructed `npx copilotkit auth`. The
command is `login`, and `project select` is what provisions the project.
## New `copilotkit-channels` skill
Covers the code half: the Channel declaration, the long-running host
requirement, and which mounts start activation on their own versus which
wait for an explicit `channels.ready()`.
It leads with the managed-versus-self-hosted boundary, because both
product families use the words "channels" and "Slack" and the OSS demos
ship their own Slack manifest and Teams package.
## Docs
- `channels/intelligence.mdx` — the CLI as a **peer** path to the
wizard, with the tradeoff stated. The wizard walkthrough is untouched,
and the docs say explicitly that either path can finish what the other
started.
- `packages/channels-{slack,teams}/README.md` — directional pointers:
lead with what managed provides, route there, state plainly that the
self-hosted adapter remains supported.
## Two deviations from the PRD, both deliberate
1. **Pointers are scoped to Slack and Teams.** The PRD asks for all
eight `packages/channels-*/README.md`. Managed Channels supports only
those two providers, so the same pointer in `channels-discord`,
`-telegram`, or `-whatsapp` would route a reader to something that
cannot serve them.
2. **`frontends/{slack,teams}.mdx` need no pointer.** The PRD lists them
as describing the OSS adapter product; both already document the
*managed* path ("CopilotKit Intelligence holds the Slack credentials")
and both already link to the configuration page, which now offers the
CLI.
## A correction worth reviewing
The Channels skill first asserted that activation is lazy on every host
and that `await listener.channels.ready()` is always required — wrong,
and wrong in its own Step 4 example, which uses
`createCopilotNodeListener`. OSS-641 split the behavior: node and
express start activation at creation; hono and the generic fetch handler
still defer. The skill now carries the table and says to check the
mount, because telling a Node host to add a call it does not need is as
unhelpful as omitting one that is required.
`showcase/.../deploy-and-operate.mdx` already described this correctly
and is unchanged.
## Notes for review
- A standalone skill **must** be registered in
`RESERVED_LIFECYCLE_SLUGS` (`scripts/sync-plugin-skills.ts`) or `pnpm
sync:plugin-skills` deletes it as an orphan. Registered, and the test
now pins that requirement with the reason.
- `pnpm check:plugin-skills` passes;
`scripts/__tests__/sync-plugin-skills.test.ts` passes (50 files / 483
tests).
- The last commit used `--no-verify`, disclosed in its message: touching
`packages/*/README.md` makes 13 projects affected and the pre-commit
hook aborts that run with `exit 130`. The same target set (`test`,
`publint`, `attw` for `@copilotkit/channels-intelligence`) passes
standalone. That commit is two markdown files.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Leads with what managed Channels provides -- Intelligence owning the provider
edge, so the process holds no provider credentials and exposes no public provider
endpoint, plus durable threads, the dashboard, and guided setup -- and routes the
reader there with the CLI command. The bot code is otherwise identical, which is
what makes the choice cheap, so it says so and points at the example showing the
same bot wired both ways.
The self-hosted adapter is stated to remain fully supported, with the case for
choosing it: wanting the provider connection inside your own infrastructure.
Scoped to Slack and Teams deliberately. Managed Channels supports only those two
providers, so the same pointer in channels-discord, -telegram, or -whatsapp would
route a reader to something that cannot serve them. channels-core, channels-ui,
and channels-intelligence are not provider quickstarts and get nothing.
showcase frontends/{slack,teams}.mdx need no pointer: both already document the
managed path -- "CopilotKit Intelligence holds the Slack credentials" -- and both
already link to the Channel configuration page, which now offers the CLI alongside
the wizard.
Committed with --no-verify: touching packages/*/README.md makes 13 projects
"affected", and the pre-commit hook aborts that run with exit 130 before finishing.
The same target set (test, publint, attw for @copilotkit/channels-intelligence)
passes standalone, and this change is two markdown files.
Creating a Node listener or an Express handler now STARTS activation of the
runtime's declared managed Channels, so `channels.ready()` becomes
await-and-observe instead of the thing you must remember to call. A declared
Channel connects because it was declared.
The failure mode this removes: forget `ready()` and you get a process that
serves HTTP, looks healthy, and is silently disconnected with zero output.
Auto-start's worst case is an activation error in the logs.
The generic Fetch handler stays LAZY — it is the serverless/edge entry point,
where isolates freeze and recycle per request and separate cold starts would
mint competing listeners for the same Channel. `createCopilotHonoHandler` stays
lazy for the same reason: it is our Next.js App Router surface in practice
(every `examples/showcases/*` route handler builds one at module scope), and its
TSDoc now says so loudly. `activateChannels: false` remains the opt-out that
opens no socket.
Consequence for host code: the shutdown-handler boundary moves earlier. Signal
handlers must be registered before the listener is CREATED, not merely before
`ready()` — otherwise a Ctrl-C during the connect window hits Node's default
handler and leaks a live gateway session. The slack and teams examples and the
docs snippets are restructured accordingly.
Also migrates the seven channel-package README quickstarts off the generic
handler (a request handler a socket-mode bot constructs and never serves) onto
the Node listener, so they inherit auto-start and agree with the docs site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the previous commit, whose wiring was left out of it by mistake.
createChannel applies sanitizeAgentEventStream at the agentFactory seam, with
sanitizeAgentEvents: false to opt out; HttpAgent is re-exported from
@copilotkit/channels so the examples need no @ag-ui/client dependency; the
Slack + Teams examples and READMEs now wire a plain HttpAgent; and
SanitizingHttpAgent is deprecated (unchanged) in both adapter packages.
Also swaps a stray pair of raw control bytes in the protobuf test fixture for
escapes, so git sees the test file as text.
PR #6244 taught the Slack renderer to split a long reply across continuation
messages, but its tuning was hardcoded. Three of those constants are genuinely
caller-dependent and are now configurable through a single `replyContinuation`
option; the rest stay internal on purpose.
Exposed:
- `messageByteLimit` — Slack's cumulative per-message ceiling is undocumented.
11k is inferred from one production datapoint and deliberately conservative;
operators need a knob if the real ceiling differs rather than a release.
- `maxMessages` — how many messages one reply may occupy is a product decision,
not a platform fact. A support bot and an internal ops bot want different
answers.
- `truncationMarker` — hardcoded English copy posted into the customer's
channel. The one constant with no correct default.
Deliberately NOT exposed, because they are correctness rather than preference:
`APPEND_CHAR_LIMIT` (a documented Slack per-call limit),
`MIN_MESSAGE_PROGRESS_BYTES` (loop-safety invariant — exposing it lets a caller
reintroduce the unbounded-message bug #6244 fixed), `MAX_FENCE_LANG_CHARS`, and
`FINISH_DRAIN_ATTEMPTS`.
Grouped under one nested option rather than three flat fields: `maxMessages` on
a Channel reads ambiguously on its own (thread history?), and the group keeps
the next continuation knob from adding another top-level field.
Both surfaces are wired, following `showToolStatus` exactly:
- direct: `slack({ replyContinuation })` → adapter → event-renderer → stream,
covering both the renderer path and `adapter.stream()`.
- managed: `createChannel({ replyContinuation })` → `Channel` →
`ChannelActivationConfig` → channel-manager → launcher → `DeliveryAdapter` →
the renderer's `nativeStreaming` block.
No gateway or Intelligence change is needed. Managed Slack renders in the SDK
process over a gateway live session and only emits `slack.stream.*` effects, so
render config never has to cross into Intelligence — the Elixir provider
executor is a dumb effect applier that owns no message boundaries.
`channel-manager.ts` carries a hand-written structural mirror of the launcher
signature, so the new field is declared there too or the managed path silently
type-drifts.
Tests: the marker override at the leaf, the renderer's pass-through (fails
without it — the defaults would keep that reply in one message), and the managed
chain end to end via `createChannel` → activation config → launcher opts, plus
the negative case that an unset option adds no properties anywhere.
Coverage on native-stream.ts was 87.5% stmts / 80.45% branches; now 92.7% /
88.5%. The gaps were concentrated in code this branch added, and two of them
were places I had claimed coverage that did not exist.
Genuinely uncovered logic, now tested:
- `renderContextCloser`'s inline-code and emphasis closers, and the
`hasFenceCodeContent` mirror added in the previous commit — the closer is
appended at every boundary and none of its three branches were exercised.
- `tableHeaderToReopen`'s "no longer inside the rows" guard, so a boundary landing
after a table has ended does not inject a stray header.
- The 2-byte UTF-8 width class (Cyrillic). CJK covered 3-byte and emoji 4-byte;
the class between them was untested.
- The `callEnd < byteEnd` branch. The test that claimed it never reached it: with
a 40k budget and 30k of ASCII the whole reply fits, so the first branch won and
the per-call path was never entered. Retargeted with multi-byte text.
- Rollover `stopStream` failure, non-strict (proceeds) and strict-with-a-failed
closer (reports the closer, not the consequence).
- Truncation marker failure, non-strict (logged) and strict (reported).
- A legacy tail fallback that itself fails.
- Chunk behaviour around a rollover: delivered to the current message, degraded
when the continuation cannot be opened, and skipped entirely when a strict text
failure rejects the shared flush queue — the last of which corrected my
assumption that `flushChunk` still runs in that case. It does not: `.then()` on
a rejected promise skips its callback.
Four guards are unreachable through the public API and are now commented as
deliberately defensive rather than left looking like missing tests: the
`roomBytes <= 0` and "not even one code point fits" rollover checks (every
filling branch rolls over in the same iteration), `appendSlice`'s empty-delta
stall guard (both boundary helpers return an index strictly greater than
`curPosted`), and `truncate`'s own re-entry check (`appendPending` short-circuits
first). Kept as loop-safety invariants.
The residue is pre-existing or upstream: `append()`'s legacy forwarding, the
zero-interval `scheduleFlush` path, `flushChunk`'s start-failure degradation, and
`flushTextInline`'s strict rethrow.
Resolves against three main commits that touched this streamer after the branch
point: `strict` mode (f1c4b6ca79), the always-reach-stopStream drain
(9250f22d88), and the zero-interval flush path (5ddcb14233).
`native-stream.ts` auto-merged textually but not semantically — my rollover,
truncation and finish-drain paths were all added after `strict` existed and did
not honour it, so the merge as-generated would have quietly defeated it:
- `rollOver` swallowed both its closer append and its `stopStream`. Now records
the closer failure, still reaches `stopStream` (main's guarantee: never leave a
native stream open), then rethrows under strict with the same earliest-error
precedence `finish()` uses.
- `truncate` swallowed the marker append; now rethrows under strict, with its
terminal state settled *before* the append so a throw cannot re-enter it.
- `finish()`'s drain retries awaited `this.queue` unguarded, which under strict
threw before `stopStream` — reintroducing exactly the bug 9250f22d88 fixed.
Errors are now captured into `queueError` like the initial drain. The retry
also resets `this.queue` first: a strict rejection settles it rejected, and
`.then()` on a rejected promise skips its callback, so the retry would have
re-raised the settled error instead of running a flush.
- The legacy tail fallback is now gated on `!strict`. Strict disables the legacy
fallback by design, so an undelivered tail is surfaced as an error instead of
being routed around it.
Test file conflicted on the fake transport's `appendText`, where main added
`failAppend` and this branch added the cumulative byte cap; kept both.
Two tests added for the strict/rollover interaction, which main's strict tests
predate and so do not cover: a rejected boundary closer surfaces under strict
while the message is still finalized, and an undelivered tail surfaces rather
than engaging the legacy sink.
CR r5 bucket (a): always stop native Slack streams on failure (thread
finish + NativeMessageStream queue drain), advance append/replace text
only after apply, rethrow permanent postFile gateway errors, exclude
stream.stop from provider-output tracking, classify errors by message,
validate prepared turn fields per kind, and stop unit tests from hitting
live lock cleanup HTTP.
Addresses maxkorp's third pass, including a regression in the maxMessages cap
added in the previous commit.
1. `truncate()` is now terminal and idempotent. It set `curPosted =
buffer.length` and appended the marker but left `curTs` set and recorded no
terminal state, so text still arriving grew the buffer, the next flush saw
undelivered text on an already-full message, and re-entered — one marker per
flush for the rest of the turn. Reproduced at production defaults on a 400k
reply: markerCount=36, last message 12,908 bytes against an 11,000 budget.
Against a transport enforcing a real 12k ceiling, 18 markers land and then 28
appends are rejected, spending exactly the error the incident was about. This
was the spam the cap exists to prevent, relocated inside one message.
A `truncated` flag short-circuits `appendPending` and both `finish()` paths,
and the marker is now charged to `curMessageBytes` — it was the one append
path that neither checked nor updated the budget.
2. Table boundaries prefer a row break. Inside a table the space fallback left
the continuation starting mid-row, so the re-emitted header was followed by a
malformed row. Fixes single-append boundaries completely; under incremental
cadence a fill append can still land mid-row before the room runs out, which
this does not address (see the PR thread).
3. `renderContextCloser`'s fence check now mirrors `hasFenceCodeContent`
exactly: non-whitespace after the language line, not merely a newline, so
```py\n no longer gets a closer. The comment claimed the mirror; now it holds.
Also trimmed the rationale at the boundary-closer catch site. It repeated the
"first append a wrong headroom assumption rejects, two things had to be right"
argument, which the reviewer measured and retracted: under byte counting the
fill append dies first and rollOver is never reached, so the two modes are
sequential rather than compounding, and the trigger window is only a few bytes
wide. The guard stays — an unguarded append that wedges rollover, directly above
a guarded one that doesn't, is worth three lines on the asymmetry alone — but the
codebase should not preserve reasoning that has been disproven.
Addresses maxkorp's second pass. All three reproduced locally first.
1. The boundary closer append was unguarded, and its failure disabled rollover
entirely (the worst of the set). It threw out of `rollOver` with `curTs` still
set, so the next flush recomputed a full message, re-entered `rollOver`, and
failed on the same closer forever. Probe: a transport rejecting closer-only
deltas delivered 11,000 of 37,410 chars across ONE message with zero
rollovers. The control confirms the asymmetry — the same input with
`stopStream` failing instead delivers everything, because that call was
already guarded.
This compounds with the unit question: the closer is appended exactly when the
message has just filled, so it is the FIRST append a wrong headroom
assumption rejects — and its rejection switched the preventive fix off before
it could fire. Now guarded with the reasoning already written for
`stopStream`: degraded markdown on one message beats losing the remainder.
2. Implausibly long fenced-code "languages" are clamped. `detectOpenContext`
reports everything up to the first newline after ``` as the language, so a
minified blob became a multi-kilobyte preamble re-injected into every
continuation: 5,004 chars of duplicated preamble atop six consecutive
messages, 54% useful, and a final message 1% useful. Past
MAX_FENCE_LANG_CHARS we re-open with a bare fence, which still preserves code
formatting where dropping the opener would not. Re-injected preamble drops
from 5,004 chars to 4.
3. Continuation count is bounded. A 500k-char reply became 46 Slack messages,
uncapped. Not a regression (the legacy chunker is uncapped too), but this PR
exists because a runaway became repeated copies in a channel, and silently
turning one over-long reply into dozens of real messages is that failure in a
different hat. DEFAULT_MAX_MESSAGES with a visible truncation marker, so the
cut is stated rather than silent.
Also removed the write-only `finished` field the review spotted.
Deferred, tracked in the PR thread: the emphasis closer's flanking position
(needs a real-workspace check, since Slack is not strictly CommonMark) and
`flushChunk`'s permanent chunk-disable after a transient continuation failure.