Commit Graph

14262 Commits

Author SHA1 Message Date
Tyler Slaton 88fc3da497 fix(next): guard root bodies against extension hydration races 2026-08-05 11:25:13 -07:00
Maxim 87a33ee6b8 fix(runtime): bound the in-memory agent runner to prevent OOM (#5837)
## Problem

The OSS/SSE runtime path — `CopilotRuntime`/`CopilotSseRuntime` built
**without** an Intelligence backend — uses `InMemoryAgentRunner`, which
kept all thread history in a process-global `Map` that nothing ever
bounded. A production Node.js deployment (`--max-old-space-size=4096`)
hit a fatal V8 OOM after ~173 threads / ~15.5k runs. Three unbounded
growth vectors: thread count, runs per thread, and per-run
`ReplaySubject(Infinity)` buffers.

## Fix

Make the in-memory runner **safe by default**, zero-config and
dependency-free.

- **`ɵBoundedThreadStore`** owns the `Map`, LRU ordering, approximate
byte accounting, and eviction. `InMemoryAgentRunner` keeps all streaming
logic and delegates storage.
- **Three bounds, whichever trips first:** `maxThreads` (**1000**, LRU),
`maxRunsPerThread` (**100**, FIFO; `Infinity`/`0` disables), `maxBytes`
(**512 MiB**, the primary guard).
- **Never evicts** a running thread, one still finalizing after
`stop()`, or the thread currently being created/appended — it accepts
temporary overage instead.
- **Thread-level `messagesSnapshot` and `createdAt`**, decoupled from
`historicRuns`, so run-cap eviction can neither drop the message history
nor drift a thread's reported creation time.
- **Subject-buffer release:** `ReplaySubject(Infinity)` is retained only
while a run is active, released on completion once events are in
`historicRuns`.
- **Both eviction paths log**, through one warn-once-per-store latch:
whole-thread eviction *and* per-thread run-cap trimming.
- **Limits validation:** `ɵnormalizeLimits` rejects negative / `NaN` /
non-integer / `-Infinity` values at construction and at `setLimits`,
clamping to defaults with a warning. Previously `maxRunsPerThread: -1`
crashed with a `TypeError` inside a fire-and-forget path.
- **Partial limit updates preserve siblings** — tuning one bound no
longer silently resets the others process-wide.

## API

Additive and non-breaking. Bounds ride on the existing options bag
introduced by `onConcurrentRun`:

```ts
export interface InMemoryAgentRunnerOptions extends InMemoryLimits {
  onConcurrentRun?: "throw" | "supersede";
}

new InMemoryAgentRunner();                                  // unchanged, safe defaults
new InMemoryAgentRunner({ onConcurrentRun: "supersede" });  // unchanged
new InMemoryAgentRunner({ maxThreads: 200, maxBytes: 128 * 1024 ** 2 });
```

Note the differing scopes, documented on both surfaces: the limits
reconfigure the **process-global** store; `onConcurrentRun` is
**per-runner**. Passing only `onConcurrentRun` leaves limits untouched.

## Interaction with `onConcurrentRun: "supersede"`

Rebased onto `main` after supersede landed, which surfaced a bug neither
change had alone. A superseded run's teardown was not isolated from the
run that replaced it: the new run cleared the shared `stopRequested`, so
the superseded run finalized as a synthetic `RUN_ERROR`, and the
`prevSubject` bridge forwarded that terminal event — plus a replayed
`RUN_STARTED` — into the **live** run's subject. `connect()` consumers
replayed a duplicate `RUN_STARTED` followed by a dangling `RUN_ERROR`.

Fixed by capturing per-run finalize intent (`RunFinalizeControl`),
widening the concurrency gate to `isRunning || stopRequested`, removing
the leaking bridge, and identity-guarding the subject release. Both
teardown paths were extracted into one shared `finalizeRun` helper so
they cannot diverge again.

## Docs

Neither constructor option was documented before this PR.

- `backend/agent-runner.mdx` — the bounds and their defaults, the
**eviction model stated once** (whole-thread eviction removes the thread
from `GET /threads`; run-cap trimming keeps it, preserving `createdAt`
and the message snapshot; the warning is once-per-store, reset by
`clearThreads()`), the `onConcurrentRun` modes, and durability options
including the first-party `SqliteAgentRunner`.
- `troubleshooting/common-issues.mdx` — growing runtime memory and the
eviction warning, cross-linked both ways.
- Runtime skill references refreshed — they still described a removed
`globalThis`-keyed store with HMR backup — and their source citations
re-anchored on stable symbols rather than line numbers.

## Tests

**1939 passing, 0 skipped** across 133 files (`@copilotkit/runtime`);
`check-types`, `build`, `publint`, and `attw` all clean.

- `ɵBoundedThreadStore` units: thread-LRU, run-cap FIFO + opt-out,
byte-ceiling eviction, never-evict-running, never-evict-finalizing (both
count and byte paths), snapshot dedup, `createdAt` stability past the
run cap, warn-once, `clear()` reset.
- Limits validation: every field × every invalid shape, plus proof the
`0`/`Infinity` sentinels still disable the run cap.
- Supersede/stop: teardown isolation with **negative** assertions (no
phantom `RUN_ERROR`, exactly one `RUN_STARTED` for a `connect()`
subscriber on a superseded thread), plus `stop()`'s reachable rollback
branches.
- **OOM guard:** drives a sustained workload across the thread-count,
runs-per-thread, and byte bounds and asserts eviction through the
runner's public surface. It replaces an earlier heap-plateau test that
was both `--expose-gc`-gated (so it never ran in normal CI) and
non-discriminating (its workload crossed no bound, so it passed with
bounding removed). The replacement was verified to fail when
`enforceRunCap` is neutered.

## Caveats & follow-ups

1. **Non-durable by design.** Eviction is history loss, same as a
restart. The durable paths are `SqliteAgentRunner` (single instance) or
the Intelligence runner.
2. **Store stays process-global.** Pre-existing cross-endpoint bleed and
process-wide `clearThreads()` are unchanged; instance-scoping is a
follow-up needing maintainer sign-off.
3. **Multiple runners with differing limits → last-constructed wins**,
with a one-time warning. Partial updates now preserve unspecified
bounds.
4. **Byte accounting is approximate** (serialized-length estimate), not
exact heap bytes, and under-counts non-serializable payloads.
5. **Behavioral change to a default path.** History that previously
persisted until OOM now evicts past the limits — shipped as a `fix`
(crash → bounded), observable at scale. Run-cap trimming now also logs
once per store.
6. **`maxBytes` is a cross-thread ceiling.** It evicts other LRU threads
and never trims the just-appended thread, so a single hot thread is
bounded by `maxRunsPerThread` only — which is why the docs steer away
from disabling that cap.
7. **Dedup weakens past the run cap.** `historicMessageIds` is rebuilt
from `historicRuns`, so a thread beyond `maxRunsPerThread` can
re-present already-seen messages. Documented with its escape hatch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-05 19:07:35 +02:00
Maxim ab71913932 Merge branch 'main' into fix/in-memory-runner-bounding 2026-08-05 18:43:27 +02:00
Maxim 77d99f6bd5 docs(reskinnable-demo): teach the reskin skill what the demo must prove (#6400)
## What does this PR do?

Reworks the repo-local `reskin` skill so it teaches **what a skin's demo
has to prove**, not just how to wire the `Skin` contract — based on
David's walkthrough of the banking demo (the "beats" and the why behind
each one), plus the layout facts from #6394.

Docs only. No code changes.

## Why

The skill documented the contract thoroughly and the demo not at all. I
tested that gap before writing anything: gave a fresh agent the **old**
skill and asked it to plan a new BI skin.

It returned a technically sound plan — correct contract usage,
server-emitted a2ui, deps arrays, OGUI sandbox, dark mode — that missed
most of the beats, and substituted its own thesis (RBAC governance) for
the demo's.

| Beat | Old skill | Reworked skill |
| --- | --- | --- |
| 1. Lead with generative UI | ✅ | ✅ |
| 2. Rich thread survives reload | ❌ absent | ✅ explicit step; renders
keyed off `result` |
| 3a. Drive the app, secret withheld | ⚠️ reframed as RBAC | ✅
confidential figure never enters the transcript |
| 3b. "What's on my screen?" | ❌ **zero readables planned** | ✅ route +
per-page readables, asked on two pages |
| 3c. Navigate via real levers | ⚠️ plain `navigateTo` | ✅ HITL confirm
→ query params → highlighted controls |
| 3d. Multimodal → durable artifact | ⚠️ no durability | ✅ artifact
outlives thread deletion (demonstrated) |
| 4. Long-term memory recall | ⚠️ one line | ✅ seeded preference +
recall-first + names what it recalled |
| 5. Stored-procedure replay | ❌ absent | ✅ seeded procedure, 3 visible
writes, distractors |
| 6. Teach a new procedure | ❌ absent | ✅ symptom-only gate, decoys,
record → save → replay |
| Presenter Reset | ❌ | ✅ |
| Pills covering the flow | 6, partial | 8, one per beat |

Same prompt, same conditions, only the skill changed. The telling detail
is 3b: `useAgentContext` was already in the skill's contract table, so
the agent *knew* the field existed — it just had no reason to prioritise
it. Contract docs say what is available; only choreography says what is
required.

## What changed

**`.claude/skills/reskin/demo-beats.md`** (new) — nine beats, each
framed by *what the audience must conclude*, then what the skin needs,
then banking's implementation with file:line. Plus seeding rules, the
presentation requirements (a pill per beat so the presenter never types,
a visible affordance on every mutation, pretty markdown prose, Reset,
the chat-placement framing), the vertical shortlist with BI called out
as highest-stakes, the quality bar, and a "which skin to copy for what"
routing table.

The beats are a **strong default that an explicit instruction
overrides**, expressed as a beat map with one row per beat that records
deliberate skips. Omission failures respond to a slot you have to fill
in, not to exhortation.

**`SKILL.md`** — beat table and override rule up front; step 0 is the
beat map, before code. Two new silent-failure rules found by tracing
banking:
- Renders must key off the tool `result`, not `status`, or they go blank
on thread replay — exactly when beat 2 is being demonstrated. Only
banking does this today.
- Beat 3b needs a route readable plus per-page on-screen readables. All
four skins register readables; only banking registers those, which is
why the beat is impossible in the other three.

Verification now ends with a 10-point demo walkthrough, since a green
build proves the wiring and nothing about the demo.

**`templates.md`** — pills-per-beat template; `seed-memories.ts` against
the real `POST /api/memories` shape (long-term memory is a seeded file,
not emergent — including why beat 6's procedure must never be seeded,
and why a seeded procedure must run with no confirmation gate, which
previously left an unresolved tool call that failed the next message); a
prompt-clause table mapping clauses to beats; replay-safe render; route
and per-page readables.

**`CLAUDE.md`** — claimed four skins in the intro and then documented
two. Now covers all four across both substrates with a beat-coverage
matrix, and corrects three false claims:
- `identifyUser` was attributed to banking alone; logistics and keel
contribute one too.
- An unsupported claim that a `Panel`'s `id` becomes its emitted
`data-testid` — the cited header comment says no such thing and the
installed package has no such behaviour. Replaced with what it actually
documents.
- "two implementations" in Reference.

Also notes keel's parameterized routes (the only skin with them) and the
shell-mounted inspector that replaced the glass engine.

## Notes for reviewers

- Verified against source rather than assumed: the entire `Skin`
contract field table in both docs was already accurate and is unchanged;
so were the Commands section, all panel-size/breakpoint/radius numbers,
the `.d.cts` filename the skill cites, and `defaultSkinId`. There were
no glass-engine references left to remove.
- Two follow-ups surfaced but deliberately not included here:
`banking/components/wow/proactive-notice.tsx` is an `animate-ping` bell
toast that is mounted nowhere, and logistics/keel already ship the full
per-user identity plumbing with no memory prompts, tools or seed file —
the cheapest beat-4 upgrades available if we want existing skins pulled
up to the bar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-05 18:32:03 +02:00
Maxim b93b0951bc docs(reskinnable-demo): teach the reskin skill what the demo must prove
The skill documented how to wire the `Skin` contract and nothing about what
the resulting demo has to demonstrate. Tested by asking a fresh agent to plan
a new BI skin with only the old skill: it produced a technically sound plan
that missed most of the banking demo's beats — no rich-thread step, zero
`useAgentContext` readables, no stored-procedure or teach-mode arc — and
substituted its own thesis (RBAC governance) for the demo's. Contract
documentation says what is available; only choreography says what is required.

Adds demo-beats.md: the nine beats framed by what the audience must conclude,
each with banking's implementation cited, plus the presentation requirements
(a pill per beat so the presenter never types, a visible affordance on every
mutation, pretty prose, Reset, the chat-placement framing), the domain
shortlist and the quality bar. The beats are a strong default an explicit
instruction can override, expressed as a beat map with a row per beat that
records deliberate skips — omission failures respond to a slot you must fill,
not to exhortation. Re-running the same planning task with the reworked skill
covers all nine.

Also folds in what tracing banking turned up, all of it silent-failure class:
renders must key off the tool `result` and not `status` or they go blank on
thread replay, exactly when "reload and the chart is still there" is being
shown; "what's on my screen?" needs a route readable plus per-page on-screen
readables, which is why that beat is impossible in the three other skins; and
long-term memory is a seeded file, not emergent, so seed-memories.ts gets a
template — including why beat 6's procedure must never be seeded and why a
seeded procedure must run without a confirmation gate.

Corrects CLAUDE.md, which claimed four skins and then documented two. Now
covers all four across both substrates with a beat-coverage matrix, fixes the
`identifyUser` attribution (logistics and keel contribute one too), drops an
unsupported claim that a Panel's `id` becomes its `data-testid`, notes keel's
parameterized routes, and acknowledges the shell-mounted inspector that
replaced the glass engine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 18:16:44 +02:00
Ran Shemtov b30527ca8a fix(showcase-mastra): render interactive open-gen-ui on a live endpoint (#6389)
## Problem (PNI-119)

`open-gen-ui-advanced` (and its minimal sibling) did **not** render
working generative UI on a **live** endpoint, though both passed under
aimock.

Both cells reused the shared `weatherAgent` (`instructions: "You are a
helpful assistant."`, gpt-4o). On a live LLM that emitted **static HTML
with no JS wiring** — the calculator's buttons rendered but did nothing
(staging repro: `7 + 8 =` left the display on `0`; captured SSE
`generateSandboxedUi` args contained only
`initialHeight/placeholderMessages/css/html`, no
`<script>`/`jsFunctions`/`jsExpressions`).

aimock hid the divergence: its fixtures match on
`userMessage`/`hasToolResult` and replay a fully-wired UI regardless of
the agent.

## Root cause

Two factors, one dominant:

1. **No dedicated agent.** Gold `langgraph-python` uses purpose-built
`open_gen_ui_agent.py` / `open_gen_ui_advanced_agent.py` whose system
prompt mandates a single interactive `generateSandboxedUi` call (iframe
restrictions, wire host functions via `Websandbox.connection.remote.*`,
visible result element). Mastra reused a generic agent.
2. **Context is a read-channel on Mastra.** The provider delivers the
design-skill + sandbox-function descriptors via `copilotkit.addContext`
→ `RunAgentInput.context`. The `@ag-ui/mastra` bridge maps that to
`requestContext.get("ag-ui").context` (a tool read-channel) and does
**not** inject it into the LLM prompt (unlike langgraph's
`CopilotKitMiddleware`). So even the tool description alone was not
enough for gpt-4o.

## Fix (surgical, showcase-only)

- `src/mastra/agents/index.ts` — add dedicated `openGenUiAgent` /
`openGenUiAdvancedAgent` porting gold's system prompts, with **dynamic
instructions** that read `requestContext.get("ag-ui").context` and fold
the design-skill + sandbox descriptors into the prompt (surfacing the
read-channel the Mastra-idiomatic way, matching what gold's agent sees).
- `src/mastra/index.ts` — register both.
- `src/app/api/copilotkit-ogui/route.ts` — point `getLocalAgent` at the
new agents.

No published-package or bridge change.

## Verification (live)

Local `next dev` against **real OpenAI** (aimock bypassed). All three
advanced pills render interactive, host-wired UI; host round-trips
confirmed in console:

- **Calculator** → `evaluateExpression 7*8 = 56`
- **Ping the host** → `notifyHost: Ping!` (+ "Host notified
successfully." in-iframe)
- **Inline expression evaluator** → `evaluateExpression 5 + 3 * 2 = 11`

**aimock unaffected** — matcher keys
(`userMessage`/`hasToolResult`/`context`) don't touch the prompt or
agent, so D6 replay still passes. Changed files typecheck clean.

## Follow-up (out of scope)

Live surfaced a benign `@copilotkit/runtime` OGUI-middleware warning: an
empty/null `jsFunctions` yields a JSON-patch `add /jsFunctions` with no
`value` (`OPERATION_VALUE_REQUIRED`, patch dropped). Tracked separately
(needs a runtime release).
2026-08-05 16:00:28 +02:00
Guido Vizoso 9c3c0c0148 feat(reskinnable-demo): improve general layout and UI (#6394)
## What does this PR do?

Reworks the reskinnable demo's layout from four flush, edge-to-edge
columns into a
shadcn-inset-style frame: the assistant column and the skin's app each
float as a
rounded card, separated by a resizable gutter.

**Layout**
- Two panels with simple bounds — assistant `min 250px / default 600px /
max 50%`,
app takes the remainder. Capping the assistant as a *share* means the
app needs no
  floor of its own and the mobile breakpoint stays a genuine 768px.
- Drag to resize, swap which side the assistant docks on, or hide it
entirely. Side
and open state persist, shell-global, so switching skins never
rearranges the
  workspace.
- Card radius is a fixed 12px in px, deliberately *not* `--radius` — the
frame is
shell chrome and must read identically in every skin. Card colours stay
themed, so
  a reskin still restyles it.

**Chat**
- `CopilotSidebar` → inline `CopilotChat`, which is what lets the
cluster live in a
  panel. That deletes a whole geometry contract (`--nw-chat-width`,
`--nw-rail-offset`, `data-nw-chat-open`) plus ~110 lines of CSS that
existed only
  to keep a floating selector off a fixed panel.
- The thread rail is a fixed-width element, not a nested panel — it sits
beside the
conversation and hides via a container query when the card is dragged
narrow.

**Skin selector**
- Now a dropdown, so its footprint stays flat as skins are added (the
pill row took
three rows at four skins). Shell controls — switcher, swap sides, hide —
are
  grouped in the card; the chat header keeps only conversation actions.

**Type**
- Base scale set to 15px at the root, so it reaches all four skins with
no per-skin
  edits.

## Notes for reviewers

- `react-resizable-panels` is pinned to **4.x**, whose API is renamed
from the 2.x/3.x
used by six sibling examples
(`Group`/`orientation`/`Separator`/`useDefaultLayout`).
  Only 4.x accepts pixel size constraints. See the header comment in
  `src/components/ui/resizable.tsx` before touching it.
- The chat CSS re-key landed as a deliberate bracket — duplicate onto
`.nw-chat`,
make the structural change, delete the originals — because its failure
mode is
silent: a lint-clean, test-clean build with subtly wrong chat
typography. A guard
  test asserts first parity, then absence.
- Adds `e2e/inset-layout.spec.ts` (9 tests). Weighted toward what
actually broke
during development — every bug here was a sizing behaviour invisible to
jsdom.
- The reskin skill taught two now-retired patterns (publishing
`--nw-nav-inset-*`,
rooting layouts at `h-screen`); both are corrected, so authoring a new
skin no
  longer produces dead code and a broken root height.

**Known limitation:** the sub-768px overlay branch is not verified.
Mobile was
already broken before this work and is explicitly out of scope; the e2e
suite runs at
1280px.

## Checklist

- [x] I have read the Contribution Guide
- [x] If the PR changes or adds functionality, I have updated the
relevant documentation
- [x] "Allow edits by maintainers" is checked
2026-08-05 10:55:12 -03:00
Guido Vizoso 1c53a8c60b Merge branch 'main' into feat/reskinnable-demo-ui 2026-08-05 09:34:49 -03:00
Guido Vizoso 7aab1bead2 docs(reskinnable-demo): document the inset frame and retire the old chrome
The reskin skill was the urgent part: it is the sanctioned path for adding a skin,
and it still taught two patterns the frame retired. It told authors to publish
--nw-nav-inset-left/right from a useEffect so the floating selector could dodge their
nav -- both the variables and that selector are gone -- and to root the layout at
h-screen overflow-hidden, which overflows the app card by the frame's padding.
Following it produced a skin with dead code and a broken root height. The nav-inset
step is removed with a short "Retired" note so it is not reintroduced by copying an
older skin, and the root guidance now explains that the CARD is the bound.

Adds a "The inset frame" section to CLAUDE.md covering what a reader needs before
touching src/shell/layout: the two-panel model and its bounds, why the thread rail is
a fixed element rather than a nested panel, the v4 API rename and the
id-becomes-data-testid behaviour, where the shell controls live, the fixed 12px card
radius, and the h-full requirement for skin layouts.

Also corrects the composition chain, the shell's ownership list, and the
four-skins-not-two count across CLAUDE.md, README.md and DESIGN.md, plus the skill's
verification step, which told authors to look for their skin in a bottom-left
floating pill.
2026-08-05 09:29:46 -03:00
Guido Vizoso c70db12a43 test(reskinnable-demo): cover the inset layout end to end
Nine new tests over what only a real browser can see: panel order per docked side,
resize bounds in both directions, persistence across a reload and across a skin
switch, the rail collapse/reopen cycle, the hide/launcher cycle, that the selector
renders options only while open, that every skin's chrome fits its card without
scrolling the document, and that the chat header shows the active skin's assistant
name.

Weighted toward what actually broke. Every bug this layout shipped was a sizing or
resize behaviour invisible to jsdom, which has no layout engine: a rail collapsed to
zero width, a rail that could not be reopened, an assistant column that could be
widened but never narrowed, and a header showing the framework default. All four
passed a green unit suite. Each assertion was mutation-checked rather than assumed --
widening the assistant cap to 95% fails only the resize test, hardcoding the header
title fails only the header test.

Navigation goes through a gotoSkin helper that waits for the frame; the shell is
client-rendered, so reading geometry straight after goto returns an empty panel set.

Updates smoke.spec.ts for the dropdown: switching opens the menu first, and the
"no other skin's chrome leaked" guards get STRICTER -- from exactly-one selector pill
to zero occurrences, since a closed menu renders no other brand.

Retires the CopilotSidebar launcher from the fixme'd specs. a2ui-canvas clicked
copilot-chat-toggle, which no longer exists -- inert only because the spec never
runs, so it would have failed the moment anyone re-enabled it. memory-learning keeps
its guarded open-chat block deliberately: the count check makes it a no-op today, and
that spec needs the docker memory stack so it cannot be run here to verify a
behavioural edit.
2026-08-05 09:29:33 -03:00
Guido Vizoso 0c8d8db275 feat(reskinnable-demo): mount the frame, free the skins, retire the floating selector
Composes ShellFrame in the per-skin layout, passing the skin's Layout as its app
slot and the shared ChatPanel as its chat slot, replacing the sibling
Layout + ChatPanel + FloatingSelector trio.

Each skin's chrome drops its viewport height for h-full: it now fills the app card,
which the frame has already inset by its own padding, so a viewport-height root
overflowed the card by exactly that much. Logistics keeps overflow-hidden -- its nav
stays pinned only while the container is bounded, which is now the card. Banking
keeps its second, unrelated canvas-clearing effect and therefore its useEffect
import.

The four --nw-nav-inset-* publishing effects go with the floating selector they fed.
Each skin published the width of its own edge-nav so a floating pill could compute a
safe band and dodge it; the selector now occupies a slot in the assistant column and
overlaps nothing, so the whole mechanism is unnecessary and its component is deleted.

This is one commit because the halves are not independently correct: without the skin
edits the cards overflow, and deleting the selector before its import is removed
would not build.
2026-08-05 09:29:18 -03:00
Guido Vizoso 58efc192e4 feat(reskinnable-demo): render the chat inline inside a resizable card
Replaces CopilotSidebar with an inline CopilotChat so the cluster can live in a
panel. CopilotSidebar was a fixed <aside> that pushed document.body's margin and
faked two columns by being handed the width of both, insetting its own contents past
the rail while the rail painted into the freed strip as a separately-fixed sibling.

That deletes an entire geometry contract: --nw-chat-width, --nw-rail-offset and
data-nw-chat-open existed only to describe a fixed panel's footprint to the rest of
the page. Also gone: the force-open-on-mount ref dance, the header slot cast
(CopilotChat has no header slot -- that is a modal concern, so the header is now an
ordinary sibling), and the rail's own fixed positioning and translate animation.

The thread rail is a FIXED-WIDTH element, not a panel. v4's collapse API fought that
three ways: a collapsed 0 written to storage was restored forever, expand() restores
the "most recent size" which after collapsing to 0 is 0, and resize() is ignored
while collapsed. Conditionally rendering it makes isInboxOpen the single source of
truth with no imperative sync.

The header reads the SKIN for its title, not the chat configuration. As
CopilotSidebar's header slot it rendered inside the chat's own provider so our labels
reached it; as a sibling it reads the wrapper's, whose default "CopilotKit Chat" is
non-null and won the ?? chain -- every skin's header showed that instead of its
assistant name. The header is skin chrome now, and holds only conversation actions.
2026-08-05 09:29:05 -03:00
Guido Vizoso 06f973823e feat(reskinnable-demo): add the inset shell frame
src/shell/layout/ owns the app's outer geometry: a padded region holding the
assistant column (selector card above chat card) and the skin's app card, separated
by a resizable gutter.

The model is deliberately just "one bounded panel, one that takes the remainder" --
assistant min 250px / default 600px / max 50%, app gets what is left. An earlier
version nested the thread rail as a resizable panel INSIDE the assistant column,
which made its floor a compound of rail + conversation and forced a derived
breakpoint, a switching collapsed floor and an app floor to compensate. Capping the
assistant as a SHARE rather than a pixel count is what removes the need for an app
floor and lets the mobile breakpoint stay a genuine 768px instead of being derived
from panel arithmetic.

- selector-card: the skin switcher as a dropdown, so its footprint stays flat as
  skins are added, plus the shell controls -- swap sides and hide. All three are
  shell concerns, which is why they are here and not in the chat header.
  useSkinThemeReconcile stays on the card root: it reads the computed
  --nw-dark-capable from inside the skin's theme root, and losing it would let a
  light-only skin render dark chat chrome.
- layout-preferences: side and open state, shell-global so switching skins never
  rearranges the workspace. Read through useSyncExternalStore because this repo
  treats react-hooks/set-state-in-effect as an error, with write-through
  persistence so only a deliberate choice is stored. The hook returns inert
  defaults outside its provider rather than throwing.
- use-is-desktop: matchMedia through the same external-store pattern.
- panel-sizes: the three numbers, in one place.
2026-08-05 09:28:49 -03:00
Guido Vizoso a36ab28019 refactor(reskinnable-demo): re-scope the chat CSS and set the base type scale
Three changes to the shell stylesheet.

Re-scopes chat typography from the SDK's [data-copilot-sidebar] attribute to a
.nw-chat wrapper. That attribute came from CopilotSidebar, which the inset frame
replaces with an inline chat, so ~23 rule blocks styling assistant markdown would
have silently stopped matching -- a lint-clean, test-clean build with subtly wrong
chat text. The migration ran as a bracket: duplicate onto .nw-chat, land the
structural change, then delete the originals, with a guard test asserting first the
parity and then the absence. Typography is provably unchanged -- a probe mounting
markdown-shaped content with the library's prose class reported 140 identical
computed values across 14 selectors before and after.

Adds the frame's own rules: .nw-panel-card (a FIXED 12px radius, deliberately not
reading --radius, because the frame is shell chrome and must read identically in
every skin) and the chat cluster's fixed-width rail with the container query that
hides it when the card is dragged narrow.

Sets the root font size to 15px. The root, not body: every rem in the shell and all
four skins resolves against it, so this is the one lever that rescales type
everywhere without per-skin edits. Spacing scales with it by design; anything that
must not move is written in px. The chat prose moves 0.9375rem -> 1rem, since that
value existed to hit 15px under a 16px root and would otherwise sit below the new
default.

Deletes the ~120-line floating-selector dock and the unused .brand-text-gradient.
2026-08-05 09:28:35 -03:00
Guido Vizoso 6c7e7b7df4 feat(reskinnable-demo): add the react-resizable-panels v4 wrapper
Pins ^4.12.2 and wraps its Group/Panel/Separator API behind two styled handles: an
8px gutter for the gap between cards and a 1px hairline. 4.x rather than the 2.x/3.x
used by six sibling examples because only 4.x accepts PIXEL size constraints, and
the layout's bounds are pixels.

Three v4 behaviours the consumers depend on, each with a test:
- The API is RENAMED from 2.x/3.x: PanelGroup->Group, direction->orientation,
  PanelResizeHandle->Separator, autoSaveId->useDefaultLayout. Most material online,
  including shadcn's Resizable block, targets the old names and will not compile.
- data-testid is DERIVED from a Panel's id and overwrites any passed in, so a
  panel's id is its query handle. Getting this wrong makes every
  [data-testid$='-panel'] query silently match nothing.
- Separator refuses flex-grow/flex-shrink overrides, so shrink-0 would be dead.

Adds a jsdom ResizeObserver stub, without which every test rendering a Group throws.

Note for anyone adding a dependency here: declare it in package.json and run
'pnpm install --lockfile-only' from the REPO ROOT. This example pins pnpm 10.10.0
while the root pins 10.33.4, so 'pnpm add' from this directory makes corepack switch
versions and the older pnpm re-resolves the entire workspace lockfile.
2026-08-05 09:28:05 -03:00
Ran Shem Tov c2cb1c9e60 fix(showcase-mastra): render interactive open-gen-ui on a live endpoint
Both open-gen-ui cells reused the shared weatherAgent
("You are a helpful assistant.", gpt-4o). On a live LLM that produced
static HTML with no JS wiring - buttons rendered but were dead. aimock
hid it: fixtures match on userMessage/hasToolResult and replay a
fully-wired UI regardless of the agent.

Gold langgraph-python uses dedicated agents whose system prompt mandates
a single interactive generateSandboxedUi call and reads the design-skill
+ sandbox-function descriptors from copilotkit context. On Mastra,
RunAgentInput.context is a read-channel (requestContext.get("ag-ui")),
not injected into the prompt, so that guidance never reached the model.

Add dedicated openGenUiAgent / openGenUiAdvancedAgent porting gold's
prompts, with dynamic instructions that fold the ag-ui context into the
system prompt, and point the ogui route at them.

Verified live (real OpenAI, aimock bypassed) - all three advanced pills
render interactive, host-wired UI with confirmed round-trips:
evaluateExpression 7*8=56, notifyHost, evaluateExpression 5+3*2=11.
aimock matcher keys are untouched, so replay is unaffected.
2026-08-05 13:29:30 +03:00
Maxim 2f24038c0d chore(skills): sync in-memory runner reference skills and mirror
Updates the runtime agent-runner skill references to match the bounded
in-memory runner: correct the InMemoryAgentRunner store as a process-global
singleton, document its bounds and onConcurrentRun concurrency handling, note
that dedup weakens past the run cap, fix rotted in-memory.ts citations onto
stable symbols, and correct the multi-instance SqliteAgentRunner scaling
guidance. The generated skills/ mirror is regenerated in lockstep so source and
mirror stay in sync.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:06:24 +02:00
Maxim c91376452a docs(runtime): document in-memory runner bounds, concurrency, and durability
Documents the now-bounded in-memory runner for users: the maxThreads /
maxRunsPerThread / maxBytes limits and their defaults, the precise eviction
model (LRU threads, per-thread run-cap, cross-thread byte ceiling enforced at
run completion), and the onConcurrentRun throw/supersede option. Clarifies that
the store is a process-global singleton shared by every runner, that dedup
weakens past the run cap, and points at the first-party SqliteAgentRunner for
durable or multi-instance deployments. Adds a troubleshooting entry for the
in-memory eviction warning.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:06:15 +02:00
Maxim 8f56cc9457 test(runtime): cover bounded store, eviction, and run teardown isolation
Adds a dedicated bounded-thread-store suite and extends the in-memory runner
suite to lock in the behaviors introduced with the bounded, teardown-isolated
runner:

- LRU thread eviction, per-thread run-cap FIFO trimming, and byte-ceiling
  eviction (including that a live/running or stop-requested thread is never
  evicted, and that a just-appended thread pushes OTHER threads out rather than
  self-evicting).
- InMemoryLimits validation/normalization: invalid bounds clamp to defaults
  instead of crashing enforceRunCap, and the 0/Infinity disable sentinels are
  preserved.
- Thread-level createdAt and message-snapshot decoupling survive run-cap
  eviction and interleaved empty-snapshot runs.
- stop() guards and the supersede path: an aborted run finalizes as a clean
  RUN_FINISHED against its own captured intent, a superseded run cannot clobber
  its replacement's state or history, and an immediate abort-throw that emitted
  nothing creates no phantom historic run.

Also restores the shared store's default limits after tests that reconfigure
the process-global store so suites stay isolated, and updates a handle-run
comment for the GLOBAL_STORE -> shared store rename.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:06:07 +02:00
Maxim e93119ae20 fix(runtime): isolate a superseded or stopped in-memory run's teardown
A run's async teardown used to read the shared, mutable store.stopRequested to
decide whether to finalize as a clean RUN_FINISHED or a synthetic RUN_ERROR.
Under supersede (or a stop() immediately followed by a new run()), a later run
resets that shared flag, so the earlier run's fire-and-forget finalization could
be mislabelled — an intentionally stopped run finalized as an error, or vice
versa — and could push history under, or clobber the state of, the newer run
that now owns the thread.

Fix by capturing a per-run RunFinalizeControl when the run starts. stop() and a
superseding run() flip THAT run's captured control (not just the store flag),
and the run's teardown reads its own captured intent, so a later run resetting
store state can never change how an earlier run finalizes.

The teardown itself is unified into a single finalizeRun helper shared by the
success and error paths (they were near-identical and must stay symmetric) and
made ownership-aware:

- It only pushes history / resets shared store state when this run still owns
  the thread (store.currentRunId still equals this run's id), so a superseded
  run cannot corrupt the successor's history or state.
- The error path additionally requires at least one real (pre-finalize) event,
  reviving a guard that had gone dead: an immediate throw that emitted nothing
  must not create a phantom historic run holding only the synthetic terminal.
- On completion it releases the run's infinite ReplaySubject buffer via an
  identity guard (store.subject === nextSubject), reclaiming the duplicate
  buffer on the owning path while leaving a live successor's subject untouched.

The concurrency branch now also triggers on store.stopRequested, not just
isRunning: stop() flips isRunning off the instant it aborts but the run keeps
finalizing, and a run() slipping through that window went entirely unhandled.
The previous-subject bridge is removed: forwarding a dying superseded run's
subject would replay its RUN_STARTED and push its terminal into the live run's
stream, an invalid AG-UI sequence — a superseded run must stay isolated to its
own subscribers.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:05:35 +02:00
Maxim 05fbd70938 fix(runtime): delegate InMemoryAgentRunner storage to the bounded store
Route every storage access in the runner through the shared ɵBoundedThreadStore
instead of the old unbounded GLOBAL_STORE Map: run() acquires threads via
getOrCreate (which applies LRU eviction), and connect/isRunning/stop/
listThreads/getThreadMessages/getThreadEvents/clearThreads read through the
store's touch-aware accessors so reads keep LRU order honest.

The constructor now accepts InMemoryLimits inline alongside onConcurrentRun.
Note the scope difference, called out in the JSDoc: onConcurrentRun is
per-runner, but the limits reconfigure the PROCESS-GLOBAL store shared by every
runner. A partial limits update coalesces each unspecified field against the
store's current effective bounds (not the hardcoded defaults), so tuning one
bound never silently resets its siblings; a genuine clobber of an
already-customized store warns once.

getThreadMessages now returns the thread-level snapshot (a shallow array-level
copy) rather than the last run's snapshot, so run-cap eviction and interleaved
empty-snapshot runs can never lose it. getThreadState is hardened to reject
arrays (which pass `typeof === "object"`) and to return a defensive shallow
copy so callers cannot mutate stored snapshot state.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:05:14 +02:00
Maxim 4bd4fff6a3 fix(runtime): add bounded in-memory thread store with limits validation
The in-memory runner previously kept every thread and run forever in an
unbounded process-global Map, so a long-lived process leaked memory without
limit. Introduce ɵBoundedThreadStore as the single backing store, enforcing
three independent bounds resolved from InMemoryLimits (defaults in
ɵINMEMORY_DEFAULTS):

- maxThreads: LRU eviction of whole threads.
- maxRunsPerThread: FIFO run-cap per thread.
- maxBytes: approximate cross-thread byte ceiling (via ɵestimateBytes),
  enforced at run completion by evicting other LRU non-running threads.

Limit values are validated and normalized once (ɵnormalizeLimits /
ɵisValidLimit): only a non-negative integer or +Infinity is well-formed.
Invalid values (negatives, -Infinity, NaN, fractional caps) would otherwise
turn the `count > limit` enforcement guards into infinite loops or a shift()
of undefined; they are instead clamped to the documented default with a single
warning. Clamp-and-warn rather than throw matches this file's best-effort
posture (ɵestimateBytes swallows serialization failures), because constructing
a non-durable convenience runner must never abort — or later surface an
unhandled rejection — on a typo'd bound.

Thread creation time and the latest non-empty message snapshot are held at the
THREAD level (InMemoryEventStore.createdAt / messagesSnapshot), decoupled from
historicRuns so run-cap FIFO eviction can neither drift the reported creation
time forward nor drop the message history. Eviction — whole-thread LRU and
per-thread run-cap trimming alike — is logged once per store (warn-once latch)
so bounded history loss is visible rather than silent.

Also defines the per-run RunFinalizeControl shape and the store's
activeFinalize holder that the run-teardown isolation builds on.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 03:04:55 +02:00
Maxim 25817605cc fix(reskinnable-demo): put real data behind the banking report, and fix the six defects it was hiding (#6378)
Fixes the six defects a 12-agent review found in the banking skin, by
fixing the thing that caused most of them: the skin had **three
disagreeing answers to "what did we spend"**.

| Source | Total | Drove |
|---|---|---|
| `charges-data.ts` fixture (45 rows, Apr–Jun) | $632,806 | the Charges
page |
| `seed.json` ledger (4 rows, **Apr–May only**) | $30,089 | the report's
**charts** |
| static `policies[].spent` | $137,000 | the report's **KPI** |

Every one of those numbers appeared in the product, and they were never
reconciled.

## The headline defect

`SpendingTrendChart` substituted a hard-coded `[3200, 4100, 3600, 5200,
4800, 6400]` / Jan–Jun series whenever fewer than three distinct months
were present. Intended as an empty state — but the ledger spanned
exactly **two** months, so the fallback was the **default path**. The
report's "Spend over time" showed six invented figures, roughly 20×
smaller than the total printed directly above them, under a card whose
own docstring reads:

> Every number is computed here from the live ledger … so a report can
never quote a figure the app disagrees with.

Reproduced in the running app before fixing (`POST
/api/banking/v1/reports`, no agent needed), and there's a nasty
interaction worth knowing: attaching an invoice dates a synthetic
transaction *today*, supplying a third month, so **attaching an invoice
masked the bug**. A test written casually would sit in the masked state
and pass.

## The fix: one ledger

The 45 charges now live in `seed.json` as real transactions across
**Apr/May/Jun**, and the Charges page reads them over REST like every
other surface.

**Team and policy are now different axes.** A charge belongs to one of
seven org teams; a policy is one of three budget envelopes (Technology /
Go-to-Market / G&A) and several teams share one. They were a single
`ExpenseRole` enum — which is exactly why covering every team meant
choosing between a seven-slice donut and discarding real charges.
`ExpenseRole` still types a member's own team; `PolicyType` types the
envelopes, joined by `policyForTeam`.

**`policies[].spent` is derived from approved charges on every read.**
It could no longer disagree with the charts — and it also now *moves*:
approving a charge previously left `spent` untouched, so the budget
never reflected the approval and the over-limit gate kept comparing
against a stale figure. Verified live: approving a \$960 charge moved
`spent` by exactly \$960.

**Over-limit is derived-only.** A charge no longer *stores*
`over-limit`; the Charges badge resolves through `withOverLimit`, the
same rule the report uses.

## The six review findings

| | Defect | Fix |
|---|---|---|
| a1 | report charted fabricated spend | charts real months; explicit
empty state at zero |
| a2 | donut its own docs said would collapse | real \$533k base — the
\$900k invoice that hit **89%** now reaches **73%**; 89% would need
~\$2.8M |
| a3 | `report` matched inside "quarterly report" | anchored both sides,
whole words |
| a4 | `as Transaction` laundered a nullable `policyId` | cast removed;
compiler checks it |
| a5 | comment claimed additions "have no policyId" | corrected — three
lines from the code that sets it |
| a6 | `?sort=banana` lit the "active" tint | params validated; unknown
reads as unset |

All six existed **identically in `examples/showcases/banking/`** — they
came from the upstream PRs this skin replayed, not from the port. Scoped
to the skin per review; banking still carries them.

## The scripted demo is unchanged by construction

- the four demo-load-bearing transactions survive byte-identical
- over-limit is still **exactly 3 charges / \$30,000**
- AWS \$15,000 still derives over-limit for the teach-mode pill
- Delta Airlines is still the only Delta charge (the fixture's
near-duplicate "Delta Air Lines" became United Airlines)
- all four status badges still appear (`Amazon Business` is kept
pending, under its policy's headroom, so a plain **Pending** chip
survives)

The donut goes 44/40/16 → **48/27/24** and is relabelled "Spend by
policy", which is what it reads.

## Verification

```
nx build react-core,a2ui-renderer,core,runtime,shared   exit 0
tsc --noEmit                                            0 errors
vitest                                                  239 passed (was 215)
eslint                                                  0 problems
```

Plus live checks against a running server: 49 transactions over 3
months, derived `spent` tracking an approval, over-limit holding at
3/\$30,000.

The 24 new tests are confirmed **red** against the old code —
`parseSort("banana")` returned `"banana"`, `parseTop("-5")` returned
`-5`, the seed spanned two months, no row carried a team, and `spent`
was stored.

## Not in this PR

A "tool replay-guard sweep" (4 items, `navigateToPageAndPerform` and
three approval tools missing the resolved-state guard `showCharges` has)
and ~13 subject-neutral items are captured as follow-ups.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-05 02:07:14 +02:00
Maxim 8829633e29 Merge branch 'main' into fix/banking-skin-real-data 2026-08-05 02:00:42 +02:00
Maxim addbe2888d test(reskinnable-demo): lock in the single-ledger invariants
Two new suites, each written so it would have failed against the previous code
rather than merely describing the new behaviour:

- derived-spend: policies[].spent equals the sum of approved charges, excludes
  pending/flagged (so the over-limit check cannot double-count the charge it is
  gating), and agrees between policies() and findPolicy(). Plus the properties
  the demo depends on — at least three distinct months (the condition whose
  absence made the trend chart fabricate), AWS $15,000 still deriving
  over-limit for the teach-mode pill, exactly one Delta charge, and a team and
  category on every ledger row.
- charges-data: parseSort and parseTop reject the values that previously slipped
  through as valid, and toChargeRow's over-limit projection only applies to
  pending charges.

Confirmed red against the old code: parseSort("banana") returned "banana",
parseTop("-5") returned -5, the seed spanned two months, no row carried a team,
and spent was a stored field.

The four existing fixtures move from ExpenseRole to PolicyType for policy
`type`, following the team/policy split.
2026-08-05 00:40:55 +02:00
Maxim d7e7c8eaf4 fix(reskinnable-demo): stop the alert marker firing on ordinary notes
The regex that decides whether a transaction note gets a red-alert prefix was
anchored on the left only, so `report` matched inside "reporter" and "quarterly
report" and `disput` inside "disputation". A note reading "attached to the
quarterly report" was served a fraud marker.

Anchored on both sides and switched from stems to whole words, verified against
the four phrasings that previously false-positived.
2026-08-05 00:40:40 +02:00
Maxim 05390dc625 fix(reskinnable-demo): stop the banking report showing invented figures
Four defects in the report's charts, all reported by review and all
reproduced in the running app before fixing.

- SpendingTrendChart substituted a hard-coded [3200, 4100, 3600, 5200, 4800,
  6400] Jan-Jun series whenever fewer than three months were present. Intended
  as an empty state, it was the DEFAULT path: the seeded ledger spanned two
  months, so the report's "Spend over time" always showed six invented numbers
  — roughly 20x smaller than the total printed directly above them — under a
  card whose own contract says every number comes from the live ledger. It now
  charts whatever months exist, with a real empty state at zero.
- SpendBreakdownChart's docstring said the report must use SpendByTeamBars
  instead, "because an attached invoice can push one team to ~96% and a donut
  cannot survive that", while the report rendered the donut anyway. The warning
  was real but its cause was the thin ledger, not the chart: against the old
  $137,000 base a $900,000 invoice took one slice to 89%. Against the real
  ~$533,000 base the same invoice reaches 73%, and 89% would need ~$2.8M.
  Robust because the data is real, not because a floor was added to the arc.
- augmentForReport built its synthetic transactions behind an `as Transaction`
  cast that was hiding a real hole: `policyId` came from an `?.id` lookup, so it
  could be undefined where the field is a required string. The cast is gone and
  the compiler checks it. Additions also now resolve their model-authored team
  to a policy envelope through `policyForTeam` rather than comparing a team name
  to a policy name, with one "Unattributed" segment for unmappable names.
- A comment inside TopChargesChart claimed document-sourced charges "have no
  policyId" — three lines above the code that gives them one.

The donut column is relabelled "Spend by policy", which is what it reads.
2026-08-05 00:40:40 +02:00
Maxim 274d46ffcb feat(reskinnable-demo): put one real ledger behind the banking skin
The skin held three disagreeing answers to "what did we spend": a 45-row
Charges fixture ($632,806), a 4-row seeded ledger ($30,089 across two
months), and static policy totals ($137,000). Each surface read a different
one, so they drifted silently — and because the ledger spanned only two
months, the report's trend chart fell back to a hard-coded series and showed
invented figures under a card that promises live numbers.

Now there is one ledger. The 45 charges live in seed.json as real
transactions across Apr/May/Jun, and the Charges page reads them over REST
like every other surface.

- Splits team from policy. A charge belongs to one of seven org teams; a
  policy is one of three budget envelopes (Technology / Go-to-Market / G&A)
  and several teams share one. These were a single `ExpenseRole` enum, which
  is why the two axes read as one thing and why covering every team meant
  either a seven-slice donut or discarding real charges. `ExpenseRole` still
  types a member's own team; `PolicyType` types the envelopes, joined by
  `policyForTeam`.
- Derives `policies[].spent` from approved charges on every read, so it can
  no longer disagree with the charts. It also now MOVES: approving a charge
  previously left `spent` untouched, so the budget never reflected the
  approval and the over-limit gate kept comparing against a stale figure.
- Makes over-limit derived-only. A charge no longer stores "over-limit"; the
  Charges table resolves the badge through `withOverLimit`, the same rule the
  report uses, so the two cannot disagree.
- Validates the `?sort=` and `?top=` params. `?sort=banana` used to be cast
  straight to a SortKey and lit the control's "active" tint while the table
  silently sorted by the default; `?top=-5` reached `slice(0, -5)` and dropped
  the LAST five rows, inverting top-N.

The scripted demo is unchanged by construction: the four demo-load-bearing
transactions survive byte-identical, over-limit is still exactly three charges
totalling $30,000, AWS $15,000 still derives over-limit for the teach-mode
pill, and Delta Airlines is still the only Delta charge (the fixture's near
-duplicate "Delta Air Lines" became United Airlines).
2026-08-05 00:40:22 +02:00
Tyler Slaton c236e33978 chore: release monorepo v1.66.2 (#6376)
## Release monorepo v1.66.2

**Scope:** `monorepo` | **Bump:** `patch`

---

### How this release process works

1. **This PR was created automatically** by the "release / create-pr"
workflow.
   It bumped the `monorepo` packages to `1.66.2`
   and generated AI-enhanced release notes.

2. **CI runs on this PR** — the full test suite (unit tests, lint, type
checks, build)
   must pass before merging. This is the review gate.

3. **Review the release notes** in `release-notes.md` in this PR.
If a Notion draft was created, you can edit the release notes there
before merging.

4. **When this PR is merged**, the `release / publish` workflow
automatically:
   - Builds all packages
   - Publishes the `monorepo` packages to npm at version `1.66.2`
   - Creates git tag `monorepo/v1.66.2`
   - Creates a GitHub Release with the final release notes

### Before merging

- [ ] CI is green (tests, lint, types, build)
- [ ] Version bumps look correct
- [ ] Release notes are accurate (edit in Notion if a draft was created)

---

> **Do not merge until CI is fully green.** The full test suite runs
automatically on this PR.
v1.66.2
2026-08-04 14:58:22 -07:00
tylerslaton 53b772552f chore: release monorepo v1.66.2 2026-08-04 21:57:57 +00:00
Tyler Slaton cc9b74bdb1 chore: release channels v0.7.3 (#6375)
## Release channels v0.7.3

**Scope:** `channels` | **Bump:** `patch`

---

### How this release process works

1. **This PR was created automatically** by the "release / create-pr"
workflow.
   It bumped the `channels` packages to `0.7.3`
   and generated AI-enhanced release notes.

2. **CI runs on this PR** — the full test suite (unit tests, lint, type
checks, build)
   must pass before merging. This is the review gate.

3. **Review the release notes** in `release-notes.md` in this PR.
If a Notion draft was created, you can edit the release notes there
before merging.

4. **When this PR is merged**, the `release / publish` workflow
automatically:
   - Builds all packages
   - Publishes the `channels` packages to npm at version `0.7.3`
   - Creates git tag `channels/v0.7.3`
   - Creates a GitHub Release with the final release notes

### Before merging

- [ ] CI is green (tests, lint, types, build)
- [ ] Version bumps look correct
- [ ] Release notes are accurate (edit in Notion if a draft was created)

---

> **Do not merge until CI is fully green.** The full test suite runs
automatically on this PR.
channels/v0.7.3
2026-08-04 14:54:52 -07:00
tylerslaton b95a43e254 chore: release channels v0.7.3 2026-08-04 21:51:00 +00:00
Mark c1adb14c48 chore(showcase): pin opentelemetry-api<1.44 for pydantic-ai v1 (#6374)
`showcase/integrations/pydantic-ai` cannot be installed from scratch
today. A clean `pip install -r requirements.txt` produces an agent that
dies on import:

```
ModuleNotFoundError: No module named 'opentelemetry._events'
```

pydantic-ai 1.0.18 imports `opentelemetry._events`. That module was
removed in **opentelemetry-api 1.44.0** (last present in 1.43.0).
pydantic-ai declares an unbounded `opentelemetry-api>=1.28.0`, so a
fresh resolve picks 1.44.0 and the import fails.

## Why nothing was red

`Dockerfile:22-24` copies **only** `requirements.txt` before `pip
install`, so that layer's cache key depends on nothing else. The agent
image has been reusing a pip layer baked before 1.44.0 shipped
(2026-07-16) — including a successful build earlier today. The failure
was masked by Docker layer caching, not absent. Any change to
`requirements.txt`, a cache eviction, or a `--no-cache` build surfaces
it.

Note that this PR busts that cache by definition, so CI's image build is
a genuine fresh-resolve test of the fix rather than a cached pass.

## Verification

Run with `pip` in a clean 3.12 venv, matching how the Dockerfile
installs:

```
RED  (main)         opentelemetry-api 1.44.0 -> import pydantic_ai raises ModuleNotFoundError
GREEN (this branch) opentelemetry-api 1.43.0 -> import pydantic_ai + pydantic_ai.ag_ui OK
```

Only the ceiling is added; `opentelemetry-sdk` follows to 1.43.0 on its
own, so a second pin isn't needed. No resolution conflict with
`logfire>=4.10.0`.

Docker and the `--d6` probe stack aren't available in my environment, so
the mandatory value-test per `showcase/AGENTS.md` rule 4 has not been
run locally — CI's image build and the dojo cells are the real gate
here.

## Scope

One line plus a comment recording why it exists and when to remove it.
The comment matters: an undocumented pin is what caused the sibling
`starlette==0.45.3` rot in #6363, where a correct-when-written pin
outlived its reason and silently capped pydantic-ai a full major.

The ceiling should come off when this package moves to pydantic-ai v2,
which requires `opentelemetry-api>=1.28.0` without needing `_events`.
2026-08-04 14:07:28 -07:00
Tyler Slaton 224dfb0d8c fix(channels): reconnect after clean gateway close (#6371)
## Summary

- replace Phoenix's retained closed transport when a live managed
session receives an unexpected WebSocket close with code 1000
- reconnect and rejoin through the existing Phoenix channel lifecycle
- preserve intentional session disconnects as terminal
- cover the clean-close recovery path with a regression test

## Root cause

Phoenix 1.8.4 does not schedule its reconnect timer for close code 1000.
CopilotKit still transitioned the managed session to `reconnecting`, so
the runtime reported that Phoenix was retrying indefinitely even though
Phoenix never created another transport.

## Impact

A brief gateway interruption that cleanly closes an established socket
can now recover once the gateway is healthy, instead of leaving the
runtime stuck and repeatedly logging the managed-session-down warning.

## Verification

- `pnpm nx run @copilotkit/channels-intelligence:test` — 188 tests
passed
- `pnpm nx run @copilotkit/channels-intelligence:check-types` — passed
- `pnpm nx format:check
--files=packages/channels-intelligence/src/realtime-gateway.ts,packages/channels-intelligence/src/realtime-gateway.test.ts`
— passed
- repository pre-commit test, package validation, and lint gates —
passed
2026-08-04 13:55:27 -07:00
Tyler Slaton 7e30957976 fix(channels): reconnect after clean gateway close 2026-08-04 13:54:36 -07:00
Mark 80fa407978 chore(showcase): pin opentelemetry-api<1.44 for pydantic-ai v1
pydantic-ai 1.0.18 imports opentelemetry._events, removed in
opentelemetry-api 1.44.0. Its own floor is an unbounded
opentelemetry-api>=1.28.0, so a fresh resolve of this package's
requirements picks 1.44.0 and import pydantic_ai fails with
ModuleNotFoundError.

The agent image kept building because the Dockerfile copies only
requirements.txt before pip install, so that layer stayed cached from
before 1.44.0 shipped (2026-07-16). Any requirements change or cache
eviction would have surfaced it.

Verified with pip in a clean 3.12 venv, as the Dockerfile installs:
  before: opentelemetry-api 1.44.0, import pydantic_ai raises
  after:  opentelemetry-api 1.43.0, import pydantic_ai OK
2026-08-04 20:34:45 +00:00
Mike Ryan 3eaeeb19ce fix(channels): preserve Slack provider diagnostics (#6373)
## Summary

- serialize Slack Carousel cards through elements and validate Card and
Carousel payloads before provider calls
- keep bounded provider diagnostics from ChannelDeliveryError through
RUN_ERROR and ChannelCanonicalRunError
- log low-cardinality error fields without validation messages or
provider bodies
- move best-effort Slack status cleanup failures to debug logs

## Why

Invalid nested Slack Card payloads failed at the provider with no useful
path at the application boundary. This change catches known Card and
Carousel shape errors locally and preserves safe JSON pointers when
Slack rejects a payload.

## Compatibility

The new error details are optional. Existing consumers keep their
current behavior, so no protocol version bump is needed.

## Validation

- NX_DAEMON=false pnpm nx run-many -t test,check-types,build
--projects=@copilotkit/channels-core,@copilotkit/channels-intelligence,@copilotkit/channels-slack,@copilotkit/runtime
--skip-nx-cache
- pnpm lint
- pnpm check:channel-native-catalogs
- targeted oxfmt and oxlint checks
- git diff --check
- pre-commit test, publint, and attw checks for affected packages

Companion Intelligence PR:
https://github.com/CopilotKit/Intelligence/pull/759
2026-08-04 13:33:41 -07:00
Mark cdd605c672 docs(pydantic-ai): port integration docs and demos to Pydantic AI v2 (#6367)
This pull request was posted by Claude Code using claude-opus-5 on
behalf of David. David has not reviewed this diff line by line.

Closes https://github.com/CopilotKit/CopilotKit/issues/6363

`Agent.to_ag_ui()`, `AGUIApp` and the whole `pydantic_ai.ag_ui` module
were removed in Pydantic AI v2. The docs installed pydantic-ai unpinned,
so following the quickstart today gets 2.22.0 and fails twice: first at
resolution (`starlette==0.45.3` conflicts with the `>=0.46.2` the
`ag-ui` extra requires), then at `AttributeError`.

## What changed

**8 doc pages** under
`showcase/shell-docs/src/content/docs/integrations/pydantic-ai/`
(`quickstart.mdx`, `quickstart/pydantic-ai.mdx`,
`human-in-the-loop.mdx`, `human-in-the-loop/agent.mdx`,
`generative-ui/tool-rendering.mdx`, and the three `shared-state/`
pages):

- the agent is served from a Starlette route via
`AGUIAdapter.dispatch_request(request, agent=agent)`
- `StateDeps` imports move from `pydantic_ai.ag_ui` to `pydantic_ai.ui`
- install commands exact-pin `pydantic-ai-slim[ag-ui,openai]==2.22.0`
and `ag-ui-protocol==0.1.19`, matching the starter fleet, plus
`starlette>=0.46.2` since the snippets import Starlette directly

**Per-request deps.** Every stateful snippet builds `StateDeps` inside
the request handler:

```python
async def run_agent(request: Request) -> Response:
    return await AGUIAdapter.dispatch_request(
        request, agent=agent, deps=StateDeps(AgentState())
    )
```

`dispatch_request` validates the client's state into `deps.state`
(`pydantic_ai/ui/_adapter.py`, `run_stream_native`), so a module-level
instance shared across requests lets concurrent runs clobber each other.
The old `to_ag_ui(deps=...)` snippets all did this.

**`examples/canvas/pydantic-ai`** — `requirements.txt` pinned,
`agent/agent.py` ported, README corrected.

**`examples/showcases/pydantic-ai-todos`** — `pyproject.toml` pinned and
`uv.lock` regenerated (it was still resolving 1.0.10), `agent/main.py`
ported, `src/agent.py` and `src/tools.py` imports moved, README and
`src/app/api/copilotkit/route.ts` comments corrected.

**`skills/copilotkit-integrations`** — beyond the issue's file list:
`SKILL.md`, `sources.md` and `references/integrations/pydantic-ai.md`
also taught `to_ag_ui()`. Same rot, same fix.

## Verified by execution

The reason these docs rotted is that nothing runs them, so everything
below was actually run, not read.

- Both install commands were run verbatim in throwaway environments. `uv
add 'pydantic-ai-slim[ag-ui,openai]==2.22.0' 'ag-ui-protocol==0.1.19'
'starlette>=0.46.2' uvicorn` and the `pip install` equivalent both
resolve, landing pydantic-ai-slim 2.22.0, ag-ui-protocol 0.1.19,
starlette 1.3.1.
- Every ```python fence on the 8 doc pages was extracted, `exec`'d, and
driven with a real `RunAgentInput` POST through
`starlette.testclient.TestClient` with the model overridden to
`TestModel`. All 8 return 200 `text/event-stream` with a `RUN_STARTED`
... `RUN_FINISHED` sequence and no `RUN_ERROR`.
- The canvas agent was installed from its `requirements.txt` and driven
the same way: 200, SSE, `RUN_STARTED` ... `TOOL_CALL_*` ...
`STATE_SNAPSHOT` ... `RUN_FINISHED`.
- The todos agent was installed with `uv sync --frozen` from the
regenerated lock and driven the same way. Two sequential requests, one
seeding a todo and one sending empty state, each saw only their own
state, confirming the per-request deps actually isolate.

Not executed: the Next.js frontends and the docs site build (no
`node_modules` in this checkout). The TypeScript edits are comment-only.

## Deliberately out of scope

`showcase/integrations/pydantic-ai` is left on its v1 fleet pin. It is
418 files, 19 mounts and 190 e2e specs, and CopilotKit said they will
take it as https://github.com/CopilotKit/CopilotKit/issues/6364. The
dojo and the docs therefore diverge until that lands.

The CI guard from the issue's last acceptance criterion is not built
here. A proposal for it is posted on
https://github.com/CopilotKit/CopilotKit/issues/6363 for the team to
own.

Two pre-existing malformed code fences were fixed in passing, because
leaving them meant the ported snippets still would not run:
`quickstart/pydantic-ai.mdx` and
`shared-state/predictive-state-updates.mdx` each had TypeScript embedded
inside an unterminated ```python fence. The TypeScript now sits in its
own fence.

Overlaps with https://github.com/CopilotKit/CopilotKit/pull/6355, which
ports `examples/integrations/pydantic-ai`. No file overlap.
2026-08-04 12:58:07 -07:00
Mike Ryan 80370b5ecb fix(channels): preserve provider diagnostics 2026-08-04 12:33:17 -07:00
Mike Ryan ef531035e6 fix(channels-slack): validate card payloads locally 2026-08-04 12:31:46 -07:00
Mike Ryan 79080816cd fix(channels-slack): serialize carousel cards as elements 2026-08-04 12:06:16 -07:00
Tyler Slaton aae4c0bd5d chore: release channels v0.7.2 (#6372)
## Release channels v0.7.2

**Scope:** `channels` | **Bump:** `patch`

---

### How this release process works

1. **This PR was created automatically** by the "release / create-pr"
workflow.
   It bumped the `channels` packages to `0.7.2`
   and generated AI-enhanced release notes.

2. **CI runs on this PR** — the full test suite (unit tests, lint, type
checks, build)
   must pass before merging. This is the review gate.

3. **Review the release notes** in `release-notes.md` in this PR.
If a Notion draft was created, you can edit the release notes there
before merging.

4. **When this PR is merged**, the `release / publish` workflow
automatically:
   - Builds all packages
   - Publishes the `channels` packages to npm at version `0.7.2`
   - Creates git tag `channels/v0.7.2`
   - Creates a GitHub Release with the final release notes

### Before merging

- [ ] CI is green (tests, lint, types, build)
- [ ] Version bumps look correct
- [ ] Release notes are accurate (edit in Notion if a draft was created)

---

> **Do not merge until CI is fully green.** The full test suite runs
automatically on this PR.
channels/v0.7.2
2026-08-04 11:50:07 -07:00
tylerslaton 1f5c70da2e chore: release channels v0.7.2 2026-08-04 18:42:33 +00:00
Tyler Slaton f1b42e2e0a feat(skills): make setup-slack-channel drive the browser by default (refs OSS-705) (#6370)
## Summary

Retesting the Slack setup end to end found the skill **too cautious to
be useful**. It stopped at almost every step, where the earlier version
stopped only for passwords and got through — and the run was rescued by
the developer telling the agent outright to just control their browser,
which cut human involvement down to typing passwords.

A run that pauses at every control is slower than the manual path it
replaced. Three changes, all pulling the same direction.

## 1. Driving is now the default, not implicit

The skill said *"most of this workflow happens in a browser"* —
descriptive, and it never told the agent to **drive** that browser. It
now does, explicitly, and checks its own capability before Phase 0
rather than assuming either way.

## 2. When there is no browser, ask for one — per harness

Generic advice is useless here, because enabling browser control differs
by harness. The agent now works out which one it is in and names the
single applicable route: Claude Code and Codex each ship their own
support and enable it differently, most other harnesses take a general
browser-use MCP server such as Playwright MCP. **If it isn't sure, it
looks it up rather than guessing.**

It also names the payoff — driving turns this into typing three secrets,
the fallback is roughly fifteen manual browser steps — because that is
what turns a shrug into a yes. The step-by-step walkthrough survives
only for an explicit decline.

## 3. Consent is batched into one authorization

Phase 0 now takes **one** yes naming the whole sequence: the Slack app
from the wizard manifest, its install into a named workspace, the
Channel, the adapter attach, and the project key.

Phase 3 and `references/intelligence-channel.md` previously required
*"state what you are about to change, get an explicit yes"* for
**every** dashboard goal — and the reference said it twice, back to
back. That is the concrete source of the stop-at-every-step behaviour.
Reading the page before acting stays required; it is no longer a reason
to check in.

## What did not change

The secret boundary. The developer still types the bot token, signing
secret, and API key themselves, and those remain the only mandatory
stops alongside anything the authorization did not cover. Batching
consent must not batch away a password — that is called out in the text.

`version` bumped 1.0.0 → 1.1.0.

## Validation

- `tsx scripts/sync-plugin-skills.ts --check` → **plugin skill mirror in
sync**
- `scripts/__tests__/sync-plugin-skills.test.ts` → 10 tests passed
- `prettier --check` clean on both changed files

## Companion

The same shift landed on the hosted big prompt in
[CopilotKit/website#445](https://github.com/CopilotKit/website/pull/445)
— default to driving, one batched authorization, harness-aware
capability request. Both surfaces now say the same thing, which was the
point of keeping them in step.

Worth noting for reviewers: this takes Atai's side on *"where the skill
asks permission to proceed, just do it"*, which was only half-applied
before. It also reverses my own per-step confirmations from the first
pass on #445 — the dogfooding run is the reason.
2026-08-04 11:19:54 -07:00
Jerel John Velarde 7c5468e5d1 fix(skills): ask for the decisions a driving agent must not invent
Batching the Phase 0 authorization removed the per-goal confirmations that were
accidentally serving as decision points. Nothing then asked the developer for the
inputs the agent cannot legitimately choose, and Phase 1 still said "Enter a
Display name" in the imperative — so an autonomous run named the bot itself.

That name is the expensive one. The wizard derives the Channel Code from it, the
Code is what createChannel({ name }) declares and what the developer types as
/invite, and Slack bot names are workspace-wide — Phase 1 already warns that a
collision blocks the install. An agent that settles it has named someone's bot for
them and can fail the install doing it.

Phase 0 now gathers four decisions in one exchange before any browser opens: the
display name, the workspace, the test channel, and whether this is throwaway.
Phase 1 consumes the chosen name instead of inventing one, Phase 1's workspace step
uses the named workspace, and Phase 2's invite names the agreed channel and says
the developer runs it.

States the rule the whole design turns on: the decisions are inputs you cannot
invent, the authorization is permission you need once, and collapsing the second
does not license skipping the first.
2026-08-04 10:30:16 -07:00
Tyler Slaton 7193856d96 fix(runtime): ignore late lock renewal failures (#6369)
## Summary

- stop handling in-flight lock renewal failures after the run has
settled
- keep aborting when a renewal fails during an active run
- cover the completion-before-renewal race with a regression test

## Why

Intelligence releases the thread lock after it accepts a terminal run
event. A renewal that was already in flight can then return a 409.
Clearing the interval stops future renewals, but it does not cancel that
pending promise, so Runtime logged an error and called `abortRun()`
after the run had completed.

The lifecycle guard makes that late rejection a no-op. Active-run
renewal failures still follow the existing abort path.

## Testing

- `pnpm nx test @copilotkit/runtime` — 1,866 tests passed
- `pnpm nx run @copilotkit/runtime:check-types`
- `pnpm nx build @copilotkit/runtime`
- `pnpm exec oxlint
packages/runtime/src/v2/runtime/handlers/intelligence/run.ts
packages/runtime/src/v2/runtime/__tests__/intelligence-lock-heartbeat.test.ts`
- `pnpm exec oxfmt --check
packages/runtime/src/v2/runtime/handlers/intelligence/run.ts
packages/runtime/src/v2/runtime/__tests__/intelligence-lock-heartbeat.test.ts`

The pre-commit hook also passed affected tests, `publint`, and `attw`.
The repo-wide `pnpm check-format` still reports 25 unrelated files
already present on `main`; both changed files pass the focused format
check.
2026-08-04 10:30:11 -07:00
Jerel John Velarde ae0dbc2fd7 feat(skills): make setup-slack-channel drive the browser by default
Retesting the Slack setup end to end found the skill too cautious to be useful.
It stopped at almost every step, where the earlier version stopped only for
passwords and got through — and the run was rescued by the developer telling the
agent outright to just control their browser. A run that pauses at every control
is slower than the manual path it replaced.

Three changes, all pulling the same direction.

Driving the browser is now stated as the default rather than left implicit in
"most of this workflow happens in a browser". When the agent has no browser tool
it asks the developer to install one before starting, and names the route for the
harness it is actually running in — Claude Code and Codex enable this differently,
most other harnesses want a browser-use MCP server — with an instruction to look
it up rather than guess. The manual walkthrough survives only for an explicit
decline.

Consent is batched into one Phase 0 authorization naming the whole sequence: the
Slack app from the wizard manifest, its install, the Channel, the adapter attach,
and the API key. Phase 3 and the Intelligence reference previously required "state
what you are about to change, get an explicit yes" for every dashboard goal, and
the reference said it twice. Reading the page before acting stays required; it is
no longer a reason to stop.

The secret boundary is untouched: the developer still types the bot token, signing
secret, and API key themselves, and those remain the only mandatory stops
alongside anything the authorization did not cover.
2026-08-04 10:13:27 -07:00
David Sanchez 22108c0948 docs(pydantic-ai): constrain the direct dep, not the transitive one
Follows the maintainer's Correction #2 on issue 6363. An exact version in a
docs install command is the same rot as the starlette==0.45.3 pin it replaced:
it goes stale silently and nobody re-resolves prose. The 2.22.0 the docs shipped
was already a version behind current the day it was written.

- docs install lines use pydantic-ai-slim[ag-ui,openai]>=2,<3, which constrains
  the dep the pages actually care about and fails loudly at the v3 boundary
- ag-ui-protocol drops out of the docs lines entirely; no doc snippet imports
  ag_ui, so naming it there was the transitive-dep noise the correction is about
- starlette>=0.46.2 stays, because the v2 snippets import Starlette directly.
  A floor with no ceiling cannot force a downgrade, so it does not recreate the
  silent backtrack
- examples/showcases/pydantic-ai-todos moves to a range in pyproject.toml and
  relocks; the uv.lock is what reproduces
- examples/canvas/pydantic-ai keeps exact pins: it has no lockfile, so
  requirements.txt is its only reproducibility artifact

Smoke-tested the open question from the issue: starlette 1.x works on
pydantic-ai v2. All 8 doc pages pass on 2.23.0 + starlette 1.3.1 and on
2.23.0 + starlette 0.52.1, so Jordan's <1.0 guard can be dropped rather
than raised.
2026-08-04 12:08:20 -05:00
Tyler Slaton 86cd674c7c fix(runtime): ignore late lock renewal failures 2026-08-04 09:26:00 -07:00
Ben Taylor 8e59bfd16b feat(skills): add channels-setup pointing at the hosted onboarding guide (#6366)
## Summary

The Channels onboarding workflow is served at
**https://copilotkit.ai/channels-guide.md**, and every other entry point
now copies one line that points there — the docs surfaces in #6357, the
website strip in
[website#444](https://github.com/CopilotKit/website/pull/444), the
README in
[channels-sdk#15](https://github.com/CopilotKit/channels-sdk/pull/15).

Coding agents reached through a **skill** had no such pointer. They
matched `setup-slack-channel`, which is scoped to Slack, to the provider
half, and to an OpenTag checkout — so "help me get my agent into Teams"
landed on a workflow that does not cover it.

`channels-setup` closes that gap as a **pointer, not a copy**. The
workflow stays in one place and is corrected there, instead of becoming
a seventh surface that drifts against the CLI on its own schedule.

## Verifying the fetch is the substance of the file

A thin pointer has one non-obvious failure mode, and it is the reason
this skill is more than two sentences:

```
$ curl -sL -o /dev/null -w "%{http_code} %{content_type}\n" https://copilotkit.ai/channels-guide.md
200 text/html; charset=utf-8
$ curl -sL https://www.copilotkit.ai/channels-guide.md | grep -o "<title>[^<]*</title>"
<title>Page not found | CopilotKit | ...</title>
```

**A missing guide does not return 404.** The site answers unknown paths
with a "Page not found" HTML page under HTTP 200. An agent that keys on
the status code gets a 73KB marketing page, concludes the fetch
succeeded, and improvises channel setup from memory — which is exactly
what the guide's boundaries exist to prevent, and it fails in the most
expensive way available here: the project installs cleanly and answers
nothing.

So the skill checks the **body**: markdown rather than HTML, the guide's
`# Build and prove a CopilotKit Channels agent` H1, and five `## Phase`
headings. If any fail it stops and hands the user the URL, no matter
what the status code said.

## Notes

- **`RESERVED_LIFECYCLE_SLUGS` is not optional.** Standalone skills are
not generated from `packages/*/skills`, so without the entry
`sync-plugin-skills` treats the directory as an orphan and deletes it.
Test updated alongside, including the hard-coded `size` (10 → 11).
- **No existing skill is modified.** `setup-slack-channel` and
`copilotkit-channels` keep their descriptions and bodies. Three skills
now match channel work; if that proves too ambiguous in practice,
narrowing the other two's `description` frontmatter is a follow-up, not
a body rewrite.
- **No manifest change.** `plugin.json` and `marketplace.json` do not
enumerate skills individually.
- No changeset — `packages/**` is untouched.

## Blocked on

**The guide is not live yet.**
[website#444](https://github.com/CopilotKit/website/pull/444) is still
open, so the URL currently serves the 404 page shown above. This skill
is inert until that merges and deploys — at which point it works with no
further change here. The sentinel means the pre-deploy state is a clean
stop rather than a wrong answer, so merging early is safe; it just isn't
useful yet.

## Validation

- `npx vitest run scripts/__tests__/sync-plugin-skills.test.ts` — 10
passed
- `pnpm run check:plugin-skills` — `plugin skill mirror in sync`, exit
0; `skills/channels-setup/` survives the orphan pass
- `npx oxfmt --check` on both changed TS files — clean
- `npx oxlint scripts/` — 0 errors
- `SKILL.md` frontmatter parsed with js-yaml: `name=channels-setup`,
`version=1.0.0`, description 660 chars
- Soft-404 behaviour confirmed against production with the `curl`
commands above

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-04 11:03:01 -05:00