D0 feasibility probe for the xmuse sidebar (no product code)

在 Claude Code 里聚合 OpenCode、dsh、agy 等本地 agent,一起干活。
你可以让 Claude 当主控,把活派给其他 agent 去干;也可以让几个 agent 平等地讨论同一个方案,互相挑毛病。不管哪种方式,agent 之间的决定、假设和待决问题都由 xmuse 的信息协作层管理:谁该知道什么、什么时候知道。每个 agent 仍在自己原生的环境里运行,协作界面是 Claude 桌面 App 里的一个面板。
v1(房间运行时)已废弃,代码留在
v1分支。
几个独立的 agent 一起干活时,难点在上下文:每个 agent 该知道什么、什么时候知道。约定改了,没收到的那个会照旧干;全量转发聊天记录,上下文又会越涨越长。xmuse 把精力放在这件事上,而不去接管 agent 本身:会话、工具、权限、上下文压缩,都由各家自己管。
具体做法:
kind=decision|assumption|question),新条目可以用 supersedes 取代旧的。条目按话题归属,只发给相关的成员。flowchart TB
USER["用户"] --> PANEL["xmuse 面板<br/>(Claude Code 插件)"]
CC["Claude Code<br/>(主控)"] --- PANEL
subgraph HELPERS["助手"]
OC["OpenCode"]
AGY["agy"]
DSH["dsh"]
end
CC -->|"MCP 工具"| HUB
PANEL -->|"HTTP"| HUB
subgraph HUB["hub (本地, 每个项目一个)"]
MAIL["消息、话题"]
ENG["唤醒引擎"]
ALIGN["约定条目"]
DB[("hub.db (SQLite)")]
end
ENG -->|"各家适配器"| HELPERS
HUB -.->|"可选"| MO["MemoryOS"]
hub 是一个本地 Python 进程,所有状态存在一个 SQLite 文件里。agent 通过一组 MCP 工具收发消息、申报约定。助手由 hub 按各家的方式叫醒:OpenCode 走它的后台服务接口,agy 和 dsh 走无界面模式。Claude 保留自己的全部能力,hub 不驱动它。
MemryOS-lite 可以作为可选的记忆层接入,不配置就不用。
需要 Linux 或 WSL、uv、Node 22,以及要接入的 agent 的 CLI。
git clone https://github.com/iiyazu/Cross-Muse.git && cd Cross-Muse
scripts/bootstrap.sh # 安装依赖并自检,可重复执行
然后在 Claude Code 的终端会话里安装面板插件:
/plugin install xmuse --marketplace <integrations/xmuse-panel 的路径>
打开面板,点"开房间",再在顶部添加助手。也可以不用面板,直接用命令行:
export PATH="$PWD/hub/.venv/bin:$PATH"
xmuse init ~/work/demo && cd ~/work/demo
xmuse agent add oc-1 --vendor opencode
xmuse hub start
xmuse send oc-1 "实现 calc.sub,写完交给 claude 复核"
Windows 加 WSL 的细节见 hub/README.md 和 面板说明。
| 路径 | 内容 |
|---|---|
hub/ | hub 和命令行 xmuse;src/xmuse/adapters/ 是各家适配器 |
shim/ | MCP 垫片,把工具调用转给 hub |
integrations/xmuse-panel/ | Claude Code 面板插件 |
integrations/dsh-bundle/ | dsh 桌面端插件包 |
skill/、templates/ | 写进项目的协作技能和 AGENTS.md 段落 |
tests/ | hub 测试,用假 agent 回放录制的输出 |
experiments/、docs/design/ | 实验、设计稿和报告 |
用机制探针验证:在一个小的模拟项目里每次只测一项功能,结果由程序判定,每格 10 次。
数据和效度讨论见 HANDOFF-BASE-REPORT.zh.md 的"S 系列阶段分析"一节和 M2b 实验报告,探针设计见 XMUSE2-PROBES.zh.md。
hub/.venv/bin/python -m pytest tests -q -p no:cacheprovider
(cd integrations/dsh-bundle && npm test)
claude plugin test integrations/xmuse-panel
hooks/register.tsx 246 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// D0 probe: nothing here is product code. It records what the mod API can do.
5// Config is read from ../../out/config.json (relative to the plugin root) so
6// the same module can be driven from different runs without editing it:
7// { "urls": ["http://127.0.0.1:18080/ping"], "pushAfterMs": 1500,
8// "pushText": "...", "asUser": false, "openPane": true, "tickMs": 300 }
9
10const PANE = 'xmuse-probe'
11const state = atom({ plugin: 'xmuse-probe', key: 'ui' } as const, {
12 room: 'none',
13 joined: '',
14 draft: '',
15 provider: 'claude',
16 presses: 0,
17})
18
19function outDir($: any) {
20 return `${$.plugin.root}/../out`
21}
22
23let chain: Promise<void> = Promise.resolve()
24
25function log($: any, obj: Record<string, unknown>) {
26 chain = chain.then(() => logNow($, obj))
27 return chain
28}
29
30async function logNow($: any, obj: Record<string, unknown>) {
31 try {
32 const path = `${outDir($)}/claude-probe.log`
33 let prev = ''
34 try {
35 prev = (await $.fs.read(path)) as string
36 } catch {
37 prev = ''
38 }
39 const line = JSON.stringify({ t: await $.clock.now(), ...obj })
40 await $.fs.write(path, prev + line + '\n')
41 } catch (err) {
42 $.ui.log(`probe log failed: ${String(err)}`, { to: 'debug' })
43 }
44}
45
46function short(s: unknown) {
47 return typeof s === 'string' ? s.slice(0, 600) : s
48}
49
50export const register: Register = on => {
51 let ticks = 0
52
53 on('classic.SessionStart', async ($, e, next) => {
54 await log($, {
55 ev: 'classic.SessionStart',
56 session_id: (e as any).session_id,
57 transcript_path: (e as any).transcript_path,
58 cwd: (e as any).cwd,
59 source: (e as any).source,
60 })
61 return next(e)
62 })
63
64 on('session.start', async ($, e, next) => {
65 const id = await $.session.id()
66 const cwd = await $.session.cwd()
67 const ver = await $.session.version()
68 const surfaces = await $.session.surfaces()
69 await log($, { ev: 'session.start', input: e, sessionId: id, cwd, ver, surfaces })
70
71 await $.command.register({
72 name: 'xmuse',
73 description: 'xmuse D0 probe: status | join <code> | push',
74 argumentHint: 'status | join <code>',
75 })
76
77 let cfg: any = {}
78 try {
79 cfg = JSON.parse((await $.fs.read(`${outDir($)}/config.json`)) as string)
80 } catch (err) {
81 await log($, { ev: 'config.missing', err: String(err) })
82 }
83
84 // 1. $.http.fetch
85 for (const url of cfg.urls ?? []) {
86 try {
87 const t0 = await $.clock.now()
88 const r = await $.http.fetch(url, { headers: { 'x-xmuse-session': id } })
89 await log($, {
90 ev: 'http.fetch',
91 url,
92 status: r.status,
93 ok: r.ok,
94 text: short(r.text),
95 ms: (await $.clock.now()) - t0,
96 })
97 } catch (err) {
98 await log($, { ev: 'http.fetch.error', url, err: String(err) })
99 }
100 }
101 // POST with header, to see the server echo
102 if (cfg.postUrl) {
103 try {
104 const r = await $.http.fetch(cfg.postUrl, {
105 method: 'POST',
106 headers: { 'content-type': 'application/json', 'x-xmuse-session': id },
107 body: JSON.stringify({ hello: 'from-mod', session: id }),
108 })
109 await log($, { ev: 'http.post', status: r.status, text: short(r.text) })
110 } catch (err) {
111 await log($, { ev: 'http.post.error', err: String(err) })
112 }
113 }
114
115 // 2. $.clock.every
116 $.clock.every(cfg.tickMs ?? 300, () => {
117 ticks += 1
118 if (ticks <= 8) void log($, { ev: 'clock.every.tick', n: ticks })
119 })
120
121 // 3. $.prompt.submit wake-up
122 if (cfg.pushAfterMs) {
123 $.clock.after(cfg.pushAfterMs, async () => {
124 await log($, { ev: 'push.submit.begin', asUser: !!cfg.asUser })
125 try {
126 const r = cfg.asUser
127 ? await $.prompt.submit({ text: cfg.pushText ?? 'ping', asUser: true })
128 : await $.prompt.submit({ text: cfg.pushText ?? 'ping' })
129 await log($, { ev: 'push.submit.done', r })
130 } catch (err) {
131 await log($, { ev: 'push.submit.error', err: String(err) })
132 }
133 })
134 }
135
136 // 6. pane
137 if (cfg.openPane) {
138 try {
139 const opened = await $.ui.open({ id: PANE, title: 'xmuse probe' })
140 await log($, { ev: 'ui.open', opened })
141 } catch (err) {
142 await log($, { ev: 'ui.open.error', err: String(err) })
143 }
144 }
145 return next(e)
146 })
147
148 on('session.end', async ($, e, next) => {
149 await log($, { ev: 'session.end', e, ticks })
150 return next(e)
151 })
152
153 on('prompt.submit', async ($, e, next) => {
154 await log($, { ev: 'prompt.submit', origin: e.origin, text: short(e.text), wait: e.wait })
155 return next(e)
156 })
157
158 on('session.append', async ($, e, next) => {
159 const m: any = e.message
160 const text = Array.isArray(m.content)
161 ? m.content.map((b: any) => (b.type === 'text' ? b.text : `[${b.type}]`)).join('|')
162 : m.content
163 if (m.role === 'user' || m.type === 'user') {
164 await log($, { ev: 'session.append', door: (e as any).door, origin: (e as any).origin, isMeta: m.isMeta, text: short(text) })
165 }
166 return next(e)
167 })
168
169 on('turn.complete', async ($, e, next) => {
170 await log($, { ev: 'turn.complete', text: short((e as any).text) })
171 return next(e)
172 })
173
174 // 5. command.run
175 on('command.run', { command: 'xmuse' }, async ($, e) => {
176 const [sub, ...rest] = e.args.trim().split(/\s+/)
177 const id = await $.session.id()
178 await log($, { ev: 'command.run', args: e.args, origin: e.origin, presentation: e.presentation })
179 if (sub === 'join') {
180 await update($, state, s => ({ ...s, joined: rest[0] ?? '' }))
181 return { text: `xmuse join code=${rest[0]} session=${id}`, exitCode: 0 }
182 }
183 if (sub === 'push') {
184 void $.prompt.submit({ text: `XMUSE-PUSH from command ${rest.join(' ')}` })
185 return { text: 'push queued' }
186 }
187 return { text: `xmuse status ok session=${id} args=${JSON.stringify(rest)}` }
188 })
189
190 // 6. UI: Pane + AbovePrompt using Input / Select / Button / Markdown
191 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192 const { Box, Text, Button, Markdown, Input, Select } = $.ui.resolve(e) as any
193 const s = (await read($, state)) as any
194 return (
195 <Box flexDirection="column" gap={1}>
196 <Text bold>xmuse room: {s.room}</Text>
197 <Text dimColor>surface={e.surface} placement={(e.props as any).placement} cols={(e.props as any).bodyColumns}</Text>
198 <Select
199 key="provider"
200 label="provider"
201 value={s.provider}
202 options={[
203 { value: 'claude', label: 'Claude' },
204 { value: 'opencode', label: 'OpenCode' },
205 { value: 'dsh', label: 'dsh' },
206 ]}
207 onSelect={(v: string) => update($, state, (x: any) => ({ ...x, provider: v }))}
208 />
209 <Input
210 key="room-name"
211 label="room"
212 placeholder="room name"
213 value={s.draft}
214 submitLabel="create"
215 onInput={(v: string) => update($, state, (x: any) => ({ ...x, draft: v }))}
216 onSubmit={(v: string) => update($, state, (x: any) => ({ ...x, room: v }))}
217 />
218 <Box>
219 <Button
220 key="invite"
221 label="Invite"
222 variant="primary"
223 onPress={() => update($, state, (x: any) => ({ ...x, presses: x.presses + 1 }))}
224 />
225 <Button key="approve" label="Approve" hotkey="a" onPress={() => update($, state, (x: any) => ({ ...x, presses: x.presses + 10 }))} />
226 </Box>
227 <Text>presses: {s.presses}</Text>
228 <Markdown text={'**timeline**\n\n- @claude hello\n- @dsh ok'} />
229 </Box>
230 )
231 })
232
233 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
234 if ((e.props as any).hasSurvey) return next(e)
235 const { Box, Text, Button } = $.ui.resolve(e) as any
236 const s = (await read($, state)) as any
237 return (
238 <Box>
239 <Text dimColor>xmuse: 2 unread @ · 1 approval </Text>
240 <Button key="open" label="Open" onPress={() => $.ui.open({ id: PANE, title: 'xmuse probe' })} />
241 <Text dimColor> presses={s.presses}</Text>
242 </Box>
243 )
244 })
245}
246types/index.d.ts 14 lines1export type XmuseProbeUi = {
2 room: string
3 joined: string
4 draft: string
5 provider: string
6 presses: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'xmuse-probe': { ui: XmuseProbeUi }
12 }
13}
14