现象
fh hub peers 输出:
forge-AAAAA [all]
forge-BBBBB [all]
forge-CCCCC (微信@<tag>) [feishu, wechat, imessage, telegram]
通过菜单栏「+ 新会话」启动的普通 claude 窗口都注册成了 channel mode peer 并订阅所有通道。多窗口在线 + hub-config.json 没设 primary_instance 时,wechat 入站消息会被 router 广播到所有"在听"的窗口(router.ts L98-113),用户必须显式"固定"一个窗口才能让对话聚焦——否则不同窗口都会试图回复同一条消息。
附带:~/.forge-hub/state/_hub/instance-identities.json 长期累积大量 zombie {"isChannel": true} 条目(每个普通会话留一条,进程退出不清),实测会到几十上百条量级。
复现
- 菜单栏点「+ 新会话」启动一个普通会话(不走「📡 通道会话」流程)
- 另一终端
fh hub peers
- 观察新窗口订阅 →
[all]
根因追踪
hub MCP 在 ~/.claude.json 顶层 mcpServers.hub 全局注册——每次 claude 调用都会启动 hub-channel.ts,不区分是否为通道会话。
hub-channel.ts:73-130 三层 fallback 读 session config:
next-session.json
- legacy
next-name.txt
instance-identities.json 里查当前 ppid
三层全 miss → 第四层兜底(L124-130):
// No launcher wrote session config — default to channel mode listening to all channels.
// Historical design assumed a launcher (e.g. menubar app) would write next-session.json before
// each `claude server:hub` invocation. Open-source users start hub-channel directly without
// a launcher, so without this default they'd land in tool mode and receive no messages despite
// having configured channels.
return { channels: ["all"] };
["all"] 在 instance-manager.ts:95 解析为 undefined = 订阅所有通道。
forge-launcher 三个启动入口对照:
| 入口 |
是否写 next-session.json |
结果 |
AppDelegate.launchNew() (「+ 新会话」按钮,L147-150) |
❌ 完全不写 |
落到第四层兜底 → 订阅 all |
AppDelegate.openSession() (resume 普通会话) |
✓ 写 {description: "..."}(HubExtension L161 channels=[],被 HubClient L220 !channels.isEmpty 过滤) |
layer 1 命中 → channels=undefined → 工具模式 ✓ |
ChannelDialog.launch()/resume() (「📡 通道会话」) |
✓ 写完整 {tag, description, channels, history} |
channel mode 订阅指定通道 ✓ |
也就是说 launchNew 是漏的那一条。openSession 走的"哪怕只写 description 都能让文件存在"间接绕开了兜底;launchNew 没有 description 可写,就直接没文件。
三条可能的修复方向(请林凡定)
| 方案 |
改动点 |
tradeoff |
| A |
AppDelegate.launchNew() 也调一次 writeSessionFile() 占位({} 即可),同时 HubClient.writeSessionFile 去掉 if !channels.isEmpty 过滤、或允许写空对象 |
改动最小,但 launcher 和 hub-client 各动一处;需要双侧协同 |
| B |
hub-channel.ts 第四层兜底从 { channels: [\"all\"] } 改成 {}(工具模式) |
改 hub 单侧;但语义改变——纯 OSS 没装 launcher 的用户会变成"默认收不到消息",需重新评估对那部分用户的引导 |
| C |
hub MCP 不再全局注册到 ~/.claude.json,仅在 launcher 显式 opt-in 的会话(通道会话)里挂载 |
架构最清晰,但改动面最大;可能影响其他默认依赖 hub MCP 工具的工作流 |
C 是结构上最干净的,但不知道你这边是否有"普通会话也想用 hub_reply 这类工具"的场景;如果有,C 不适用。
A 是最小可行修复,能立刻让"普通会话不订阅任何通道"成为默认。
临时 workaround
不依赖修复,用户侧两种方式:
~/.forge-hub/hub-config.json 设 \"primary_instance\": \"<微信窗口的 tag>\" —— 路由收敛到指定窗口(其他窗口仍然"在听",但不会收到消息)
- 普通窗口跑
fh hub listen none —— 显式取消订阅(用 channels: [\"none\"] 标记此 instance 不接任何通道)
环境
- forge-hub 0.2.0 (Homebrew)
- forge-launcher (本地编译,main 分支近期版本)
- macOS 14+ Apple Silicon
现象
fh hub peers输出:通过菜单栏「+ 新会话」启动的普通
claude窗口都注册成了 channel mode peer 并订阅所有通道。多窗口在线 +hub-config.json没设primary_instance时,wechat 入站消息会被 router 广播到所有"在听"的窗口(router.ts L98-113),用户必须显式"固定"一个窗口才能让对话聚焦——否则不同窗口都会试图回复同一条消息。附带:
~/.forge-hub/state/_hub/instance-identities.json长期累积大量 zombie{"isChannel": true}条目(每个普通会话留一条,进程退出不清),实测会到几十上百条量级。复现
fh hub peers[all]根因追踪
hub MCP 在
~/.claude.json顶层mcpServers.hub全局注册——每次claude调用都会启动hub-channel.ts,不区分是否为通道会话。hub-channel.ts:73-130三层 fallback 读 session config:next-session.jsonnext-name.txtinstance-identities.json里查当前 ppid三层全 miss → 第四层兜底(L124-130):
["all"]在instance-manager.ts:95解析为undefined= 订阅所有通道。forge-launcher 三个启动入口对照:
AppDelegate.launchNew()(「+ 新会话」按钮,L147-150)AppDelegate.openSession()(resume 普通会话){description: "..."}(HubExtension L161 channels=[],被 HubClient L220!channels.isEmpty过滤)ChannelDialog.launch()/resume()(「📡 通道会话」){tag, description, channels, history}也就是说
launchNew是漏的那一条。openSession走的"哪怕只写 description 都能让文件存在"间接绕开了兜底;launchNew没有 description 可写,就直接没文件。三条可能的修复方向(请林凡定)
AppDelegate.launchNew()也调一次writeSessionFile()占位({}即可),同时HubClient.writeSessionFile去掉if !channels.isEmpty过滤、或允许写空对象hub-channel.ts第四层兜底从{ channels: [\"all\"] }改成{}(工具模式)~/.claude.json,仅在 launcher 显式 opt-in 的会话(通道会话)里挂载C 是结构上最干净的,但不知道你这边是否有"普通会话也想用 hub_reply 这类工具"的场景;如果有,C 不适用。
A 是最小可行修复,能立刻让"普通会话不订阅任何通道"成为默认。
临时 workaround
不依赖修复,用户侧两种方式:
~/.forge-hub/hub-config.json设\"primary_instance\": \"<微信窗口的 tag>\"—— 路由收敛到指定窗口(其他窗口仍然"在听",但不会收到消息)fh hub listen none—— 显式取消订阅(用channels: [\"none\"]标记此 instance 不接任何通道)环境