diff --git a/README.md b/README.md index b820af4..2061a1b 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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. diff --git a/index.html b/index.html index 502a6c8..326ded0 100644 --- a/index.html +++ b/index.html @@ -225,6 +225,9 @@

+ diff --git a/src-tauri/src/notify.rs b/src-tauri/src/notify.rs index 281cb56..65a7446 100644 --- a/src-tauri/src/notify.rs +++ b/src-tauri/src/notify.rs @@ -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] @@ -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")); diff --git a/src/core/__tests__/agents.test.ts b/src/core/__tests__/agents.test.ts new file mode 100644 index 0000000..b049dcd --- /dev/null +++ b/src/core/__tests__/agents.test.ts @@ -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, 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 \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 "))?.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 "))?.agent).toBe("codex"); + expect(detectAgent(push("x\n\nCo-authored-by: Cursor "))?.agent).toBe("cursor"); + expect(detectAgent(push("x\n\n🤖 Generated with opencode\nCo-Authored-By: opencode "))?.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"); + }); +}); diff --git a/src/core/agents.ts b/src/core/agents.ts new file mode 100644 index 0000000..34201b0 --- /dev/null +++ b/src/core/agents.ts @@ -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 = { + 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; +} diff --git a/src/core/format.ts b/src/core/format.ts index b777d72..9e3e35c 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -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"; @@ -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}`; @@ -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, diff --git a/src/core/i18n.ts b/src/core/i18n.ts index e190e4d..9b51327 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -43,6 +43,9 @@ const dict = { "cat.release": "Releases", "cat.social": "Stars, forks, miembros", "cat.external": "Avisos externos (gitbell notify)", + "cat.agent": "Agentes (Claude, Copilot, Codex…)", + "options.agentsOwn": "Avisar aunque el agente actúe con mi cuenta", + "recent.agent": "Agente", // sounds "sounds.title": "Sonidos", "sounds.enabled": "Reproducir sonido con cada aviso", @@ -187,6 +190,9 @@ const dict = { "cat.release": "Releases", "cat.social": "Stars, forks, members", "cat.external": "External notices (gitbell notify)", + "cat.agent": "Agents (Claude, Copilot, Codex…)", + "options.agentsOwn": "Notify even when the agent acts under my account", + "recent.agent": "Agent", "sounds.title": "Sounds", "sounds.enabled": "Play a sound with every notification", "sounds.volume": "Volume", diff --git a/src/core/poller.ts b/src/core/poller.ts index 64a8033..5855780 100644 --- a/src/core/poller.ts +++ b/src/core/poller.ts @@ -153,13 +153,17 @@ export class Poller { if (!last) return []; // first contact: just remember, don't spam - const { events: wanted, ignoreOwn, login } = this.settings; + const { events: wanted, ignoreOwn, agentsOwn, login } = this.settings; const out: Notice[] = []; for (const ev of events) { if (BigInt(ev.id) <= BigInt(last)) break; - if (ignoreOwn && login && ev.actor.login === login) continue; - const n = toNotice(ev); + const n = toNotice(ev, wanted.agent); if (!n || !wanted[n.category]) continue; + // your own activity is noise... unless an agent did it under your name: + // cloud sessions push with your credentials and that's exactly the + // "did my agent finish?" moment + const mine = !!login && ev.actor.login === login; + if (mine && ignoreOwn && !(n.agent && agentsOwn)) continue; out.push(n); } return out; diff --git a/src/core/types.ts b/src/core/types.ts index c4077cc..83ae57d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,7 +13,8 @@ export type EventCategory = | "branch" | "release" | "social" - | "external"; + | "external" + | "agent"; export const ALL_CATEGORIES: EventCategory[] = [ "push", @@ -25,6 +26,7 @@ export const ALL_CATEGORIES: EventCategory[] = [ "release", "social", "external", + "agent", ]; export function categoryLabel(cat: EventCategory): string { @@ -48,6 +50,8 @@ export interface Settings { pollSeconds: number; /** skip events where actor === login (you already know what you did) */ ignoreOwn: boolean; + /** ...except when an agent did it under your account (cloud sessions push as you) */ + agentsOwn: boolean; /** master switch for audio */ soundsEnabled: boolean; /** 0..1 */ @@ -75,9 +79,11 @@ export const DEFAULT_SETTINGS: Settings = { release: true, social: false, external: true, + agent: true, }, pollSeconds: 60, ignoreOwn: true, + agentsOwn: true, soundsEnabled: true, volume: 0.8, glass: 0.7, @@ -91,6 +97,7 @@ export const DEFAULT_SETTINGS: Settings = { release: "sound2", social: "none", external: "sound1", + agent: "sound2", }, }; @@ -109,6 +116,8 @@ export interface GhEvent { export interface Notice { id: string; category: EventCategory; + /** set when a coding agent did it; label like "Claude Code" */ + agent?: string; title: string; body: string; url: string; diff --git a/src/ui/preview.ts b/src/ui/preview.ts index 68a635a..a7dc619 100644 --- a/src/ui/preview.ts +++ b/src/ui/preview.ts @@ -34,6 +34,7 @@ export function preview(): void { let token: string | null = noToken ? null : "preview"; const recent = noToken ? [] : [ + { id: "4", category: "agent" as const, agent: "Claude Code", title: "🤖 Claude Code (@eJosR-Coding) abrió PR #14 en eJosR-Coding/gitbell", body: "feat(agents): detect coding agents", url: "https://claude.ai/code/session_01X", actor: "eJosR-Coding", avatar: "https://avatars.githubusercontent.com/u/9919?v=4", repo: "eJosR-Coding/gitbell", at: new Date(Date.now() - 3 * 60e3).toISOString() }, { id: "3", category: "pr" as const, title: "ana abrió PR #12 en eJosR-Coding/gitbell", body: "feat(home): activity first, settings second", url: "https://github.com/eJosR-Coding/gitbell/pull/12", actor: "ana", avatar: "https://avatars.githubusercontent.com/u/583231?v=4", repo: "eJosR-Coding/gitbell", at: new Date(Date.now() - 12 * 60e3).toISOString() }, { id: "2", category: "external" as const, title: "Claude Code terminó", body: "/home/ejos/Documents/proyectos_code/gitbell", url: "", actor: "cli", avatar: "", repo: "", at: new Date(Date.now() - 55 * 60e3).toISOString() }, { id: "1", category: "push" as const, title: "ana hizo push a eJosR-Coding/gitbell", body: "2 commits en main: feat(i18n): spanish and english UI", url: "https://github.com/eJosR-Coding/gitbell", actor: "ana", avatar: "https://avatars.githubusercontent.com/u/583231?v=4", repo: "eJosR-Coding/gitbell", at: new Date(Date.now() - 7200e3).toISOString() }, diff --git a/src/ui/styles.css b/src/ui/styles.css index 3c96657..444df73 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -312,3 +312,11 @@ h3.sub { font-size: 12px; font-weight: 600; color: var(--muted); margin: 10px 0 background: color-mix(in srgb, var(--card) calc(var(--glass, 0.7) * 100%), transparent); border-color: var(--glass-line); } + +/* ===== agent tag in the activity list */ +.tag { + display: inline-block; vertical-align: 1px; + font-size: 10.5px; font-weight: 600; letter-spacing: 0.02em; + color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); + border-radius: 999px; padding: 1px 7px; +} diff --git a/src/ui/ui.ts b/src/ui/ui.ts index ec5be96..33bfa05 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -373,6 +373,10 @@ export class Ui { void this.commit({ ...this.settings, pollSeconds: v }); }); + const agentsOwn = $("#agents-own"); + agentsOwn.checked = this.settings.agentsOwn; + agentsOwn.addEventListener("change", () => void this.commit({ ...this.settings, agentsOwn: agentsOwn.checked })); + const ignoreOwn = $("#ignore-own"); ignoreOwn.checked = this.settings.ignoreOwn; ignoreOwn.addEventListener("change", () => void this.commit({ ...this.settings, ignoreOwn: ignoreOwn.checked })); @@ -493,6 +497,12 @@ export class Ui { else img.replaceWith(Object.assign(document.createElement("span"), { className: "notice-dot" })); const a = li.querySelector(".notice-title")!; a.textContent = n.title; + if (n.agent) { + const tag = document.createElement("span"); + tag.className = "tag"; + tag.textContent = n.agent; + a.after(" ", tag); + } if (n.url) { a.addEventListener("click", (e) => { e.preventDefault();