From fba239917e24bdd3880ebd92e8d5565ca89f136c Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Sat, 12 Sep 2026 16:17:44 -0500 Subject: [PATCH 1/2] wip(agents): detector, category and tests (not merged, pending decision) --- index.html | 3 + src-tauri/src/notify.rs | 6 +- src/core/__tests__/agents.test.ts | 74 ++++++++++++++ src/core/agents.ts | 165 ++++++++++++++++++++++++++++++ src/core/format.ts | 22 +++- src/core/i18n.ts | 6 ++ src/core/poller.ts | 10 +- src/core/types.ts | 11 +- src/ui/styles.css | 8 ++ src/ui/ui.ts | 10 ++ 10 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 src/core/__tests__/agents.test.ts create mode 100644 src/core/agents.ts 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/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(); From 1fc7edfe4759caa7e5a1ee2dd9799cc8c27da92c Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Sat, 12 Sep 2026 22:37:34 -0500 Subject: [PATCH 2/2] feat(agents): detect coding agents and notify with a link to their session GitHub has no agent flag, so detectAgent() reads the fingerprints tools leave by default: Co-Authored-By trailers (Claude, Codex, Cursor, OpenCode), session trailers (Claude-Session, Agent-Logs-Url), bot logins (claude[bot], copilot-swe-agent[bot], chatgpt-codex-connector[bot], devin, jules), branch prefixes and PR body markers. Aider via author name. A hit moves the event to the new "Agents" category (own toggle + sound), names the agent as the actor, prefixes the toast with a robot, and links the agent's session when one was recorded. "Ignore my own activity" has an opt-out for agents acting under your account, since cloud sessions push as you. Toast URL allowlist extended to claude.ai, chatgpt.com and cursor.com. README documents what is and isn't detected (tools that don't sign are invisible) and a PostToolUse hook that reports every Claude Code commit regardless of attribution. 8 new tests (17 total). --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/preview.ts | 1 + 2 files changed, 52 insertions(+) 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/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() },