Skip to content

fix(sdk): watch-mode chat subscriptions survive quiet windows - #4548

Merged
kathiekiwi merged 36 commits into
feat/agent-storybook-galleryfrom
fix/watch-mode-keepalive-tri-13065
Aug 12, 2026
Merged

fix(sdk): watch-mode chat subscriptions survive quiet windows#4548
kathiekiwi merged 36 commits into
feat/agent-storybook-galleryfrom
fix/watch-mode-keepalive-tri-13065

Conversation

@kathiekiwi

@kathiekiwi kathiekiwi commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Two related fixes to the chat transport's watch/read-only subscription lifecycle.

TRI-13065 — In watch mode the chat stream died at the first long-poll window boundary after a turn completed: the EOF-reconnect path was gated on isStreaming, which turn-complete clears, so a watcher stopped hearing later turns. Watch mode now keeps reconnecting across quiet windows and only stops on abort or a settled session. The bounded give-up budget still applies to normal (mid-turn) streams, but not to watch mode, where empty windows are expected.

TRI-13070reconnectToStream derived mutation rights from mere signal presence (sendStopOnAbort: !!options.abortSignal), so a passive/read-only subscriber that passed an abortSignal would append a {kind:"stop"} to .in on unmount and could stop a turn it didn't own. Subscription lifecycle is not session ownership: reconnectToStream now takes an explicit stopOnAbort option that defaults to false, and the owning turn paths (sendMessages, sendAction) pass sendStopOnAbort: true explicitly. A read-only subscription ending never mutates the session; it still cancels its own request.

@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 550b717

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Patch
@trigger.dev/python Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/build Patch
@trigger.dev/core Patch
@trigger.dev/react-hooks Patch
@trigger.dev/redis-worker Patch
@trigger.dev/rsc Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/rbac Patch
@trigger.dev/sso Patch
trigger.dev Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f89e2a4a-3fb5-4f4c-ac51-321c3af1cb06

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Watch mode now reconnects after completed turns and idle-window EOFs. It continues beyond the normal EOF resubscribe limit. The subscription stops when the session is settled or the operation is aborted. Tests cover reconnect behavior, settled-session termination, aborts during backoff, and updated SSE fixtures. A patch changeset documents the SDK change.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains both fixes, but it omits the required issue closure, checklist, testing, changelog, and screenshots sections. Add the template sections, complete the checklist, document test steps, add a short changelog entry, and include the issue closure reference.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary watch-mode chat subscription fix and follows a concise conventional format.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/watch-mode-keepalive-tri-13065

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@e24e584

trigger.dev

npm i https://pkg.pr.new/trigger.dev@e24e584

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@e24e584

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@e24e584

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@e24e584

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@e24e584

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@e24e584

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@e24e584

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@e24e584

commit: e24e584

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…ate on give-up

- reconnect no longer peek-settles in watch mode, so a settled peek between
  turns can't close the standing subscription before the next turn.
- the returned stream now aborts its resubscribe loop when the reader is
  cancelled, instead of leaking it.
- clear and persist isStreaming before the budget-exhaustion throw so a
  reload doesn't reopen a doomed subscription.
…stream-error

A consumer cancelling the watch stream aborts the resubscribe loop, which
reaches controller.close() on an already-closed controller. The resulting
'Invalid state' throw was surfaced as a bogus stream-error on every clean
watch-viewer unmount. Wrap the remaining bare close sites to match the
existing pattern.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +1805 to +1811
// Watch mode is a standing subscription: it outlives turn-complete
// (which clears `isStreaming`) and idle windows EOF by design, so the
// give-up budget doesn't apply. Only abort or a settled session ends it.
while (
state.isStreaming &&
(this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) &&
!currentSubscription?.sessionSettled &&
!combinedSignal.aborted &&
eofResubscribes < MAX_EOF_RESUBSCRIBES
!combinedSignal.aborted

@devin-ai-integration devin-ai-integration Bot Aug 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 A cancelled older chat connection can wrongly mark the new, in-progress reply as finished

When an older subscription that was cancelled mid-backoff finally tears down, it clears the shared "a reply is in progress" flag (state.isStreaming = false at packages/trigger-sdk/src/v3/chat.ts:1856) even though it was cancelled and a newer reply already set that flag, so the new reply is recorded as finished while it is still arriving.
Impact: If the page is reloaded during that new reply, it will not resume and the rest of the answer is lost.

Race between supersede and the EOF-backoff tail of the old stream

sendMessages (packages/trigger-sdk/src/v3/chat.ts:866-880) aborts the previous stream, then sets state.isStreaming = true and subscribes again — both streams share the same ChatSessionState object from this.sessions. If the aborted stream was sleeping inside the backoff in resumeAfterEof, the abort resolves the sleep, the loop breaks, the new throw block is correctly skipped (!combinedSignal.aborted), but the following tail block is not guarded by combinedSignal.aborted and unconditionally writes state.isStreaming = false plus notifySessionChange, clobbering the successor turn's state and persisting isStreaming: false to the customer's storage. On reload, reconnectToStream returns null because state.isStreaming === false, so the in-flight turn is never resumed. Adding !combinedSignal.aborted to the tail condition (mirroring the guard used in the new budget-exhausted block at packages/trigger-sdk/src/v3/chat.ts:1838-1842) avoids the clobber.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines 1808 to +1811
while (
state.isStreaming &&
(this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) &&
!currentSubscription?.sessionSettled &&
!combinedSignal.aborted &&
eofResubscribes < MAX_EOF_RESUBSCRIBES
!combinedSignal.aborted

@devin-ai-integration devin-ai-integration Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 With watch mode on, sending a message leaves the chat stuck in a sending state forever

The message stream is kept open indefinitely after the reply has finished (this.watchMode || at packages/trigger-sdk/src/v3/chat.ts:1809) instead of ending, so the chat never returns to a ready state and keeps re-contacting the server forever.

Impact: On a chat configured for watch mode, every send leaves the UI spinning with the input disabled after the answer is complete, and the browser keeps opening new requests for that chat indefinitely.

Why the standing-subscription condition also captures owning send streams, and why the settled exit can never fire

subscribeToSessionStream is shared by sendMessages (packages/trigger-sdk/src/v3/chat.ts:877-880), sendAction (packages/trigger-sdk/src/v3/chat.ts:1288-1291) and reconnectToStream. The new loop condition drops the state.isStreaming guard whenever this.watchMode is set, regardless of which caller opened the stream. Previously, in watch mode a send stream read past trigger:turn-complete (packages/trigger-sdk/src/v3/chat.ts:2014), which sets state.isStreaming = false, and then closed at the next body EOF because resumeAfterEof's state.isStreaming guard was false. Now the loop keeps resubscribing.

The only remaining exits are combinedSignal.aborted and currentSubscription.sessionSettled. sessionSettled is derived from the X-Session-Settled response header (packages/core/src/v3/apiClient/runStream.ts:424), and the server only ever emits that header when the request carried X-Peek-Settled: 1 (apps/webapp/app/services/realtime/s2realtimeStreams.server.ts:417-432, apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts:186). sendMessages/sendAction never set peekSettled, and reconnectToStream now explicitly disables it in watch mode (packages/trigger-sdk/src/v3/chat.ts:1184). So for a watch-mode send there is no terminating condition other than an explicit abort — the returned ReadableStream never closes, and the AI SDK consumer never leaves the streaming state.

The PR's own tests reflect this: the watch-mode sendMessages streams in the "superseded stream teardown" suite only finish after transport.dispose() or an explicit supersede/abort.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@kathiekiwi kathiekiwi changed the title fix(sdk): watch-mode chat subscriptions survive quiet windows fix(sdk,webapp): watch-mode keepalive, query read-only boundary, chat.agent durability suite Aug 12, 2026
@kathiekiwi kathiekiwi changed the title fix(sdk,webapp): watch-mode keepalive, query read-only boundary, chat.agent durability suite feat(webapp,sdk): dashboard agent plan enforcement — and fixes Aug 12, 2026
@kathiekiwi kathiekiwi changed the title feat(webapp,sdk): dashboard agent plan enforcement — and fixes fix(sdk): watch-mode chat subscriptions survive quiet windows Aug 12, 2026
…ry (#4549)

## What & why

The agent's query tool is read-only, but that was true by three separate
facts and only one of them had a test. This proves the boundary holds by
contract rather than by prompt, and stops a broken query from burning a
whole agent turn.

Two small guards, the rest is tests.
[TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165).

## Stack

Stacked on **#4548** (watch-mode keepalive). Merge that first.

## What's inside

- **A route-level read-only test**
(`apps/webapp/test/queryRouteReadOnly.test.ts`) that drives
`api.v1.query` with a real signed environment JWT: a multi-statement
write and a mutating statement are both refused before anything reaches
ClickHouse, and a plain read passes so the seam stays live.
- **`readonly=1` made non-overridable** in `queryService.server.ts` —
caller `clickhouseSettings` were spread after the defaults and could
clear it.
- **A per-turn query-retry cap** in the agent's `run_query` tool
(`internal-packages/dashboard-agent/src/tool-api.ts`): three consecutive
failures returns a terminal "stop and answer with what you have".

## Key decisions

- **The read-only guarantee is grammar-level, not filtered.** TRQL has
no write statements — they don't parse — ClickHouse runs with
`readonly=1`, and the org/project/env scoping is injected server-side
from the credential. The request body can't widen scope or turn a read
into a write.
- **The deny test runs through the route, not the parser.** A
parser-only test would stay green if a refactor routed agent SQL around
the compiler; driving the real route with a signed JWT pins the boundary
end-to-end, and the deliberate positive read keeps the assertion honest.
- **The retry cap lives per turn, not in the prompt.** A failed query
hands the model the database error to fix, and usually it does — but the
only other limit was the turn's 10 steps, so one query the model
couldn't fix could eat the whole turn and leave the user with no answer.
The tool set is built per turn, so the counter caps consecutive
failures; a success resets it. The retry instruction rides the error
text, so the prompt prefix is unchanged.

## Testing

- `queryRouteReadOnly.test.ts` — write statements → 400, ClickHouse
never called; a read passes.
- `tool-query-retry-cap.test.ts` — terminal at the third consecutive
failure, counter resets on a success.
- The load-bearing guards were control-broken first (readonly override
re-enabled; cap removed) and the tests went red.
@kathiekiwi
kathiekiwi merged commit 297d6b6 into feat/agent-storybook-gallery Aug 12, 2026
9 of 10 checks passed
@kathiekiwi
kathiekiwi deleted the fix/watch-mode-keepalive-tri-13065 branch August 12, 2026 08:28

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines +1 to +5
---
"@trigger.dev/sdk": patch
---

Watch-mode chat streams now survive quiet windows and keep delivering later turns, and a reply cut off by a lost connection now shows an error instead of appearing finished.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Pull request bundles several unrelated features and fixes instead of one change

The change set mixes many unrelated pieces of work (the chat stream fix in packages/trigger-sdk/src/v3/chat.ts:1804-1852) with a new monthly message allowance, watch plan limits, query read-only enforcement, a query retry cap and an investigation sweep backstop, so the repository's one-change-per-request rule is broken.
Impact: Reviewers cannot evaluate or revert the stated fix independently of several unrelated behaviour changes shipped alongside it.

Which unrelated changes are bundled together

CONTRIBUTING.md states: "We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features."

The stated intent covers only TRI-13065/TRI-13070 in packages/trigger-sdk/src/v3/chat.ts. The diff additionally contains:

  • Dashboard agent monthly message quota: apps/webapp/app/services/dashboardAgentQuota.server.ts, internal-packages/dashboard-agent-db/drizzle/0004_stale_corsair.sql, apps/webapp/app/components/dashboard-agent/message-quota.ts.
  • Watch plan limits: apps/webapp/app/services/dashboardAgentWatchLimits.server.ts, apps/webapp/app/services/dashboardAgentWatches.server.ts:353-374.
  • Query API read-only pinning and 429 mapping: apps/webapp/app/services/queryService.server.ts:400, apps/webapp/app/routes/api.v1.query.ts:84-89.
  • Agent query retry cap: internal-packages/dashboard-agent/src/tool-api.ts:189-205.
  • Investigation sweep poison-row handling: apps/webapp/app/services/dashboardAgentInvestigationSweep.server.ts:116-153, internal-packages/dashboard-agent-db/drizzle/0005_ambitious_mordo.sql.
Prompt for agents
CONTRIBUTING.md requires each pull request to address a single issue. This branch bundles the SDK chat-transport fixes (packages/trigger-sdk/src/v3/chat.ts) with several independent webapp/internal-package features: the dashboard agent monthly message quota (dashboardAgentQuota.server.ts, new agent_message_usage migration, message-quota.ts and the UI wiring), watch plan limits (dashboardAgentWatchLimits.server.ts plus enforcement inside createDashboardAgentWatch), the query API read-only pinning and 429 mapping, the agent run_query consecutive-failure cap, and the investigation sweep poison-row backstop. Split these into separate pull requests so each can be reviewed, released and reverted on its own.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 53 to +54

return resolveMessageQuota({
isFreePlan,
used: usedElsewhere === undefined ? undefined : usedElsewhere + countUserMessages(messages),
});
return resolveMessageQuota({ isFreePlan, used });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Client cap of 20 can disagree with the server's plan limit once billing ships

useAgentMessageQuota calls resolveMessageQuota without a limit, so the browser always compares the server's org-wide used against the hard-coded FREE_PLAN_MESSAGE_LIMIT of 20 (apps/webapp/app/components/dashboard-agent/message-quota.ts:5). The server, meanwhile, refuses against the platform limit agentMessages (apps/webapp/app/services/dashboardAgentQuota.server.ts:15). Today the platform key is absent so the server is effectively unlimited and the 20 is purely a client-side nudge, but as soon as cloud billing sets agentMessages to anything other than 20, free-plan users will see the upgrade block (and be blocked from sending, via the atMessageCap guard in submit) at a count the server would still accept — or vice versa. Worth wiring the limit through the ?quota=1 response alongside used.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +140 to +149
// Only a real user message consumes quota; action turns were refused above.
countsAgainstQuota = agentTurnCountsAgainstQuota(parsed);
if (countsAgainstQuota) {
const quota = await resolveAgentMessageQuota(dashboardAgentDb, {
organizationId: project.organizationId,
});
if (quota?.reached) {
return json({ error: MESSAGE_QUOTA_REACHED_ERROR, limit: quota.limit }, { status: 403 });
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Quota check and increment are not atomic, so concurrent sends can overshoot the cap

resolveAgentMessageQuota reads the counter and recordAgentMessageSent bumps it in a separate write after the upstream append succeeds, with the mint/forward round trip in between. Several tabs (or the create path racing an in append) can all read used = limit - 1 and all be admitted. Given the code comments describe the cap as a nudge rather than a security boundary this is likely acceptable, but it is worth confirming the product expectation before billing hooks the real limit up.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

kathiekiwi added a commit that referenced this pull request Aug 12, 2026
… — and fixes (#4516)

Plan enforcement for the dashboard agent — message quota and watch
limits — plus the component gallery, fixes and test hardening from the
same stack (#4548, #4549, #4550, #4552, #4556 merged here).

## Plan enforcement
([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863))

**Agent message quota.** The Free-plan allowance becomes a real
server-side limit with a durable counter. New `agent_message_usage`
table keyed `(organization_id, period)` — deliberately not joined to
chats, so deleting a chat can't free quota within the period. Both send
paths count one user message (wakes never count) and refuse at the cap
with `403 message_quota_reached`, which the client renders as an upgrade
panel, never a silent drop. The refusal code is a single shared constant
on both sides.

**Watch limits.** A watch whose window exceeds the plan's
`agentWatchMaxHours`, or that would push the org past its
`agentWatchers` count, is refused with `watch_limit_reached` (409 on the
API, an upgrade hint on the card). Plan limits only tighten the existing
code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A
plan limit of zero means zero, not unlimited. Questions answerable
instantly are answered before any plan refusal — a one-shot consumes no
slot and never sees an upgrade nag.

**Fails open by design.** Cloud ships the actual per-plan numbers
separately (TRI-12863 P0). Until then absent limits resolve to the
unlimited sentinel and the upgrade UI is gated on billing presence —
self-hosted sees no cap, no upsell, with tests proving the fallback.
Both quotas are nudges, not security boundaries: a failing limit read
never blocks a send.

## Component gallery

An admin-only gallery of every agent card state: five
`storybook.agent-*` pages (chat UI, view blocks, report, investigation,
watch) with their shared shell and manifest, demo fixtures, two
demo-only cards, toast examples, and the screenshot script. No LLM and
no data — every state renders from fixtures under
`dashboard-agent/demo/`, never reachable from a production path.
Designers and reviewers can look at every state, including the report
states, without seeding anything.

## And fixes

**SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065,
TRI-13070) — watch mode keeps reconnecting across empty long-poll
windows and only stops on abort or a settled session; a passive
subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is
explicit, default off). Review findings fixed alongside: a superseded
stream's async teardown no longer removes the live successor's abort
controller or multi-tab claim, and stopping a generation hands the chat
back to the user's other tabs.

**Query boundary pinned end-to-end**
([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a
route-level test drives `api.v1.query` with a real signed environment
JWT (writes refused before ClickHouse, a read passes); `readonly=1` made
non-overridable; a per-turn cap stops the model burning a turn rewriting
a query it can't fix (deterministic SQL errors only — busy/transport
rejections don't count).

**chat.agent durability regression suite**
([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) —
testcontainers-backed coverage of the two audit criticals (cross-tenant
isolation, no duplicate mid-stream turn, both control-broken) plus
crash-resume, cursor-based refresh, clean rollback of a mid-write turn
failure (torn by a real constraint violation), and OOM-restart replay.

**Investigation sweep backoff** — stale investigations get an attempt
counter and backoff so a poison row can't pin the sweep queue head
(migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`).

## Screenshots

<img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19"
src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1"
/>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants