Skip to content

AI rules: a rule with no Runner of its own shows the Global Runner, where OneUptime AI runs no Bash commands - #5252

Merged
simlarsen merged 11 commits into
masterfrom
claude/runner-default-global
Oct 25, 2026
Merged

simlarsen merged 11 commits into
masterfrom
claude/runner-default-global

Conversation

@simlarsen

@simlarsen simlarsen commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

The problem

The maintainer, on an AI rule's Runner field that read No Runner with "Add a Runner under Runbooks › Runners for OneUptime AI to run commands on, then pick it here.":

If there are no runners, it should use the global runner by default.

Runbook steps (#5154) and Code repositories (#5062) already open on the Global Runner when nothing else is picked. An AI rule was the one Runner field that did not: a rule that picked none read No Runner, and a project without its own Runners got a nudge to install one.

What I changed, and why

An AI rule that picks no Runner now shows the Global Runner. This happens wherever the project uses global Runners and the server has one, which covers every new Cloud project. The field now works like the step and repository pickers, and like the rule's own LLM Provider field, which shows the global provider when the project has none of its own.

  • Menu order: the project's default Runner (tagged), then Global Runner with its status, then your other Runners. This is the same order a step's picker uses. Under its name the Global Runner says: Run by OneUptime for every project. OneUptime AI runs no Bash commands on it.
  • The line under the picker says what that means, and the way on:
    • With Runners of your own: OneUptime AI runs no Bash commands on the Global Runner, which every project shares. Pick one of your Runners to run them.
    • With none: … Add a Runner under Runbooks › Runners to run them. The link opens in a new tab. The page re-reads Runners when you come back to the tab, so a Runner you just added can be picked right away.
    • For someone who cannot read the project's Runners: the first sentence only.
  • Choosing the Global Runner saves "none". It is never saved as "global" or as a Runner id. The server already refuses a rule that names another project's Runner or a global one, so a rule still stores only its own project's Runner, or none.
  • When there is no Global Runner to show, the field reads No Runner as before and can be cleared back to it. That is a project that turned global Runners off, or a server that runs none.
  • An older rule that composes commands also keeps No Runner. It runs those commands on the Command Runners it names, so a "no Bash commands" line would be wrong for it.
  • The field's own line loses "inside your network", because the default is not inside your network: Where OneUptime AI runs Bash commands for this rule. Commands on the affected cluster or host go through its AI agent.

What the Global Runner runs for a rule, and what it never runs. A global Runner runs JavaScript steps in a sandbox that every project shares. A shell cannot be kept apart from other projects on that host, so OneUptime AI's Bash commands never run there.

For a rule on the Global Runner:

  • Bash commands: OneUptime AI composes none. That was already true for a rule with no Runner, and the server does not change.
  • Cluster and host commands: commands on the affected cluster or host still go through its AI agent.
  • Runbooks OneUptime AI starts: their JavaScript steps run on the Global Runner when the step picks no Runner. Their Bash, SSH and kubectl steps need a Runner of your own, as before.
  • Pull requests: these run on the repository's Runner, which is the Global Runner unless the repository picks one.

I did not weaken any layer that keeps a shell off a global Runner. A new guard block now pins every one of them (details under Tests).

A rule never follows the project's default Runner on its own. Picking a Runner in a rule is the consent for OneUptime AI's commands to run there. Making a Runner the default for runbook steps is not that consent. So:

  • A new rule still starts on the project's default Runner, saved as its own pick.
  • A rule that picked none shows the Global Runner, even when the project has a default.

Every other Runner field:

  • Runbook steps: a JavaScript step opens on the Global Runner. A Bash, SSH or kubectl step never does: it says it needs a Runner of your own and links to Runbooks › Runners.
  • Code repositories: open on the Global Runner, with the line that it does not build or test the change.
  • Telemetry AI rules and Kubernetes clusters: have no Runner field.

These already behaved as the maintainer asks. A new cross-picker test now pins all of them (details under Tests).

What changes for existing customers

  • No behaviour change on the server. A rule that saved "No Runner" still saves none, composes no Bash commands, and fixes exactly as before. Only its label changes: it now reads Global Runner (or No Runner where the project or server has no global Runner).
  • No migration. Nothing is written to existing rules.
  • More requests when the rule dialog opens. The rule's Runner field now also reads the global Runners list and the project's Use global Runners switch, the same reads a runbook's Steps page makes.

Follow-up I recommend (not built here)

OneUptime AI composing JavaScript for the Global Runner. This would let a rule do real work there: calling a public API with a secret the project lets global Runners use. Examples are purging a CDN, toggling a feature flag, or scaling a SaaS service.

It needs its own design and security review before anyone builds it:

  • an AI command type and approval card for JavaScript;
  • whether it may ever run without asking;
  • how to stop secrets leaving through a prompt-injected URL, since the sandbox reaches the public internet.

A sandbox for shell commands on shared Runners is a much larger isolation project (one VM per job). Until that exists, a shell stays on a Runner of the project's own.

Right-to-left labels in the picker box (already there before this PR, and in every Runner picker). In Persian, the box shows "Runner سراسری · متصل" laid out left to right, so a right-to-left reader meets the words in the wrong order. The open menu is correct.

The cause is the shared Dropdown's value label: it uses dir="auto" (#5145), so an English record name keeps its start in view. A translated label that begins with a Latin word therefore takes the left-to-right direction.

Recommended fix for the right-to-left lane: draw translated or composed labels in the page's direction, and keep dir="auto" for record names.

Fixed for every Runner picker in the UI review below: an opt-in isInPageDirection on the Dropdown's options lays the Global Runner's label out the page's way. Other dropdowns whose translated labels start with a Latin word are left for the right-to-left lane.

Docs (17 languages)

  • AI SRE → Which incidents are investigated and fixed → Runner: the Global Runner default, that no Bash commands run there, and No Runner where there is no global Runner. Each translation names the labels exactly as its Dashboard draws them.
  • Upgrading → The pull-request switches moved into the auto remediation rules: a rule that picked none now reads Global Runner.
  • Runbooks → Runners → Global Runners: the Global Runner / Your own Runner table gains a row, "OneUptime AI's Bash commands | Never | For the AI rules that pick it".

Tests

  • Common/Tests/App/Dashboard/RunnerPickerModel.test.ts
    • The rule model in every kind of project:
      • no Runners: the Global Runner is the one choice;
      • Runners but no default: the Global Runner first;
      • a default: default, then Global, then the rest, and a rule never follows the default;
      • global Runners off, or a server with none: No Runner.
    • Each case checks the live status, that the choice is stored as none and never as an id or "global", the line kind (getRuleRunnerLine), the folded value, and older rules.
    • A cross-picker matrix: JavaScript, Bash, SSH and kubectl steps, code repositories and AI rules, each in those five kinds of project, with a hand-written table of what the box shows and what the record runs on. It also asserts that the Global Runner is only ever the outcome for work that global Runners can run.
  • Common/Tests/App/Dashboard/AutoRemediationRuleFields.test.tsx
    • Renders the field in every state: the label, menu order and descriptions, the lines and their link (new tab), that nothing can be cleared on the Global Runner, and that picking it saves null.
    • A disconnected Global Runner shows its status, and nothing about the rule waits on it.
    • Readers who cannot read the project's Runners.
    • The older commands rule (No Runner).
    • Focus refresh.
    • The loader's three independent reads, and each read failing on its own.
  • Common/Tests/App/Dashboard/RunnerPicker.test.tsx: in a project with no Runner of its own, each Runner field rendered:
    • a JavaScript step shows the Global Runner;
    • Bash, SSH and kubectl steps show the "needs a Runner of your own" box with its link;
    • a code repository shows the Global Runner;
    • an AI rule shows the Global Runner, its "no Bash commands" line and link, and no waiting line.
  • Common/Tests/App/Dashboard/AiRulesTable.test.tsx: the field's new line, and that the field passes isGlobalRunnerShown (false only for an older commands rule) and passes null through for the Global Runner.
  • Common/Tests/Server/Utils/Runner/GlobalRunnerChoiceGuard.test.ts: the rule's choices now read the global Runners. A new block, OneUptime AI's commands never run on a global Runner, whatever a rule shows, reads the source of each layer:
    • Picker: stores the Global Runner as none; global Runners never run a rule's work.
    • Engine: composes commands only for a rule that picks a Runner.
    • Toolkit: looks a command's Runner up in the rule's own project with canRunAiCommands.
    • Picking a Runner: never allows a global Runner to run AI commands.
    • Heartbeat and claim: the heartbeat tells a global Runner canRunAiCommands: false; the global claim serves runbook-origin jobs only.
    • Runner itself: refuses commands composed by OneUptime AI.
  • Common/Tests/Server/Services/AutoRemediationDecisionRecording.test.ts: a rule that saved no Runner (shown as Global Runner) still picks a runbook, composes no commands, and the engine never reads a global Runner (hasGlobalRunnerForRunbooks and findGlobalRunners are not called).
  • Docs facts:
    • AutomaticFixPullRequestsDocs and AutomaticRemediationSwitchDocs pin the new sentences, and that each translation bolds its Dashboard's "Global Runner".
    • GlobalRunnersDocsFacts pins the new table row.
  • Offline browser suite packages/E2E/AutoRemediationRules
    • The fixture now models the global Runners route and the project's switch (?globalRunners=0|disconnected, ?useGlobalRunners=0) and Persian (?lang=fa, right to left).
    • Existing Runner tests are updated for the Global Runner default.
    • New tests: going back to none by picking the Global Runner (null saved); global Runners off reads No Runner and clears; a disconnected Global Runner; no Runners shows the Global Runner with the "add" line; no Runners and no global Runner reads No Runner.
    • The field at 1280px and 390px, light and dark, with and without Runners, with the menu open. These check nothing runs off the screen, and that the box and its line fit the dialog.
    • German and Persian in the Dashboard's own words.
  • The other Runner fields in the browser
    • packages/E2E/GlobalRunners: in a project with no Runner of its own, a JavaScript step's field on the Global Runner and a Bash step's "needs a Runner of your own" line, at 1280px and 390px, light and dark, with the menu open.
    • packages/E2E/Code: a repository's Runner field on the Global Runner in dark mode, at both of that suite's widths. Light was already covered.

What I ran

I ran all of this locally, before and after merging master (#5248). Everything passed.

Common (jest)

  • The 36 suites that read the files I changed (1,662 tests). These include RunnerPickerModel, RunnerPicker, AutoRemediationRuleFields, AiRulesTable, GlobalRunnerChoiceGuard, RunnerChoice, GlobalRunnerWork, AutoRemediationFix, AutoRemediationDecisionRecording, RemediationCommandTools, RemediationExecutionRunner, RunnerJobGlobalRunnerLane, the code repository Runner suites, the Runners pages, and the form and card guards.
  • npx jest Guard and Locale, plus UpdateChecksReadHeldRows, DeleteChecksReadHeldRows, HookWritesCallersMayMake, ListOrderColumnModels, PermissionCatalogueCoverage, SchemaMigrationsOnlineDdl, TestSendRoutesAskTheRule and AnalyticsSortKeyPageBoundScope (214 suites).
  • npm run compile-tests (all three programs).

App (jest)

  • Every Tests/Dashboard/*Guard suite, RunnersLiveUnderRunbooks, RunnerUi, the AI settings wiring suites, StepExecutorsGlobalRunner, Locale, and every root-level Tests/*.test.ts (55 suites).
  • Docs:
    • the general Tests/FeatureSet/Docs/Docs* suites;
    • every *Translations suite;
    • the facts and upgrade suites that read ai/ai-sre, installation/upgrading and runbooks/agents.
  • npm run compile.

Dashboard and i18n

  • tsc --noEmit on the Dashboard.
  • i18n:check and i18n:extract: 0 new keys, no translation lost.
  • npm run i18n:validate.

Docs tooling: docs:localize-anchors on the three pages (0 changes) and docs:check-anchors.

Lint and checks

  • npx prettier --check and npx eslint --no-ignore on every changed file.
  • The ReDoS checker: no new patterns.
  • npm run compile in packages/E2E.

Offline browser suites, in Chromium, with CI=1 on ports of my own

Suite Result Time this PR adds
test-auto-remediation-rules-ui (CI group 7) 103 passed in 7.1 minutes about 1 minute: 4 width/theme checks (39 s), 2 locale checks (10 s), 3 new Fix-step tests (12 s)
test-global-runners-ui, new describe 4 passed 13 s
test-code-ui, "a repository's Runner" 17 passed, 1 phone skip that was already there 6 s over both projects

Group 7 was about 16.5 minutes on master, so this takes it to about 17.5, under the 22-minute line. I did not change any workflow file.

Not run

  • The whole App and Common jest folders: CI runs those.
  • A live stack: the server's behaviour does not change.
  • The deployed E2E specs: none of them names the Runner field's words or test ids. I grepped packages/E2E/Tests for the old line and for "No Runner".

Master failures I saw: none.

Surfaces for the UI review

  • An AI rule's Runner field: Incidents or Alerts → AI → Settings → More settings → AI rules → Create AI Rule → Fix step → More fields → Runner. To render it without a stack: cd packages/E2E && CI=1 npm run test-auto-remediation-rules-ui, or node AutoRemediationRules/Fixture/server.js.
    • The states are steered by the URL: ?runners=0, ?defaultRunner=prod, ?globalRunners=0|disconnected, ?useGlobalRunners=0, ?rules=saved (the edit dialogs), ?lang=de|fa, ?theme=dark.
    • Screenshots land in output/playwright/auto-remediation-rules-ui/ (runner-global-*, no-runners-global-runner, runner-global-runners-off, runner-global-de and runner-global-fa).
  • A runbook step's Runner field (unchanged, newly checked): npm run test-global-runners-ui, screenshots steps-no-runners-*.
  • A code repository's Runner field (unchanged, newly checked in dark mode): npm run test-code-ui, screenshot code-repository-runner-global-dark-*.
  • Docs: ai/ai-sre (Runner bullet), installation/upgrading (the rule Runner note), runbooks/agents (Global Runners table), in 17 languages.

UI review

What I looked at. The AI rule's Runner field in the create and edit dialogs (Incidents and Alerts → AI → Settings → More settings → AI rules, Fix step, More fields), rendered from this PR's offline fixture with the Dashboard's Inter font at 2x. I checked it at 1280, 1024, 768 and 390px, in light and dark, and in English, German and Persian, with the menu open, in every state the fixture steers:

  • Runners of the project's own, or none, and a project default.
  • Global Runners turned off, a server with none, a Global Runner that is disconnected, and a failed read of the global Runners.
  • A reader who may not read the project's Runners (a state I added to my harness).
  • A saved rule's pick, a pick the reader cannot see, and an older rule that composes commands.

For comparison I also rendered master's field, the runbook step and code repository Runner pickers, and the three docs changes.

What I changed, and why

  • The line under the box now reads like its neighbours' hints. It was the size of the field's own description (text-sm), with a bold link. The three pickers above it in the same fold (Code Repositories, MCP Servers, LLM Provider) have small hints, so the Runner's line read as a second description. An AI rule's line is now drawn as those hints are:

    • text-xs;
    • close under its box (mt-1.5), with a field's gap before the next field (mb-2);
    • its Runbooks › Runners link underlined only.

    A runbook step keeps the step editor's size. The code repository's Edit Settings form keeps its form-size lines: its one field has no hints beside it.

  • Persian: the box now reads right to left. Before, it laid "Runner سراسری · متصل" out left to right, so a reader met "Runner" after "Connected". This is what the builder's follow-up below describes.

    • The cause: the shared Dropdown's value label reads a picked label's direction from its first letter (dir="auto", Custom Ping Monitor Template Not Available During Network Device Import #5145). That is right for a record name, and wrong for the product's own words when the translation starts with a Latin loanword.
    • The fix: DropdownOption gains an opt-in isInPageDirection. RunnerPicker sets it on the Global Runner (open or not), on a pick the reader cannot see ("A Runner you do not have access to") and on a step's "Unknown Runner (id)".
    • Result: the AI rule, runbook step and code repository pickers all read right to left in Persian. A Runner's own name keeps dir="auto", and no other dropdown changes.
  • The copy is unchanged. I read every sentence against the screenshots and kept the builder's words. The line under the box stands on its own (people read it without the field's description), and every locale already carries it.

Tests I added or changed

  • Common/Tests/UI/Components/Dropdown.test.tsx: a label with isInPageDirection has no dir and still truncates. Left out, or false, the label keeps dir="auto".

  • Common/Tests/App/Dashboard/RunnerPicker.test.tsx, new block what a Runner field shows, and its line:

    • every picker's Global Runner is laid out the page's way;
    • a Runner's own name and a project default keep dir="auto";
    • hidden and unknown picks are laid out the page's way;
    • each picker's line size;
    • the rule's link is plain, while a step's link in its box stays bold (negative control).
  • Common/Tests/App/Dashboard/AutoRemediationRuleFields.test.tsx:

    • in every state that has a line (Global Runner pick, add or unread; a hidden pick; No Runner with nothing to pick), the line is drawn as a hint and its link is underlined only;
    • the Global Runner's value has no dir, and a picked Runner keeps dir="auto".

    This replaces the builder's text-sm pin.

  • packages/E2E/AutoRemediationRules/AutoRemediationRules.spec.ts:

    • the 1280/390, light/dark test checks that the line has the Code Repositories hint's computed size, line height and colour;
    • the More fields "one look" test checks that the Runner's line sits under its own box, with a field's gap before the next field;
    • the Persian test checks that the box's computed direction is rtl and that its words follow each other from the right (German: from the left).
  • Each new check fails on the branch as the builder left it (12 jest tests, 5 browser tests and the "one look" test) and passes now.

Screenshots (in .github/pr-assets/runner-default-global/; its README says how they were made)

Before (master) After
Before: No Runner, with a line to add one After: Global Runner, with a small line under it
Before: the menu lists your Runners only After: Global Runner first, saying what it is
A project default first Global Runners turned off
The project default, tagged, then the Global Runner No Runner, as before
Persian, before the review Persian, after Phone, dark
Persian before: laid out left to right Persian after: right to left A phone in dark mode

German: after-de.png. The whole Fix step with More fields open, so the Runner's line can be seen beside the other hints: after-more-fields.png.

What I ran after merging master (3388931)
Everything passed.

  • Common (jest):
    • the 50 suites that read the files I changed, with the builder's related suites (1,939 tests);
    • npx jest Guard and Locale, plus UpdateChecksReadHeldRows, DeleteChecksReadHeldRows, HookWritesCallersMayMake, ListOrderColumnModels, PermissionCatalogueCoverage, SchemaMigrationsOnlineDdl, TestSendRoutesAskTheRule and AnalyticsSortKeyPageBoundScope (214 suites, 8,080 tests);
    • my three changed suites once more with ee/ moved aside.
  • App (jest): every Tests/Dashboard/*Guard suite, the Runner and AI settings suites, Locale, every root-level test, and the six docs suites that read the Runner picker (61 suites, 2,293 tests).
  • Type checks:
    • tsc --noEmit in Common, the Dashboard, App and E2E;
    • Common's npm run compile-tests;
    • --listFiles in Common, App and E2E: nothing outside each package and Common.
  • Offline browser suites (Chromium, CI=1, my own ports):
    • test-auto-remediation-rules-ui: 103 passed in 6.4 minutes. My checks add no test, only a few reads inside existing ones, so CI group 7 keeps the builder's estimate.
    • test-global-runners-ui: 52 passed.
    • test-code-ui, "a repository's Runner": 17 passed, plus the phone skip that was already there.
  • Lint: npx prettier --check and npx eslint --no-ignore on every file the branch changes.
  • ReDoS checker: no new patterns.

Not run

  • The ee suites: none of them covers these components beyond a type import.
  • A live stack: the server does not change.
  • The whole App and Common folders: CI runs them.

Master failures I saw: none.

Left alone

  • Other dropdowns whose translated label starts with a Latin word still take the label's own direction in Persian. That is the right-to-left lane's systemic change; it can now set isInPageDirection on each such label.
  • The builder's follow-ups above stand.

🤖 Generated with Claude Code

valeriamustafaeva and others added 6 commits October 25, 2026 11:23
…r, and says no Bash command runs there

- A rule that picks none reads Global Runner wherever the project uses
  global Runners and the server has one, as a runbook step and a code
  repository do; picking it stores none, so nothing changes for a rule
  that saved No Runner. OneUptime AI still runs no Bash commands for it:
  a shell is never run on a host every project shares.
- The line under the picker says so, with the way on: pick one of your
  Runners, or add one under Runbooks > Runners where the project has none.
- No Runner stays where there is no Global Runner to show (global Runners
  off, a server with none) and for an older rule that composes commands
  on its Command Runners.
- A rule's choices read the global Runners and the project's switch, each
  on its own; the field's line no longer says inside your network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er, where OneUptime AI runs no Bash commands, in 17 languages

- AI SRE: the rule's Runner lists your Runners and Global Runner; a new
  rule starts on the project's default, else Global Runner; no Bash
  command runs there; No Runner where the project or server has no
  global Runner.
- Upgrading: the rule Runner note says what a rule that picked none reads.
- Runbooks > Runners: the Global Runner table gains a row for OneUptime
  AI's Bash commands (never on a global Runner).
- The docs facts suites pin the new sentences and the table row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at 1280px and 390px, light and dark, in German and Persian

- The AI rules fixture serves the global Runners from their own route and
  the project's switch (?globalRunners=0|disconnected, ?useGlobalRunners=0),
  and Persian right to left (?lang=fa).
- The Runner tests follow the Global Runner default: going back to none is
  picking it (null saved), global Runners off reads No Runner and clears,
  no Runners shows the Global Runner with where to add one.
- Prettier and the loop tests read module state through functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, light and dark

- Global Runners: in a project with no Runner of its own, the JavaScript
  step's field on Global Runner and the Bash step's 'needs a Runner of
  your own' line, with the open menu on the screen, at both widths and
  both themes.
- Code: a repository's Runner field on Global Runner in dark mode, at a
  laptop's and a phone's width (the suite's two projects).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ngine never asks for a global Runner

A rule that saved no Runner - shown as Global Runner in its form - is
read as none: OneUptime AI picks a runbook as before, composes no Bash
commands, and the engine reads no global Runner at all.

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

simlarsen commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

valeriamustafaeva and others added 5 commits October 25, 2026 12:50
…, and the Global Runner reads right to left in Persian

UI review of the AI rule's Runner field on the Global Runner:

- The line under the box ("OneUptime AI runs no Bash commands on the
  Global Runner, ...") was the size of the field's own description, with a
  bold link, under three pickers whose hints are small (Code Repositories,
  MCP Servers, LLM Provider). It is now drawn as they are: text-xs, close
  under its box, a field's gap before the next field, its Runbooks ›
  Runners link underlined only. Steps and code repositories keep theirs.
- In Persian the box laid "Runner سراسری · متصل" out left to right, the
  shared Dropdown reading a picked label by its first, Latin, letter
  (dir="auto", #5145), so "Runner" came after "Connected". DropdownOption
  gains isInPageDirection, an opt-in for a label of the product's own
  words: the box lays it out the page's way. Every Runner picker sets it on
  the Global Runner; a Runner's own name keeps dir="auto".

Tests: the Dropdown flag; every picker's Global Runner in the page's
direction and a Runner's name in its own; each picker's line size; the
rule's lines in each state; offline browser checks that the line has the
Code Repositories hint's size and colour at 1280 and 390, light and dark,
and sits under its own box, and that the Persian box reads right to left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the product's own words

"A Runner you do not have access to" and a step's "Unknown Runner (id)"
are the product's words as well: in Persian they start with a Latin word
("Runnerی که به آن دسترسی ندارید"), and the box drew them left to right.
They take isInPageDirection like the Global Runner; a Runner's own name
keeps reading its own direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, in a phone, dark mode, German and Persian

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n like the hints, and the Persian box reads from the right

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant