Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ the toast. That's it.
something new arrives. Because why not.
- **`gitbell notify` from any script.** Agents, CI, cron: if it can run a
command, it can ring your bell. See below.
- **Knows when an agent did it.** Commits and PRs from Claude Code, Copilot,
Codex, Cursor, OpenCode, Aider, Devin or Jules land in their own category,
the toast names the agent, and the click opens the agent's session when the
commit carried a link. Honest limits below.
- **Polite polling.** Honors GitHub's `X-Poll-Interval`, uses ETags so
unchanged polls cost zero rate limit, and backs off automatically when
throttled.
Expand Down Expand Up @@ -109,6 +113,32 @@ directory (`~/.local/share/dev.ejos.gitbell/` on Linux,
| `org:name` | every repository in an organization you belong to |
| `@me` | everything you watch or follow (your received-events feed) |

## Agents 🤖

GitHub has no "an agent did this" flag. GitBell reads the fingerprints the
tools leave by default: `Co-Authored-By` trailers, session-link trailers
(`Claude-Session:`, `Agent-Logs-Url:`), bot logins (`copilot-swe-agent[bot]`,
`claude[bot]`), branch prefixes (`claude/`, `copilot/`, `cursor/`) and PR
body markers. A hit moves the event to the **Agents** category with its own
toggle and sound, the toast reads "🤖 Claude Code (@you) pushed to …", and
the link prefers the agent's session. Your own agents count even with
"ignore my own activity" on, because cloud sessions push as you.

| Tool | Detected out of the box |
|---|---|
| Claude Code (CLI, desktop, web, GitHub Action) | yes |
| GitHub Copilot coding agent | yes, always |
| Codex (CLI, app, cloud) | yes |
| Cursor agent, OpenCode, Aider, Devin, Jules | yes |
| Gemini CLI, Cline, Roo Code, Kilo Code | no: they don't sign commits |

**The catch:** detection only works when the tool signs. If you turn
attribution off (every tool lets you), or use a tool that doesn't sign, the
commit is indistinguishable from yours, for GitBell and for anyone. For
those cases use the hook below: the agent tells GitBell directly, no git
forensics involved. Models (DeepSeek, GPT, Claude) are not tools; the mark
comes from whatever wraps them.

## Notify from scripts and agents 🤖

GitBell doubles as a local notification endpoint. While it's running in the
Expand Down Expand Up @@ -146,6 +176,27 @@ nothing was pushed yet. In `~/.claude/settings.json`:
}
```

To be told about every commit Claude Code makes, whether or not it signs
them, hook the `Bash` tool and look for `git commit`:

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "in=$(cat); echo \"$in\" | jq -e '.tool_input.command | test(\"git commit\")' >/dev/null && gitbell notify \"Claude Code committed\" --body \"$(echo \"$in\" | jq -r .cwd)\""
}
]
}
]
}
}
```

Any tool that can run a shell command works the same way: CI scripts, long
test runs, deploys, cron jobs.

Expand Down
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ <h2 data-i18n="options.title"></h2>
<label class="toggle">
<input id="ignore-own" type="checkbox" /> <span data-i18n="options.ignoreOwn"></span>
</label>
<label class="toggle">
<input id="agents-own" type="checkbox" /> <span data-i18n="options.agentsOwn"></span>
</label>
<label class="toggle">
<input id="autostart" type="checkbox" /> <span data-i18n="options.autostart"></span>
</label>
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/notify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ use tauri::AppHandle;
/// webview, and the webview renders content written by strangers on the
/// internet, so we don't trust it blindly (trust boundary, see SECURITY.md).
fn is_allowed_url(url: &str) -> bool {
url.starts_with("https://github.com/")
// GitHub itself plus the places agent sessions live
["https://github.com/", "https://claude.ai/", "https://chatgpt.com/", "https://cursor.com/"]
.iter()
.any(|p| url.starts_with(p))
}

#[tauri::command]
Expand Down Expand Up @@ -73,6 +76,7 @@ mod tests {
#[test]
fn only_github_https() {
assert!(is_allowed_url("https://github.com/tauri-apps/tauri/pull/1"));
assert!(is_allowed_url("https://claude.ai/code/session_01ABC"));
assert!(!is_allowed_url("http://github.com/x"));
assert!(!is_allowed_url("https://github.com.evil.io/x"));
assert!(!is_allowed_url("file:///etc/passwd"));
Expand Down
74 changes: 74 additions & 0 deletions src/core/__tests__/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { beforeAll, describe, expect, it } from "vitest";
import { detectAgent } from "../agents";
import { toNotice } from "../format";
import { initLang } from "../i18n";
import type { GhEvent } from "../types";

const ev = (type: string, payload: Record<string, unknown>, actor = "ejos"): GhEvent => ({
id: "9",
type,
actor: { login: actor, avatar_url: "" },
repo: { name: "acme/api" },
payload,
created_at: "2026-09-12T10:00:00Z",
});
const push = (message: string, author = { name: "Ana", email: "ana@example.com" }, ref = "refs/heads/main") =>
ev("PushEvent", { ref, size: 1, commits: [{ message, author }] });

describe("detectAgent", () => {
it("claude code trailers, with session url when present", () => {
const h = detectAgent(push("fix: x\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01A"));
expect(h?.agent).toBe("claude");
expect(h?.sessionUrl).toBe("https://claude.ai/code/session_01A");
expect(detectAgent(push("fix: x\n\nCo-Authored-By: Claude <noreply@anthropic.com>"))?.sessionUrl).toBeNull();
});

it("copilot: author name, committer bot login, Agent-Logs-Url", () => {
expect(detectAgent(push("x", { name: "Copilot", email: "1+copilot@users.noreply.github.com" }))?.agent).toBe("copilot");
expect(detectAgent(push("x\n\nAgent-Logs-Url: https://github.com/acme/api/sessions/abc"))?.sessionUrl)
.toBe("https://github.com/acme/api/sessions/abc");
expect(detectAgent(ev("PullRequestEvent", { pull_request: { head: { ref: "main" } } }, "copilot-swe-agent[bot]"))?.agent).toBe("copilot");
});

it("codex, cursor, opencode trailers", () => {
expect(detectAgent(push("x\n\nCo-authored-by: Codex <noreply@openai.com>"))?.agent).toBe("codex");
expect(detectAgent(push("x\n\nCo-authored-by: Cursor <cursoragent@cursor.com>"))?.agent).toBe("cursor");
expect(detectAgent(push("x\n\n🤖 Generated with opencode\nCo-Authored-By: opencode <noreply@opencode.ai>"))?.agent).toBe("opencode");
});

it("branch prefixes and PR body markers", () => {
expect(detectAgent(ev("PullRequestEvent", { action: "opened", pull_request: { head: { ref: "claude/fix-login-abc" }, body: "" } }))?.agent).toBe("claude");
expect(detectAgent(ev("PullRequestEvent", { action: "opened", pull_request: { head: { ref: "feat/x" }, body: "…\n\n🤖 Generated with [Claude Code](https://claude.com)" } }))?.agent).toBe("claude");
expect(detectAgent(ev("CreateEvent", { ref_type: "branch", ref: "copilot/add-tests" }))?.agent).toBe("copilot");
});

it("humans stay humans", () => {
expect(detectAgent(push("feat: real work by a person"))).toBeNull();
expect(detectAgent(ev("PullRequestEvent", { pull_request: { head: { ref: "feature/login" }, body: "manual" } }))).toBeNull();
});

it("unknown [bot] actors are flagged as generic bots", () => {
expect(detectAgent(push("x"))).toBeNull();
expect(detectAgent(ev("WatchEvent", {}, "dependabot[bot]"))?.agent).toBe("bot");
});
});

describe("toNotice with agents", () => {
beforeAll(() => initLang("es", undefined));
const e = push("feat: x\n\nClaude-Session: https://claude.ai/code/session_01A");

it("moves the event to the agent category, names the agent, links the session", () => {
const n = toNotice(e)!;
expect(n.category).toBe("agent");
expect(n.agent).toBe("Claude Code");
expect(n.title).toBe("🤖 Claude Code (@ejos) hizo push a acme/api");
expect(n.url).toBe("https://claude.ai/code/session_01A");
});

it("falls back to the plain category when agents are disabled", () => {
const n = toNotice(e, false)!;
expect(n.category).toBe("push");
expect(n.agent).toBeUndefined();
expect(n.title).toBe("ejos hizo push a acme/api");
});
});
165 changes: 165 additions & 0 deletions src/core/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Who did this: a human or a coding agent? GitHub has no "agent" flag, so
// we read the fingerprints each tool leaves behind. All of them are
// optional and users can turn them off, so a hit is "probable", never
// "certain". Sources for each signal are in APUNTES.md / README.

import type { GhEvent } from "./types";

export type AgentId =
| "claude" // Claude Code (CLI, web, GitHub Action)
| "copilot" // GitHub Copilot coding agent
| "codex" // OpenAI Codex
| "cursor" // Cursor agent
| "opencode" // OpenCode
| "devin" // Cognition Devin
| "jules" // Google Jules
| "aider" // Aider
| "bot"; // some other [bot] account

export const AGENT_LABEL: Record<AgentId, string> = {
claude: "Claude Code",
copilot: "Copilot",
codex: "Codex",
cursor: "Cursor",
opencode: "OpenCode",
devin: "Devin",
jules: "Jules",
aider: "Aider",
bot: "Bot",
};

export interface AgentHit {
agent: AgentId;
/** link to the agent's own session/logs when the commit carried one */
sessionUrl: string | null;
/** which fingerprint fired, for debugging and tests */
signal: string;
}

// ---- fingerprints -----------------------------------------------------------

/** GitHub logins that are agents. Matched case-insensitively, exact. */
const LOGINS: [string, AgentId][] = [
["claude[bot]", "claude"],
["copilot-swe-agent[bot]", "copilot"],
["copilot", "copilot"],
["chatgpt-codex-connector[bot]", "codex"],
["cursor[bot]", "cursor"],
["devin-ai-integration[bot]", "devin"],
["google-labs-jules[bot]", "jules"],
];

/** Email domains/addresses that show up in Co-Authored-By trailers or as commit authors. */
const EMAILS: [RegExp, AgentId][] = [
[/@anthropic\.com$/i, "claude"],
[/^noreply@openai\.com$/i, "codex"],
[/@cursor\.com$/i, "cursor"],
[/@opencode\.ai$/i, "opencode"],
];

/** Branch prefixes the cloud agents use for their work. */
const BRANCHES: [RegExp, AgentId][] = [
[/^claude\//, "claude"],
[/^copilot\//, "copilot"],
[/^cursor\//, "cursor"],
[/^codex\//, "codex"],
[/^devin\//, "devin"],
];

/** Free-text markers in commit messages or PR bodies. */
const TEXT: [RegExp, AgentId][] = [
[/Generated with \[?Claude Code\]?/i, "claude"],
[/^Claude-Session:\s*https?:\/\/\S+/im, "claude"],
[/^Agent-Logs-Url:\s*https?:\/\/\S+/im, "copilot"],
[/Generated with opencode/i, "opencode"],
[/^Co-Authored-By:.*\bCodex\b/im, "codex"],
[/^Co-Authored-By:.*\bCursor\b/im, "cursor"],
[/^Co-Authored-By:.*\bClaude\b/im, "claude"],
[/\(aider\)/i, "aider"],
];

const SESSION_TRAILERS = [
/^Claude-Session:\s*(https?:\/\/\S+)/im,
/^Agent-Logs-Url:\s*(https?:\/\/\S+)/im,
];

// ---- detection --------------------------------------------------------------

function fromLogin(login: string | undefined): AgentHit | null {
if (!login) return null;
const l = login.toLowerCase();
for (const [name, agent] of LOGINS) if (l === name) return { agent, sessionUrl: null, signal: `login:${login}` };
if (l.endsWith("[bot]")) return { agent: "bot", sessionUrl: null, signal: `login:${login}` };
return null;
}

function fromText(text: string | undefined, where: string): AgentHit | null {
if (!text) return null;
for (const [re, agent] of TEXT) {
if (re.test(text)) {
let sessionUrl: string | null = null;
for (const tr of SESSION_TRAILERS) {
const m = tr.exec(text);
if (m) {
sessionUrl = m[1];
break;
}
}
return { agent, sessionUrl, signal: `${where}:${re.source.slice(0, 24)}` };
}
}
return null;
}

function fromEmail(email: string | undefined, name: string | undefined, where: string): AgentHit | null {
if (email) for (const [re, agent] of EMAILS) if (re.test(email)) return { agent, sessionUrl: null, signal: `${where}:${email}` };
if (name && /^copilot$/i.test(name)) return { agent: "copilot", sessionUrl: null, signal: `${where}:${name}` };
if (name && /\(aider\)/i.test(name)) return { agent: "aider", sessionUrl: null, signal: `${where}:${name}` };
return null;
}

function fromBranch(ref: string | undefined): AgentHit | null {
if (!ref) return null;
for (const [re, agent] of BRANCHES) if (re.test(ref)) return { agent, sessionUrl: null, signal: `branch:${ref}` };
return null;
}

/**
* Inspect an event for agent fingerprints. Order: actor login (strongest),
* then per-type payload details (commit trailers/authors, PR branch/body).
*/
export function detectAgent(ev: GhEvent): AgentHit | null {
const p = ev.payload;
const byActor = fromLogin(ev.actor?.login);
if (byActor && byActor.agent !== "bot") return byActor;

let hit: AgentHit | null = null;
switch (ev.type) {
case "PushEvent": {
const commits: { message?: string; author?: { name?: string; email?: string } }[] = p.commits ?? [];
for (const c of commits) {
hit = fromText(c.message, "commit") ?? fromEmail(c.author?.email, c.author?.name, "author");
if (hit) break;
}
hit ??= fromBranch((p.ref as string | undefined)?.replace(/^refs\/heads\//, ""));
break;
}
case "PullRequestEvent":
case "PullRequestReviewEvent":
case "PullRequestReviewCommentEvent": {
const pr = p.pull_request ?? {};
hit = fromLogin(pr.user?.login)
?? fromBranch(pr.head?.ref)
?? fromText(pr.body, "pr-body")
?? fromText(pr.title, "pr-title");
if (hit?.agent === "bot" && !byActor) hit = null; // PR by a generic bot but human actor: not ours to flag
break;
}
case "CreateEvent":
hit = fromBranch(p.ref);
break;
default:
break;
}
return hit ?? byActor;
}
22 changes: 18 additions & 4 deletions src/core/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// GitHub event -> human sentence. This is the file you'll edit most when
// you want the toasts to say something different.

import { AGENT_LABEL, detectAgent } from "./agents";
import { t } from "./i18n";
import type { EventCategory, GhEvent, Notice } from "./types";

Expand Down Expand Up @@ -28,12 +29,19 @@ const shortRef = (ref: string | undefined) => (ref ?? "").replace(/^refs\/heads\
const firstLine = (s: string | undefined) => (s ?? "").split("\n")[0].trim();
const clip = (s: string, n = 90) => (s.length > n ? s.slice(0, n - 1) + "…" : s);

/** Returns null for event types we don't narrate. */
export function toNotice(ev: GhEvent): Notice | null {
const category = categoryOf(ev.type);
/**
* Returns null for event types we don't narrate. When a coding agent did it
* and `agentsEnabled`, the notice moves to the "agent" category, the agent
* is named as the actor, and the link prefers the agent's session.
*/
export function toNotice(ev: GhEvent, agentsEnabled = true): Notice | null {
let category = categoryOf(ev.type);
if (!category) return null;

const who = ev.actor.login;
const hit = agentsEnabled ? detectAgent(ev) : null;
const agentLabel = hit ? AGENT_LABEL[hit.agent] : undefined;
if (hit) category = "agent";
const who = agentLabel ? `${agentLabel} (@${ev.actor.login})` : ev.actor.login;
const repo = ev.repo.name;
const p = ev.payload;
const repoUrl = `https://github.com/${repo}`;
Expand Down Expand Up @@ -142,9 +150,15 @@ export function toNotice(ev: GhEvent): Notice | null {
}
}

if (hit) {
title = `🤖 ${title}`;
if (hit.sessionUrl) url = hit.sessionUrl;
}

return {
id: ev.id,
category,
agent: agentLabel,
title,
body,
url,
Expand Down
Loading
Loading