Fix bridge auto-connect flap; guild allowlist; mention-strip; HYDRA_SPAWN_MODEL - #44
Fix bridge auto-connect flap; guild allowlist; mention-strip; HYDRA_SPAWN_MODEL#44kwliang1 wants to merge 5 commits into
Conversation
Add `guilds: Record<string, GroupPolicy>` to access.json. Applies to any channel in the guild that has no explicit `groups` entry; per-channel `groups` still wins. Threads `guildId` through InboundMessage from both gateways (Discord = msg.guildId, Slack = msg.team). Opt in a whole server in one entry instead of enumerating every channel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Daemon command interceptors (spawn:, kill:, list sessions, /sessions, help, etc.) anchor with ^ — in a guild channel where requireMention is true, the user must @bot to trigger gate, so msg.content arrives as `<@1234> list sessions` and none of the regexes match. Commands then fall through to the byte session, which may or may not handle them consistently. One-line normalization at the top of the isAllowed block strips a leading mention so the same regex hits in DMs and guild channels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default stays `claude-opus-4-6[1m]` to preserve the maintainer's setup, but the env var override means fresh installs without 1M-context credits can drop the [1m] suffix and avoid the silent 402 → no-reply failure mode in spawned sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug ------- A Claude Code plugin's MCP server boots whenever the plugin is enabled in CLAUDE_CONFIG_DIR. bridge.ts (the plugin's MCP server) unconditionally called connectSocket() on startup and registered with the daemon as `SESSION_ID = process.env.HYDRA_SESSION_ID ?? 'main'`. Result: every Claude Code session that happens to share the byte config dir — a separate IDE window, another `claude` conversation, even an editor extension — also boots a bridge that claims `main`. The daemon's register handler treats a duplicate registration as a restart and `.end()`s the existing socket. The kicked bridge then reconnect-loops (5s timer), gets accepted, kicks the other one, and the two flap forever. Symptom: typing indicators in Discord but no replies (the reply tool's tool_call is interrupted by the next disconnect). The maintainer's setup hides this by using a dedicated `CLAUDE_CONFIG_DIR=~/.claude-personal` for byte alone, so no other session has the plugin loaded. That's avoidance, not a fix — fresh installs that just follow the README and use `~/.claude` for everything hit the loop the moment they open a second Claude window. The fix ------- Bridge no longer auto-connects. Connect only when `HYDRA_BRIDGE_AUTOCONNECT=1` is in the environment. - `start-byte-v2.sh` / `start-slack-byte.sh` set it. - The daemon's session spawner (`session-lifecycle.ts`) sets it for every spawned sub-session (alongside `HYDRA_SESSION_ID`). - Every other Claude session that has the plugin loaded still gets the skills (`/discord:access`, `/discord:configure`) and the MCP tool definitions, but the bridge sits dormant — no socket, no `main` registration, no flap. Why an env var, not (a) per-session ID or (b) daemon-side first-wins ------------------------------------------------------------------- - Per-session ID: bridge has no stable signal to derive one from. We can't use SESSION_ID (Claude Code overwrites that), and Claude session IDs change on restart so they'd defeat byte-reclaim-on-bounce. - Daemon-side first-wins: stops the flap, but breaks the legitimate "byte died, new byte should reclaim main" path unless we add bridge heartbeats and dead-bridge detection. Bigger change for the same effect. Env-var gate is three lines in bridge.ts, one in session-lifecycle.ts, one each in the byte launchers. Surgical. Migration --------- Anyone running a hand-rolled byte launcher (not `start-byte-v2.sh`) must add `export HYDRA_BRIDGE_AUTOCONNECT=1` before invoking `caffeinate claude --channels …`. Without it, byte boots silently and never registers with the daemon. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it the session boots into an interactive "use my browser?" prompt on first Claude Code launch in a new config dir, blocking on stdin forever. No bridge registration, no replies, silent dead bot. Hit this twice in a fresh setup; the Esc-to-dismiss recovery isn't obvious because the prompt only renders if you attach to the tmux session. `--no-chrome` is the safe default — Playwright and other browser-test tooling don't depend on Claude-in-Chrome. Flip per-session if you actually want the integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sf8193
left a comment
There was a problem hiding this comment.
Review Summary
1 Blocker, 1 Should-fix, 1 Nit — inline comments below plus one non-diff finding.
Non-inline finding
Should-fix — threadReply lookup at daemon/router.ts:401 doesn't fall back to guild policy.
gate() in access.ts:162 now resolves policy via groups[channelId] ?? guilds[guildId], but the threadReply lookup at router.ts:401 only checks groups:
const policy = result.access.groups[channelId]
if (policy?.threadReply) { ... }A channel covered solely by a guild-level entry with threadReply: true will pass gate() but never get a thread created — messages go to main with chat_id = msg.channelId.
Suggested fix:
const policy = result.access.groups[channelId]
?? (msg.guildId ? result.access.guilds?.[msg.guildId] : undefined)(Flagged by both sp-reviewer and typescript-reviewer.)
| } | ||
|
|
||
| const threadKillMatch = msg.content.match(/^(?:kill|\/kill)\s*$/i) | ||
| const threadKillMatch = cmdContent.match(/^(?:kill|\/kill)\s*$/i) |
There was a problem hiding this comment.
Blocker — regression: && msg.isThread guard dropped.
On main, this reads if (threadKillMatch && msg.isThread). After this PR it becomes if (threadKillMatch) — the msg.isThread guard was silently lost during the msg.content → cmdContent rewrite.
A bare kill or /kill typed in a top-level guild channel by an allowlisted user will now route to handleThreadKillIntercept without thread context, potentially killing the wrong session or erroring.
Fix: restore if (threadKillMatch && msg.isThread) {.
| // Strip a leading bot mention so commands typed in guild channels | ||
| // (where requireMention forces the user to @bot first) match the same | ||
| // anchored regexes as commands typed in DMs. | ||
| const cmdContent = msg.content.replace(/^<@!?\d+>\s*/, '') |
There was a problem hiding this comment.
Nit — mention-stripped cmdContent is only used for command matching; raw msg.content (with <@id> prefix) is still forwarded to the Claude session.
This is fine if the intent is only to fix command routing. But in guild channels where requireMention is true, the model sees <@!123456789> do the thing in every message. Worth a comment clarifying the intent, or stripping the mention from forwarded content too.
sf8193
left a comment
There was a problem hiding this comment.
Reviewed with dual-agent process (sp-reviewer + typescript-reviewer). 4 findings below.
Overall: clean PR with good documentation. The autoconnect guard and mention-strip are solid solutions. Two should-fix items around the guild fallback need addressing before merge.
Should-fix: threadReply lookup at line 401 missing guild fallback
The post-gate threadReply lookup at daemon/router.ts:401 uses result.access.groups[channelId] without falling through to the guild policy. If a message was admitted by gate() via the new guilds fallback (no per-channel groups entry), this returns undefined and threadReply: true on the guild policy is silently ignored — replies go inline instead of threads.
Fix: mirror the fallback chain from gate():
const policy = result.access.groups[channelId]
?? (msg.guildId ? result.access.guilds[msg.guildId] : undefined)Should-fix: msg.content at line 359 not converted to cmdContent
msg.content.toLowerCase().startsWith(info.tmuxName) still uses raw msg.content. In a guild channel with requireMention: true, @bot <session-name> do something won't match because of the leading <@...>. Same class of bug the cmdContent strip fixes.
| // (where requireMention forces the user to @bot first) match the same | ||
| // anchored regexes as commands typed in DMs. | ||
| const cmdContent = msg.content.replace(/^<@!?\d+>\s*/, '') | ||
|
|
There was a problem hiding this comment.
Should-fix (flagged by both reviewers): The mention-strip is good, but two references below this block were missed:
-
Line 359:
msg.content.toLowerCase().startsWith(info.tmuxName)— in a guild channel withrequireMention,@bot <session> ...won't match because of the leading<@...>prefix. Should usecmdContent. -
Line 401: The post-gate
threadReplylookup usesresult.access.groups[channelId]without the guild fallback. If a message passesgate()via the newguildspolicy,threadReply: trueis silently ignored. Should mirror:result.access.groups[channelId] ?? (msg.guildId ? result.access.guilds[msg.guildId] : undefined)
| ? msg.parentChannelId ?? msg.channelId | ||
| : msg.channelId | ||
| const policy = access.groups[channelId] | ||
| // Per-channel policy wins; guild-level acts as a fallback for any channel in the guild. |
There was a problem hiding this comment.
Nit: Worth a doc note in ACCESS.md: a guild-level policy with allowFrom: [] makes the bot respond to every user in every channel of that guild (since empty allowFrom skips the user filter at line 167). Larger blast radius than a single-channel open policy. The ACCESS.md example shows allowFrom: [] without calling this out.
| // Override via HYDRA_SPAWN_MODEL in the daemon's .env. The default uses the | ||
| // 1M-context Opus build; switch to a non-[1m] variant if your Claude account | ||
| // doesn't have 1M-context credits enabled (otherwise spawned sessions 402 on | ||
| // first API call and never reply). |
There was a problem hiding this comment.
Nit: Consider logging the resolved SPAWN_MODEL at daemon startup so a typo in the env var is visible in logs rather than silently 402ing every spawn.
Four related changes from a fresh-install setup session. Each is a separate commit; the last one is the load-bearing fix.
1.
fix: gate bridge daemon-connect on HYDRA_BRIDGE_AUTOCONNECT(b1e61ed)The scenario
Set up hydra on a brand-new machine following the README. Installed the plugin once:
Pointed byte at the default config dir (
CLAUDE_CONFIG_DIR=~/.claude). Daemon started fine. Byte connected fine. DMs round-tripped. Spawned sub-sessions in threads.Then opened a second Claude Code session in another window — completely unrelated work — and Discord replies stopped. Bot would 👀-ack messages, show a typing indicator, then nothing. The daemon log showed an endless reconnect storm:
The root cause
A Claude Code plugin's MCP server (
bridge.ts) boots whenever the plugin is enabled inCLAUDE_CONFIG_DIR.bridge.tsunconditionally calledconnectSocket()on startup and registered with the daemon as:Every Claude session sharing the byte config dir — second IDE window, another conversation, sometimes even an editor extension — also booted a bridge and claimed
main. The daemon's register handler interprets a duplicate registration as a restart and.end()s the existing socket. The kicked bridge then reconnect-loops on its 5s timer, gets accepted, kicks the other one. Forever.The reply tool fires a
tool_callover the socket. If the next disconnect lands beforetool_resultcomes back, the reply silently fails. From Discord's side: typing indicator, nothing.Why the existing setup works
start-byte-v2.shusesCLAUDE_CONFIG_DIR=~/.claude-personal— a dedicated dir nothing else uses. So no other Claude session has the plugin loaded. It avoids the bug; it doesn't fix it. Anyone who setsCLAUDE_CONFIG_DIR=~/.claudeand forgets to keep their other Claude windows out of that dir hits the loop immediately.The fix
Bridge no longer auto-connects. Only connects when
HYDRA_BRIDGE_AUTOCONNECT=1is in the environment.start-byte-v2.sh/start-slack-byte.shset it.daemon/session-lifecycle.tssets it for every spawned sub-session, alongsideHYDRA_SESSION_ID./discord:access,/discord:configure) and the MCP tool definitions. The bridge sits dormant. No socket, nomainregistration, no flap.Alternatives considered
SESSION_IDis overwritten by Claude Code, and Claude session IDs change on restart — would break byte-reclaim-on-bounce.--channelsflag and only connect then. No clean way to inspect parent process args from the MCP subprocess.Env-var gate: three lines in
bridge.ts, one insession-lifecycle.ts, one each in the two byte launchers. Surgical.Migration
Anyone running a hand-rolled byte launcher (not
start-byte-v2.sh) must addexport HYDRA_BRIDGE_AUTOCONNECT=1beforecaffeinate claude --channels …. Without it, byte boots silently and never registers with the daemon. The README has been updated with this requirement.2.
feat: guild-level access policy fallback(9d345cc)Adds
guilds: Record<string, GroupPolicy>toaccess.json. Applies to any channel in the guild that has no explicitgroupsentry; per-channelgroupsstill wins. ThreadsguildIdthroughInboundMessagefrom both gateways (Discord =msg.guildId, Slack =msg.team).Lets you opt in a whole server in one entry instead of enumerating every channel.
3.
fix: strip leading bot mention before daemon command regex(2f6b0b4)Daemon command interceptors (
spawn:,kill:,list sessions,/sessions,help, etc.) all anchor with^. In a guild channel whererequireMention: true,msg.contentarrives as<@1234> list sessions, none of the regexes match, the commands fall through to byte. One-line normalization at the top of theisAllowedblock fixes all of them at once.4.
feat: make spawn model configurable via HYDRA_SPAWN_MODEL(a9c4fde)Default stays
claude-opus-4-6[1m]to preserve the maintainer's setup. Env var override lets fresh installs without 1M-context credits drop the[1m]suffix and avoid the silent 402 → no-reply failure mode in spawned sessions.Test plan
~/.claude-personal— byte and a second Claude window in~/.claudecoexist without flap.@bot list sessionsin a guild channel hits the daemon interceptor, not byte.guilds: { <id>: { requireMention: true } }inaccess.jsonenables every channel in that server.HYDRA_SPAWN_MODEL=claude-opus-4-8in the daemon's.envspawns sub-sessions on 4-8 instead of 4-6[1m].HYDRA_BRIDGE_AUTOCONNECTfromstart-byte-v2.shreproduces the original silent-no-register failure mode.