SLOPSHOPPER

xmuse-probe

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

newpanebandcommandpromptnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xmuse-probe
│ ┃ xmuse-probe ✕ › fix the failing auth test and add an audit log call │ ┃ xmuse room: none │ ┃ ⏺ Read(src/auth.ts) │ ┃ surface=terminal placement=dock cols=56 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ provider: Claude ▾ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ room: room name ⏎ create ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Invite ][ Approve ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ presses: 0 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ timeline › /xmuse │ ┃ ⎿ xmuse-probe: xmuse status ok session=preview-session args=[] │ ┃ • @claude hello │ ┃ • @dsh ok │ xmuse: 2 unread @ · 1 approval [ Open ] presses=0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
xmuse: 2 unread @ · 1 approval [ Open ] presses=0
Pane · xmuse-probe
xmuse room: none surface=terminal placement=dock cols=56 provider: Claude ▾ room: room name ⏎ create [ Invite ][ Approve ] presses: 0 timeline • @claude hello • @dsh ok
README

xmuse

English

在 Claude Code 里聚合 OpenCode、dsh、agy 等本地 agent,一起干活。

你可以让 Claude 当主控,把活派给其他 agent 去干;也可以让几个 agent 平等地讨论同一个方案,互相挑毛病。不管哪种方式,agent 之间的决定、假设和待决问题都由 xmuse 的信息协作层管理:谁该知道什么、什么时候知道。每个 agent 仍在自己原生的环境里运行,协作界面是 Claude 桌面 App 里的一个面板。

v1(房间运行时)已废弃,代码留在 v1 分支。

关注的问题

几个独立的 agent 一起干活时,难点在上下文:每个 agent 该知道什么、什么时候知道。约定改了,没收到的那个会照旧干;全量转发聊天记录,上下文又会越涨越长。xmuse 把精力放在这件事上,而不去接管 agent 本身:会话、工具、权限、上下文压缩,都由各家自己管。

具体做法:

  • 约定是条目,不是聊天。 agent 把决定、假设、待决问题作为条目申报(kind=decision|assumption|question),新条目可以用 supersedes 取代旧的。条目按话题归属,只发给相关的成员。
  • 唤醒时只带差量。 hub 叫醒一个 agent 时,只附上它还没见过的变化;新会话、交接或检测到上下文被压缩后,改发一份有预算上限的完整快照,分配给它的工作排在最前面。唤醒包的生成是确定性的,不调用 LLM。
  • 按需唤醒。 消息点名谁就叫醒谁,没点名的交给主控。每条因果链有唤醒次数和 token 上限,防止 agent 互相叫醒打转。
  • 私信默认不外传。 用户私下发给某个成员的内容只留在它那里。成员想转给别人时,要经用户批准。

怎么工作

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 次。

  • 约定变更能送达。 用户只私信一个成员改了分工。不经 hub 时,另一个成员 10 次里 10 次没同步,成员主动转告只有 1 次,那一次对方也没改过来;经 hub 时 10 次都同步(单侧 Fisher 检验,p=0.006)。在变更发生的那几轮里,经 hub 的组只唤醒 1 次,token 比全员广播少约 46%。
  • 约定条目比普通消息更会被照做。 同一条格式规则,作为普通消息发出时,40 个成员里有 9 个认为它不是团队规则、不遵守;作为约定条目发出时,20 个成员里没有一个违反(p=0.019)。
  • 约定的开销很小。 在多 agent 编码实验里,约定部分占每次唤醒 token 的比例最高 0.4%。

数据和效度讨论见 HANDOFF-BASE-REPORT.zh.md 的"S 系列阶段分析"一节和 M2b 实验报告,探针设计见 XMUSE2-PROBES.zh.md。

局限

  • 单机、单用户。agent 和 hub 以同一个系统用户运行,权限模型只防误操作,不防有意绕过的 agent。
  • 面板依赖 Claude Code 的插件接口,目前按 Windows 桌面 App 加 WSL 的组合开发和测试。
  • 验证用的是小规模模拟项目,还没有在大型真实项目里系统地测过。

开发

hub/.venv/bin/python -m pytest tests -q -p no:cacheprovider
(cd integrations/dsh-bundle && npm test)
claude plugin test integrations/xmuse-panel

许可证

MIT

Source 2 files
hooks/register.tsx 246 lines
1import { 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}
246
types/index.d.ts 14 lines
1export 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