# A zero-dependency GitHub Issue poller for multi-agent coding teams

> Source: <https://gist.github.com/atraining/d666e2d20e5abfdf92eb74a0d5f4918d>
> Published: 2026-05-27 10:50:50+00:00

-
-
Save atraining/d666e2d20e5abfdf92eb74a0d5f4918d to your computer and use it in GitHub Desktop.

[Learn more about bidirectional Unicode characters](https://github.co/hiddenchars)

| #!/usr/bin/env node | |
| /** | |
| * gh-agent-watch.js — GitHub-Issue poller for multi-agent coding teams | |
| * | |
| * ============================================================================= | |
| * WHAT THIS DOES (for AI agents reading this file) | |
| * ============================================================================= | |
| * | |
| * This script is your "inbox watcher". It polls a GitHub repo for issues | |
| * labelled with your agent name (e.g. "agent:tester") and wakes you whenever | |
| * something new arrives or an existing issue is updated. | |
| * | |
| * You run it once at startup inside a persistent Monitor process. Every line it | |
| * prints to stdout is a signal for your orchestrator to wake you and let you | |
| * act. Between wakes, you sleep. You do NOT need to poll manually. | |
| * | |
| * ============================================================================= | |
| * TEAM MODEL (generic, rename roles to fit your project) | |
| * ============================================================================= | |
| * | |
| * Any number of agents can participate. Each has a unique role name. Common | |
| * example set: | |
| * | |
| * implementer — writes and commits code (the only agent with write access | |
| * to source files; all others review, advise, or document) | |
| * tester — writes test cases, user journeys, and acceptance criteria; | |
| * reports failures as new issues to implementer | |
| * reviewer — code review, architecture, and technical quality gating | |
| * docs — user-facing documentation; touches no source code | |
| * legal — compliance, licensing, regulatory gating before release | |
| * market — competitive analysis, domain model, product direction | |
| * | |
| * You can use any names you like. The only contract: every agent's name must | |
| * match exactly the label suffix used in GitHub, e.g. agent:tester. | |
| * | |
| * ============================================================================= | |
| * HOW ROUTING WORKS | |
| * ============================================================================= | |
| * | |
| * Issues are routed by label. An issue labelled "agent:tester" sits in the | |
| * tester's queue. Routing rules (for AI agents): | |
| * | |
| * - To send an issue TO another agent: | |
| * gh issue edit <num> --add-label "agent:<target>" \ | |
| * --remove-label "agent:<you>" | |
| * Add a one-line comment explaining WHY you are re-routing. | |
| * | |
| * - To create a new issue for another agent: | |
| * gh issue create --repo <owner/repo> \ | |
| * --title "<Area>: <what> (<why>)" \ | |
| * --label "agent:<target>" --label "<theme>" \ | |
| * --body "..." | |
| * | |
| * - To respond to the current owner of an issue: comment on it. | |
| * gh issue comment <num> --body "..." | |
| * | |
| * - To close a resolved issue: | |
| * gh issue close <num> --comment "Done. <commit-sha or reason>" | |
| * | |
| * - FREI issues (no agent:* label) are visible to every agent's heartbeat. | |
| * Any agent may claim them by adding their own label and a comment. | |
| * | |
| * ============================================================================= | |
| * QUEUE DISCIPLINE (rules for AI agents, non-negotiable) | |
| * ============================================================================= | |
| * | |
| * 1) idle != done. | |
| * The heartbeat prints either "idle, queue empty" (genuinely done) or | |
| * "idle, N open tickets" followed by PENDING lines (NOT done — those are | |
| * your open assignments). When you see PENDING lines, your next action is | |
| * to open the top ticket and work it, not report "idle". | |
| * | |
| * 2) Every ticket must be actively resolved. For each open ticket decide: | |
| * (a) handle it yourself and close with a reason / commit SHA | |
| * (b) re-route to the correct agent with a one-line comment | |
| * (c) escalate to a human (label "agent:human") with a clear question | |
| * Leaving a ticket untouched is not allowed. | |
| * | |
| * 3) Stuck? Cross-comment. If you are blocked on a ticket for >3 rounds | |
| * without progress, look for thematically related tickets (same theme:* | |
| * label) in other agents' queues and comment there. You may comment on any | |
| * ticket. You may NOT take ownership (change the agent label) unless the | |
| * ticket has had no activity for 7+ days — then claim it and explain why. | |
| * | |
| * ============================================================================= | |
| * SELF-MUTE AFTER YOUR OWN POSTS (--ack flag) | |
| * ============================================================================= | |
| * | |
| * Problem: this watcher cannot tell whether a new comment was written by YOU | |
| * or by another agent, because all agents may share a single GitHub account | |
| * (e.g. a bot account). So your own comment would trigger a spurious WAKE. | |
| * | |
| * Fix: after you post a comment or edit labels on an issue, run: | |
| * | |
| * node gh-agent-watch.js <your-name> --ack <issue-num> [<issue-num> ...] | |
| * | |
| * This re-baselines that issue's signature in the local state file so the next | |
| * poll sees no diff. It exits immediately (no poll loop). | |
| * | |
| * ============================================================================= | |
| * USAGE | |
| * ============================================================================= | |
| * | |
| * # Start the watcher (run inside a persistent/Monitor process): | |
| * node gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60] | |
| * | |
| * # Acknowledge your own edits (self-mute, call after every gh post/edit): | |
| * node gh-agent-watch.js <agent-name> --ack <num> [<num> ...] | |
| * | |
| * # Examples: | |
| * node gh-agent-watch.js tester --repo myorg/myproject | |
| * node gh-agent-watch.js implementer --repo myorg/myproject --interval 30 | |
| * node gh-agent-watch.js tester --ack 42 43 | |
| * | |
| * Prerequisites: `gh` CLI installed and authenticated (`gh auth login`). | |
| * | |
| * ============================================================================= | |
| * OUTPUT FORMAT (for orchestrators parsing stdout) | |
| * ============================================================================= | |
| * | |
| * WAKE NEU #<n> <title> — new issue just appeared in your queue | |
| * WAKE UPDATE #<n> <title> — existing issue was updated (new comment etc) | |
| * PENDING #<n> <title> — heartbeat: open issue already in your queue | |
| * FREI #<n> [<age>] <title> — unclaimed issue (no agent:* label) | |
| * ROLLE: <agent> — <reminder> — heartbeat role reminder line | |
| * [gh-agent-watch <agent>] ... — informational/error lines (not action signals) | |
| * | |
| * ============================================================================= | |
| * SETUP IN 3 STEPS | |
| * ============================================================================= | |
| * | |
| * Step 1 — Install gh CLI and authenticate: | |
| * https://cli.github.com/ → gh auth login | |
| * | |
| * Step 2 — Create agent labels in your GitHub repo (once, by a human or script): | |
| * gh label create "agent:implementer" --repo owner/repo --color "0075ca" | |
| * gh label create "agent:tester" --repo owner/repo --color "e4e669" | |
| * gh label create "agent:reviewer" --repo owner/repo --color "d93f0b" | |
| * gh label create "agent:docs" --repo owner/repo --color "0e8a16" | |
| * gh label create "agent:human" --repo owner/repo --color "ffffff" | |
| * # Add more as needed. | |
| * | |
| * Step 3 — Start each agent's watcher in a persistent process: | |
| * node gh-agent-watch.js implementer --repo owner/repo | |
| * node gh-agent-watch.js tester --repo owner/repo | |
| * # etc. | |
| * | |
| * State is stored in ~/.gh-agent-watch/<agent-name>.json (cross-session). | |
| * Delete the file to reset seen-state (causes all open issues to fire as NEU). | |
| * | |
| * ============================================================================= | |
| */ | |
| const { execSync } = require('node:child_process'); | |
| const fs = require('node:fs'); | |
| const path = require('node:path'); | |
| // --------------------------------------------------------------------------- | |
| // Argument parsing | |
| // --------------------------------------------------------------------------- | |
| const args = process.argv.slice(2); | |
| if (!args[0] || args[0].startsWith('-')) { | |
| console.error('Usage: gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60]'); | |
| console.error(' gh-agent-watch.js <agent-name> --ack <num> [<num> ...]'); | |
| console.error(''); | |
| console.error('The agent-name must match the label suffix used in GitHub.'); | |
| console.error('Example label: "agent:tester" → agent-name: "tester"'); | |
| process.exit(2); | |
| } | |
| const agent = args[0]; | |
| // --repo (default: tries to read from `gh repo view` in cwd, falls back to error) | |
| const repoFlagIdx = args.indexOf('--repo'); | |
| const repoFlagEq = (args.find(a => a.startsWith('--repo=')) || '').split('=')[1]; | |
| let repo = repoFlagEq | |
| || (repoFlagIdx >= 0 ? args[repoFlagIdx + 1] : null); | |
| if (!repo) { | |
| // Auto-detect from current working directory | |
| try { | |
| repo = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', { | |
| encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] | |
| }).trim(); | |
| } catch { | |
| console.error('[gh-agent-watch] Could not detect repo. Pass --repo owner/repo explicitly.'); | |
| process.exit(2); | |
| } | |
| } | |
| // --interval in seconds (default 60) | |
| const intervalFlagEq = (args.find(a => a.startsWith('--interval=')) || '').split('=')[1]; | |
| const intervalFlagIdx = args.indexOf('--interval'); | |
| const interval = Number( | |
| intervalFlagEq | |
| || (intervalFlagIdx >= 0 ? args[intervalFlagIdx + 1] : 60) | |
| ) || 60; | |
| // --ack <num> [<num> ...] | |
| const ackIdx = args.indexOf('--ack'); | |
| const ackNums = ackIdx >= 0 | |
| ? args.slice(ackIdx + 1).filter(a => /^\d+$/.test(a)).map(Number) | |
| : []; | |
| // --------------------------------------------------------------------------- | |
| // State persistence (~/.gh-agent-watch/<agent>.json) | |
| // --------------------------------------------------------------------------- | |
| const stateDir = path.join(process.env.HOME || process.env.LOCALAPPDATA || '.', '.gh-agent-watch'); | |
| fs.mkdirSync(stateDir, { recursive: true }); | |
| const stateFile = path.join(stateDir, `${agent}.json`); | |
| function loadState() { | |
| try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')); } | |
| catch { return { seen: {} }; } | |
| } | |
| function saveState(s) { | |
| fs.writeFileSync(stateFile, JSON.stringify(s, null, 2)); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Poll FREI: open issues with no agent:* label (fall-through / unclaimed) | |
| // These appear in every agent's heartbeat so nothing gets lost. | |
| // --------------------------------------------------------------------------- | |
| function pollFrei() { | |
| try { | |
| const raw = execSync( | |
| `gh issue list --repo ${repo} --state open --limit 200 --json number,title,labels,createdAt`, | |
| { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | |
| ); | |
| return JSON.parse(raw).filter(i => | |
| !i.labels.some(l => l.name.startsWith('agent:')) | |
| ); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Main poll: check for new or updated issues in this agent's queue | |
| // --------------------------------------------------------------------------- | |
| function poll() { | |
| let issues; | |
| try { | |
| const raw = execSync( | |
| `gh issue list --repo ${repo} --label "agent:${agent}" --state open --limit 200 ` | |
| + `--json number,title,updatedAt,comments,createdAt`, | |
| { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | |
| ); | |
| issues = JSON.parse(raw); | |
| } catch (e) { | |
| console.error(`[gh-agent-watch ${agent}] gh failed: ${e.message.split('\n')[0]}`); | |
| return; | |
| } | |
| const state = loadState(); | |
| const seen = state.seen || {}; | |
| let changed = 0; | |
| for (const i of issues) { | |
| const key = String(i.number); | |
| // Signature: updatedAt + comment count. Any change fires a WAKE. | |
| const sig = `${i.updatedAt}|${(i.comments || []).length}`; | |
| if (seen[key] !== sig) { | |
| const verb = seen[key] === undefined ? 'NEU' : 'UPDATE'; | |
| console.log(`WAKE ${verb} #${i.number} ${i.title}`); | |
| seen[key] = sig; | |
| changed++; | |
| } | |
| } | |
| state.seen = seen; | |
| state.lastPoll = new Date().toISOString(); | |
| saveState(state); | |
| // Heartbeat — emitted every ~5 poll intervals when nothing changed. | |
| // Tells the agent about its full open queue and any unclaimed issues. | |
| if (changed === 0) { | |
| const now = Date.now(); | |
| const heartbeatDue = !state.heartbeat | |
| || Date.now() - state.heartbeat > 5 * interval * 1000; | |
| if (heartbeatDue) { | |
| const lines = []; | |
| if (issues.length === 0) { | |
| lines.push(`[gh-agent-watch ${agent}] idle, queue empty`); | |
| } else { | |
| lines.push(`[gh-agent-watch ${agent}] idle, ${issues.length} open ticket(s) in queue:`); | |
| // Cluster by age: NEU < 2h, MID 2-24h, ALT > 24h | |
| const neu = [], mid = [], alt = []; | |
| for (const i of issues) { | |
| const ageH = (now - new Date(i.createdAt)) / 36e5; | |
| if (ageH < 2) neu.push(i); | |
| else if (ageH < 24) mid.push(i); | |
| else alt.push(i); | |
| } | |
| const byUpdated = (a, b) => new Date(b.updatedAt) - new Date(a.updatedAt); | |
| const fmt = (i) => `PENDING #${i.number} ${i.title}`; | |
| if (neu.length > 0) { | |
| lines.push('--- NEW (just assigned) ---'); | |
| for (const i of neu.sort(byUpdated)) lines.push(fmt(i)); | |
| } | |
| for (const i of mid.sort(byUpdated)) lines.push(fmt(i)); | |
| if (alt.length > 0) { | |
| lines.push('--- STALE (> 24h in queue) ---'); | |
| for (const i of alt.sort(byUpdated)) lines.push(fmt(i)); | |
| } | |
| } | |
| // Unclaimed issues — visible across all agents' heartbeats | |
| const frei = pollFrei(); | |
| if (frei.length > 0) { | |
| lines.push(` UNCLAIMED (no agent label): ${frei.length} open`); | |
| for (const i of frei.slice(0, 5)) { | |
| const ageH = Math.round((now - new Date(i.createdAt)) / 36e5); | |
| const age = ageH < 2 ? 'new' : ageH < 24 ? `${ageH}h` : `${Math.round(ageH / 24)}d`; | |
| lines.push(` FREI #${i.number} [${age}] ${i.title}`); | |
| } | |
| if (frei.length > 5) lines.push(` ... and ${frei.length - 5} more`); | |
| } | |
| for (const l of lines) console.log(l); | |
| // Role reminder — nudges agent to act on PENDING tickets, not stay idle | |
| console.log( | |
| `ROLLE: ${agent} — read your agent self-doc (agenten/${agent}.md or equivalent). ` | |
| + `idle != done: PENDING = your open assignments. ` | |
| + `Consult specialist agents before escalating to human. ` | |
| + `Check other agents' open tickets proactively for issues you can help with.` | |
| ); | |
| state.heartbeat = now; | |
| saveState(state); | |
| } | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // --ack mode: re-baseline specific issues to suppress self-triggered wakes. | |
| // | |
| // WHY: If all agents post under the same GitHub account (e.g. a shared bot), | |
| // the watcher cannot use viewerDidAuthor to distinguish "I posted this" from | |
| // "another agent posted this". Without --ack, every comment you write would | |
| // immediately trigger a WAKE UPDATE on your own issue. | |
| // | |
| // HOW: After posting a comment or editing labels, call: | |
| // node gh-agent-watch.js <agent> --ack <issue-num> [...] | |
| // This fetches the issue's current state, writes its signature into `seen`, | |
| // and exits. The next regular poll finds no diff and stays silent. | |
| // --------------------------------------------------------------------------- | |
| function ack(nums) { | |
| const state = loadState(); | |
| const seen = state.seen || {}; | |
| for (const num of nums) { | |
| let raw; | |
| try { | |
| raw = execSync( | |
| `gh issue view ${num} --repo ${repo} --json number,updatedAt,comments`, | |
| { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } | |
| ); | |
| } catch (e) { | |
| console.error(`[gh-agent-watch ${agent}] --ack #${num}: gh failed: ${e.message.split('\n')[0]}`); | |
| continue; | |
| } | |
| const i = JSON.parse(raw); | |
| const sig = `${i.updatedAt}|${(i.comments || []).length}`; | |
| seen[String(i.number)] = sig; | |
| console.log(`[gh-agent-watch ${agent}] ack #${i.number} -> ${sig}`); | |
| } | |
| state.seen = seen; | |
| saveState(state); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Entry point | |
| // --------------------------------------------------------------------------- | |
| if (ackNums.length > 0) { | |
| ack(ackNums); | |
| process.exit(0); | |
| } | |
| console.log(`[gh-agent-watch ${agent}] started — repo=${repo}, interval=${interval}s`); | |
| console.log(`[gh-agent-watch ${agent}] watching label "agent:${agent}" for new/updated issues`); | |
| console.log(`[gh-agent-watch ${agent}] state file: ${stateFile}`); | |
| poll(); | |
| setInterval(poll, interval * 1000); |

# gh-agent-watch

A zero-dependency GitHub Issue poller for multi-agent coding teams.

Each AI agent runs one instance. It watches for issues labelled `agent:<name>`

and prints a `WAKE`

line to stdout whenever something new arrives. Your agent

orchestrator (Claude Code, a Monitor process, etc.) picks up that line and wakes

the agent to act.

## Why this exists

When multiple AI agents collaborate on a project, they need an inbox. GitHub

Issues are a natural fit: they are persistent, auditable, and accessible from

any terminal. This script gives each agent a continuous, low-noise feed of

exactly the issues that are routed to them — nothing more.

## Prerequisites

- Node.js 18+
installed and authenticated (`gh`

CLI`gh auth login`

)- A GitHub repo with
`agent:*`

labels (see setup below)

## Quickstart

```
# 1. Download the script (or copy it into your repo)
curl -O https://raw.githubusercontent.com/yourorg/yourrepo/main/gh-agent-watch.js

# 2. Create agent labels in your repo (one-time, run as a human or admin script)
REPO=yourorg/yourrepo
for role in implementer tester reviewer docs human; do
  gh label create "agent:$role" --repo $REPO --color "0075ca" 2>/dev/null || true
done

# 3. Start each agent's watcher in a persistent process
node gh-agent-watch.js implementer --repo yourorg/yourrepo
node gh-agent-watch.js tester      --repo yourorg/yourrepo
# etc.
```

If you run from inside the repo directory, `--repo`

is auto-detected via

`gh repo view`

.

## Agent roles (example team — rename freely)

| Agent | What they do |
|---|---|
`implementer` |
Writes and commits code; the only agent touching source files |
`tester` |
Writes test cases, user journeys, reports failures as issues |
`reviewer` |
Code review, architecture, technical quality gating |
`docs` |
User-facing documentation; never modifies source code |
`legal` |
Compliance and regulatory gating before release |
`market` |
Competitive analysis, domain model, product direction |
`human` |
Escalation target for decisions only a human should make |

Use any names you like. The only constraint: the name must match the GitHub

label suffix exactly. Label `agent:tester`

→ agent name `tester`

.

## How routing works

Issues move between agents by changing the `agent:*`

label.

```
# Route an issue to another agent
gh issue edit 42 --add-label "agent:reviewer" --remove-label "agent:tester"
# Always add a one-line comment explaining why you are re-routing.
gh issue comment 42 --body "Routing to reviewer: needs architecture sign-off."

# Create a new issue for another agent
gh issue create --repo yourorg/yourrepo \
  --title "Auth: token refresh missing on 401 (breaks mobile flow)" \
  --label "agent:implementer" --label "theme:auth" \
  --body "**Observed:** ...\n**Why it matters:** ...\n**Suggestion:** ..."

# Close a resolved issue
gh issue close 42 --comment "Fixed in commit abc1234."

# Escalate to a human
gh issue edit 42 --add-label "agent:human" --remove-label "agent:tester"
gh issue comment 42 --body "Needs product decision: which behaviour is correct?"
```

Unclaimed issues (no `agent:*`

label) appear in every agent's heartbeat as

`FREI`

lines. Any agent may claim them.

## Self-mute after your own posts

If all agents post under the same GitHub account (e.g. a shared bot), the

watcher cannot distinguish your own comment from another agent's. Your post

would otherwise trigger a spurious `WAKE UPDATE`

.

Fix: after every `gh issue comment`

or `gh issue edit`

, run:

```
node gh-agent-watch.js tester --ack 42 43
```

This re-baselines the issue's signature in local state and exits immediately.

## Output format

```
WAKE NEU #42 Auth: token refresh missing on 401
WAKE UPDATE #7 Docs: onboarding flow needs rewrite
PENDING #12 Tests: checkout flow user journey incomplete
FREI #99 [2h] Refactor: extract payment module
ROLLE: tester — read your agent self-doc ...
```

| Prefix | Meaning |
|---|---|
`WAKE NEU` |
New issue just appeared in your queue |
`WAKE UPDATE` |
Existing issue was updated (comment, label change, etc.) |
`PENDING` |
Heartbeat: this issue is already open in your queue |
`FREI` |
Unclaimed issue (no `agent:*` label) — anyone can take it |
`ROLLE` |
Heartbeat role reminder line |

## State file

Seen-state is stored in `~/.gh-agent-watch/<agent-name>.json`

. Delete it to

reset (all open issues will fire as `NEU`

on the next poll).

## Options

```
node gh-agent-watch.js <agent-name> [--repo owner/repo] [--interval 60]
node gh-agent-watch.js <agent-name> --ack <num> [<num> ...]
```

| Flag | Default | Description |
|---|---|---|
`--repo` |
auto-detect via gh | GitHub repo in `owner/repo` format |
`--interval` |
60 | Poll interval in seconds |
`--ack` |
— | Re-baseline issues after your own edits |

## Queue discipline for AI agents

These are not suggestions. An agent that ignores them blocks the whole team.

-
**idle != done.** Heartbeat prints`PENDING`

lines for your open tickets.

When you see them, act on them — do not report "idle". -
**Every ticket gets resolved.** For each open ticket: handle it, re-route

it (with a comment), or escalate to`agent:human`

. Leaving it untouched is

the one forbidden option. -
**Stuck? Cross-comment.** After 3+ rounds without progress, look for

related tickets in other agents' queues (same`theme:*`

label) and comment

there. You may comment anywhere. You may not change ownership unless the

ticket has had no activity for 7+ days.
