{"id":47313125,"url":"https://github.com/johnsonlee/agora","last_synced_at":"2026-03-17T12:53:29.180Z","repository":{"id":337802857,"uuid":"1155293842","full_name":"johnsonlee/agora","owner":"johnsonlee","description":null,"archived":false,"fork":false,"pushed_at":"2026-02-11T14:01:23.000Z","size":36,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-11T19:17:45.473Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/johnsonlee.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-11T10:51:42.000Z","updated_at":"2026-02-11T14:01:51.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/johnsonlee/agora","commit_stats":null,"previous_names":["johnsonlee/agora"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/johnsonlee/agora","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/johnsonlee%2Fagora","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/johnsonlee%2Fagora/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/johnsonlee%2Fagora/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/johnsonlee%2Fagora/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/johnsonlee","download_url":"https://codeload.github.com/johnsonlee/agora/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/johnsonlee%2Fagora/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30623941,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-17T11:26:08.186Z","status":"ssl_error","status_checked_at":"2026-03-17T11:24:37.311Z","response_time":56,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-03-17T12:53:28.442Z","updated_at":"2026-03-17T12:53:29.172Z","avatar_url":"https://github.com/johnsonlee.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Agora 🏛️\n\nAI vs AI debate arena. Watch Claude, Gemini, and ChatGPT debate each other in real-time.\n\n## Why?\n\nSometimes two AIs debating can spark insights that neither would produce alone. Or at least it's entertaining to watch.\n\n## Quick Start\n\n```bash\n# Install dependencies\nnpm install\n\n# Run with default topic (Claude vs Gemini)\nnpm start\n\n# Run with custom topic\nnpm start \"静态类型 vs 动态类型哪个更适合大型项目\"\n\n# Choose any two services to debate\nnpm start \"AI会取代程序员吗\" claude chatgpt\nnpm start \"AI会取代程序员吗\" gemini chatgpt\n```\n\nAvailable services: `claude`, `gemini`, `chatgpt`\n\n## How It Works\n\n1. Spawns two Chrome windows (any two of Claude / Gemini / ChatGPT), connects via CDP after login\n2. You log in manually (first time only - sessions are saved in `./profiles/`)\n3. Press Enter to start\n4. Both AIs receive the topic and state their opening positions (B's opening streams to A in real-time)\n5. They debate back and forth with real-time streaming sync — each AI sees the other's response as it's being generated\n6. Runs indefinitely (Ctrl+C to stop), transcript auto-saved to `./logs/` after each round\n\n```\n┌──────────────────┐      ┌──────────────────┐\n│   Claude         │      │   Gemini         │\n│   Browser        │      │   Browser        │\n│                  │      │                  │\n│   ┌──────────┐   │      │   ┌──────────┐   │\n│   │          │   │ ───► │   │          │   │\n│   │  Chat    │   │      │   │  Chat    │   │\n│   │          │   │ ◄─── │   │          │   │\n│   └──────────┘   │      │   └──────────┘   │\n└──────────────────┘      └──────────────────┘\n         │                         │\n         └─────────┬───────────────┘\n                   │\n                   ▼\n            ┌─────────────┐\n            │   Agora     │\n            │ Orchestrator│\n            └─────────────┘\n```\n\n## Project Structure\n\n```\nagora/\n├── src/\n│   ├── index.js      # Entry point, Chrome launcher, service registry\n│   ├── arena.js      # Debate orchestrator\n│   ├── bridge.js     # Generic chat bridge (no service-specific selectors)\n│   ├── claude.js     # Claude bridge\n│   ├── gemini.js     # Gemini bridge\n│   ├── chatgpt.js    # ChatGPT bridge\n│   └── templates.js  # i18n moderator/turn prompt templates\n├── profiles/         # Browser sessions (gitignored)\n├── logs/             # Debate transcripts\n└── package.json\n```\n\n## Adding More AI Services\n\nThe bridge uses generic DOM discovery — no service-specific CSS selectors needed. Just extend `ChatBridge`:\n\n```javascript\nimport { ChatBridge } from './bridge.js'\n\nexport class NewServiceBridge extends ChatBridge {\n  constructor(page) {\n    super(page, {\n      name: 'NewService',\n      url: 'https://newservice.example.com',\n    })\n    this.useEnterToSubmit = true\n  }\n}\n```\n\nThen register it in `src/index.js`:\n\n```javascript\nconst SERVICES = {\n  // ...existing services\n  newservice: { BridgeClass: NewServiceBridge, profileDir: './profiles/newservice', url: 'https://newservice.example.com' },\n}\n```\n\n## Technical Journey\n\n### Round 1: Playwright\n\nThe first attempt used Playwright to launch and control browsers. Problems:\n\n- **Bot detection**. Playwright injects automation markers (`navigator.webdriver = true`, modified `Runtime.enable` domains, etc.) that Cloudflare and other bot-detection systems pick up immediately. Claude.ai and ChatGPT both blocked automated sessions on sight.\n- **Session persistence**. Playwright's browser contexts don't map cleanly to real Chrome user-data directories. Cookies and login state were lost between runs, forcing re-login every time.\n\n### Round 2: Puppeteer (launch mode)\n\nSwitched to `puppeteer.launch()` with `puppeteer-extra-plugin-stealth`. Better, but still not enough:\n\n- **`puppeteer.launch()` still sets `--enable-automation`** and other flags that leak automation intent. Stealth plugin patches many signals but not all — Cloudflare's challenge page still detected it intermittently.\n- **Service-specific CSS selectors**. Each AI service (Claude, Gemini, ChatGPT) has a different DOM structure for chat messages. Early versions hardcoded selectors like `.agent-turn .markdown` or `[data-message-author-role=\"assistant\"]`. These broke every time a service updated their frontend.\n- **Response detection by selector**. Used `isStillStreaming()` with service-specific selectors (e.g. `.result-streaming` for ChatGPT). Fragile — a single class name change breaks the entire flow.\n\n### Round 3: spawn Chrome + `puppeteer.connect()` + generic DOM discovery (current)\n\nThe current architecture splits browser control into two phases:\n\n**Phase 1 — Spawn a bare Chrome process** via `child_process.spawn()` with `--remote-debugging-port` and `--user-data-dir`. No Puppeteer, no automation flags. Chrome opens the AI service URL as a completely normal browser. The user logs in manually and passes any Cloudflare challenges.\n\n**Phase 2 — Connect Puppeteer after login** via `puppeteer.connect({ browserURL })`. At this point the session is already authenticated. Puppeteer only provides the CDP bridge for DOM interaction — it never launched the browser, so no automation traces exist.\n\n**Generic DOM discovery** replaces all service-specific selectors:\n\n| Problem | Old approach | Current approach |\n|---|---|---|\n| Find chat input | Hardcoded `#prompt-textarea`, `div.ProseMirror` | Find the largest visible `[contenteditable=\"true\"]` on page |\n| Find response container | Hardcoded `.agent-turn`, `message-content` | Send a probe message, walk up from the deepest match to find the scrollable ancestor, then watch for new sibling nodes |\n| Detect new responses | Service-specific `getResponseCount()` | Mark all existing children with `data-agora-seen`, any unmarked child is new |\n| Detect streaming | Service-specific `isStillStreaming()` + polling selectors | Compare `extractResponse()` across two polls + detect visible stop/cancel buttons |\n| Extract response text | Service-specific selectors | Multi-level extraction: Level 1 (container children), Level 2 (scroll container → wrapper → block), Level 3 (raw innerText fallback) |\n\nResult: **zero service-specific CSS selectors**. Adding a new AI service is ~10 lines of code (see [Adding More AI Services](#adding-more-ai-services)).\n\n### Pitfalls solved along the way\n\n- **Gemini's Angular DOM replacement**. Gemini renders a `\u003cpending-request\u003e` placeholder that Angular replaces with the actual response node. A cached `ElementHandle` pointing to the placeholder becomes stale. Fixed by multi-level extraction that walks the live DOM tree instead of relying on a single cached node.\n- **ElementHandle memory leak**. `page.evaluateHandle()` returns handles that must be `dispose()`d. The `findInput()` call runs every 300ms during `updateInput()` — without caching, handles accumulated until Node.js OOM-crashed at ~4GB after 15 minutes. Fixed by caching handles and disposing on replacement via `_setHandle()`.\n- **Frame detachment**. Long-running debates (7+ rounds) occasionally trigger page re-renders that detach the main frame, crashing Puppeteer calls. Fixed with `resetDOM()` cleanup + `framenavigated` listener for proactive recovery.\n- **macOS sleep**. Screen sleep suspends the Chrome process and breaks the CDP WebSocket connection. Fixed by spawning `caffeinate -dims` to prevent system sleep during debates.\n\n## Caveats\n\n- **ToS**: This automates web interfaces, which may violate terms of service. Use for personal experiments only.\n- **Rate limits**: Don't run too many rounds or too frequently.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjohnsonlee%2Fagora","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjohnsonlee%2Fagora","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjohnsonlee%2Fagora/lists"}