claude-beacon 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -189,12 +189,14 @@ Replace `/home/you` with your home directory (`echo $HOME`). Bun installs global
189
189
  claude --dangerously-load-development-channels server:claude-beacon
190
190
  ```
191
191
 
192
+ > `server:claude-beacon` matches the key you used in `.mcp.json`. The `--dangerously-load-development-channels` flag is required because claude-beacon is a custom channel plugin (research preview) — it's safe to use, it just isn't on Anthropic's built-in allowlist yet.
193
+
192
194
  You should see:
193
195
  ```
194
196
  Listening for channel messages from: server:claude-beacon
195
197
  ```
196
198
 
197
- The server is now running. Push a commit, trigger a CI run, or let a PR fall behindnotifications will appear in your session automatically.
199
+ **Verify it's working:** go to your repo on GitHub Settings Webhooks click your webhook Recent Deliveries. Trigger any push you should see a green delivery. In Claude Code, watch for `[claude-beacon]` log lines confirming receipt.
198
200
 
199
201
  ---
200
202
 
@@ -320,9 +322,11 @@ To make this automatic, add to `~/.claude/CLAUDE.md`:
320
322
 
321
323
  ```markdown
322
324
  ## GitHub CI Channel — session filter
323
- When claude-beacon MCP connects, call `set_filter` immediately:
324
- run `git remote get-url origin` (parse to owner/repo) and
325
- `git branch --show-current`, then call set_filter with those values.
325
+ When the claude-beacon MCP server connects, call `set_filter` immediately with:
326
+ - repo: run `git remote get-url origin | sed 's/.*github\.com[:/]\(.*\)\.git$/\1/'`
327
+ - branch: run `git branch --show-current`
328
+ - label: the branch name
329
+ - worktree_path: run `git rev-parse --show-toplevel`
326
330
  ```
327
331
 
328
332
  For full details — routing rules, systemd setup, comparison table — see **[docs/multi-session.md](docs/multi-session.md)**.
@@ -1,11 +1,12 @@
1
- # github-ci-channel configuration
1
+ # claude-beacon configuration
2
2
  #
3
3
  # All fields are optional — omitted fields keep their default values.
4
- # Pass this file at startup: npx @modelcontextprotocol/inspector --config ./my-config.yaml
5
- # Or via the mcp entry in .claude/settings.json:
6
- # "args": ["run", "src/index.ts", "--config", "/path/to/config.yaml"]
4
+ # Pass this file at startup:
5
+ # claude-beacon-mux --config /path/to/this-file.yaml
6
+ # Or in .mcp.json args:
7
+ # "args": ["--config", "/path/to/this-file.yaml"]
7
8
  #
8
- # Environment variables still override YAML for server.port and server.debounce_ms.
9
+ # Environment variables (WEBHOOK_PORT, REVIEW_DEBOUNCE_MS) still override YAML.
9
10
 
10
11
  # ── Server ────────────────────────────────────────────────────────────────────
11
12
  server:
package/dist/index.js CHANGED
@@ -211,7 +211,7 @@ ${H}`);return{summary:I.join(`
211
211
  `),meta:{source:"github-ci",event:$,action:U.action??"",repo:X,status:D,job_name:z.name,job_url:z.html_url}}}if($==="check_suite"||$==="check_run"){let z=$==="check_suite"?U.check_suite:U.check_run;if(!z||U.action!=="completed")return W$(`[skip] ${$}: action=${U.action??"none"} (only "completed" acts)`),null;if(z.conclusion!=="failure"){let W="name"in z?z.name??"?":z.app?.name??"?";return W$(`[skip] ${$} "${W}" on ${X}: conclusion=${z.conclusion} (only "failure" notifies)`),null}let D=z.conclusion??"unknown",G=Q3(z.conclusion),Q="name"in z?z.name:z.app?.name??"Check";return{summary:`${G} ${$} ${D}: ${Q} on ${X}`,meta:{source:"github-ci",event:$,status:D,repo:X}}}return{summary:`GitHub event "${$}" (action: ${U.action??"none"}) on ${X}`,meta:{source:"github-ci",event:$,action:U.action??"",repo:X}}}function PA($,U=I4){let J=$.pull_request;if(!J)return null;let X=$.repository?.full_name??"unknown",z=J.mergeable_state,D=_$(J.title??"",200),G=_$(J.head.ref??"",100),Q=_$(J.base.ref??"",100),W={repo:X,pr_number:String(J.number),pr_title:D,pr_url:J.html_url,head_branch:G,base_branch:Q},Y={source:"github-ci",event:"pull_request",action:$.action??"",repo:X,pr_number:String(J.number),pr_title:D,head_branch:G,base_branch:Q,pr_url:J.html_url,mergeable_state:z,sender:$.sender?.login??""},H=U.behavior.worktrees.mode;if(z==="dirty"){let I=k5(H,W,!0),O=bJ(U.behavior.on_merge_conflict.instruction,{...W,worktree_steps:I});return{summary:[`\u26A0\uFE0F MERGE CONFLICT \u2014 PR #${J.number}: "${D}"`,`Repo: ${X} | Branch: ${G} \u2192 ${Q}`,`URL: ${J.html_url}`,"",O].join(`
212
212
  `),meta:Y}}if(z==="behind"){let I=k5(H,W,!1),O=bJ(U.behavior.on_branch_behind.instruction,{...W,worktree_steps:I});return{summary:[`\u2B07\uFE0F BRANCH BEHIND BASE \u2014 PR #${J.number}: "${D}"`,`Repo: ${X} | Branch: ${G} \u2192 ${Q}`,`URL: ${J.html_url}`,"",O].join(`
213
213
  `),meta:Y}}return W$(`[skip] pull_request PR #${J.number} on ${X}: mergeable_state=${z} (only "dirty" or "behind" notifies)`),null}function SA($){return{Authorization:`Bearer ${$}`,Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"}}async function vA($,U,J){let X=await Uz(`https://api.github.com/repos/${$}/pulls/${U}`,{headers:SA(J)});if(!X.ok)return"unknown";return(await X.json()).mergeable_state}async function Um($,U,J,X,z=I4){await new Promise((Q)=>setTimeout(Q,4000));let D=await Uz(`https://api.github.com/repos/${$}/pulls?state=open&base=${U}&per_page=20`,{headers:SA(J)},15000);if(!D.ok){W$(`PR list fetch failed: ${D.status}`);return}let G=await D.json();for(let Q of G){if(!EA(Q.user.login,z.webhooks.allowed_authors)){if(!await TA($,Q.number,J,z.webhooks.allowed_authors)){W$(`PR #${Q.number} author "${Q.user.login}" not in allowed_authors \u2014 skipping`);continue}}let W=await vA($,Q.number,J);if(W==="unknown")await new Promise((H)=>setTimeout(H,5000)),W=await vA($,Q.number,J);if(W!=="dirty"&&W!=="behind")continue;let Y=PA({action:"synchronize",pull_request:{number:Q.number,title:Q.title,state:"open",html_url:Q.html_url,head:{ref:Q.head.ref,sha:""},base:{ref:Q.base.ref,sha:""},mergeable:W!=="dirty",mergeable_state:W,user:Q.user},repository:{full_name:$},sender:Q.user},z);if(!Y)continue;W$(`PR #${Q.number} is ${W} \u2014 notifying Claude`);try{await X(Y,{repo:$,branch:Q.head.ref})}catch(H){W$(`Failed to notify for PR #${Q.number}:`,H)}}}function Jm($,U,J){let X=J.repository?.full_name??"unknown";if($==="pull_request_review"&&U==="submitted"){let{review:z,pull_request:D}=J;if(!z||!D||z.state==="pending")return null;return{reviewEvent:{type:"review",reviewer:z.user.login,state:z.state,body:_$(z.body??"(no review body)"),url:z.html_url},prMeta:{prNumber:D.number,prTitle:_$(D.title??"",200),prUrl:D.html_url,repo:X}}}if($==="pull_request_review_comment"&&U==="created"){let{comment:z,pull_request:D}=J;if(!z||!D)return null;return{reviewEvent:{type:"review_comment",reviewer:z.user.login,body:_$(z.body),url:z.html_url,path:z.path},prMeta:{prNumber:D.number,prTitle:_$(D.title??"",200),prUrl:D.html_url,repo:X}}}if($==="issue_comment"&&U==="created"){let{comment:z,issue:D}=J;if(!z||!D?.pull_request)return null;return{reviewEvent:{type:"issue_comment",reviewer:z.user.login,body:_$(z.body),url:z.html_url},prMeta:{prNumber:D.number,prTitle:_$(D.title??"",200),prUrl:D.html_url,repo:X}}}if($==="pull_request_review_thread"&&U==="unresolved"){let{thread:z,pull_request:D}=J,G=z?.comments[0];if(!z||!D||!G)return null;return{reviewEvent:{type:"unresolved_thread",reviewer:J.sender?.login??G.user.login,body:_$(G.body),url:G.html_url,path:G.path},prMeta:{prNumber:D.number,prTitle:_$(D.title??"",200),prUrl:D.html_url,repo:X}}}return null}function zm($,U){if($==="ping")return!1;if($==="pull_request"){let X=U.pull_request?.mergeable_state;return["opened","synchronize","reopened"].includes(U.action??"")&&(X==="dirty"||X==="behind")}if($==="push")return!0;return["workflow_run","workflow_job","check_suite","check_run"].includes($)&&U.action==="completed"}function RA(){let $=new G3({name:"github-ci-channel",version:"1.0.0"},{capabilities:{experimental:{"claude/channel":{}}}});return $.tool("fetch_workflow_logs","Fetch logs for a GitHub Actions workflow run. Use when a CI failure notification arrives and you need to diagnose the root cause.",{run_url:lD.string().describe("GitHub Actions run URL (e.g. https://github.com/owner/repo/actions/runs/12345)")},async({run_url:U})=>{let J=process.env.GITHUB_TOKEN;if(!J)return{content:[{type:"text",text:"GITHUB_TOKEN not set \u2014 cannot fetch logs."}]};let X=U.match(/github\.com\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)/);if(!X)return{content:[{type:"text",text:`Could not parse run URL: ${U}`}]};let[,z,D,G]=X;try{let Q=await Uz(`https://api.github.com/repos/${z}/${D}/actions/runs/${G}/logs`,{headers:{Authorization:`Bearer ${J}`,Accept:"application/vnd.github+json"},redirect:"manual"},60000),W;if(Q.status===302||Q.status===301){let I=Q.headers.get("location");if(!I)return{content:[{type:"text",text:"GitHub redirected without a location header"}]};W=await Uz(I,{},60000)}else W=Q;if(!W.ok)return{content:[{type:"text",text:`GitHub API error: ${W.status} ${W.statusText}`}]};let Y=await W.text();return{content:[{type:"text",text:Y.length>KA?`...(truncated)
214
- ${Y.slice(-KA)}`:Y}]}}catch(Q){return{content:[{type:"text",text:`Fetch failed: ${String(Q)}`}]}}}),$}async function _A($,U){await $.server.notification({method:"notifications/claude/channel",params:{channel:"github-ci",content:U.summary,meta:U.meta}}),W$(`Pushed to Claude: ${U.meta.status??U.meta.mergeable_state} on ${U.meta.repo}`)}function jA($,U){let J=U.repository?.full_name??"unknown";if($==="workflow_run")return{repo:J,branch:U.workflow_run?.head_branch??null};if($==="pull_request"||MA.has($))return{repo:J,branch:U.pull_request?.head.ref??null};return{repo:J,branch:null}}function Xm($,U,J){if(J.webhooks.allowed_events.length>0&&!J.webhooks.allowed_events.includes($))return W$(`Skipping event "${$}" \u2014 not in allowed_events`),new Response("Skipped",{status:200});if(J.webhooks.allowed_repos.length>0&&U&&!J.webhooks.allowed_repos.includes(U))return W$(`Skipping repo "${U}" \u2014 not in allowed_repos`),new Response("Skipped",{status:200});return null}function EA($,U){return U.filter((J)=>!J.includes("@")).includes($)}async function TA($,U,J,X){let z=X.filter((W)=>W.includes("@")).map((W)=>W.toLowerCase());if(z.length===0)return!1;let D=await Uz(`https://api.github.com/repos/${$}/pulls/${U}/commits`,{headers:{Authorization:`Bearer ${J}`,Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"}},1e4);if(!D.ok)return!1;let G=await D.json(),Q=/^Co-Authored-By:.*<([^>]+)>/gim;for(let{commit:W}of G)for(let Y of W.message.matchAll(Q))if(z.includes(Y[1]?.toLowerCase()??""))return!0;return!1}async function Dm($,U){let J=$.pull_request?.user.login??"";if(EA(J,U))return!0;let X=$.pull_request?.number,z=$.repository?.full_name??"",D=process.env.GITHUB_TOKEN;if(D&&X!==void 0)return TA(z,X,D,U);return!1}function Gm($,U){let J="";if($==="pull_request_review")J=` (review.state=${U.review?.state??"?"}; need submitted+non-pending)`;else if($==="issue_comment")J=` (issue.pull_request present: ${!!U.issue?.pull_request}; need PR comment, not issue comment)`;W$(`[skip] ${$}/${U.action??"?"} \u2014 parseReviewWebhookPayload returned null${J}`)}function ZA($,U=I4){return Bun.serve({port:U.server.port,async fetch(J){if(J.method==="GET")return new Response(JSON.stringify({status:"ok",server:"github-ci-channel"}),{headers:{"Content-Type":"application/json"}});if(J.method!=="POST")return new Response("Method not allowed",{status:405});let X=await J.text();if(nh(X))return W$("Rejected oversized payload"),new Response("Payload too large",{status:413});let z=J.headers.get("x-hub-signature-256");if(!th(X,z))return W$("Signature verification failed"),new Response("Unauthorized",{status:401});let D=J.headers.get("x-github-event")??"unknown",G=J.headers.get("x-github-delivery")??"";if(ch(G))return W$(`Duplicate delivery ${G} \u2014 skipping`),new Response("OK",{status:200});if(D==="ping")return W$("Ping received \u2014 webhook configured successfully"),new Response("pong",{status:200});let Q;try{Q=JSON.parse(X)}catch{return new Response("Invalid JSON",{status:400})}let W=Q.repository?.full_name,Y=Xm(D,W,U);if(Y)return Y;if(W$(`Received: ${D} (${Q.action??"no action"}) delivery=${G}`),D==="push"){let O=Q,b=O.ref.replace("refs/heads/",""),q=process.env.GITHUB_TOKEN,B=new Set(U.server.main_branches);if(!B.has(b))W$(`[skip] push to "${b}" \u2014 not a main branch (${[...B].join(", ")}) \u2014 PR checks skipped`);else if(!q)W$(`[skip] push to "${b}" \u2014 GITHUB_TOKEN not set \u2014 PR checks skipped`);else W$(`Push to ${b} \u2014 checking open PRs for merge status`),Um(O.repository.full_name,b,q,$,U);return new Response("OK",{status:200})}if(MA.has(D)){let O=Jm(D,Q.action,Q);if(O){let{reviewEvent:b,prMeta:q}=O,w=`${q.repo}/${q.prNumber}`,L=jA(D,Q);if(!ph(w,q,b,async(K,T)=>{let j=oh(K,T,U);try{await $(j,L),W$(`PR review notification sent for PR #${T.prNumber} (${K.length} event(s))`)}catch(h){W$(`Failed to send PR review notification for PR #${T.prNumber}:`,h)}},{debounceMs:U.server.debounce_ms,cooldownMs:U.server.cooldown_ms,maxEvents:U.server.max_events_per_window}))W$(`PR #${q.prNumber} review event discarded (cooldown active)`)}else Gm(D,Q);return new Response("OK",{status:200})}if(!zm(D,Q))return W$(`Skipping non-actionable event: ${D}/${Q.action??""}`),new Response("Skipped",{status:200});if(D==="pull_request"&&!await Dm(Q,U.webhooks.allowed_authors))return W$(`Skipping pull_request \u2014 PR author "${Q.pull_request?.user.login}" not in allowed_authors`),new Response("Skipped",{status:200});let H=D==="pull_request"?PA(Q,U):$m(D,Q,U);if(!H)return W$(`[skip] ${D}/${Q.action??"?"}: parse returned null (see reason above)`),new Response("OK",{status:200});let I=jA(D,Q);try{await $(H,I)}catch(O){return W$("Failed to send notification:",O),new Response("Notification failed",{status:500})}return new Response("OK",{status:200})}})}var b1=(...$)=>console.error("[github-ci]",...$);function Qm($){let U=$.indexOf("--config"),J=U!==-1?$[U+1]??null:null,X=[];for(let z=0;z<$.length;z++)if($[z]==="--author"&&$[z+1])X.push($[z+1]??"");return{configPath:J,authors:X}}var{configPath:W3,authors:kA}=Qm(process.argv.slice(2)),TU=I4;if(W3)try{TU=cB(W3),b1(`Loaded config from ${W3}`)}catch($){b1(`ERROR: Failed to load config: ${$}`),process.exit(1)}if(kA.length>0)TU.webhooks.allowed_authors=[...new Set([...TU.webhooks.allowed_authors,...kA])];if(TU.webhooks.allowed_authors.length===0)b1("ERROR: webhooks.allowed_authors is required and must not be empty.",`
214
+ ${Y.slice(-KA)}`:Y}]}}catch(Q){return{content:[{type:"text",text:`Fetch failed: ${String(Q)}`}]}}}),$}async function _A($,U){await $.server.notification({method:"notifications/claude/channel",params:{channel:"github-ci",content:U.summary,meta:U.meta}}),W$(`Pushed to Claude: ${U.meta.event??U.meta.status??U.meta.mergeable_state} on ${U.meta.repo}`)}function jA($,U){let J=U.repository?.full_name??"unknown";if($==="workflow_run")return{repo:J,branch:U.workflow_run?.head_branch??null};if($==="pull_request"||MA.has($))return{repo:J,branch:U.pull_request?.head.ref??null};return{repo:J,branch:null}}function Xm($,U,J){if(J.webhooks.allowed_events.length>0&&!J.webhooks.allowed_events.includes($))return W$(`Skipping event "${$}" \u2014 not in allowed_events`),new Response("Skipped",{status:200});if(J.webhooks.allowed_repos.length>0&&U&&!J.webhooks.allowed_repos.includes(U))return W$(`Skipping repo "${U}" \u2014 not in allowed_repos`),new Response("Skipped",{status:200});return null}function EA($,U){return U.filter((J)=>!J.includes("@")).includes($)}async function TA($,U,J,X){let z=X.filter((W)=>W.includes("@")).map((W)=>W.toLowerCase());if(z.length===0)return!1;let D=await Uz(`https://api.github.com/repos/${$}/pulls/${U}/commits`,{headers:{Authorization:`Bearer ${J}`,Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"}},1e4);if(!D.ok)return!1;let G=await D.json(),Q=/^Co-Authored-By:.*<([^>]+)>/gim;for(let{commit:W}of G)for(let Y of W.message.matchAll(Q))if(z.includes(Y[1]?.toLowerCase()??""))return!0;return!1}async function Dm($,U){let J=$.pull_request?.user.login??"";if(EA(J,U))return!0;let X=$.pull_request?.number,z=$.repository?.full_name??"",D=process.env.GITHUB_TOKEN;if(D&&X!==void 0)return TA(z,X,D,U);return!1}function Gm($,U){let J="";if($==="pull_request_review")J=` (review.state=${U.review?.state??"?"}; need submitted+non-pending)`;else if($==="issue_comment")J=` (issue.pull_request present: ${!!U.issue?.pull_request}; need PR comment, not issue comment)`;W$(`[skip] ${$}/${U.action??"?"} \u2014 parseReviewWebhookPayload returned null${J}`)}function ZA($,U=I4){return Bun.serve({port:U.server.port,async fetch(J){if(J.method==="GET")return new Response(JSON.stringify({status:"ok",server:"github-ci-channel"}),{headers:{"Content-Type":"application/json"}});if(J.method!=="POST")return new Response("Method not allowed",{status:405});let X=await J.text();if(nh(X))return W$("Rejected oversized payload"),new Response("Payload too large",{status:413});let z=J.headers.get("x-hub-signature-256");if(!th(X,z))return W$("Signature verification failed"),new Response("Unauthorized",{status:401});let D=J.headers.get("x-github-event")??"unknown",G=J.headers.get("x-github-delivery")??"";if(ch(G))return W$(`Duplicate delivery ${G} \u2014 skipping`),new Response("OK",{status:200});if(D==="ping")return W$("Ping received \u2014 webhook configured successfully"),new Response("pong",{status:200});let Q;try{Q=JSON.parse(X)}catch{return new Response("Invalid JSON",{status:400})}let W=Q.repository?.full_name,Y=Xm(D,W,U);if(Y)return Y;if(W$(`Received: ${D} (${Q.action??"no action"}) delivery=${G}`),D==="push"){let O=Q,b=O.ref.replace("refs/heads/",""),q=process.env.GITHUB_TOKEN,B=new Set(U.server.main_branches);if(!B.has(b))W$(`[skip] push to "${b}" \u2014 not a main branch (${[...B].join(", ")}) \u2014 PR checks skipped`);else if(!q)W$(`[skip] push to "${b}" \u2014 GITHUB_TOKEN not set \u2014 PR checks skipped`);else W$(`Push to ${b} \u2014 checking open PRs for merge status`),Um(O.repository.full_name,b,q,$,U);return new Response("OK",{status:200})}if(MA.has(D)){let O=Jm(D,Q.action,Q);if(O){let{reviewEvent:b,prMeta:q}=O,w=`${q.repo}/${q.prNumber}`,L=jA(D,Q);if(!ph(w,q,b,async(K,T)=>{let j=oh(K,T,U);try{await $(j,L),W$(`PR review notification sent for PR #${T.prNumber} (${K.length} event(s))`)}catch(h){W$(`Failed to send PR review notification for PR #${T.prNumber}:`,h)}},{debounceMs:U.server.debounce_ms,cooldownMs:U.server.cooldown_ms,maxEvents:U.server.max_events_per_window}))W$(`PR #${q.prNumber} review event discarded (cooldown active)`)}else Gm(D,Q);return new Response("OK",{status:200})}if(!zm(D,Q))return W$(`Skipping non-actionable event: ${D}/${Q.action??""}`),new Response("Skipped",{status:200});if(D==="pull_request"&&!await Dm(Q,U.webhooks.allowed_authors))return W$(`Skipping pull_request \u2014 PR author "${Q.pull_request?.user.login}" not in allowed_authors`),new Response("Skipped",{status:200});let H=D==="pull_request"?PA(Q,U):$m(D,Q,U);if(!H)return W$(`[skip] ${D}/${Q.action??"?"}: parse returned null (see reason above)`),new Response("OK",{status:200});let I=jA(D,Q);try{await $(H,I)}catch(O){return W$("Failed to send notification:",O),new Response("Notification failed",{status:500})}return new Response("OK",{status:200})}})}var b1=(...$)=>console.error("[github-ci]",...$);function Qm($){let U=$.indexOf("--config"),J=U!==-1?$[U+1]??null:null,X=[];for(let z=0;z<$.length;z++)if($[z]==="--author"&&$[z+1])X.push($[z+1]??"");return{configPath:J,authors:X}}var{configPath:W3,authors:kA}=Qm(process.argv.slice(2)),TU=I4;if(W3)try{TU=cB(W3),b1(`Loaded config from ${W3}`)}catch($){b1(`ERROR: Failed to load config: ${$}`),process.exit(1)}if(kA.length>0)TU.webhooks.allowed_authors=[...new Set([...TU.webhooks.allowed_authors,...kA])];if(TU.webhooks.allowed_authors.length===0)b1("ERROR: webhooks.allowed_authors is required and must not be empty.",`
215
215
  Add your GitHub username (and optionally your email for co-author matching via bots like Devin).`,`
216
216
  Example config.yaml:`,`
217
217
  webhooks:`,`