Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
52c3077
feat(github): limit whose PR feedback wakes agents
johnmatthewtennant Oct 27, 2026
e11d459
fix(github): keep membership lookups from holding feedback forever
johnmatthewtennant Oct 27, 2026
2186cee
test(settings): expect the GitHub comment authors card
johnmatthewtennant Oct 27, 2026
1fad9fe
feat(github): decide PR feedback by ordered allow and deny rules
johnmatthewtennant Oct 28, 2026
3f1e0ef
refactor(github): drop converting saved author lists to rules
johnmatthewtennant Oct 28, 2026
bce98da
fix(github): judge waiting feedback by current rules and fresh member…
johnmatthewtennant Oct 29, 2026
0a1f62c
style(github): lay out the comment author rules as a Settings form
johnmatthewtennant Oct 29, 2026
067d37b
fix(github): hold waiting feedback whose author it cannot check
johnmatthewtennant Oct 29, 2026
ecb5490
feat(github): choose what feedback no rule matches gets
johnmatthewtennant Oct 29, 2026
4beed20
test(github): cover rules saved in Settings changing a running watch
johnmatthewtennant Oct 29, 2026
31cdefb
docs(github): say body rules see what a wake quotes
johnmatthewtennant Oct 29, 2026
a604e11
fix(github): recheck new feedback against rules saved during a lookup
johnmatthewtennant Oct 29, 2026
e0d5bef
fix(github): match body rules against the text a wake quotes
johnmatthewtennant Oct 29, 2026
4ce1145
fix(github): leave feedback admission to the final recheck
johnmatthewtennant Oct 29, 2026
b7bbac8
fix(github): stop membership fallback when a watch stops
johnmatthewtennant Oct 29, 2026
50aa164
fix(github): count failed membership reads once a poll
johnmatthewtennant Oct 29, 2026
e5ba22c
fix(github): match rule patterns in linear time
johnmatthewtennant Oct 29, 2026
6490dfc
fix(github): show why rules are not rules on the Rules field
johnmatthewtennant Oct 29, 2026
460f75b
docs(github): allow Copilot by its exact bot login
johnmatthewtennant Oct 29, 2026
d570324
fix(github): match an escaped surrogate pair as one character
johnmatthewtennant Oct 29, 2026
88d72e4
fix(github): reject rules naming more organizations than a read can ask
johnmatthewtennant Oct 29, 2026
9b1965e
fix(github): hold feedback while GitHub answers no membership read
johnmatthewtennant Oct 29, 2026
1829b5f
fix(github): match a legacy octal escape as one character
johnmatthewtennant Oct 29, 2026
5bad688
fix(github): hold feedback while saved rules no longer parse
johnmatthewtennant Oct 29, 2026
69ffeb4
fix(github): read new feedback before rereading what is held
johnmatthewtennant Oct 29, 2026
434b647
Refuse pattern escapes that mean something else alone
johnmatthewtennant Oct 29, 2026
069617e
Keep held feedback held across limited reads
johnmatthewtennant Oct 29, 2026
99676a2
Count membership retries from the requests sent
johnmatthewtennant Oct 29, 2026
98458bb
Merge main: decide watch feedback from the pull request store
johnmatthewtennant Oct 29, 2026
0bffb49
fix(github): read memberships apart from deciding a watch
johnmatthewtennant Oct 29, 2026
ee226db
fix(github): let a watch's first read learn feedback held elsewhere
johnmatthewtennant Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,58 @@ so the agent can finish and retire its Interest. Attention delivers the wake
in the conversation where it called the tool: no message is sent, and the agent
is told its text is data. It quotes the failed checks' names and the new
comments, line by line, each clipped, with GitHub's links. Agents comment as the
`gh` account, as their owner does, so GitHub cannot tell them apart: a comment of
that account that starts with 🤖, as an agent's do, wakes nobody, and the owner's
other comments wake the agent. A watch is saved in Attention on the device and respects its on/off switch. Device-local GitHub watches saved before Attention are dropped, not imported: they name no Interest. It ends when the
`gh` account, as their owner does, so GitHub cannot tell them apart: by default
a comment of that account that starts with 🤖, as an agent's do, wakes nobody,
and the owner's other comments wake the agent. A watch is saved in Attention on the device and respects its on/off switch. Device-local GitHub watches saved before Attention are dropped, not imported: they name no Interest. It ends when the
pull request merges or closes, after 20 wakes, after 30 minutes without a
successful read, or when the agent calls it with `stop: true`.

Settings → Plugins → GitHub comment authors decides whose feedback wakes agents,
since anyone can comment on a public pull request. It holds rules, one per
line, saved on the device. Each starts with `allow` or `deny`, followed by any
of these conditions, all of which must hold:

- `author:login`, an exact login, or `author:@me`, the signed-in `gh` account.
A bot's login ends in `[bot]`.
- `author:/pattern/`, a JavaScript regular expression the login must match.
- `org:name`, membership of a GitHub organization, named exactly.
- `body:/pattern/`, a regular expression the comment's first 1,000 characters
must match. That is the most of a comment a wake quotes.
- `prefix:text`, what the comment starts with, leading space aside. It runs to
the end of the line, spaces included, so it comes last.

A bare `allow` or `deny`, with no conditions, matches everyone.

Logins match case aside, so an author pattern always has the `i` flag; a
pattern may also use `m`, `s` and `u`. Patterns take JavaScript's syntax but not
backreferences, lookaround or escapes that mean something else alone, such as
`\c1`, `\U0041` or `\q`, and `src/bundled/github/pattern.ts` matches them
as an automaton, in time the text's length bounds, so a pattern like
`/^(a+)+$/` cannot stall the app on a hostile comment. The first rule that matches decides, and
When no rule matches decides the rest: Wake the agent or Don't wake it.
Saving compiles every pattern and rejects the rules, naming the line, when one
is not a rule, or when they name more organizations than one membership read
can ask about. Saved rules that no longer parse, such as a pattern with
lookaround an earlier build accepted, stay in Settings with their error, and
hold all feedback until they are fixed rather than falling back to the defaults. The defaults keep the behavior from before rules: agents'
comments wake nobody, and everyone else's wake the agent.

```text
deny author:@me prefix:🤖
```
Comment thread
johnmatthewtennant marked this conversation as resolved.

with When no rule matches set to Wake the agent. Adding, say,
`allow org:block` and `allow author:copilot-pull-request-reviewer[bot]` and choosing Don't wake it
limits feedback to those. Membership is asked of `gh` only for organizations the rules
name, and kept for an hour; feedback whose author's membership cannot be read
waits for a later poll, until ten polls in a row fail for that login alone and
it counts as no member. While GitHub answers no membership read, not even about
the signed-in account, no failure counts, so an outage holds feedback rather
than letting it past an `org:` deny rule. A membership hidden from the `gh` account, or a
misspelled organization, counts as none, and bots and deleted accounts are in
no organization. Each poll reads the rules again, so a change applies to
running watches. CI and pull request state are not limited.

The Agents runner starts a mention run from a `p` tag, or from any new message
by the other member of a one-to-one DM the agent is in; group DMs still need
the mention. It also owns watches and timers, and runs attention work in Janet's lanes: each
Expand Down
16 changes: 14 additions & 2 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.snapshot()
.map((card) => card.pluginId)
.sort(),
["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"],
[
"block.builderlab",
"buzz.channels",
"buzz.emoji",
"buzz.github",
"buzz.pairing",
],
);
// The device-local template library remains reachable without a selected community.
assert.equal(
Expand Down Expand Up @@ -201,7 +207,13 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
.snapshot()
.map((card) => card.pluginId)
.sort(),
["block.builderlab", "buzz.channels", "buzz.emoji", "buzz.pairing"],
[
"block.builderlab",
"buzz.channels",
"buzz.emoji",
"buzz.github",
"buzz.pairing",
],
);

await vi.waitFor(() =>
Expand Down
85 changes: 85 additions & 0 deletions src/bundled/github/AuthorSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import { AuthorSettings } from "./AuthorSettings";
import { DEFAULT_RULES, loadPolicy, POLICY_KEY } from "./authors";

afterEach(() => {
cleanup();
localStorage.clear();
});

it("shows the default rules, and saves only rules that parse", async () => {
const user = userEvent.setup();
render(<AuthorSettings active={() => true} />);
expect(screen.getByRole("status")).toHaveTextContent(
"Comments from anyone no rule denies can wake your agents.",
);
expect(screen.getByRole("radio", { name: "Wake the agent" })).toBeChecked();
const rules = screen.getByRole("textbox", { name: /Rules/ });
expect(rules).toHaveValue(DEFAULT_RULES);
const save = screen.getByRole("button", { name: "Save" });
expect(save).toBeDisabled();
await user.clear(rules);
await user.type(rules, "deny author:@me prefix:🤖{Enter}allow author:/(/");
await user.click(save);
expect(rules).toBeInvalid();
expect(rules).toHaveAccessibleDescription(
expect.stringContaining("Line 2: Invalid regular expression"),
);
expect(loadPolicy().text).toBe(DEFAULT_RULES);
// Editing checks the rules again, so restoring the saved ones clears it.
await user.clear(rules);
await user.type(rules, DEFAULT_RULES);
expect(rules).toBeValid();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(save).toBeDisabled();
await user.clear(rules);
await user.type(
rules,
"deny author:@me prefix:🤖{Enter}allow org:block{Enter} allow author:copilot-pull-request-reviewer[[bot] ",
);
await user.click(screen.getByRole("radio", { name: "Don’t wake it" }));
await user.click(save);
expect(loadPolicy().text).toBe(
"deny author:@me prefix:🤖\nallow org:block\nallow author:copilot-pull-request-reviewer[bot]",
);
expect(loadPolicy().fallback).toBe("deny");
expect(rules).toHaveValue(loadPolicy().text);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(save).toBeDisabled();
await user.click(screen.getByRole("radio", { name: "Wake the agent" }));
expect(save).toBeEnabled();
await user.click(save);
expect(loadPolicy().fallback).toBe("allow");
expect(screen.getByRole("status")).toBeInTheDocument();
});

it("shows saved rules that no longer parse, with why, until they are fixed", async () => {
const user = userEvent.setup();
const text = "allow author:/^(?!eve$)[a-z]+$/";
localStorage.setItem(
POLICY_KEY,
JSON.stringify({ version: 2, text, fallback: "deny" }),
);
render(<AuthorSettings active={() => true} />);
const rules = screen.getByRole("textbox", { name: /Rules/ });
expect(rules).toHaveValue(text);
expect(rules).toBeInvalid();
expect(rules).toHaveAccessibleDescription(
expect.stringContaining("Line 1: a pattern's groups can only be"),
);
expect(screen.getByRole("status")).toHaveTextContent(
"no comment or review wakes your agents",
);
expect(screen.getByRole("radio", { name: "Don’t wake it" })).toBeChecked();
await user.clear(rules);
await user.type(rules, "deny author:eve{Enter}allow author:/^[[a-z]+$/");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(loadPolicy().error).toBeUndefined();
expect(rules).toBeValid();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
103 changes: 103 additions & 0 deletions src/bundled/github/AuthorSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState } from "react";
import { Button } from "../../shared/design-system/ui/Button";
import { Field } from "../../shared/design-system/ui/Field";
import { Header } from "../../shared/design-system/ui/Header";
import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
import { Textarea } from "../../shared/design-system/ui/Textarea";
import { loadPolicy, open, parse, savePolicy, type Rule } from "./authors";

/** Whose pull request comments and reviews wake agents watching it. */
export function AuthorSettings({ active }: { active(): boolean }) {
const [saved, setSaved] = useState(loadPolicy);
const [draft, setDraft] = useState(saved.text);
const [fallback, setFallback] = useState(saved.fallback);
/** Why the rules could not be saved, apart from why they are not rules. */
const [error, setError] = useState("");
// After a Save, each edit checks the rules again.
const [checked, setChecked] = useState(saved.error !== undefined);
const parsed = parse(draft);
const dirty =
typeof parsed === "string" ||
parsed.text !== saved.text ||
fallback !== saved.fallback;
return (
<section aria-label="GitHub comment authors settings">
<Header
title="GitHub comment authors"
subtitle="Agents watching a pull request are woken by CI, and by the comments and reviews these rules allow."
/>
<form
aria-label="GitHub comment author rules"
onSubmit={(event) => {
event.preventDefault();
if (!active() || !dirty) return;
setChecked(true);
if (typeof parsed === "string") return;
try {
const policy = savePolicy(draft, fallback);
setSaved(policy);
setDraft(policy.text);
setChecked(false);
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
}
}}
>
<SettingsGroup layout="form">
{saved.error !== undefined && (
<p role="status" className="m-0 text-body-sm text-subtle">
Your saved rules no longer parse, so no comment or review wakes
your agents until you fix them.
</p>
)}
{open(saved) && (
<p role="status" className="m-0 text-body-sm text-subtle">
Comments from anyone no rule denies can wake your agents. To limit
them, allow only org:name or author:login, and don’t wake them
when no rule matches.
</p>
)}
<Field
label="Rules"
error={checked && typeof parsed === "string" ? parsed : undefined}
description="One per line: allow or deny, then any of author:login, author:/pattern/, org:organization, body:/pattern/ and prefix:text, which runs to the end of the line. Patterns are JavaScript regular expressions without backreferences, lookaround or escapes JavaScript reads as plain letters, such as \q. body: sees a comment's first 1,000 characters, the most a wake quotes. A bare allow or deny matches everyone. author:@me is your GitHub account, a bot's login ends in [bot], and logins match case aside. The first rule that matches decides. Membership your GitHub account cannot see, or of a misspelled organization, counts as none."
>
<Textarea
rows={6}
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
autoComplete="off"
value={draft}
onChange={(event) => {
setDraft(event.target.value);
}}
/>
</Field>
<Field label="When no rule matches" nativeLabel={false}>
<RadioGroup<Rule["effect"]>
aria-label="When no rule matches"
value={fallback}
onValueChange={setFallback}
>
<Radio value="allow" label="Wake the agent" />
<Radio value="deny" label="Don’t wake it" />
</RadioGroup>
</Field>
{error && (
Comment thread
johnmatthewtennant marked this conversation as resolved.
<p role="alert" className="m-0 text-body-sm text-danger">
{error}
</p>
)}
<div className="flex flex-wrap items-center justify-end gap-3">
<Button type="submit" variant="primary" disabled={!dirty}>
Save
</Button>
</div>
</SettingsGroup>
</form>
</section>
);
}
Loading
Loading