This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
node gather-ctl.js <command> [argument]GatherV2 must be running with CDP exposed on port 9222. There are no build or compile steps — it's plain Node.js CommonJS.
The entire project is a single-file CLI (gather-ctl.js) with no framework, no build system, and no test suite.
Control flow:
main()parsesprocess.argv, dispatches to a command branch.- Every command calls
withGather(fn), which:- Fetches
http://localhost:9222/jsonto find the Gather page target and itswebSocketDebuggerUrl. - Opens a raw WebSocket to that URL.
- Exposes an
ev(expr)helper that sends aRuntime.evaluateCDP message and returns the resolved value. - Calls
fn(ev), then immediately terminates the WebSocket (ws.terminate()— not a graceful close).
- Fetches
- All Gather state reads and mutations happen via JavaScript snippets in the
JSobject, evaluated inside Gather's renderer process viaRuntime.evaluate.
Meeting types:
GatherV2 has three meeting types detected differently:
- Room meeting —
u.currentMeetingis set (non-null). Full feature set available. - Hallway Conversation — proximity-triggered when avatars get close;
u.currentMeetingis null. Detected by DOM presence oflock-conversation-buttonorunlock-conversation-button. Onlyhand,lock, andvieware available;musicandrecordrequire a room meeting. - External Meeting — an external call (Zoom, Google Meet, etc.) triggered from within Gather; a popup with title "External meeting detected" appears (buttons: "Join next meeting", "Go to desk").
u.currentMeetingis null, no lock/unlock buttons, nodata-testidon any popup element. Detected by DOM presence of a<span>whose trimmed text equals"External meeting detected". No Gather AV controls apply; onlystatus(read-only) is relevant.
The status command prints a meet: line (EXTERNAL, HALLWAY, or ROOM) when in any conversation.
Gather internals accessed via CDP:
window.gatherDev.Repos.localMediaSelfInfo— mic state (_audioMuteClicked,toggleAudioMuteClicked())window.gatherDev.Repos.gameSpace.currentSpaceUserOrUndefined— user object: hand, availability, desk, meeting membership, dancing (startDancing(),stopDancing())window.gatherDev.Repos.avConnections.inputState— screen share state (ownScreenShareEnabled)window.gatherDev.Repos.reactionsFrontend— emoji reactions (sendEmote(emoji)— takes the raw emoji character, e.g.👋)window.gatherDev.Repos.videoViewMode.inputState— meeting view mode (videoViewMode: "Grid"|"Carousel"read-only;"Grid"= meeting/video-grid view,"Carousel"= office/game-map view). Do not write this observable directly. UsesetViewMode('Grid'|'Carousel')which dispatches the correct Redux action (setViewMode(A) { dispatch(gB(A)) }). In Room Meetings,setViewMode()alone is insufficient — it updates Redux state but does not trigger React Router navigation, leaving the target view's components unmounted (black background). Click[data-testid="meeting-view-nav"]or[data-testid="office-view-nav"]instead, withsetViewModeas fallback. In Hallway Conversations, fall back tosetViewMode():meeting-view-navis absent, and clickingoffice-view-navwould exit the conversation.window.gatherDev.Repos.syncedMusicPlaybackFrontend— shared meeting music (startPlayback(playlist),stopPlayback(), guarded bycanStartPlayback/canStopPlayback;playback.playlistreflects current track orundefinedwhen not playing)window.gatherDev.MoveController.moveSpaceUserToDesk()— move to own desk- DOM buttons (
[data-testid="toggle-camera-*"],[data-testid="toggle-screen-share-button"]) — camera and screen share toggles - DOM buttons (
[data-testid="lock-conversation-button"],[data-testid="unlock-conversation-button"]) — meeting lock toggle;lock-conversation-buttonpresent = unlocked (click to lock),unlock-conversation-buttonpresent = locked (click to unlock). Presence of either button also signals "in any conversation" (scheduled or Hallway). - Meeting toolbar more-options menu (
button[aria-haspopup="menu"]) — recording start/stop
Platform setup:
| Method | macOS | Windows |
|---|---|---|
| Per-session (no modification) | open -a GatherV2 --args --remote-debugging-port=9222 |
"%LOCALAPPDATA%\Programs\GatherV2\GatherV2.exe" --remote-debugging-port=9222 |
| Persistent patch | sudo ./patch-gather.sh (wraps the binary) |
.\patch-gather.ps1 (patches shortcuts) |
A Stream Deck plugin mirrors all CLI commands as hardware buttons with live state feedback:
~/Developments/Stream Deck/GatherV2 StreamDeck Plugin/
The plugin uses the same CDP approach and shares the same JS snippets. When adding a new CLI command, update the plugin too: add the JS snippet to src/gatherV2/js-snippets.ts, extend GatherState in src/gatherV2/types.ts, create a new action in src/actions/, add image assets under net.wulfaz.gatherV2.sdPlugin/imgs/actions/, register in manifest.json and src/plugin.ts, then run npm run build.
- The
wspackage is used directly for the CDP WebSocket connection (no higher-level CDP library). - CDP timeout is hard-coded at 15 seconds per
ev()call. - JS snippets that interact with dialogs or async UI use polling loops (
for i < 10; sleep 150ms) — they are intentionally fragile to Gather UI changes. - "In any conversation" detection (
inAnyMeeting) uses DOM presence oflock-conversation-buttonorunlock-conversation-button. This covers room meetings (u.currentMeetingset) and Hallway Conversations (u.currentMeetingnull). External Meetings are tracked separately viaexternalMeetingand are excluded frominAnyMeeting.hand,lock, andviewuse theinAnyMeetingcheck. - External Meeting detection uses
[...document.querySelectorAll('span')].find(s => s.textContent?.trim() === 'External meeting detected'). The popup has nodata-testid— Gather uses obfuscated class names only.hallwayConversationexplicitly guards againstexternalMeetingbeing true to avoid false positives. recordandmusicrequire a room meeting only (u.currentMeetingmust be set).recordandshareadditionally require the toolbar button to be present in the DOM.lockadditionally requires being a meeting host (non-hosts do not see the lock button).reactionaccepts 8 fixed emojis (wave, heart, tada, thumbsup, rofl, clap, 100, fire) mapped to their Unicode characters. Only these 8 are accepted server-side; arbitrary emojis are silently dropped by GatherV2.dancekeeps the WebSocket open for the full duration (timer runs in Node.js between twoev()calls). Duration is capped at 0.5–10 seconds.viewworks in both room meetings and Hallway Conversations. State is read fromvideoViewMode.inputState.videoViewMode. In Room Meetings, changes are applied by clicking[data-testid="meeting-view-nav"](→ Grid) or[data-testid="office-view-nav"](→ Carousel) to trigger React Router navigation —setViewMode()alone only changes Redux state and leaves the view components unmounted. In Hallway Conversations,setViewMode('Grid'|'Carousel')is used as fallback:meeting-view-navis absent, andoffice-view-navwould exit the conversation.musicrequires a room meeting;MusicPlaybackListenum values:SoftAmbience|LofiChill|SimpleEnergy(string enum — values equal keys).startPlaybackandstopPlaybackare synchronous (fire-and-forget); a 300–500 ms settle delay is added after each call.