xmuse collaboration panel: assistants, threads and messages from the project's hub, in a right-side pane (T8)

在 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 2018 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ConnInfo } from '../types'
5import type { Pal, Tone } from './logic'
6import {
7 C_AMBER, C_BLUE, C_GREEN, C_ORANGE, C_RED, agoText, approvalHasAlways, approvalKindLabel, approvalText, approvalsOf, avatarSvg, backoffMs,
8 buildRows, buildSay, buildWakePrompt, displayName, doneInfo, expandNewlines, fmtClock, hardBreaks, isAutoForward,
9 isDoneMsg, isLoopbackUrl, isToClaude, lastLines, matchHubs, maxWakeId, membersOf, messagesFor, noiseSummary, parseHubs, parseMentions, replyLabel,
10 statusOf, summaryLine, tabsOf, toLabel, toolCount, unreadThreadsFor, wakeCandidates, zhNotice,
11 workingMembers, changedWorker, doneLine, finishedMembers, textsOf, toPosix, typingSteps,
12 badgeOf, completeMention, dayLabel, foldLong, isLightTheme, memberColor, mentionCandidates, mixHex, palOf, rowAt, splitBlocks,
13 trailingMention, vendorKey, canCompact, ctxLevel, ctxText, mergeCompactInstructions, hubStartScript,
14 TONES, ctxBarSvg, noiseTitle, noiseTone, draftTail, freshMessage, revealSteps, shortModel,
15} from './logic'
16
17// xmuse panel (slice T8d): read-only view of the project's hub plus sending. Design: docs/design/XMUSE2-T8.zh.md §2.1, §3.
18// Windowing rule from experiments: `$` may only be passed to functions declared at the top level of this file.
19// T8e: approval cards, working strip, add-assistant, wake Claude. T8f: typing effect and done line. H1: open-room button.
20// T8 wrap-up: stop, retract, compaction (context size per member, the 压缩 button, the session.compact hook), the hub
21// found through WSL instead of fixed paths, and a known room's hub started by itself after a reboot.
22// TODO: "open in terminal" (H7).
23// DEV (./dev.ts) is false in the repository and true in a dev mod folder. It switches the dev-only things: LOAD_MARK,
24// FOCUS_MARK and REBIND_MARK (written for diagnosis), the ROOM_OVERRIDE fallback and DEV_AVATAR_DIR.
25import { DEV, DEV_AVATAR_DIR, FOCUS_MARK, LOAD_MARK, REBIND_MARK, ROOM_OVERRIDE } from './dev'
26
27const PANE = 'xmuse'
28// The registry directory (hubs.json, sidebar.key, install.json) and how to start a hub are found once through WSL and
29// kept in the store: see hubSetup. Nothing machine-specific is written here.
30const STORE_SETUP = 'setup' // HubSetup
31const STORE_KNOWN = 'knownRooms' // string[]: room directories this panel has joined; one found down gets its hub started
32const STORE_CONN = 'conn' // key prefix, 'conn:<room dir>' (connKey): { url, token, member }, the operator token from /v1/join
33// The sidebar's Claude joins as the room's coordinator: its id is the room's `host` in hubs.json.
34const STORE_AUTO = 'autoWake' // boolean: submit a turn when a message for the panel arrives (default off)
35const STORE_SEEN = 'wakeSeen' // number: highest message id already handled (submitted or baseline)
36
37const connAtom = atom({ plugin: 'xmuse', key: 'conn' } as const, { phase: 'idle', error: '' } as ConnInfo)
38const viewAtom = atom({ plugin: 'xmuse', key: 'view' } as const, null as any)
39const tabAtom = atom({ plugin: 'xmuse', key: 'tab' } as const, 'all')
40const replyAtom = atom({ plugin: 'xmuse', key: 'replyTo' } as const, 0)
41const genAtom = atom({ plugin: 'xmuse', key: 'gen' } as const, 0)
42const promotedAtom = atom({ plugin: 'xmuse', key: 'promoted' } as const, {} as Record<string, string>)
43const lastReadAtom = atom({ plugin: 'xmuse', key: 'lastRead' } as const, '')
44const themeAtom = atom({ plugin: 'xmuse', key: 'light' } as const, false) // the /config theme is a light one
45const mentionAtom = atom({ plugin: 'xmuse', key: 'mention' } as const, null as string | null) // typed prefix after a trailing @, or null
46// the text the Input is drawn with (a @ completion, a retracted message put back), or null: a new `value` on the same
47// Input replaces its text in place, so the field keeps the focus (a new key would mount a fresh, unfocused field)
48const forcedAtom = atom({ plugin: 'xmuse', key: 'forced' } as const, null as string | null)
49const decisionAtom = atom({ plugin: 'xmuse', key: 'decision' } as const, { id: 0, text: '', ok: true })
50const avatarVerAtom = atom({ plugin: 'xmuse', key: 'avatarVer' } as const, 0)
51let draftLive = '' // the Input's text as last reported by onInput; read at press time, no redraw per key
52const avatarImgs: Record<string, string> = {} // member id or 'v:<vendor>' -> data: URI of a custom avatar PNG
53let avatarTried: Record<string, boolean> = {}
54let decisionN = 0
55const toastSeen = new Set<string>()
56let toastSeeded = false
57const sendNAtom = atom({ plugin: 'xmuse', key: 'sendN' } as const, 0) // goes into the Input key: a new key clears it
58const sendingAtom = atom({ plugin: 'xmuse', key: 'sending' } as const, false)
59const addOpenAtom = atom({ plugin: 'xmuse', key: 'addOpen' } as const, false)
60const expandedAtom = atom({ plugin: 'xmuse', key: 'expanded' } as const, '') // id of the member whose card is open under the chips, or ''
61const foldAtom = atom({ plugin: 'xmuse', key: 'fold' } as const, {} as Record<string, boolean>) // opened rows: done lines, noise runs, warnings
62// Message actions (reply / promote) show on hover. If hover does not work in the desktop pane, set this to false: the buttons stay visible.
63const HOVER_ACTIONS: boolean = true
64const STORE_BRIEF = 'briefMissing' // boolean: the room was opened and has no project brief yet
65const TOOL = 'xmuse_send' // registered as mcp__xmuse__xmuse_send
66const TOOL_BRIEF = 'xmuse_brief' // mcp__xmuse__xmuse_brief: writes the project brief
67const openAtom = atom({ plugin: 'xmuse', key: 'open' } as const, { busy: false, msg: '', error: '' })
68// the typing effect of one member's partial output: run > 0 while a Client (grow.tsx) is typing it in, 0 when idle
69const typedAtom = atom({ plugin: 'xmuse', key: 'typed' } as const, { id: '', full: '', from: 0, steps: [] as number[], run: 0 })
70const doneAtom = atom({ plugin: 'xmuse', key: 'done' } as const, {} as Record<string, { tools: number; startedMs: number; endedMs: number }>)
71const briefAtom = atom({ plugin: 'xmuse', key: 'brief' } as const, false) // mirrors STORE_BRIEF so the panel redraws
72const DONE_KEEP_MS = 30_000 // how long the grey "finished" line stays
73let typeRunN = 0 // run counter: part of the grow Client's key, a new run is a fresh instance
74let lastTexts: Record<string, string> = {} // partial output seen in the last view, per running member
75const POOL = 5000 // messages a tab can scroll through: the view's own plus every older page loaded
76const OLDER_PAGE = 100 // one "加载更早的消息" press
77const seenMsgs = new Map<number, any>() // every timeline item seen this load, by id (the newest copy wins)
78let seenRoom = ''
79// older pages fetched from /v1/timeline (oldest first), and whether the hub has still older ones
80const olderAtom = atom({ plugin: 'xmuse', key: 'older' } as const, { items: [] as any[], hasMore: true, loading: false })
81const busyAtom =atom({ plugin: 'xmuse', key: 'busy' } as const, {} as Record<string, boolean>) // approval ids in flight
82const selfCtxAtom = atom({ plugin: 'xmuse', key: 'selfCtx' } as const, 0) // this session's own context fill (session.measure)
83// a member's message that just arrived, drawn growing to its full length (the vendors give no token stream for it)
84const revealAtom = atom({ plugin: 'xmuse', key: 'reveal' } as const, { id: 0, steps: [] as number[], run: 0 })
85// the model picker of one member: what the hub offers (act models), or loading / an error
86type ModelPick = { member: string; current: string; options: { value: string; label?: string }[]; format: string; note: string; loading: boolean; error: string }
87const modelAtom = atom({ plugin: 'xmuse', key: 'modelPick' } as const, null as ModelPick | null)
88let feedMaxId = -1 // highest timeline id seen this load; -1 until the first view sets the baseline (history: no effect)
89let revealRunN = 0
90const REVEAL_STEP_MS = 70
91const TYPE_STEP_MS = 150
92const NARROW_COLS = 56 // below this many cells (about 420 px) member chips shrink to avatar and state dot
93const GROW_GRACE_MS =1500 // a grow Client that has not reported this long after its last step is dropped (growDone clears the atom)
94
95// ---- hub access ----------------------------------------------------------------------------------------------
96
97// token: operator (speaks as the user); memberToken: this session's own member; sid: the session the member was bound
98// to last. Both tokens live in the hub's state (db, tokens/), so they outlive a hub restart: only `url` changes then.
99type HubConn = { url: string; token: string; member: string; memberToken?: string; dir?: string; sid?: string }
100const suspect = new Set<string>() // room dirs whose stored address failed a call: check it before trusting it again
101let legacyConnDropped = false
102
103const elapsedStart = (v: any): number => (typeof v === 'number' && isFinite(v) ? v : 0) // started_at as a Client prop (plain JSON)
104
105// Every atom write redraws the whole panel (and voids the handlers of its buttons), so the writes that run on every
106// poll skip a value that did not change (read first, compare). `read` and `update` need the atom as a const of this
107// file, so the check is written at each call site instead of in a helper.
108async function setConn($: any, phase: ConnInfo['phase'], error: string) {
109 const cur = await read($, connAtom)
110 if (cur.phase === phase && cur.error === error) return
111 await update($, connAtom, () => ({ phase, error }))
112}
113
114/** One store key per room directory: sessions on different rooms share the plugin's store and must not overwrite each other. */
115function connKey(dir: string): string {
116 return STORE_CONN + ':' + dir
117}
118
119async function clearConn($: any, dir: string) {
120 suspect.delete(dir)
121 try { await $.store.delete(connKey(dir)) } catch { /* nothing stored */ }
122}
123
124/** Does this hub take our operator token? 'ok', 'denied' (another room, or its state was reset) or 'down'. 3 s at most. */
125async function probeToken($: any, url: string, token: string): Promise<'ok' | 'denied' | 'down'> {
126 try {
127 const r: any = await Promise.race([
128 $.http.fetch(`${url}/v1/view?since=0&wait=0`, { method: 'GET', headers: { authorization: `Bearer ${token}` } }),
129 $.clock.sleep(3000).then(() => { throw new Error('timeout') }),
130 ])
131 return r.status === 200 || r.status === 204 ? 'ok' : 'denied'
132 } catch { return 'down' }
133}
134
135/**
136 * The member stays bound to the session that uses it: after the desktop app restarted (a new session id) the member
137 * is moved over with /v1/rebind (the member token proves it is ours), once per session. Failing that, the panel still
138 * works on the operator token; only the model's xmuse_send would be refused as "bound to another session".
139 * The rebind races a 3 s timeout like probeToken (no hang: see §3.2); the outcome goes to REBIND_MARK under DEV.
140 */
141async function ensureBound($: any, c: HubConn): Promise<HubConn> {
142 const sid: string = await $.session.id()
143 if (c.sid === sid || !c.memberToken) return c
144 const mark = async (s: string) => {
145 if (DEV) { try { await $.fs.write(REBIND_MARK, `${new Date().toISOString()} ${s}\n`) } catch { /* diagnostics only */ } }
146 }
147 try {
148 const r: any = await Promise.race([
149 $.http.fetch(`${c.url}/v1/rebind`, {
150 method: 'POST', headers: { authorization: `Bearer ${c.memberToken}`, 'content-type': 'application/json' },
151 body: JSON.stringify({ session_id: sid }),
152 }),
153 $.clock.sleep(3000).then(() => { throw new Error('timeout') }),
154 ])
155 if (r.status !== 200) { await mark(`rebind ${r.status}`); return c }
156 } catch (err) { await mark(`rebind ${errText(err)}`); return c }
157 const n = { ...c, sid }
158 await $.store.set(connKey(n.dir ?? await roomDirFor($)), n)
159 await mark('rebind ok')
160 return n
161}
162
163function errText(err: unknown): string {
164 const s = err instanceof Error ? err.message : String(err)
165 return s.slice(0, 200)
166}
167
168/** No room for this directory yet: the panel offers the "open room" button instead of retrying. */
169class NoRoomError extends Error {}
170/** A room this panel has joined before has no hub answering (after a reboot): the panel starts it once by itself. */
171class HubDownError extends Error {}
172
173// stateDir: the registry directory as a Windows path; distro and python: from install.json, which every hub writes
174type HubSetup = { stateDir: string; distro: string; python: string; path?: string }
175let setupCache: HubSetup | null = null
176let autoStartTried = false
177
178async function readText($: any, path: string): Promise<string | null> {
179 try { return String(await $.fs.read(path)) } catch { return null }
180}
181
182async function withInstall($: any, s: HubSetup): Promise<HubSetup> {
183 try {
184 const j = JSON.parse((await readText($, `${s.stateDir}/install.json`)) ?? '')
185 return { ...s, python: typeof j?.python === 'string' ? j.python : s.python, distro: typeof j?.distro === 'string' && j.distro ? j.distro : s.distro,
186 path: typeof j?.path === 'string' ? j.path : s.path }
187 } catch { return s }
188}
189
190/**
191 * Where the hub registry is and how to start a hub. The stored answer is used while its directory still reads; else
192 * the default WSL distro is asked for its registry directory (registry.py directory()), which also boots WSL after a
193 * reboot. install.json, when a hub has written it, names the interpreter.
194 */
195async function hubSetup($: any): Promise<HubSetup> {
196 if (setupCache) return setupCache
197 const stored = (await $.store.get(STORE_SETUP)) as HubSetup | undefined
198 if (stored?.stateDir && ((await readText($, `${stored.stateDir}/install.json`)) !== null
199 || (await readText($, `${stored.stateDir}/hubs.json`)) !== null)) {
200 setupCache = await withInstall($, stored)
201 } else {
202 const r = await $.process.run(['wsl.exe', '-e', 'sh', '-s'], {
203 stdin: 'echo "$WSL_DISTRO_NAME"\necho "${XDG_STATE_HOME:-$HOME/.local/state}/xmuse"\n', timeoutMs: 30_000 })
204 const [distro, dir] = String(r.stdout ?? '').split(/\r?\n/).map((s: string) => s.trim())
205 if (r.exitCode !== 0 || !distro || !dir || !dir.startsWith('/')) {
206 throw new Error(`找不到 WSL 里的 xmuse 登记目录(wsl.exe 退出码 ${r.exitCode})`)
207 }
208 setupCache = await withInstall($, { stateDir: `//wsl.localhost/${distro}${dir}`, distro, python: '' })
209 }
210 if (JSON.stringify(stored) !== JSON.stringify(setupCache)) await $.store.set(STORE_SETUP, setupCache)
211 return setupCache
212}
213
214async function knownRooms($: any): Promise<string[]> {
215 const v = await $.store.get(STORE_KNOWN)
216 return Array.isArray(v) ? v.filter((x: unknown) => typeof x === 'string') : []
217}
218
219async function addKnown($: any, dir: string) {
220 const k = await knownRooms($)
221 if (!k.includes(dir)) await $.store.set(STORE_KNOWN, [...k, dir].slice(-20))
222}
223
224/** A hub answers at this address (any HTTP status counts); 3 s at most. */
225async function hubAlive($: any, url: string, key: string): Promise<boolean> {
226 try {
227 await Promise.race([
228 $.http.fetch(`${url}/v1/rooms`, { method: 'GET', headers: { 'x-xmuse-sidebar': key } }),
229 $.clock.sleep(3000).then(() => { throw new Error('timeout') }),
230 ])
231 return true
232 } catch { return false }
233}
234
235/**
236 * The room directory of this session and the hubs registered for it: the session's cwd, or (DEV, no hub for the cwd)
237 * the override file's directory. getConn and roomDirFor use the same rule.
238 */
239async function lookupRoom($: any) {
240 const cwd: string = await $.session.cwd()
241 let hubsText = ''
242 let hubsErr = ''
243 let stateDir = ''
244 try {
245 stateDir = (await hubSetup($)).stateDir
246 hubsText = await $.fs.read(`${stateDir}/hubs.json`)
247 } catch {
248 hubsErr = '读不到本机登记表(hubs.json):WSL 没启动,或还没有 hub'
249 }
250 let roomDir = toPosix(cwd)
251 let cands = matchHubs(parseHubs(hubsText), cwd).filter(e => isLoopbackUrl(e.url ?? ''))
252 if (!cands.length && DEV) {
253 // Dev only: a session whose own directory has no room can point at a test room's directory.
254 let alt = ''
255 try { alt = String(await $.fs.read(ROOM_OVERRIDE)).trim() } catch { /* no override */ }
256 if (alt) { roomDir = alt; cands = matchHubs(parseHubs(hubsText), alt).filter(e => isLoopbackUrl(e.url ?? '')) }
257 }
258 return { cwd, roomDir, cands, hubsErr, stateDir }
259}
260
261async function roomDirFor($: any): Promise<string> {
262 return (await lookupRoom($)).roomDir
263}
264
265/**
266 * Stored connection of this session's room, or find the room for this session's directory and join it with the
267 * sidebar key. Connections are kept per room directory (the store is shared by every session of the plugin).
268 */
269async function getConn($: any): Promise<HubConn> {
270 if (!legacyConnDropped) {
271 legacyConnDropped = true
272 try { await $.store.delete('conn') } catch { /* nothing stored */ } // the old single, session-shared key
273 }
274 const { cwd, roomDir, cands, hubsErr, stateDir } = await lookupRoom($)
275 const stored = (await $.store.get(connKey(roomDir))) as HubConn | undefined
276 // joined before (the list, or a connection kept from before the list existed)
277 const kr = await knownRooms($)
278 const known = kr.includes(roomDir) || kr.includes(toPosix(cwd)) || (!!stored?.dir && stored.dir === roomDir)
279 if (known && !kr.includes(roomDir)) await addKnown($, roomDir)
280 if (!cands.length && known) throw new HubDownError(hubsErr || '房间的 hub 没在运行')
281 if (!cands.length) throw new NoRoomError(hubsErr || '这个目录还没有 xmuse 房间')
282 // A stored connection is reused while the hub still takes it. The hub restarted on a new address: the tokens still
283 // hold there, so only the address moves (a re-join would be refused: the member is still bound to the session that
284 // last used it).
285 if (stored && typeof stored.url === 'string' && typeof stored.token === 'string') {
286 const urls = cands.map(e => String(e.url).replace(/\/$/, ''))
287 if (urls.includes(stored.url) && !suspect.has(roomDir)) {
288 return ensureBound($, stored)
289 } else {
290 let down = 0
291 for (const url of [stored.url, ...urls.filter(u => u !== stored.url)].filter(u => urls.includes(u))) {
292 const p = await probeToken($, url, stored.token)
293 if (p === 'ok') {
294 suspect.delete(roomDir)
295 const moved = { ...stored, url }
296 if (url !== stored.url) await $.store.set(connKey(roomDir), moved)
297 return ensureBound($, moved)
298 }
299 if (p === 'down') down++
300 }
301 if (known && down === urls.length) throw new HubDownError(`连不上 ${urls.join('、')}`)
302 await clearConn($, roomDir) // a hub that refuses our token (its state was reset): join afresh below
303 }
304 }
305
306 let key = ''
307 try { key = String(await $.fs.read(`${stateDir}/sidebar.key`)).trim() } catch { /* handled below */ }
308 if (!/^[0-9a-f]{64}$/.test(key)) throw new Error('读不到侧栏凭据(sidebar.key)')
309
310 const sid: string = await $.session.id()
311 let last = '没有可用的 hub'
312 let unreachable = 0
313 for (const e of cands) {
314 const url = String(e.url).replace(/\/$/, '')
315 // The coordinator id is the room's host from hubs.json (it differs per room); a registry without it: 'claude'.
316 const member = typeof e.host === 'string' && e.host ? e.host : 'claude'
317 try {
318 const body: Record<string, unknown> = { dir: roomDir, vendor: 'claude', session_id: sid, name: member }
319 const r = await $.http.fetch(`${url}/v1/join`, {
320 method: 'POST',
321 headers: { 'content-type': 'application/json', 'x-xmuse-sidebar': key },
322 body: JSON.stringify(body),
323 })
324 let data: any = null
325 try { data = JSON.parse(r.text) } catch { /* not JSON */ }
326 if (r.status === 200 && data && typeof data.operator_token === 'string') {
327 const conn: HubConn = { url, dir: roomDir, token: data.operator_token, member: String(data.member_id ?? ''),
328 memberToken: typeof data.member_token === 'string' ? data.member_token : undefined, sid }
329 await $.store.set(connKey(roomDir), conn)
330 await addKnown($, roomDir)
331 return conn
332 }
333 if (r.status === 404 && data?.error === 'no_room') throw new NoRoomError('hub 已启动,但还没有房间')
334 if (r.status === 409 && data?.error === 'member_exists') {
335 // a sidebar join takes a member bound to another session over, so this is a removed member or another vendor's id
336 last = `成员 ${member} 不能加入:已被移出房间,或这个 id 属于别的厂商`
337 continue
338 }
339 last = `join 失败 ${r.status}: ${String(data?.message ?? data?.error ?? '').slice(0, 120)}`
340 } catch (err) {
341 if (err instanceof NoRoomError) throw err
342 last = `连不上 ${url}: ${errText(err)}`
343 unreachable++
344 }
345 }
346 // every registered address is dead (entries a killed hub left behind, as after a reboot)
347 if (known && unreachable === cands.length) throw new HubDownError(last)
348 throw new Error(last)
349}
350
351/** One authenticated call to the hub. A 401 re-joins once; a network failure drops the stored connection. */
352async function callHub($: any, method: string, path: string, body?: unknown): Promise<{ status: number; data: any }> {
353 for (let attempt = 0; attempt < 2; attempt++) {
354 const conn = await getConn($)
355 let r: any
356 try {
357 r = await $.http.fetch(conn.url + path, {
358 method,
359 headers: { authorization: `Bearer ${conn.token}`, 'content-type': 'application/json' },
360 body: body === undefined ? undefined : JSON.stringify(body),
361 })
362 } catch (err) {
363 if (conn.dir) suspect.add(conn.dir) // the hub may have restarted on another port: getConn checks before trusting it (tokens kept)
364 throw new Error(`连不上 ${conn.url}: ${errText(err)}`)
365 }
366 if (r.status === 401 && attempt === 0) { await clearConn($, conn.dir ?? await roomDirFor($)); continue }
367 let data: any = null
368 try { data = r.text ? JSON.parse(r.text) : null } catch { /* 204 or not JSON */ }
369 return { status: r.status, data }
370 }
371 throw new Error('令牌失效,重新加入后仍是 401')
372}
373
374// ---- polling loop --------------------------------------------------------------------------------------------
375
376async function pollOnce($: any, gen: number, fails: number) {
377 if ((await read($, genAtom)) !== gen) return // superseded by a newer loop (retry, reload)
378 try {
379 const cur = await read($, viewAtom)
380 const since = Number(cur?.version ?? 0)
381 const r = await callHub($, 'GET', `/v1/view?since=${since}&wait=25`)
382 if ((await read($, genAtom)) !== gen) return
383 if (r.status === 204) {
384 $.clock.after(50, () => { void pollOnce($, gen, 0) })
385 return
386 }
387 if (r.status !== 200 || !r.data) {
388 throw new Error(`hub 返回 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 120)}`)
389 }
390 await trackDone($, cur, r.data)
391 await typeStart($, r.data) // before the view lands, so the new text is drawn from its typed prefix
392 await revealStart($, r.data) // likewise: a new member message is first drawn empty, then grows
393 await update($, viewAtom, () => r.data)
394 await settleBusy($, r.data)
395 await setConn($, 'ok', '')
396 await wakeCheck($, r.data, false)
397 await refreshStatus($, r.data)
398 await refreshTheme($)
399 await loadAvatars($, r.data)
400 await toastNew($, r.data)
401 await autoRead($, r.data)
402 $.clock.after(50, () => { void pollOnce($, gen, 0) })
403 } catch (err) {
404 if (err instanceof NoRoomError) { // not an outage: stop polling, the panel shows the open-room button
405 await setConn($, 'noroom', err.message)
406 return
407 }
408 if (err instanceof HubDownError && !autoStartTried) { // a room joined before, no hub (a reboot): start it, once per load
409 autoStartTried = true
410 await setConn($, 'connecting', '')
411 $.clock.after(0, () => { void openRoom($, true) })
412 return
413 }
414 const n = fails + 1
415 const wait = backoffMs(n)
416 await setConn($, 'error', `${errText(err)}(${Math.round(wait / 1000)} 秒后重试)`)
417 $.clock.after(wait, () => { void pollOnce($, gen, n) })
418 }
419}
420
421async function startLoop($: any) {
422 const gen = (await read($, genAtom)) + 1
423 await update($, genAtom, () => gen)
424 await setConn($, 'connecting', '')
425 void pollOnce($, gen, 0)
426}
427
428// ---- finished line and typing effect (T8f) ---------------------------------------------------------------------
429
430/** A member that stopped running since the last view gets a grey "finished" line for DONE_KEEP_MS. */
431async function trackDone($: any, oldView: any, newView: any) {
432 const fin = finishedMembers(oldView, newView)
433 const runningNow = workingMembers(newView).map(m => m.id)
434 const cur = await read($, doneAtom)
435 if (!fin.length && !runningNow.some(id => cur[id])) return
436 const now = await $.clock.now()
437 await update($, doneAtom, m => {
438 const n = { ...m }
439 for (const id of runningNow) delete n[id] // running again: the old line goes
440 for (const f of fin) n[f.id] = { tools: f.tools, startedMs: f.startedMs, endedMs: now }
441 return n
442 })
443 for (const f of fin) $.clock.after(DONE_KEEP_MS, () => { void expireDone($, f.id, now) })
444}
445
446async function expireDone($: any, id: string, endedMs: number) {
447 const cur = await read($, doneAtom)
448 if (cur[id]?.endedMs !== endedMs) return // replaced by a newer run: that one has its own timer
449 await update($, doneAtom, m => { const n = { ...m }; delete n[id]; return n })
450}
451
452/**
453 * Partial output of the last member whose text changed is typed in over 4 steps of TYPE_STEP_MS. The steps run in a
454 * Client (grow.tsx), not here: the atom is written once at the start and once when the Client reports it is done.
455 */
456async function typeStart($: any, newView: any) {
457 const ch = changedWorker(lastTexts, newView)
458 lastTexts = textsOf(newView)
459 if (!ch) return
460 const prev = await read($, typedAtom)
461 // Continue from what was on screen for this member; text that is not an extension of it appears at once.
462 let from = ch.text.length
463 if (ch.text.startsWith(ch.old)) {
464 from = ch.old.length
465 if (prev.run > 0 && prev.id === ch.id && prev.full === ch.old) from = Math.min(from, prev.from) // still typing: restart from its start
466 }
467 if (from >= ch.text.length) { await update($, typedAtom, () => ({ id: ch.id, full: ch.text, from, steps: [], run: 0 })); return }
468 const run = ++typeRunN
469 await update($, typedAtom, () => ({ id: ch.id, full: ch.text, from, steps: typingSteps(from, ch.text.length, 4), run }))
470 $.clock.after(4 * TYPE_STEP_MS + GROW_GRACE_MS, () => { void growDone($, { grown: `t:${ch.id}:${run}` }) }) // the Client never reported: end it
471}
472
473/**
474 * A member's message that a fresh view brought grows to its full length in a few steps (about 40 characters each,
475 * REVEAL_STEP_MS apart), so it reads as streamed; a Client (grow.tsx) runs the steps. The first view of a load only
476 * sets the baseline; a newer arrival replaces it.
477 */
478async function revealStart($: any, view: any) {
479 const tl: any[] = Array.isArray(view?.timeline) ? view.timeline : []
480 const max = tl.reduce((n, m) => (typeof m?.id === 'number' && m.id > n ? m.id : n), 0)
481 if (feedMaxId < 0) { feedMaxId = max; return }
482 const m = freshMessage(view, feedMaxId)
483 feedMaxId = Math.max(feedMaxId, max)
484 if (!m) return
485 const steps = revealSteps(String(m.text ?? '').length)
486 if (!steps.length) return
487 const run = ++revealRunN
488 await update($, revealAtom, () => ({ id: m.id, steps, run }))
489 $.clock.after(steps.length * REVEAL_STEP_MS + GROW_GRACE_MS, () => { void growDone($, { grown: `r:${m.id}:${run}` }) }) // likewise
490}
491
492/** ui.message from a grow Client: it showed its last step; the atom goes idle and the normal rendering comes back. */
493async function growDone($: any, data: any) {
494 const id = typeof data?.grown === 'string' ? data.grown : ''
495 const kind = id.slice(0, 2) // 't:<member>:<run>' | 'r:<message id>:<run>'
496 const n = Number(id.slice(id.lastIndexOf(':') + 1))
497 if (kind === 't:') await update($, typedAtom, t => (t.run === n ? { ...t, steps: [], run: 0 } : t))
498 else if (kind === 'r:') await update($, revealAtom, r => (r.run === n ? { id: 0, steps: [], run: 0 } : r))
499}
500
501// ---- opening a room (H1) -------------------------------------------------------------------------------------
502
503async function openStep($: any, msg: string) {
504 await update($, openAtom, () => ({ busy: true, msg, error: '' }))
505}
506
507async function openFail($: any, error: string) {
508 await update($, openAtom, () => ({ busy: false, msg: '', error }))
509}
510
511/** A registry entry for exactly this (POSIX) directory whose hub answers (a killed hub leaves its entry behind). */
512async function findHub($: any, dir: string, key: string): Promise<{ url: string } | null> {
513 const text = await readText($, `${(await hubSetup($)).stateDir}/hubs.json`)
514 if (text === null) return null
515 for (const e of parseHubs(text)) {
516 if (!isLoopbackUrl(e.url ?? '') || toPosix(e.dir ?? '').toLowerCase() !== dir.toLowerCase()) continue
517 const url = String(e.url).replace(/\/$/, '')
518 if (await hubAlive($, url, key)) return { url }
519 }
520 return null
521}
522
523/**
524 * The "open room" button: start a hub for this directory inside WSL (unless one is registered already), create the
525 * room with the sidebar key (this session becomes a member and the host), keep the tokens, and start the panel loop.
526 * Each step shows in the panel; each failure shows its reason there.
527 */
528/** The directory a room is opened for: the session's own, or (dev) the override file's. getConn uses the same rule. */
529async function roomTarget($: any): Promise<string> {
530 if (DEV) {
531 let alt = ''
532 try { alt = String(await $.fs.read(ROOM_OVERRIDE)).trim() } catch { /* no override */ }
533 if (alt) return alt
534 }
535 return toPosix(await $.session.cwd())
536}
537
538async function openRoomLater($: any) {
539 // A press handler has a 10 s budget; opening a room takes longer, so it runs off the press.
540 $.clock.after(0, () => { void openRoom($) })
541}
542
543/** The error card's "启动 hub": the automatic start, by hand (it runs once per load by itself). */
544async function startHubLater($: any) {
545 await setConn($, 'connecting', '')
546 $.clock.after(0, () => { void openRoom($, true) })
547}
548
549/**
550 * `auto`: the panel found a room it had joined with no hub answering (after a reboot). Only the hub is started (no
551 * `xmuse init`, no room created); the normal join then takes over, and a hub without that room shows the open button.
552 */
553async function openRoom($: any, auto = false) {
554 if ((await read($, openAtom)).busy) return
555 try {
556 await openStep($, auto ? '房间的 hub 没在运行,自动启动…' : '读取工作目录…')
557 const dir = await roomTarget($)
558 if (!dir.startsWith('/') || /['\r\n\0]/.test(dir)) throw new Error(`不支持的工作目录: ${dir}`)
559 const setup = await hubSetup($)
560 // the key may not exist yet on a machine where no hub ever ran: then nothing can answer, and the hub makes it
561 let key = String((await readText($, `${setup.stateDir}/sidebar.key`)) ?? '').trim()
562
563 let hub = /^[0-9a-f]{64}$/.test(key) ? await findHub($, dir, key) : null
564 if (!hub) {
565 if (!setup.python || /[\r\n\0]/.test(setup.python) || !/^[\w.-]*$/.test(setup.distro)) {
566 throw new Error('没有 hub 的安装记录(登记目录里的 install.json):先在 WSL 里手动启动一次 hub,之后面板就能自己启动')
567 }
568 await openStep($, '在 WSL 里启动 hub…')
569 // The script goes in on stdin (no quoting through wsl.exe's command line); see hubStartScript for why a unit.
570 // `xmuse init` makes the workspace's state (idempotent); the hub's own output goes to the state dir's hub.log.
571 const script = hubStartScript({ python: setup.python, dir, path: setup.path, init: !auto })
572 const argv = setup.distro ? ['wsl.exe', '-d', setup.distro, '-e', 'bash', '-l', '-s'] : ['wsl.exe', '-e', 'bash', '-l', '-s']
573 const r = await $.process.run(argv, { stdin: script, timeoutMs: 25_000 })
574 if (r.exitCode !== 0) throw new Error(`WSL 启动命令失败(退出码 ${r.exitCode}): ${String(r.stderr || r.stdout).replace(/\s+/g, ' ').slice(0, 160)}`)
575 await openStep($, '等 hub 出现在登记表(启动时要先检查各家模型,约 10–30 秒)…')
576 for (let i = 0; i < 60 && !hub; i++) {
577 await $.clock.sleep(1000)
578 if (!/^[0-9a-f]{64}$/.test(key)) key = String((await readText($, `${setup.stateDir}/sidebar.key`)) ?? '').trim()
579 if (/^[0-9a-f]{64}$/.test(key)) hub = await findHub($, dir, key)
580 }
581 if (!hub) throw new Error('60 秒内 hub 没有登记(可能还在启动,稍后点重试;日志在房间状态目录 logs/hub.log,'
582 + '单元状态:systemctl --user status xmuse-hub-*)')
583 }
584 if (auto) {
585 await clearConn($, dir) // the old address is dead: join afresh at the new one
586 await update($, openAtom, () => ({ busy: false, msg: '', error: '' }))
587 await startLoop($)
588 return
589 }
590
591 await openStep($, '创建房间…')
592 if (!/^[0-9a-f]{64}$/.test(key)) throw new Error('读不到侧栏凭据(sidebar.key)')
593 const sid: string = await $.session.id()
594 const body: Record<string, unknown> = { dir, vendor: 'claude', session_id: sid, member_id: 'claude' } // a new room's host is the sidebar's Claude
595 const r = await $.http.fetch(`${hub.url}/v1/rooms`, {
596 method: 'POST',
597 headers: { 'content-type': 'application/json', 'x-xmuse-sidebar': key },
598 body: JSON.stringify(body),
599 })
600 let data: any = null
601 try { data = JSON.parse(r.text) } catch { /* not JSON */ }
602 if (r.status === 200 && data && typeof data.operator_token === 'string') {
603 const conn: HubConn = { url: hub.url, dir, token: data.operator_token, member: String(data.member_id ?? ''),
604 memberToken: typeof data.member_token === 'string' ? data.member_token : undefined, sid }
605 await $.store.set(connKey(dir), conn)
606 await addKnown($, dir)
607 if (data.brief_missing === true) { await $.store.set(STORE_BRIEF, true); await update($, briefAtom, () => true) }
608 } else if (r.status === 409 && data?.error === 'room_exists') {
609 await clearConn($, dir) // the hub already has a room (another session opened it): the normal join takes over below
610 } else {
611 throw new Error(`建房失败 ${r.status}: ${String(data?.message ?? data?.error ?? r.text ?? '').slice(0, 140)}`)
612 }
613 await update($, openAtom, () => ({ busy: false, msg: '', error: '' }))
614 await startLoop($)
615 } catch (err) {
616 if (!auto) { await openFail($, errText(err)); return }
617 await update($, openAtom, () => ({ busy: false, msg: '', error: '' })) // the error card says it, with a start button
618 await setConn($, 'error', `启动 hub 失败:${errText(err)}`)
619 }
620}
621
622/** The panel's "let Claude write it" button: asks the model for the brief and to save it with xmuse_brief. */
623async function askBrief($: any) {
624 try {
625 await $.prompt.submit({ text: '[xmuse] 这个项目的协作室还没有项目简介。请先读一下项目(README、目录结构、配置文件),'
626 + '写一段项目简介(目标、技术栈、约定,200 字内),然后调用工具 xmuse_brief,参数 text 填这段简介,保存到协作室。' })
627 } catch (err) {
628 $.ui.toast(`发送失败: ${errText(err)}`)
629 }
630}
631
632async function dismissBrief($: any) {
633 await $.store.set(STORE_BRIEF, false)
634 await update($, briefAtom, () => false)
635}
636
637/** The model's xmuse_brief tool: writes the project brief with the operator token. */
638async function briefTool($: any, e: any): Promise<any> {
639 const text = typeof e?.text === 'string' ? e.text.trim() : ''
640 if (!text) return { deny: '缺少 text(项目简介正文)' }
641 try {
642 const r = await callHub($, 'POST', '/v1/act', { act: 'brief', text })
643 if (r.status !== 200) return { deny: `保存失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 200)}` }
644 await dismissBrief($)
645 return { result: `项目简介已保存${r.data?.entry ? `(${r.data.entry})` : ''}` }
646 } catch (err) {
647 return { deny: `保存失败: ${errText(err)}` }
648 }
649}
650
651// ---- actions -------------------------------------------------------------------------------------------------
652
653async function markRead($: any, threads: string[]) {
654 for (const t of threads) {
655 try { await callHub($, 'POST', '/v1/act', { act: 'read', thread: t }) } catch { /* the next view shows it unread again */ }
656 }
657}
658
659/** Move the read cursor of what the current tab shows, once per (tab, view version). */
660async function autoRead($: any, view: any) {
661 const tab = await read($, tabAtom)
662 const todo = unreadThreadsFor(view, tab)
663 if (!todo.length) return
664 const mark = `${tab}:${view.version ?? ''}`
665 if ((await read($, lastReadAtom)) === mark) return
666 await update($, lastReadAtom, () => mark)
667 await markRead($, todo)
668}
669
670async function setTab($: any, tab: string) {
671 await update($, tabAtom, () => tab)
672 await update($, replyAtom, () => 0)
673 const view = await read($, viewAtom)
674 if (view) await markRead($, unreadThreadsFor(view, tab))
675}
676
677async function setReply($: any, id: number) {
678 await update($, replyAtom, () => id)
679}
680
681async function sendMsg($: any, text: string) {
682 if (await read($, sendingAtom)) return // a send is in flight: ignore the repeat press
683 const view = await read($, viewAtom)
684 const body = buildSay(expandNewlines(text), await read($, tabAtom), await read($, replyAtom), view)
685 if (!body) return
686 await update($, sendingAtom, () => true)
687 try {
688 const r = await callHub($, 'POST', '/v1/act', body)
689 if (r.status !== 200) {
690 $.ui.toast(`发送失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
691 return
692 }
693 await update($, replyAtom, () => 0)
694 draftLive = ''
695 await update($, mentionAtom, () => null)
696 await update($, forcedAtom, () => null)
697 await update($, sendNAtom, n => n + 1) // new Input key: the box comes back empty
698 refocusInput($)
699 } catch (err) {
700 $.ui.toast(`发送失败: ${errText(err)}`)
701 } finally {
702 await update($, sendingAtom, () => false)
703 }
704}
705
706async function approve($: any, id: string, decision: 'once' | 'always' | 'reject', label = '') {
707 if ((await read($, busyAtom))[id]) return
708 await update($, busyAtom, m => ({ ...m, [id]: true }))
709 try {
710 const r = await callHub($, 'POST', '/v1/act', { act: 'approve', id, decision })
711 if (r.status !== 200) $.ui.toast(`审批失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
712 else await leaveDecision($, decision === 'reject' ? `✗ 已拒绝 ${label}` : `✓ 已批准 ${label}`, decision !== 'reject')
713 } catch (err) {
714 $.ui.toast(`审批失败: ${errText(err)}`)
715 } finally {
716 await update($, busyAtom, m => { const n = { ...m }; delete n[id]; return n })
717 }
718}
719
720/** Enter in the input: with the @ list showing it picks the first member (keyboard completion), else it sends. */
721async function submitInput($: any, v: string) {
722 draftLive = v
723 const q = trailingMention(v)
724 if (q !== null) {
725 const cands = mentionCandidates(await read($, viewAtom), q, 6)
726 if (cands.length) { await pickMention($, String(cands[0]!.id)); return }
727 }
728 await sendMsg($, v)
729}
730
731/** "撤回": the hub cancels what was not delivered yet and interrupts a turn that took it; the text goes back into the input. */
732async function retractMsg($: any, id: number, text: string) {
733 if ((await read($, busyAtom))[`rt${id}`]) return
734 await update($, busyAtom, m => ({ ...m, [`rt${id}`]: true }))
735 try {
736 const r = await callHub($, 'POST', '/v1/act', { act: 'retract', id })
737 if (r.status !== 200) { $.ui.toast(`撤回失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`); return }
738 const cut: string[] = Array.isArray(r.data?.canceled) ? r.data.canceled : []
739 const hit: string[] = Array.isArray(r.data?.interrupted) ? r.data.interrupted : []
740 const parts = [cut.length ? `${cut.map(displayName).join('、')} 还没收到` : '', hit.length ? `已打断 ${hit.map(displayName).join('、')}` : '']
741 .filter(Boolean)
742 $.ui.toast(`已撤回${parts.length ? ':' + parts.join(';') : '(对方可能已经读过)'},原文放回输入框`)
743 draftLive = text
744 await update($, forcedAtom, () => text)
745 } catch (err) {
746 $.ui.toast(`撤回失败: ${errText(err)}`)
747 } finally {
748 await update($, busyAtom, m => { const n = { ...m }; delete n[`rt${id}`]; return n })
749 }
750}
751
752/** "停止" on a working member: its queued mail is canceled and its running turn interrupted (hub act stop). */
753async function stopMember($: any, id: string) {
754 if ((await read($, busyAtom))[`st${id}`]) return
755 await update($, busyAtom, m => ({ ...m, [`st${id}`]: true }))
756 try {
757 const r = await callHub($, 'POST', '/v1/act', { act: 'stop', member: id })
758 if (r.status !== 200) $.ui.toast(`停止失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
759 else $.ui.toast(r.data?.stopped ? `已停止 ${displayName(id)}` : `${displayName(id)} 没有在执行`)
760 } catch (err) {
761 $.ui.toast(`停止失败: ${errText(err)}`)
762 } finally {
763 await update($, busyAtom, m => { const n = { ...m }; delete n[`st${id}`]; return n })
764 }
765}
766
767/**
768 * "压缩" on a member. opencode: the hub compacts it in the background with its own instruction (act compact) and posts
769 * a notice when done. This session's own Claude: the engine compacts it ($.session.compact), and the session.compact
770 * hook below adds the hub's instruction. A press has a 10 s budget, so the Claude one runs off the press.
771 */
772async function compactMember($: any, id: string, self: boolean) {
773 if ((await read($, busyAtom))[`cp${id}`]) return
774 // the button turns into "压缩中…" at once; for opencode it stays so until the view carries the hub's own flag
775 await update($, busyAtom, m => ({ ...m, [`cp${id}`]: true }))
776 if (self) { $.clock.after(0, () => { void compactSelf($, id) }); return }
777 let started = false
778 try {
779 const r = await callHub($, 'POST', '/v1/act', { act: 'compact', member: id })
780 started = r.status === 200
781 if (started) $.ui.toast(`${displayName(id)} 开始压缩,完成后会在私聊里通知`)
782 else if (r.data?.error === 'busy') $.ui.toast(`${displayName(id)} 正在执行,等它停下再压缩`)
783 else if (r.data?.error === 'unsupported') $.ui.toast(`${displayName(id)} 不支持由协作室触发压缩`)
784 else if (r.data?.error === 'no_session') $.ui.toast(`${displayName(id)} 还没有会话,不需要压缩`)
785 else $.ui.toast(`压缩失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
786 } catch (err) {
787 $.ui.toast(`压缩失败: ${errText(err)}`)
788 } finally {
789 if (started) $.clock.after(15_000, () => { void unbusy($, `cp${id}`) }) // by then the view shows compacting/compacted
790 else await unbusy($, `cp${id}`)
791 }
792}
793
794/** A view that carries a member's compacting flag takes over from the button's own "压缩中…". */
795async function settleBusy($: any, view: any) {
796 const busy = await read($, busyAtom)
797 for (const m of membersOf(view)) if (m.compacting && busy[`cp${m.id}`]) await unbusy($, `cp${m.id}`)
798}
799
800async function unbusy($: any, key: string) {
801 await update($, busyAtom, m => { const n = { ...m }; delete n[key]; return n })
802}
803
804async function compactSelf($: any, id: string) {
805 await update($, busyAtom, m => ({ ...m, [`cp${id}`]: true }))
806 try {
807 const r = await $.session.compact({})
808 if (r?.skip) $.ui.toast(`没有压缩:${String(r.skip).slice(0, 100)}`)
809 else {
810 const k = (n: unknown) => ctxText(n) || '?'
811 $.ui.toast(r?.tokensBefore ? `已压缩:${k(r.tokensBefore)} → ${k(r.tokensAfter)}` : '已压缩')
812 }
813 } catch (err) {
814 $.ui.toast(`压缩失败: ${errText(err)}`)
815 } finally {
816 await update($, busyAtom, m => { const n = { ...m }; delete n[`cp${id}`]; return n })
817 }
818}
819
820/**
821 * Any compaction of this session's main conversation (/compact, the engine's auto one, the panel button): the hub's
822 * instruction goes to the summarizer after the person's own text. Whatever fails, the compaction goes on unchanged.
823 */
824async function compactHook($: any, e: any, next: any) {
825 if (e.agentId || e.trigger === 'precompute') return next(e)
826 let hub = ''
827 try {
828 // at most 5 s in all (room lookup included): a hub that does not answer must not hold the compaction up
829 const r: any = await Promise.race([
830 (async () => {
831 const stored = (await $.store.get(connKey(await roomDirFor($)))) as HubConn | undefined
832 if (!stored?.member) return null
833 return callHub($, 'GET', `/v1/compact_instructions?member=${encodeURIComponent(stored.member)}`)
834 })(),
835 $.clock.sleep(5000).then(() => null),
836 ])
837 if (r?.status === 200 && typeof r.data?.text === 'string') hub = r.data.text
838 } catch { /* no hub: the engine's own prompt alone */ }
839 if (!hub) return next(e)
840 return next({ ...e, instructions: mergeCompactInstructions(e.instructions, hub) })
841}
842
843/** The member card's model button: opens (or closes) the picker with what the hub offers for that member. */
844async function toggleModelPicker($: any, id: string) {
845 if ((await read($, modelAtom))?.member === id) { await update($, modelAtom, () => null); return }
846 await update($, modelAtom, () => ({ member: id, current: '', options: [], format: '', note: '', loading: true, error: '' }))
847 try {
848 const r = await callHub($, 'POST', '/v1/act', { act: 'models', member: id })
849 if ((await read($, modelAtom))?.member !== id) return // closed or switched meanwhile
850 if (r.status !== 200) {
851 await update($, modelAtom, m => m && { ...m, loading: false, error: `读取失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}` })
852 return
853 }
854 await update($, modelAtom, () => ({ member: id, current: String(r.data?.current ?? ''), options: Array.isArray(r.data?.options) ? r.data.options : [],
855 format: String(r.data?.format ?? ''), note: String(r.data?.note ?? ''), loading: false, error: '' }))
856 } catch (err) {
857 await update($, modelAtom, m => m && { ...m, loading: false, error: `读取失败: ${errText(err)}` })
858 }
859}
860
861/** A model picked (or typed): member_edit, which the member's next turn uses. */
862async function setModel($: any, id: string, model: string) {
863 const name = model.trim()
864 const cur = await read($, modelAtom)
865 if (!name || (await read($, busyAtom))[`md${id}`]) return
866 if (cur && name === cur.current) { await update($, modelAtom, () => null); return }
867 await update($, busyAtom, m => ({ ...m, [`md${id}`]: true }))
868 try {
869 const r = await callHub($, 'POST', '/v1/act', { act: 'member_edit', member: id, model: name })
870 if (r.status !== 200) {
871 await update($, modelAtom, m => m && { ...m, error: `没有换成:${String(r.data?.message ?? r.data?.error ?? r.status).slice(0, 120)}` })
872 return
873 }
874 $.ui.toast(`${displayName(id)} 换成 ${name},下一轮起生效`)
875 await update($, modelAtom, () => null)
876 } catch (err) {
877 await update($, modelAtom, m => m && { ...m, error: `没有换成:${errText(err)}` })
878 } finally {
879 await unbusy($, `md${id}`)
880 }
881}
882
883const OLDER_MAX_PAGES = 10 // one press fetches up to this many pages while they show nothing but folded notices
884
885/**
886 * Older history from /v1/timeline. A page that the panel would fold entirely into notice rows (the same buildRows the
887 * draw uses, for the open tab) shows the person nothing new, so the next page is fetched too, up to OLDER_MAX_PAGES.
888 * The atom is written once at the end: one redraw, the button reads "加载中…" meanwhile.
889 */
890async function loadOlder($: any) {
891 const cur = await read($, olderAtom)
892 if (cur.loading) return
893 const view = await read($, viewAtom)
894 const ids = [...cur.items, ...seenMsgs.values(), ...(Array.isArray(view?.timeline) ? view.timeline : [])].map((m: any) => m?.id).filter((x: any) => typeof x === 'number')
895 if (!ids.length) return
896 await update($, olderAtom, o => ({ ...o, loading: true }))
897 try {
898 const tabId = await read($, tabAtom)
899 const activeTab = tabsOf(view).some(t => t.id === tabId) ? tabId : 'all'
900 let before = Math.min(...ids)
901 let got: any[] = []
902 let hasMore = true
903 let pages = 0
904 let shows = false
905 while (pages < OLDER_MAX_PAGES && hasMore && !shows) {
906 const r = await callHub($, 'GET', `/v1/timeline?before=${before}&limit=${OLDER_PAGE}`)
907 if (r.status !== 200) {
908 $.ui.toast(`加载失败 ${r.status}`)
909 if (!got.length) { await update($, olderAtom, o => ({ ...o, loading: false })); return }
910 break
911 }
912 pages++
913 const items: any[] = Array.isArray(r.data?.items) ? r.data.items : []
914 got = [...items, ...got]
915 hasMore = !!r.data?.has_more && items.length > 0
916 const ids2 = items.map((m: any) => m?.id).filter((x: any) => typeof x === 'number')
917 if (ids2.length) before = Math.min(before, ...ids2)
918 shows = buildRows(messagesFor({ ...view, timeline: items }, activeTab, POOL).reverse()).some(row => row.kind !== 'noise')
919 }
920 if (!shows && hasMore && pages >= OLDER_MAX_PAGES) $.ui.toast(`更早的 ${pages * OLDER_PAGE} 条都是通知,已折叠;再点继续`)
921 await update($, olderAtom, o => ({ items: [...got, ...o.items], hasMore, loading: false }))
922 } catch (err) {
923 $.ui.toast(`加载失败: ${errText(err)}`)
924 await update($, olderAtom, o => ({ ...o, loading: false }))
925 }
926}
927
928/** A member chip pressed: its full card opens (one at a time); pressing the open one closes it. */
929async function toggleExpand($: any, id: string) {
930 await update($, expandedAtom, v => (v === id ? '' : id))
931}
932
933async function toggleAddOpen($: any) {
934 await update($, addOpenAtom, v => !v)
935}
936
937async function addMember($: any, vendor: string) {
938 if ((await read($, busyAtom))['add']) return
939 await update($, busyAtom, m => ({ ...m, add: true }))
940 try {
941 const r = await callHub($, 'POST', '/v1/act', { act: 'member_add', vendor }) // name and model: the hub's defaults
942 if (r.status !== 200) {
943 $.ui.toast(`添加失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
944 return
945 }
946 $.ui.toast(`已添加 ${String(r.data?.member_id ?? vendor)}`)
947 await update($, addOpenAtom, () => false)
948 } catch (err) {
949 $.ui.toast(`添加失败: ${errText(err)}`)
950 } finally {
951 await update($, busyAtom, m => { const n = { ...m }; delete n.add; return n })
952 }
953}
954
955// ---- status line, working-strip clock, waking Claude ---------------------------------------------------------
956
957async function pendingForMe($: any, view: any): Promise<number> {
958 const seen = await $.store.get(STORE_SEEN)
959 return wakeCandidates(view, typeof seen === 'number' ? seen : maxWakeId(view)).length
960}
961
962async function refreshStatus($: any, view: any) {
963 $.ui.status(summaryLine(view, await pendingForMe($, view), (await $.store.get(STORE_AUTO)) === true))
964}
965
966/** Messages for the panel newer than the handled mark: submit one turn for all of them (auto on, or `force`). */
967async function wakeCheck($: any, view: any, force: boolean) {
968 let seen = await $.store.get(STORE_SEEN)
969 if (typeof seen !== 'number') { // first sight: what is already there is history, not a wake-up
970 seen = maxWakeId(view)
971 await $.store.set(STORE_SEEN, seen)
972 }
973 const pend = wakeCandidates(view, seen)
974 if (!pend.length) return
975 if (!force && (await $.store.get(STORE_AUTO)) !== true) return
976 const top = pend.reduce((n, m) => Math.max(n, m.id), seen)
977 await $.store.set(STORE_SEEN, top) // mark first so a second view cannot submit the same ones
978 $.prompt.submit({ text: buildWakePrompt(pend) }).catch(async (err: unknown) => {
979 await $.store.set(STORE_SEEN, seen) // not taken: they count as pending again
980 $.ui.toast(`叫醒 Claude 失败: ${errText(err)}`)
981 })
982}
983
984async function wakeNow($: any) {
985 if ((await read($, busyAtom)).wake) return
986 await update($, busyAtom, m => ({ ...m, wake: true }))
987 try {
988 const view = await read($, viewAtom)
989 if (view) { await wakeCheck($, view, true); await refreshStatus($, view) }
990 } finally {
991 await unbusy($, 'wake')
992 }
993}
994
995async function toggleAuto($: any) {
996 const on = (await $.store.get(STORE_AUTO)) === true
997 await $.store.set(STORE_AUTO, !on)
998 const view = await read($, viewAtom)
999 if (view && !on) await wakeCheck($, view, false)
1000 if (view) await refreshStatus($, view)
1001}
1002
1003async function promote($: any, id: number) {
1004 if ((await read($, busyAtom))[`pr${id}`]) return
1005 await update($, busyAtom, m => ({ ...m, [`pr${id}`]: true }))
1006 try {
1007 const r = await callHub($, 'POST', '/v1/act', { act: 'promote', message_id: id })
1008 if (r.status !== 200) {
1009 $.ui.toast(`设为决定失败 ${r.status}: ${String(r.data?.message ?? r.data?.error ?? '').slice(0, 100)}`)
1010 return
1011 }
1012 const entry = String(r.data?.entry ?? '已设')
1013 await update($, promotedAtom, m => ({ ...m, [String(id)]: entry }))
1014 } catch (err) {
1015 $.ui.toast(`设为决定失败: ${errText(err)}`)
1016 } finally {
1017 await unbusy($, `pr${id}`)
1018 }
1019}
1020
1021/** The model's xmuse_send tool: one `say` through the panel's own token. Answers { result } or { deny }. */
1022/** Claude speaks as its own member (member token, /v1/tools/xmuse_send), never as the user (the operator token). */
1023async function callAsMember($: any, args: Record<string, unknown>): Promise<{ status: number; data: any }> {
1024 for (let attempt = 0; attempt < 2; attempt++) {
1025 const conn = await getConn($)
1026 if (!conn.memberToken) { await clearConn($, conn.dir ?? await roomDirFor($)); if (attempt === 0) continue; throw new Error('没有成员令牌(重新加入后仍缺)') }
1027 const r = await $.http.fetch(`${conn.url}/v1/tools/xmuse_send`, {
1028 method: 'POST',
1029 // the member is bound to this session (ensureBound): a bound member's tool calls name it
1030 headers: { authorization: `Bearer ${conn.memberToken}`, 'content-type': 'application/json',
1031 ...(conn.sid ? { 'x-xmuse-session': conn.sid } : {}) },
1032 body: JSON.stringify({ args }), // the hub's tool route reads body.args
1033 })
1034 if (r.status === 401 && attempt === 0) { await clearConn($, conn.dir ?? await roomDirFor($)); continue }
1035 let data: any = null
1036 try { data = r.text ? JSON.parse(r.text) : null } catch { /* not JSON */ }
1037 return { status: r.status, data }
1038 }
1039 throw new Error('成员令牌失效,重新加入后仍是 401')
1040}
1041
1042async function sendTool($: any, e: any): Promise<any> {
1043 const kind = typeof e?.kind === 'string' && e.kind ? e.kind : 'message'
1044 if (kind !== 'message') return { deny: '面板工具暂只支持 message' }
1045 const to = typeof e?.to === 'string' ? e.to.trim() : ''
1046 const text = typeof e?.body === 'string' ? e.body.trim() : ''
1047 if (!to) return { deny: '缺少 to(#room、群 id 或成员 id)' }
1048 if (!text) return { deny: '缺少 body' }
1049 const view = await read($, viewAtom)
1050 const ments: string[] = Array.isArray(e?.mentions) ? e.mentions.filter((x: unknown) => typeof x === 'string') : parseMentions(text, view)
1051 const body: Record<string, unknown> = { act: 'say', to, text, mentions: ments }
1052 if (typeof e?.reply_to === 'number' && e.reply_to > 0) body.reply_to = e.reply_to
1053 try {
1054 const r = await callAsMember($, { to, body: text, mentions: ments, ...(body.reply_to ? { reply_to: body.reply_to } : {}) })
1055 const brief = r.data ? JSON.stringify(r.data).slice(0, 300) : ''
1056 // A tool error comes back as 200 with {error: {code, message}}: report it as a failure, never as "sent".
1057 const err = r.data?.error
1058 if (r.status !== 200 || err) {
1059 const why = typeof err === 'object' && err ? `${err.code ?? ''} ${err.message ?? ''}`.trim() : String(r.data?.message ?? err ?? brief)
1060 return { deny: `发送失败 ${r.status}: ${why.slice(0, 200)}` }
1061 }
1062 return { result: `已发送到 ${to}${brief ? `:${brief}` : ''}` }
1063 } catch (err) {
1064 return { deny: `发送失败: ${errText(err)}` }
1065 }
1066}
1067
1068async function registerTool($: any) {
1069 try {
1070 await $.tool.register({
1071 name: TOOL,
1072 description: '通过 xmuse 协作室发消息给其他助手或用户(以本会话成员身份)。to 填 "#room"、群 id 或成员 id;回复某条消息时带 reply_to。',
1073 isDeferred: false,
1074 inputSchema: {
1075 type: 'object',
1076 properties: {
1077 to: { type: 'string', description: '"#room"、群 id 或成员 id' },
1078 body: { type: 'string', description: '消息正文' },
1079 mentions: { type: 'array', items: { type: 'string' }, description: '要 @ 的成员 id;省略时按正文里的 @名字 自动识别' },
1080 reply_to: { type: 'number', description: '回复的消息 id' },
1081 kind: { type: 'string', description: '默认 message;面板暂只支持 message' },
1082 },
1083 required: ['to', 'body'],
1084 },
1085 })
1086 } catch { /* the model just does not get the tool; the panel still works */ }
1087 try {
1088 await $.tool.register({
1089 name: TOOL_BRIEF,
1090 description: '把项目简介(目标、技术栈、约定,200 字内)保存到 xmuse 协作室;新加入的助手会在入场包里读到它。',
1091 isDeferred: false,
1092 inputSchema: {
1093 type: 'object',
1094 properties: { text: { type: 'string', description: '项目简介正文' } },
1095 required: ['text'],
1096 },
1097 })
1098 } catch { /* no brief tool: the user writes it by hand */ }
1099}
1100
1101async function openPanel($: any) {
1102 let title = 'xmuse'
1103 try {
1104 const base = (await roomTarget($)).split('/').filter(Boolean).pop()
1105 if (base) title = `xmuse · ${base}`
1106 } catch { /* plain title */ }
1107 try { await $.ui.open({ id: PANE, title }) } catch { /* not placeable now; /xmuse retries */ }
1108}
1109
1110// ---- theme, avatars, copy, draft, toasts ---------------------------------------------------------------------
1111
1112/** The `theme` row of /config decides the palette; read at start, after each view and on config.set. */
1113async function refreshTheme($: any) {
1114 try {
1115 const rows = await $.config.list()
1116 const row = rows.find((r: any) => r.key === 'theme')
1117 const light = isLightTheme(row?.value)
1118 if (light !== (await read($, themeAtom))) await update($, themeAtom, () => light)
1119 } catch { /* keep the current palette */ }
1120}
1121
1122// the names a vendor's avatar file may go by besides its key (people name them after the product or the CLI)
1123const AVATAR_ALIASES: Record<string, string[]> = { google: ['gemini', 'agy', 'antigravity'], deepseek: ['dsh'], opencode: [], claude: [] }
1124const AVATAR_MAX_B64 = 90_000 // the PNG goes into the Svg source (at most 131072 characters): about 67 KB of file
1125
1126/**
1127 * Custom avatars: <dir>/<member id>.png, else the vendor's: <key>.png or one of its aliases (gemini.png, dsh.png...).
1128 * Each is looked up once per load; the Svg embeds the PNG. A file too big to embed is said once, with its size.
1129 */
1130async function loadAvatars($: any, view: any) {
1131 let changed = false
1132 let dir = DEV_AVATAR_DIR
1133 if (!DEV) { try { dir = `${(await hubSetup($)).stateDir}/avatars` } catch { return } }
1134 const list: any[] = Array.isArray(view?.members) ? view.members : []
1135 const tries: [string, string[]][] = []
1136 for (const m of list) {
1137 tries.push([String(m.id), [String(m.id)]])
1138 const vk = vendorKey(String(m.id), m.vendor)
1139 if (vk) tries.push(['v:' + vk, [vk, ...(AVATAR_ALIASES[vk] ?? [])]])
1140 }
1141 tries.push(['human', ['human']])
1142 const tooBig: string[] = []
1143 for (const [key, files] of tries) {
1144 if (avatarTried[key]) continue
1145 avatarTried[key] = true
1146 for (const file of files) {
1147 try {
1148 const r = await $.fs.read(`${dir}/${file}.png`, { as: 'bytes' })
1149 const b64 = String(r?.base64 ?? '')
1150 if (!b64) continue
1151 if (b64.length >= AVATAR_MAX_B64) { tooBig.push(`${file}.png(${Math.round((b64.length * 3) / 4 / 1024)}KB)`); continue }
1152 avatarImgs[key] = `data:image/png;base64,${b64}`
1153 changed = true
1154 break
1155 } catch { /* no such file: the next name, else the drawn logo stays */ }
1156 }
1157 }
1158 if (tooBig.length) $.ui.toast(`头像太大没有用上:${tooBig.join('、')},请缩到 60KB 以内(如 96×96)`)
1159 if (changed) await update($, avatarVerAtom, n => n + 1)
1160}
1161
1162async function copyText($: any, text: string, surface: any) {
1163 try {
1164 const r = await $.ui.copy({ text, surface })
1165 $.ui.toast(r.isCopied ? '已复制' : `复制失败(${r.reason})`)
1166 } catch (err) {
1167 $.ui.toast(`复制失败: ${errText(err)}`)
1168 }
1169}
1170
1171/** A line under the approval cards for 8 seconds after a decision. */
1172async function leaveDecision($: any, text: string, ok: boolean) {
1173 const n = ++decisionN
1174 await update($, decisionAtom, () => ({ id: n, text, ok }))
1175 $.clock.after(8000, () => { void clearDecision($, n) })
1176}
1177
1178async function clearDecision($: any, n: number) {
1179 if ((await read($, decisionAtom)).id === n) await update($, decisionAtom, () => ({ id: n, text: '', ok: true }))
1180}
1181
1182/** onInput: remember the text; redraw only when the trailing @word changes (that shows or hides the member list). */
1183async function inputChanged($: any, v: string) {
1184 draftLive = v
1185 const q = trailingMention(v)
1186 if (q !== (await read($, mentionAtom))) await update($, mentionAtom, () => q)
1187}
1188
1189/** A member picked from the @ list: the trailing @word becomes "@id ", drawn as the same Input's new value (it keeps focus). */
1190async function pickMention($: any, id: string) {
1191 draftLive = completeMention(draftLive, id)
1192 await update($, forcedAtom, () => draftLive)
1193 await update($, mentionAtom, () => null)
1194 refocusInput($) // in case the surface still moved the ring (Enter went to the field's submit)
1195}
1196
1197/**
1198 * After a send the Input takes a new key (that is what empties it), and a fresh field has no focus: put the ring
1199 * back on it. Off the handler (the engine awaits the new element); a refusal changes nothing. DEV: the answer is
1200 * written next to the load mark, to see what the surface does with it.hooks/logic.ts 1003 lines1// Pure logic for the xmuse panel: no $, no network, no state, so it can be unit-tested later.
2// Hub view shape: hub/src/xmuse/room.py view(). Every field may be missing; callers get safe defaults.
3
4export type Member = {
5 id: string
6 vendor?: string
7 status?: string
8 online?: boolean
9 paused?: boolean
10 unread?: number
11 queued?: number
12 description?: string
13 progress?: Progress | null
14 model?: string // what its next turn runs on (member_edit changes it)
15 context_tokens?: number // the latest turn's context size (T8c H3)
16 compacting?: boolean // a hub-run compaction is in flight
17 compacted?: boolean // compacted since its last turn: context_tokens is 0 until the next turn
18}
19export type Progress = { turn?: number; started_at?: number; tools?: number | unknown[]; last_tool?: string; text?: string; updated_at?: number }
20export type Approval = { id: string; kind?: string; member?: string; summary?: string; detail?: unknown; risk?: string; at?: number }
21export type Thread = { id: string; kind?: string; title?: string; members?: string[]; private?: boolean; unread?: number }
22export type Msg = {
23 id: number
24 thread?: string
25 from?: string
26 to?: string[]
27 mentions?: string[]
28 kind?: string
29 text?: string
30 at?: number
31 reply_to?: number
32 via?: string | null
33 for_me?: boolean
34 outcome?: string | null
35}
36export type Tab = { id: string; label: string; unread: number; peer?: string; group?: boolean }
37
38// ---- paths ---------------------------------------------------------------------------------------------------
39
40/** Windows or UNC path (as the Claude session sees it) to the POSIX form the hub registry uses. */
41export function toPosix(p: string): string {
42 let s = String(p || '').trim().replace(/\\/g, '/')
43 const unc = s.match(/^\/\/(?:wsl\.localhost|wsl\$)\/[^/]+(\/.*)?$/i)
44 if (unc) s = unc[1] ?? '/'
45 else {
46 const drive = s.match(/^([A-Za-z]):(\/.*)?$/)
47 if (drive) s = `/mnt/${drive[1]!.toLowerCase()}${drive[2] ?? ''}`
48 }
49 if (s.length > 1) s = s.replace(/\/+$/, '')
50 return s
51}
52
53export type HubEntry = { room_id?: string; name?: string; dir?: string; url?: string; host?: string; pid?: number; started_at?: number }
54
55/** hubs.json text to entries; broken or missing content gives []. */
56export function parseHubs(text: string): HubEntry[] {
57 try {
58 const data = JSON.parse(text)
59 const list = Array.isArray(data?.hubs) ? data.hubs : []
60 return list.filter((e: any) => e && typeof e === 'object' && typeof e.url === 'string')
61 } catch {
62 return []
63 }
64}
65
66/**
67 * Entries whose room dir is exactly the session's cwd, newest hub first. Not a parent's: a hub serves only its own
68 * directory (join elsewhere is 400 wrong_dir), so a subfolder of a room's directory has no room of its own yet.
69 */
70export function matchHubs(entries: HubEntry[], cwd: string): HubEntry[] {
71 const me = toPosix(cwd).toLowerCase()
72 return entries.filter(e => !!e.dir && toPosix(e.dir).toLowerCase() === me)
73 .sort((a, b) => (b.started_at ?? 0) - (a.started_at ?? 0))
74}
75
76export function isLoopbackUrl(url: string): boolean {
77 return /^https?:\/\/(127\.\d+\.\d+\.\d+|localhost|\[::1\])(:\d+)?(\/|$)/i.test(String(url))
78}
79
80// ---- people --------------------------------------------------------------------------------------------------
81
82const PALETTE = ['#5B8DEF', '#3FB68B', '#E0A030', '#C86DD7', '#E06C75', '#4FB3C8', '#9AB84A', '#D98B5F']
83const FIXED: Record<string, string> = { claude: '#D97757', human: '#9A8CF0' }
84
85export function hashStr(s: string): number {
86 let h = 0
87 for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
88 return h
89}
90
91export function colorOf(id: string): string {
92 return FIXED[id] ?? PALETTE[hashStr(id) % PALETTE.length]!
93}
94
95/** a * (1 - t) + b * t, per channel; both '#RRGGBB'. */
96export function mixHex(a: string, b: string, t: number): string {
97 const pa = /^#([0-9a-f]{6})$/i.exec(a), pb = /^#([0-9a-f]{6})$/i.exec(b)
98 if (!pa || !pb) return a
99 const ch = (h: string, i: number) => parseInt(h.slice(i, i + 2), 16)
100 const out = [0, 2, 4].map(i => Math.round(ch(pa[1]!, i) * (1 - t) + ch(pb[1]!, i) * t))
101 return '#' + out.map(n => n.toString(16).padStart(2, '0')).join('')
102}
103
104// ---- vendors: logo avatars and name colours ------------------------------------------------------------------
105
106export type VendorKey = 'claude' | 'opencode' | 'deepseek' | 'google' | ''
107
108/** The vendor family of a member: from the vendor field, else guessed from the id (opencode-2 -> opencode). */
109export function vendorKey(id: string, vendor?: string): VendorKey {
110 const v = String(vendor ?? '').toLowerCase()
111 const base = String(id ?? '').toLowerCase().replace(/-\d+$/, '')
112 for (const s of [v, base]) {
113 if (s.includes('claude')) return 'claude'
114 if (s.includes('opencode')) return 'opencode'
115 if (s === 'agy' || s.includes('antigravity') || s.includes('google') || s.includes('gemini')) return 'google'
116 if (s === 'dsh' || s.includes('deepseek')) return 'deepseek'
117 }
118 return ''
119}
120
121// [dark theme, light theme]. opencode is teal on purpose: it must not look like Claude's orange.
122const VENDOR_COLOR: Record<string, [string, string]> = {
123 claude: ['#D97757', '#C4562F'],
124 opencode: ['#2DB7A3', '#14806F'],
125 deepseek: ['#5B7CFF', '#3350D0'],
126 google: ['#E0559E', '#B93580'],
127 human: ['#9A8CF0', '#6A4FD0'],
128}
129
130/** Colour of a member's name: by vendor, the user in purple, others from the hashed palette (darker in the light theme). */
131export function memberColor(id: string, vendor: string | undefined, light: boolean): string {
132 const k = id === 'human' ? 'human' : vendorKey(id, vendor)
133 const pair = k ? VENDOR_COLOR[k] : undefined
134 if (pair) return pair[light ? 1 : 0]
135 const c = colorOf(id)
136 return light ? mixHex(c, '#000000', 0.25) : c
137}
138
139/** Small number for a corner badge when several members share a vendor: opencode-2 -> '2', plain opencode -> '1'. */
140export function badgeOf(id: string, vendor: string | undefined, all: { id: string; vendor?: string }[]): string {
141 const k = vendorKey(id, vendor)
142 if (!k) return ''
143 const same = all.filter(m => vendorKey(m.id, m.vendor) === k)
144 if (same.length < 2) return ''
145 const m = /-(\d+)$/.exec(id)
146 return m ? m[1]! : '1'
147}
148
149// ---- colour palettes (dark / light) for what must be a concrete colour -----------------------------------------
150
151export type Pal = {
152 bubble: string; bubbleBorder: string
153 mine: string; mineBorder: string
154 toClaude: string
155 codeBg: string; hoverBg: string; banner: string
156}
157
158export const PAL_DARK: Pal = {
159 bubble: '#2A2A2E', bubbleBorder: '#3A3A40',
160 mine: '#4B3F8F', mineBorder: '#6A5CC0',
161 toClaude: '#2E2723',
162 codeBg: '#1A1A1D', hoverBg: '#1F1F23', banner: '#3A2F14',
163}
164
165export const PAL_LIGHT: Pal = {
166 bubble: '#F1F1F4', bubbleBorder: '#D2D2DA',
167 mine: '#E4DFFA', mineBorder: '#A99BEA',
168 toClaude: '#FCEEE6',
169 codeBg: '#E7E7EC', hoverBg: '#FFFFFF', banner: '#FFF1CF',
170}
171
172/** Translucent tints that read on both a dark and a light pane. The desktop app's own light/dark setting is not
173 * visible to a mod (the /config theme row is the terminal's), so the panel no longer picks a palette by theme. */
174export const PAL_TINT: Pal = {
175 bubble: '#8C8C9614', bubbleBorder: '#8C8C9659',
176 mine: '#7C6AE62E', mineBorder: '#7C6AE6A6',
177 toClaude: '#E8875B24',
178 codeBg: '#8C8C961F', hoverBg: '#8C8C9640', banner: '#F0B42938',
179}
180
181export function palOf(_light: boolean): Pal {
182 return PAL_TINT
183}
184
185/** The `theme` row of /config: any value naming "light" is a light theme (auto and unknown count as dark). */
186export function isLightTheme(value: unknown): boolean {
187 return /light/i.test(String(value ?? ''))
188}
189
190// ---- logo avatars ---------------------------------------------------------------------------------------------
191
192function esc(s: string): string {
193 return s.replace(/[<>&"]/g, '')
194}
195
196/**
197 * Avatar as SVG. Logos are simplified shapes drawn here (no official bitmaps): Claude an orange burst, OpenCode a black
198 * terminal square, DeepSeek a blue whale; unknown vendors the initial on a coloured disc. `image` (a data: URI of a PNG)
199 * replaces the logo; `badge` is a small number in the corner.
200 */
201export function avatarSvg(id: string, size: number, vendor?: string, badge = '', image = ''): string {
202 const k = id === 'human' ? 'human' : vendorKey(id, vendor)
203 let body: string
204 if (image) {
205 body = `<defs><clipPath id="c"><circle cx="16" cy="16" r="16"/></clipPath></defs>`
206 + `<image href="${image}" x="0" y="0" width="32" height="32" preserveAspectRatio="xMidYMid slice" clip-path="url(#c)"/>`
207 } else if (k === 'claude') {
208 let rays = ''
209 for (let i = 0; i < 12; i++) {
210 const a = (i * Math.PI) / 6
211 const r1 = 3, r2 = i % 2 ? 9 : 12.5
212 rays += `<line x1="${(16 + Math.cos(a) * r1).toFixed(2)}" y1="${(16 + Math.sin(a) * r1).toFixed(2)}" x2="${(16 + Math.cos(a) * r2).toFixed(2)}" y2="${(16 + Math.sin(a) * r2).toFixed(2)}"/>`
213 }
214 body = `<circle cx="16" cy="16" r="16" fill="#D97757"/><g stroke="#FFF4EC" stroke-width="2.6" stroke-linecap="round">${rays}</g>`
215 } else if (k === 'opencode') {
216 body = `<rect x="1" y="1" width="30" height="30" rx="6" fill="#0E0E10" stroke="#8A8A92" stroke-width="1.5"/>`
217 + `<polyline points="8,11 14,16 8,21" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>`
218 + `<line x1="17" y1="22" x2="24" y2="22" stroke="#9A9AA2" stroke-width="2.6" stroke-linecap="round"/>`
219 } else if (k === 'deepseek') {
220 body = `<circle cx="16" cy="16" r="16" fill="#4D6BFE"/>`
221 + `<path d="M5 17 C5 11.5 10 9 15 10 C19 10.8 21 13 23 14.5 C24.5 12.5 26 11.5 27.5 11 C27.5 13.5 26.5 15.5 25 17 C26.5 18 27.5 20 27.5 22.5 C25.5 21.8 24 21 22.5 19.5 C20 23.5 14 24.5 9.5 22 C7 20.6 5.4 19 5 17 Z" fill="#FFFFFF"/>`
222 + `<circle cx="10" cy="16.5" r="1.3" fill="#4D6BFE"/>`
223 + `<path d="M13 9.5 C13 7.5 12 6.5 10.5 6 M13 9.5 C13.5 7.5 14.5 6.5 16 6.2" fill="none" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>`
224 } else if (k === 'google') {
225 body = '<circle cx="16" cy="16" r="16" fill="#E0559E"/>'
226 + '<path d="M16 5 C17 11.5 20.5 15 27 16 C20.5 17 17 20.5 16 27 C15 20.5 11.5 17 5 16 C11.5 15 15 11.5 16 5 Z" fill="#FFFFFF"/>'
227 } else {
228 const letter = esc(initialOf(id))
229 const fs = letter.length > 1 || letter === '你' ? 13 : 15
230 body = `<circle cx="16" cy="16" r="16" fill="${k === 'human' ? '#7B6CE0' : colorOf(id)}"/>`
231 + `<text x="16" y="21" font-size="${fs}" font-family="sans-serif" font-weight="600" fill="#fff" text-anchor="middle">${letter}</text>`
232 }
233 const bdg = badge
234 ? `<circle cx="25.5" cy="25.5" r="6.5" fill="#1B1B1F" stroke="#FFFFFF" stroke-width="1.2"/>`
235 + `<text x="25.5" y="28.6" font-size="9" font-family="sans-serif" font-weight="700" fill="#fff" text-anchor="middle">${esc(badge).slice(0, 2)}</text>`
236 : ''
237 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32">${body}${bdg}</svg>`
238}
239
240export function displayName(id: string | undefined): string {
241 if (!id) return '?'
242 if (id === 'human') return '你'
243 if (id === 'hub') return 'hub'
244 return id
245}
246
247export function initialOf(id: string | undefined): string {
248 if (id === 'human') return '你'
249 const c = (id ?? '?').trim().charAt(0)
250 return c ? c.toUpperCase() : '?'
251}
252
253export function statusLine(m: Member): string {
254 let s: string
255 if (m.paused || m.status === 'paused') s = '⏸ 已暂停'
256 else if (m.status === 'running') s = '● 执行中'
257 else if (m.online === false || m.status === 'offline') s = '○ 离线'
258 else s = '○ 空闲'
259 const q = Number(m.queued ?? 0)
260 return q > 0 ? `${s} · 排队 ${q}` : s
261}
262
263// ---- view ----------------------------------------------------------------------------------------------------
264
265export function membersOf(view: any): Member[] {
266 const list: Member[] = Array.isArray(view?.members) ? view.members.filter((m: any) => m && typeof m.id === 'string') : []
267 // Only the panel's own member is hidden; a host that is someone else shows (the caller marks it with a star).
268 const me = view?.me?.id
269 return list.filter(m => m.id !== me)
270}
271
272export function threadsOf(view: any): Thread[] {
273 return Array.isArray(view?.threads) ? view.threads.filter((t: any) => t && typeof t.id === 'string') : []
274}
275
276export function timelineOf(view: any): Msg[] {
277 return Array.isArray(view?.timeline) ? view.timeline.filter((m: any) => m && typeof m.id === 'number') : []
278}
279
280function tabLabel(t: Thread, hostIds: string[]): string {
281 const ms = (t.members ?? []).filter(x => x !== 'human' && !hostIds.includes(x))
282 if (t.kind === 'dm') {
283 if (ms.length === 1) return ms[0]!
284 const other = (t.members ?? []).filter(x => x !== 'human')
285 if (other.length === 1) return other[0]!
286 return t.title || t.id
287 }
288 return t.title || t.id
289}
290
291/** "全部" first, then every thread except #room (which "全部" sends to). */
292export function tabsOf(view: any): Tab[] {
293 const hostIds = [view?.room?.host, view?.me?.id].filter((x): x is string => typeof x === 'string')
294 const ths = threadsOf(view).filter(t => t.id !== '#room')
295 const roomT = threadsOf(view).find(t => t.id === '#room')
296 const allUnread = threadsOf(view).reduce((n, t) => n + Number(t.unread ?? 0), 0)
297 void roomT
298 const out: Tab[] = [{ id: 'all', label: '全部', unread: allUnread }]
299 const dms = ths.filter(t => t.kind === 'dm')
300 const groups = ths.filter(t => t.kind !== 'dm')
301 for (const t of [...dms, ...groups]) {
302 const tab: Tab = { id: t.id, label: (t.private && t.kind !== 'dm' ? '🔒' : '') + tabLabel(t, hostIds), unread: Number(t.unread ?? 0) }
303 if (t.kind === 'dm') tab.peer = tabLabel(t, hostIds) // a private chat: the draw puts that member's colour dot before the label
304 else tab.group = true
305 out.push(tab)
306 }
307 return out
308}
309
310/** Thread id to use for `say` and `read` on a tab. */
311export function threadForTab(tab: string): string {
312 return tab === 'all' ? '#room' : tab
313}
314
315/** Thread ids whose read cursor should move when the tab is shown. */
316export function unreadThreadsFor(view: any, tab: string): string[] {
317 const ths = threadsOf(view)
318 if (tab === 'all') return ths.filter(t => Number(t.unread ?? 0) > 0).map(t => t.id)
319 const t = ths.find(x => x.id === tab)
320 return t && Number(t.unread ?? 0) > 0 ? [t.id] : []
321}
322
323export function messagesFor(view: any, tab: string, limit = 50): Msg[] {
324 const all = timelineOf(view)
325 const shown = tab === 'all' ? all : all.filter(m => m.thread === tab)
326 return shown.slice(-limit)
327}
328
329export function toLabel(m: Msg): string {
330 const ments = Array.isArray(m.mentions) ? m.mentions : []
331 const to = Array.isArray(m.to) ? m.to : []
332 const th = m.thread ?? ''
333 let who: string[]
334 if (ments.length) who = ments
335 else if (th.startsWith('dm:')) who = to
336 else if (th === '#room' || th === '') who = []
337 else who = [th]
338 return who.length ? who.map(displayName).join('、') : '全员'
339}
340
341export function fmtClock(at: number | undefined): string {
342 if (typeof at !== 'number' || !isFinite(at)) return ''
343 const d = new Date(at * 1000)
344 const p = (n: number) => String(n).padStart(2, '0')
345 return `${p(d.getHours())}:${p(d.getMinutes())}`
346}
347
348/** "xmuse · opencode 执行中 · 1 条待审批 · 2 条给你"; with nothing going on: "xmuse · 3 助手 · 0 未读". */
349export function summaryLine(view: any, forMe = 0, auto = true): string {
350 // Most urgent first: approvals, messages waiting for Claude, paused, running, unread (the line has no colour).
351 const ms = membersOf(view)
352 const unread = threadsOf(view).reduce((s, t) => s + Number(t.unread ?? 0), 0)
353 const running = ms.filter(m => m.status === 'running' || m.progress).map(m => m.id)
354 const paused = ms.filter(m => m.paused || m.status === 'paused').map(m => m.id)
355 const appr = approvalsOf(view).length
356 const parts: string[] = []
357 if (appr) parts.push(`⚠ ${appr} 条待审批`)
358 if (forMe) parts.push(auto ? `${forMe} 条给你` : `⚠ ${forMe} 条消息等 Claude 处理`)
359 if (paused.length) parts.push(`${paused.join('、')} 已暂停`)
360 if (running.length) parts.push(`${running.join('、')} 执行中`)
361 if (unread) parts.push(`${unread} 未读`)
362 if (!parts.length) parts.push(`${ms.length} 助手`, '空闲')
363 return ['xmuse', ...parts].join(' · ')
364}
365
366export function approvalsOf(view: any): Approval[] {
367 return Array.isArray(view?.approvals) ? view.approvals.filter((a: any) => a && typeof a.id === 'string') : []
368}
369
370/** What the card shows for an approval: the summary (or detail text), cut to 200 chars. */
371export function approvalText(a: Approval): string {
372 let t = typeof a.summary === 'string' && a.summary ? a.summary : typeof a.detail === 'string' ? a.detail : ''
373 if (!t && a.detail && typeof a.detail === 'object') { try { t = JSON.stringify(a.detail) } catch { t = '' } }
374 return t.length > 200 ? t.slice(0, 200) + '…' : t
375}
376
377/** Alignment proposals and resume requests have one yes; shell and share requests have once / always / reject. */
378export function approvalHasAlways(a: Approval): boolean {
379 return a.kind !== 'alignment' && a.kind !== 'resume'
380}
381
382// ---- working strip -------------------------------------------------------------------------------------------
383
384export function workingMembers(view: any): Member[] {
385 return membersOf(view).filter(m => m.progress && typeof m.progress === 'object')
386}
387
388/** "1:12" or "1:02:03"; startedAt in epoch seconds (milliseconds also accepted), nowMs in milliseconds. */
389export function fmtElapsed(startedAt: number | undefined, nowMs: number): string {
390 if (typeof startedAt !== 'number' || !isFinite(startedAt) || !nowMs) return '0:00'
391 const startMs = startedAt > 1e11 ? startedAt : startedAt * 1000
392 const sec = Math.max(0, Math.floor((nowMs - startMs) / 1000))
393 const p = (n: number) => String(n).padStart(2, '0')
394 const h = Math.floor(sec / 3600)
395 const m = Math.floor((sec % 3600) / 60)
396 return h > 0 ? `${h}:${p(m)}:${p(sec % 60)}` : `${m}:${p(sec % 60)}`
397}
398
399export function toolCount(p: Progress | null | undefined): number {
400 if (!p) return 0
401 return Array.isArray(p.tools) ? p.tools.length : Number(p.tools ?? 0) || 0
402}
403
404/** "● opencode 执行中 1:12 · 9 个工具 · 排队 2" */
405export function workingLine(m: Member, nowMs: number): string {
406 const p = m.progress
407 const parts = [`● ${m.id} 执行中 ${fmtElapsed(p?.started_at, nowMs)}`, `${toolCount(p)} 个工具`]
408 const lt = typeof p?.last_tool === 'string' ? p.last_tool.trim() : ''
409 if (lt) parts.push(`最近: ${lt.length > 40 ? lt.slice(0, 40) + '…' : lt}`)
410 const q = Number(m.queued ?? 0)
411 if (q > 0) parts.push(`排队 ${q}`)
412 return parts.join(' · ')
413}
414
415/** "49 秒" or "1 分 49 秒" or "1 小时 2 分". */
416export function fmtDuration(sec: number): string {
417 const s = Math.max(0, Math.floor(sec))
418 if (s < 60) return `${s} 秒`
419 const m = Math.floor(s / 60)
420 if (m < 60) return `${m} 分 ${s % 60} 秒`
421 return `${Math.floor(m / 60)} 小时 ${m % 60} 分`
422}
423
424export function startedMsOf(startedAt: number | undefined): number {
425 if (typeof startedAt !== 'number' || !isFinite(startedAt) || startedAt <= 0) return 0
426 return startedAt > 1e11 ? startedAt : startedAt * 1000
427}
428
429/** Members that were running in the old view and are present but idle in the new one (last seen tools and start). */
430export function finishedMembers(oldView: any, newView: any): { id: string; tools: number; startedMs: number }[] {
431 if (!oldView) return []
432 const now = membersOf(newView)
433 const out: { id: string; tools: number; startedMs: number }[] = []
434 for (const m of workingMembers(oldView)) {
435 const cur = now.find(x => x.id === m.id)
436 if (cur && !(cur.progress && typeof cur.progress === 'object')) {
437 out.push({ id: m.id, tools: toolCount(m.progress), startedMs: startedMsOf(m.progress?.started_at) })
438 }
439 }
440 return out
441}
442
443/** "✓ opencode 已完成 · 3 个工具 · 1 分 49 秒" */
444export function doneLine(id: string, tools: number, startedMs: number, endedMs: number): string {
445 const parts = [`✓ ${id} 已完成`, `${tools} 个工具`]
446 if (startedMs > 0 && endedMs > startedMs) parts.push(fmtDuration((endedMs - startedMs) / 1000))
447 return parts.join(' · ')
448}
449
450export function textsOf(view: any): Record<string, string> {
451 const out: Record<string, string> = {}
452 for (const m of workingMembers(view)) out[m.id] = String(m.progress?.text ?? '')
453 return out
454}
455
456/** The last running member whose partial output differs from `prev`. */
457export function changedWorker(prev: Record<string, string>, view: any): { id: string; text: string; old: string } | null {
458 let hit: { id: string; text: string; old: string } | null = null
459 for (const m of workingMembers(view)) {
460 const text = String(m.progress?.text ?? '')
461 const old = prev[m.id] ?? ''
462 if (text !== old) hit = { id: m.id, text, old }
463 }
464 return hit
465}
466
467/** Shown lengths of the typing effect, from `from` (exclusive) to `to` (inclusive), in n steps. */
468export function typingSteps(from: number, to: number, n = 4): number[] {
469 const out: number[] = []
470 for (let i = 1; i <= n; i++) out.push(Math.min(to, from + Math.ceil(((to - from) * i) / n)))
471 out[out.length - 1] = to
472 return out
473}
474
475/** What of a member's partial output to draw: the typed prefix while the typing effect runs for that member. */
476export function typedText(full: string, typed: { id: string; full: string; shown: number } | undefined, id: string): string {
477 if (typed && typed.id === id && typed.full === full && typed.shown < full.length) return full.slice(0, Math.max(0, typed.shown))
478 return full
479}
480
481/**
482 * The message a fresh view brought that the feed reveals as if typed: the newest one past `sinceId` that a member
483 * (not the user) wrote as plain chat text. null when there is none.
484 */
485export function freshMessage(view: any, sinceId: number): Msg | null {
486 const tl: Msg[] = Array.isArray(view?.timeline) ? view.timeline : []
487 let hit: Msg | null = null
488 for (const m of tl) {
489 if (typeof m?.id !== 'number' || m.id <= sinceId || m.from === 'human') continue
490 if (m.kind === 'system' || m.kind === 'done' || (m as any).retracted || !String(m.text ?? '').trim()) continue
491 if (!hit || m.id > hit.id) hit = m
492 }
493 return hit
494}
495
496/** Shown lengths for revealing a message of `len` characters: about 40 characters a step, 3 to 10 steps. */
497export function revealSteps(len: number): number[] {
498 return len > 0 ? typingSteps(0, len, Math.min(10, Math.max(3, Math.ceil(len / 40)))) : []
499}
500
501/** The tail of a live draft: its last `max` characters, cut at a line start when one is near, "…" before a cut. */
502export function draftTail(text: string, max = 280): string {
503 const t = String(text ?? '').trim()
504 if (t.length <= max) return t
505 let s = t.slice(-max)
506 const nl = s.indexOf('\n')
507 if (nl >= 0 && nl < 80) s = s.slice(nl + 1)
508 return '…' + s
509}
510
511/** Last n non-empty lines of the member's partial output ('' when there is none). */
512export function lastLines(text: string | undefined, n = 2): string {
513 const ls = String(text ?? '').split('\n').map(x => x.trimEnd()).filter(x => x.trim())
514 return ls.slice(-n).map(x => (x.length > 160 ? x.slice(0, 160) + '…' : x)).join('\n')
515}
516
517// ---- messages ------------------------------------------------------------------------------------------------
518
519/** "↩ #12 first 30 chars" when the replied-to message is on the timeline, else "↩ #12". */
520export function replyLabel(view: any, id: number): string {
521 const orig = timelineOf(view).find(m => m.id === id)
522 const t = orig ? String(orig.text ?? '').replace(/\s+/g, ' ').trim() : ''
523 return t ? `↩ #${id} ${t.length > 30 ? t.slice(0, 30) + '…' : t}` : `↩ #${id}`
524}
525
526/** A reply the hub forwarded by itself: `via` is set and is not a human relay (cli / sidebar / coordinator). */
527export function isAutoForward(m: Msg): boolean {
528 const v = m.via
529 if (!v || typeof v !== 'string') return false
530 return v !== 'cli' && v !== 'coordinator' && !v.endsWith(' sidebar')
531}
532
533// ---- waking Claude -------------------------------------------------------------------------------------------
534
535function isForPanel(m: Msg, me: string): boolean {
536 if (m.for_me === true) return true
537 if (Array.isArray(m.mentions) && m.mentions.includes(me)) return true
538 const th = m.thread ?? ''
539 return th.startsWith('dm:') && th.slice(3).split('+').includes(me)
540}
541
542/** Messages to the panel's own member from someone who is not the user (the user's go through the prompt box). */
543export function wakeCandidates(view: any, afterId = 0): Msg[] {
544 const me = view?.me?.id
545 if (typeof me !== 'string' || !me) return []
546 return timelineOf(view).filter(m =>
547 m.id > afterId && m.kind !== 'system' && m.from !== me && m.from !== 'human' && m.from !== 'hub' && isForPanel(m, me))
548}
549
550export function maxWakeId(view: any): number {
551 return wakeCandidates(view, 0).reduce((n, m) => Math.max(n, m.id), 0)
552}
553
554export function buildWakePrompt(msgs: Msg[]): string {
555 if (msgs.length === 1) {
556 const m = msgs[0]!
557 return `[xmuse] ${m.from ?? '?'} 在 ${m.thread ?? '#room'} 给你发了消息 #${m.id}:${String(m.text ?? '').slice(0, 500)}。用 xmuse_send 工具回复(reply_to=${m.id})。`
558 }
559 const lines = msgs.slice(0, 8).map(m => `- ${m.from ?? '?'} 在 ${m.thread ?? '#room'},消息 #${m.id}:${String(m.text ?? '').slice(0, 300)}`)
560 const more = msgs.length > 8 ? `\n(另有 ${msgs.length - 8} 条更早的没列出)` : ''
561 return `[xmuse] 有 ${msgs.length} 条消息给你:\n${lines.join('\n')}${more}\n用 xmuse_send 工具逐条回复(reply_to 填对应消息 id)。`
562}
563
564// ---- sending -------------------------------------------------------------------------------------------------
565
566/** @names in the text that are real member ids (host included: the user may @claude). Order kept, no repeats. */
567export function parseMentions(text: string, view: any): string[] {
568 const ids: string[] = Array.isArray(view?.members) ? view.members.map((m: any) => String(m.id)) : []
569 const found: string[] = []
570 const re = /@([A-Za-z0-9][A-Za-z0-9-]*)/g
571 let m: RegExpExecArray | null
572 while ((m = re.exec(text)) !== null) {
573 const name = m[1]!
574 const hit = ids.find(i => i.toLowerCase() === name.toLowerCase())
575 if (hit && !found.includes(hit)) found.push(hit)
576 }
577 return found
578}
579
580export type SayBody = { act: 'say'; to: string; text: string; mentions: string[]; reply_to?: number }
581
582/** The Input is one line: a literal backslash-n in the text becomes a real line break. */
583export function expandNewlines(text: string): string {
584 return text.replace(/\\n/g, '\n')
585}
586
587export function buildSay(text: string, tab: string, replyTo: number, view: any): SayBody | null {
588 const t = text.trim()
589 if (!t) return null
590 const body: SayBody = { act: 'say', to: threadForTab(tab), text: t, mentions: parseMentions(t, view) }
591 if (replyTo > 0) body.reply_to = replyTo
592 return body
593}
594
595// ---- display -------------------------------------------------------------------------------------------------
596
597/**
598 * The Markdown renderer treats a single newline as a space. Outside ``` / ~~~ fences, a non-blank line followed
599 * directly by a non-blank line gets two trailing spaces (a hard break). Blank lines, fence lines and everything
600 * inside a fence stay as they are; list, table, heading and quote lines are not harmed by trailing spaces.
601 */
602export function hardBreaks(text: string): string {
603 const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
604 let fence = ''
605 const out: string[] = []
606 for (let i = 0; i < lines.length; i++) {
607 const line = lines[i]!
608 const m = line.match(/^\s{0,3}(`{3,}|~{3,})/)
609 if (fence) {
610 if (m && m[1]![0] === fence[0] && m[1]!.length >= fence.length && !line.replace(m[0], '').trim()) fence = ''
611 out.push(line)
612 continue
613 }
614 if (m) { fence = m[1]!; out.push(line); continue }
615 const next = lines[i + 1]
616 const brk = line.trim() !== '' && next !== undefined && next.trim() !== '' && !/ {2}$/.test(line)
617 out.push(brk ? line + ' ' : line)
618 }
619 return out.join('\n')
620}
621
622// ---- retry ---------------------------------------------------------------------------------------------------
623
624/** 2s, 4s, 8s ... capped at 30s. `fails` is the number of consecutive failures so far (>= 1). */
625export function backoffMs(fails: number): number {
626 return Math.min(30000, 2000 * 2 ** Math.max(0, fails - 1))
627}
628
629// ---- status, notices, done lines, approvals (polish batch 1) -------------------------------------------------
630
631// Text and border colours are theme keys, so they follow the light / dark theme by themselves.
632export const C_BLUE = 'suggestion'
633export const C_AMBER = 'warning'
634export const C_RED = 'error'
635export const C_GREEN = 'success'
636export const C_ORANGE = 'claude'
637
638/** Dot colour and label of a member's state (A1, A2). Waiting for approval wins, then paused, running, offline, idle. */
639export function statusOf(m: Member, waiting = false): { dot: string; label: string; color: string } {
640 if (waiting) return { dot: '⚠', label: '等审批', color: C_AMBER }
641 if (m.paused || m.status === 'paused') return { dot: '⏸', label: '已暂停', color: C_AMBER }
642 if (m.status === 'running' || (m.progress && typeof m.progress === 'object')) return { dot: '●', label: '执行中', color: C_BLUE }
643 if (m.online === false || m.status === 'offline') return { dot: '●', label: '离线', color: 'subtle' }
644 return { dot: '●', label: '空闲', color: 'inactive' }
645}
646
647// ---- starting a hub inside WSL ---------------------------------------------------------------------------------
648
649/** A POSIX shell single-quoted word. */
650export function shq(s: string): string {
651 return `'${String(s).replace(/'/g, `'\\''`)}'`
652}
653
654/**
655 * The bash script (fed to `wsl.exe ... bash -l -s` on stdin) that starts the hub for `dir`. A process left behind by
656 * a wsl.exe call dies when that call returns, so with a systemd user manager the hub runs as a transient unit
657 * (`hub run` itself, as `hub start` would launch it: XMUSE_STATE set, output appended to the state's logs/hub.log;
658 * --collect unloads it when the hub exits); without one it falls back to setsid. `path`: the PATH a hub last ran with
659 * (install.json), since a login shell may lack the agent CLIs. `init`: also make the workspace (`xmuse init`).
660 * WSL_INTEROP points at init's socket, which outlives this call (Windows exes such as agy need it).
661 */
662export function hubStartScript(o: { python: string; dir: string; path?: string; init: boolean }): string {
663 const unit = 'xmuse-hub-' + Array.from(o.dir).reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7).toString(16)
664 return [
665 'set -e',
666 `PY=${shq(o.python)}`,
667 `D=${shq(o.dir)}`,
668 'cd "$D"',
669 // no recorded PATH (a hub older than the record): the usual per-user CLI dirs a login shell skips
670 o.path ? `export PATH=${shq(o.path)}`
671 : 'for p in "$HOME/.opencode/bin" "$HOME/.local/bin"; do [ -d "$p" ] && PATH="$p:$PATH"; done; export PATH',
672 o.init ? '"$PY" -m xmuse init "$D" >/dev/null' : '',
673 'I=/run/WSL/1_interop; [ -S "$I" ] || I=$(ls -t /run/WSL/*_interop 2>/dev/null | head -1)',
674 'if command -v systemd-run >/dev/null && systemctl --user show-environment >/dev/null 2>&1; then',
675 // the unit runs `hub run` itself, so it lives exactly as long as the hub; a unit of the same name still loaded
676 // is a hub that does not answer (the panel found none alive): it is stopped first
677 ' ST=$("$PY" -c \'import sys; from pathlib import Path; from xmuse import config; print(config.find_state(Path(sys.argv[1])))\' "$D")',
678 ' mkdir -p "$ST/logs"',
679 // a stopped transient unit can stay loaded under its name: each start takes a fresh one (dir hash + time)
680 ` systemctl --user stop '${unit}*' 2>/dev/null || true`,
681 ` systemctl --user reset-failed '${unit}*' 2>/dev/null || true`,
682 ` U=${unit}-$(date +%s)`,
683 ' systemd-run --user --unit="$U" --collect --working-directory="$D" --setenv=XMUSE_STATE="$ST" \\',
684 ' --setenv=PATH="$PATH" --setenv=HOME="$HOME" ${I:+--setenv=WSL_INTEROP=$I} \\',
685 ' ${WSL_DISTRO_NAME:+--setenv=WSL_DISTRO_NAME=$WSL_DISTRO_NAME} \\',
686 ' /bin/sh -c \'exec "$0" -m xmuse hub run >> "$1/logs/hub.log" 2>&1\' "$PY" "$ST" >/dev/null',
687 ' echo "started unit $U"',
688 'else',
689 ' setsid nohup "$PY" -m xmuse --ws "$D" hub start </dev/null >/dev/null 2>&1 &',
690 ' echo started',
691 'fi',
692 ].filter(Boolean).join('\n') + '\n'
693}
694
695// ---- context size and compaction (T8c H3) ----------------------------------------------------------------------
696
697export const AUTO_COMPACT_TOKENS = 500_000 // the hub's auto_compact_tokens default
698
699/** "312k" for a member's latest context size; '' when the hub has none (no turn yet, or a vendor that reports none). */
700export function ctxText(tokens: unknown): string {
701 const t = Number(tokens)
702 if (!isFinite(t) || t <= 0) return ''
703 return t >= 1_000_000 ? `${(t / 1_000_000).toFixed(1)}M` : `${Math.max(1, Math.round(t / 1000))}k`
704}
705
706/** 0 normal, 1 past 80% of the auto-compact mark, 2 past it. */
707export function ctxLevel(tokens: unknown, mark = AUTO_COMPACT_TOKENS): number {
708 const t = Number(tokens)
709 if (!isFinite(t) || t <= 0) return 0
710 return t >= mark ? 2 : t >= mark * 0.8 ? 1 : 0
711}
712
713/** A model name short enough for a member card: the part after the provider, at most 24 characters. */
714export function shortModel(model: unknown): string {
715 const s = String(model ?? '').trim()
716 const tail = s.includes('/') ? s.slice(s.lastIndexOf('/') + 1) : s
717 return tail.length > 24 ? tail.slice(0, 23) + '…' : tail
718}
719
720/** Whether the panel offers "压缩" for this member: opencode via the hub, or this session's own Claude via the engine. */
721export function canCompact(m: Member, meId: string): boolean {
722 return (!!meId && m.id === meId) || vendorKey(String(m.id), String(m.vendor ?? '')) === 'opencode'
723}
724
725/**
726 * What Claude's summarizer is told: the person's own /compact text first (it wins where they differ), then the hub's
727 * instruction (task, handoffs, alignment points). Either may be empty.
728 */
729export function mergeCompactInstructions(user: string | undefined, hub: string | undefined): string {
730 const u = String(user ?? '').trim()
731 const x = String(hub ?? '').trim()
732 if (!x) return u
733 if (!u) return x
734 return `${u}\n\n(以下是 xmuse 协作室的压缩要求;和上面冲突时以上面为准)\n${x}`
735}
736
737/** 0 normal, 1 over 5 minutes, 2 over 15 minutes (E3). */
738export function elapsedLevel(startedAt: number | undefined, nowMs: number): number {
739 const s = startedMsOf(startedAt)
740 if (!s || !nowMs) return 0
741 const sec = (nowMs - s) / 1000
742 return sec > 900 ? 2 : sec > 300 ? 1 : 0
743}
744
745/** The trailing "@word" of the draft (what is after the last @ when that word starts with @), or null. */
746export function trailingMention(draft: string): string | null {
747 const m = /(?:^|\s)@([A-Za-z0-9-]*)$/.exec(draft)
748 return m ? m[1]! : null
749}
750
751/** Members whose id starts with the typed prefix, at most n; the user's own member is not offered. */
752export function mentionCandidates(view: any, q: string, n = 6): Member[] {
753 const me = view?.me?.id
754 const list: Member[] = Array.isArray(view?.members) ? view.members.filter((m: any) => m && typeof m.id === 'string' && m.id !== me) : []
755 const lq = q.toLowerCase()
756 return list.filter(m => m.id.toLowerCase().startsWith(lq)).slice(0, n)
757}
758
759/** The draft with its trailing @word replaced by "@id ". */
760export function completeMention(draft: string, id: string): string {
761 return draft.replace(/@[A-Za-z0-9-]*$/, `@${id} `)
762}
763
764/** Long text folded to its first lines: null when it is short. Never cuts inside a ``` fence (a closing fence is added). */
765export function foldLong(text: string, maxLines = 14, maxChars = 900, keep = 8): { text: string; hidden: number } | null {
766 const lines = String(text ?? '').split('\n')
767 if (lines.length <= maxLines && text.length <= maxChars) return null
768 let cut = Math.min(keep, lines.length)
769 while (cut > 1 && lines.slice(0, cut).join('\n').length > maxChars) cut--
770 const head = lines.slice(0, cut)
771 let open = false
772 for (const l of head) if (/^\s{0,3}(```|~~~)/.test(l)) open = !open
773 if (open) head.push('```')
774 return { text: head.join('\n'), hidden: lines.length - cut }
775}
776
777export type Block = { kind: 'md' | 'diff'; text: string }
778
779/** Splits text at ```diff fences: diff blocks are drawn with Code, the rest with Markdown. */
780export function splitBlocks(text: string): Block[] {
781 const out: Block[] = []
782 const lines = String(text ?? '').split('\n')
783 let cur: string[] = []
784 let diff: string[] | null = null
785 const pushMd = () => { if (cur.join('\n').trim()) out.push({ kind: 'md', text: cur.join('\n') }); cur = [] }
786 for (const l of lines) {
787 if (diff === null) {
788 if (/^\s{0,3}```diff\s*$/.test(l)) { pushMd(); diff = []; continue }
789 cur.push(l)
790 } else if (/^\s{0,3}```\s*$/.test(l)) {
791 if (diff.length) out.push({ kind: 'diff', text: diff.join('\n') })
792 diff = null
793 } else diff.push(l)
794 }
795 if (diff !== null) cur.push('```diff', ...diff) // unterminated: leave it as plain text
796 pushMd()
797 return out.length ? out : [{ kind: 'md', text: String(text ?? '') }]
798}
799
800/** Day heading for the divider between days: 今天 / 昨天 / "10-11 周日". */
801export function dayLabel(at: number | undefined, nowMs: number): string {
802 if (typeof at !== 'number' || !isFinite(at)) return ''
803 const d = new Date(at * 1000)
804 const n = new Date(nowMs)
805 const key = (x: Date) => x.getFullYear() * 400 + x.getMonth() * 32 + x.getDate()
806 const y = new Date(nowMs - 86400000)
807 if (key(d) === key(n)) return '今天'
808 if (key(d) === key(y)) return '昨天'
809 const p = (v: number) => String(v).padStart(2, '0')
810 return `${p(d.getMonth() + 1)}-${p(d.getDate())} 周${'日一二三四五六'[d.getDay()]}`
811}
812
813export type NoticeInfo = { kind: 'noise' | 'warn' | 'error'; text: string; raw: string; cmds?: string[] }
814
815/** The quoted strings of a Python/JSON list literal as the hub prints a shell request: ['a', "b"] -> [a, b]. */
816export function parseCmdList(s: string): string[] {
817 const out: string[] = []
818 const re = /'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"/g
819 let m: RegExpExecArray | null
820 while ((m = re.exec(s))) out.push((m[1] ?? m[2] ?? '').replace(/\\(["'\\])/g, '$1').replace(/\\n/g, '\n'))
821 return out
822}
823
824const DECISION_ZH: Record<string, string> = { once: '已批准一次', always: '已总是批准', reject: '已拒绝', expired: '已过期' }
825
826/** A hub system notice (first line) as short Chinese. Warnings and errors are kept apart; unknown text stays as it is. */
827export function zhNotice(rawText: string): NoticeInfo {
828 const raw = String(rawText ?? '').split('\n')[0]!.trim()
829 const warn = (text: string): NoticeInfo => ({ kind: 'warn', text, raw })
830 let m: RegExpMatchArray | null
831 const noise = (text: string, cmds?: string[]): NoticeInfo => ({ kind: 'noise', text, raw, ...(cmds ? { cmds } : {}) })
832 // command traces first: an assistant asked to run commands, and how the approval ended (the commands
833 // themselves may say "error", which must not turn the trace into a red warning)
834 if ((m = raw.match(/^(\S+) asks permission: (\w+) (\[.*?\])(?: —|$)/))) {
835 const cmds = parseCmdList(m[3]!)
836 return noise(`${m[1]} 请求${approvalKindLabel(m[2])}(${cmds.length} 条)`, cmds)
837 }
838 if ((m = raw.match(/^approval \S+ \((\w+) (\[.*\])\): (\w+)/))) {
839 const cmds = parseCmdList(m[2]!)
840 return noise(`${DECISION_ZH[m[3]!] ?? m[3]}:${approvalKindLabel(m[1])}(${cmds.length} 条)`, cmds)
841 }
842 if (/^审批 \S+ 已过期/.test(raw)) return noise('审批已过期(助手那边的请求已不存在)')
843 if ((m = raw.match(/^(\S+) paused(?: \((.*)\))?/))) return warn(`⏸ ${m[1]} 已暂停${m[2] ? `(${m[2]})` : ''}`)
844 if ((m = raw.match(/^(\S+) hit its vendor usage limit/))) return warn(`⚠ ${m[1]} 触到厂商额度上限,消息已挂起`)
845 if ((m = raw.match(/^turn \S+ of (\S+) ended without xmuse_send/))) return warn(`⚠ ${m[1]} 的回合结束了,但没有回复`)
846 if ((m = raw.match(/^chain (\S+) capped/))) return warn(`⚠ 连锁对话 ${m[1]} 已封顶,待处理消息被扣下`)
847 if (/no longer bound/i.test(raw) || /\bstall/i.test(raw)) return warn(`⚠ ${raw}`)
848 if (/\b(failed|error)\b/i.test(raw)) return { kind: 'error', text: `⚠ ${raw}`, raw }
849 if ((m = raw.match(/^(\S+) joined the room \((.*)\)$/))) return noise(`${m[1]} 加入(${m[2]})`)
850 if ((m = raw.match(/^(\S+) opened the room/))) return noise(`${m[1]} 打开了房间`)
851 if ((m = raw.match(/^(\S+) left its sidebar session/))) return noise(`${m[1]} 离开了侧栏(仍是成员)`)
852 if ((m = raw.match(/^(\S+) was removed from the room/))) return noise(`${m[1]} 被移出房间`)
853 if ((m = raw.match(/^(\S+) now hosts the room/))) return noise(`${m[1]} 成为主控`)
854 if ((m = raw.match(/^(\S+) was edited: (.*)$/))) return noise(`${m[1]} 的设置已修改:${m[2]}`)
855 if ((m = raw.match(/^private group (\S+) created with (.*)$/))) return noise(`私有小群 ${m[1]} 已建立:${m[2]!.split(/,\s*/).join('、')}`)
856 if ((m = raw.match(/^(\S+) was added to (\S+)$/))) return noise(`${m[1]} 加入 ${m[2]}`)
857 if ((m = raw.match(/^(\S+) was removed from (\S+)$/))) return noise(`${m[1]} 被移出 ${m[2]}`)
858 if ((m = raw.match(/^(\S+) left (\S+)$/))) return noise(`${m[1]} 退出 ${m[2]}`)
859 return noise(raw)
860}
861
862export type NoiseItem = { id: number; text: string; at?: number; cmds?: string[] }
863export type Row =
864 | { kind: 'msg'; m: Msg }
865 | { kind: 'group'; key: string; items: Msg[] } // same sender, same place, within 3 minutes: items oldest first
866 | { kind: 'noise'; key: string; items: NoiseItem[] }
867
868const GROUP_GAP_SEC = 180
869const GROUP_MAX = 6
870
871function plainMsg(m: Msg): boolean {
872 return m.kind !== 'system' && m.kind !== 'done' && !(m as any).retracted
873}
874
875/** older and newer (newest-first list) go under one avatar: same sender, thread and addressees, close in time. */
876function groupable(older: Msg, newer: Msg): boolean {
877 if (!plainMsg(older) || !plainMsg(newer)) return false
878 if (older.from !== newer.from || (older.thread ?? '') !== (newer.thread ?? '')) return false
879 if (toLabel(older) !== toLabel(newer)) return false
880 const a = older.at, b = newer.at
881 return typeof a === 'number' && typeof b === 'number' && b - a <= GROUP_GAP_SEC
882}
883
884/** Newest-first messages to display rows: a run of consecutive noise notices becomes one row (items oldest first). */
885export function buildRows(msgs: Msg[]): Row[] {
886 const rows: Row[] = []
887 let run: NoiseItem[] = []
888 const flush = () => {
889 if (!run.length) return
890 const items = run.slice().reverse()
891 rows.push({ kind: 'noise', key: `ns-${items[0]!.id}`, items })
892 run = []
893 }
894 for (const m of msgs) {
895 const zn = m.kind === 'system' ? zhNotice(String(m.text ?? '')) : null
896 if (zn && zn.kind === 'noise') {
897 run.push({ id: m.id, text: zn.text, at: m.at, ...(zn.cmds ? { cmds: zn.cmds } : {}) })
898 continue
899 }
900 flush()
901 const last = rows[rows.length - 1]
902 const newer = last?.kind === 'msg' ? last.m : last?.kind === 'group' ? last.items[0]! : null
903 if (last && newer && groupable(m, newer)) {
904 if (last.kind === 'msg') { rows[rows.length - 1] = { kind: 'group', key: `g-${m.id}`, items: [m, last.m] }; continue }
905 if (last.kind === 'group' && last.items.length < GROUP_MAX) { rows[rows.length - 1] = { kind: 'group', key: `g-${m.id}`, items: [m, ...last.items] }; continue }
906 }
907 rows.push({ kind: 'msg', m })
908 }
909 flush()
910 return rows
911}
912
913/** Time (epoch seconds) a row stands for: its newest item. */
914export function rowAt(r: Row): number | undefined {
915 if (r.kind === 'msg') return r.m.at
916 if (r.kind === 'group') return r.items[r.items.length - 1]?.at
917 return r.items[r.items.length - 1]?.at
918}
919
920// ---- event cards in the feed: a tone per kind of event, so actions read apart from messages ----------------------
921
922export type ToneKey = 'ask' | 'ok' | 'no' | 'info' | 'warn' | 'error' | 'done'
923// fg: a theme colour name for the icon; bg / border: translucent tints that read on a dark and a light pane
924export type Tone = { icon: string; fg: string; bg: string; border: string }
925export const TONES: Record<ToneKey, Tone> = {
926 ask: { icon: '⌘', fg: 'warning', bg: '#D299220F', border: '#D2992240' },
927 ok: { icon: '✓', fg: 'success', bg: '#3FB9500D', border: '#3FB95038' },
928 no: { icon: '✕', fg: 'error', bg: '#F851490D', border: '#F8514938' },
929 info: { icon: '•', fg: 'subtle', bg: '#8C8C960A', border: '#8C8C962E' },
930 warn: { icon: '!', fg: 'warning', bg: '#D299220F', border: '#D2992240' },
931 error: { icon: '!', fg: 'error', bg: '#F851490F', border: '#F8514945' },
932 done: { icon: '✓', fg: 'success', bg: '#3FB9500A', border: '#3FB95030' },
933}
934
935/** The tone of a run of notices: a pending request wins, then a rejection, an approval; else plain information. */
936export function noiseTone(items: NoiseItem[]): ToneKey {
937 const t = items.map(i => i.text)
938 if (t.some(x => x.includes('请求'))) return 'ask'
939 if (t.some(x => x.includes('拒绝'))) return 'no'
940 if (t.some(x => x.includes('批准'))) return 'ok'
941 return 'info'
942}
943
944/** A noise run's title without its time (the card shows the time apart): the events joined, or "N 件事" past three. */
945export function noiseTitle(items: NoiseItem[]): string {
946 return items.length > 3 ? `${items[items.length - 1]!.text} 等 ${items.length} 件事` : items.map(i => i.text).join('、')
947}
948
949/** A small context meter: a rounded track and a fill up to the auto-compact mark, green / amber / red. */
950export function ctxBarSvg(tokens: unknown, mark = AUTO_COMPACT_TOKENS, w = 56, h = 6): string {
951 const t = Math.max(0, Number(tokens) || 0)
952 const ratio = Math.min(1, t / mark)
953 const lvl = ctxLevel(t, mark)
954 const fill = lvl === 2 ? '#F85149' : lvl === 1 ? '#D29922' : '#3FB950'
955 const fw = t > 0 ? Math.max(h, Math.round(ratio * w)) : 0
956 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
957 + `<rect width="${w}" height="${h}" rx="${h / 2}" fill="#8C8C9640"/>`
958 + (fw ? `<rect width="${fw}" height="${h}" rx="${h / 2}" fill="${fill}"/>` : '') + '</svg>'
959}
960
961/** One line for a noise run: the events joined, or "共 N 件事" past three. */
962export function noiseSummary(items: NoiseItem[]): string {
963 const when = fmtClock(items[items.length - 1]?.at)
964 const body = items.length > 3 ? `共 ${items.length} 件事` : items.map(i => i.text).join('、')
965 return `· ${when ? when + ' · ' : ''}${body}`
966}
967
968const KIND_ZH: Record<string, string> = { shell: '执行命令', share: '分享私信', alignment: '对齐提案', resume: '恢复会话' }
969export function approvalKindLabel(kind: string | undefined): string {
970 const k = kind || 'shell'
971 return KIND_ZH[k] ?? k
972}
973
974/** "3 分钟前" for an epoch-seconds time ('' when unknown). */
975export function agoText(at: number | undefined, nowMs: number): string {
976 if (typeof at !== 'number' || !isFinite(at) || !nowMs) return ''
977 const sec = Math.max(0, Math.floor((nowMs - (at > 1e11 ? at : at * 1000)) / 1000))
978 if (sec < 60) return '刚刚'
979 if (sec < 3600) return `${Math.floor(sec / 60)} 分钟前`
980 if (sec < 86400) return `${Math.floor(sec / 3600)} 小时前`
981 return `${Math.floor(sec / 86400)} 天前`
982}
983
984/** A message the assistant wrote when it ended its turn with xmuse_done: text is "{summary}\nnext: {who}". */
985export function isDoneMsg(m: Msg): boolean {
986 return m.kind === 'done'
987}
988
989export function doneInfo(m: Msg): { summary: string; next: string; outcome: string } {
990 const t = String(m.text ?? '')
991 const k = t.lastIndexOf('\nnext: ')
992 const body = k >= 0 ? t.slice(0, k) : t
993 const next = k >= 0 ? t.slice(k + 7).trim() : ''
994 let summary = body.split('\n').find(l => l.trim())?.trim() ?? ''
995 if (summary.length > 80) summary = summary.slice(0, 80) + '…'
996 return { summary, next, outcome: typeof m.outcome === 'string' ? m.outcome : '' }
997}
998
999/** Gets the emphasised "to Claude" look: addressed to the panel's member, and not written by the user or by itself. */
1000export function isToClaude(m: Msg, me: string): boolean {
1001 return m.for_me === true && m.from !== 'human' && m.from !== me
1002}
1003hooks/dev.ts 9 lines1// Development switches of the panel. This copy has DEV false and names no file on any machine; a dev mod folder
2// hot-reloaded in a desktop session keeps its own copy with DEV true and paths of its own (see README.zh.md).
3export const DEV: boolean = false
4export const ROOM_OVERRIDE = '' // dev: a file holding a room directory to use instead of the session's
5export const LOAD_MARK = '' // dev: a file written on each load
6export const FOCUS_MARK = '' // dev: a file the last $.ui.focus answer is written to
7export const REBIND_MARK = '' // dev: a file the last ensureBound outcome is written to
8export const DEV_AVATAR_DIR = '' // dev: avatars, instead of <registry>/avatars
9hooks/grow.tsx 60 lines1import type { ClientModule } from 'claude-code'
2
3// Surface module (a Client region): text that grows step by step (the typing effect of a running member's draft, the
4// reveal of a new message). Its own timer advances it, so the panel is not redrawn per step. When the last step is
5// shown it posts { grown: id } (ui.message in register.tsx) and the panel swaps in its normal rendering.
6// draftTail is a copy of ./logic.ts (a surface module has no documented import): keep it in step.
7
8type GrowProps = {
9 id: string // 't:<member>:<run>' or 'r:<message id>:<run>'; posted back when done
10 text: string
11 from: number // characters shown before the first step
12 stepMs: number
13 steps: number[] // cumulative shown lengths, the last one is text.length
14 mode: string // 'draft' (dim tail in a bubble in the making) or 'md' (a message, Markdown)
15}
16type GrowState = { i: number }
17
18function draftTail(text: string, max = 280): string {
19 const t = String(text ?? '').trim()
20 if (t.length <= max) return t
21 let s = t.slice(-max)
22 const nl = s.indexOf('\n')
23 if (nl >= 0 && nl < 80) s = s.slice(nl + 1)
24 return '…' + s
25}
26
27/** Text a surface can always take: no control characters but tab and newline, and no half of a surrogate pair at a cut. */
28function clean(s: string): string {
29 const t = s.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
30 const last = t.charCodeAt(t.length - 1)
31 return last >= 0xd800 && last <= 0xdbff ? t.slice(0, -1) : t
32}
33
34const Grow: ClientModule<GrowProps, GrowState> = (p, surface) => {
35 const { Text } = surface.elements
36 const st = surface.state
37 if (!st) {
38 surface.setState({ i: 0 })
39 const stop = surface.every(p.stepMs, () => {
40 const s = surface.state
41 const i = (s ? s.i : 0) + 1
42 surface.setState({ i })
43 if (i >= p.steps.length) {
44 stop()
45 surface.post({ grown: p.id })
46 }
47 })
48 }
49 const i = st ? st.i : 0
50 const done = i >= p.steps.length
51 const shown = i <= 0 || !p.steps.length ? p.from : p.steps[Math.min(i, p.steps.length) - 1]!
52 const prefix = clean(p.text.slice(0, shown))
53 // Plain Text in both modes: a Markdown element as the root of a Client made the desktop refuse the tree ("returned a
54 // tree the page cannot draw"); the panel draws the message as Markdown once this is done.
55 if (p.mode === 'md') return <Text>{done ? clean(p.text) : `${prefix} ▍`}</Text>
56 return <Text dimColor>{`${draftTail(prefix)} ▍`}</Text>
57}
58
59export default Grow
60hooks/elapsed.tsx 64 lines1import type { ClientModule } from 'claude-code'
2
3// Surface module (a Client region): the once-a-second elapsed time of a running member. It counts on its own timer, so the
4// panel is not redrawn every second. Props are plain data; baseMs is the host clock ($.clock.now()) when the panel drew.
5// The sandbox Date.now is not the host clock, so the time shown is baseMs plus the ticks counted here.
6// fmtElapsed, elapsedLevel and the two colours are copies of ./logic.ts (a surface module has no documented import): keep them in step.
7
8type ElProps = { startedAt: number; baseMs: number; kind: string } // kind: 'dots' (dots and time), 'plain', 'level' (coloured by age)
9type ElState = { base: number; ticks: number }
10
11const C_AMBER = 'warning'
12const C_RED = 'error'
13
14function startMsOf(startedAt: number | undefined): number {
15 if (typeof startedAt !== 'number' || !isFinite(startedAt) || startedAt <= 0) return 0
16 return startedAt > 1e11 ? startedAt : startedAt * 1000
17}
18
19function fmtElapsed(startedAt: number | undefined, nowMs: number): string {
20 const s = startMsOf(startedAt)
21 if (!s || !nowMs) return '0:00'
22 const sec = Math.max(0, Math.floor((nowMs - s) / 1000))
23 const p = (n: number) => String(n).padStart(2, '0')
24 const h = Math.floor(sec / 3600)
25 const m = Math.floor((sec % 3600) / 60)
26 return h > 0 ? `${h}:${p(m)}:${p(sec % 60)}` : `${m}:${p(sec % 60)}`
27}
28
29function elapsedLevel(startedAt: number | undefined, nowMs: number): number {
30 const s = startMsOf(startedAt)
31 if (!s || !nowMs) return 0
32 const sec = (nowMs - s) / 1000
33 return sec > 900 ? 2 : sec > 300 ? 1 : 0
34}
35
36const Elapsed: ClientModule<ElProps, ElState> = (p, surface) => {
37 const { Text } = surface.elements
38 const st = surface.state
39 if (!st) {
40 // start the one timer; the callback reads the live state, so it follows the props of later draws
41 surface.setState({ base: p.baseMs, ticks: 0 })
42 surface.every(1000, () => {
43 const s = surface.state
44 if (s) surface.setState({ base: s.base, ticks: s.ticks + 1 })
45 })
46 } else if (st.base !== p.baseMs) {
47 surface.setState({ base: p.baseMs, ticks: 0 }) // the panel redrew with a fresh clock reading: count from it
48 }
49 const ticks = st && st.base === p.baseMs ? st.ticks : 0
50 const now = p.baseMs + ticks * 1000
51 const t = fmtElapsed(p.startedAt, now)
52 if (p.kind === 'dots') {
53 const dots = ['· ', '·· ', '···'][Math.floor(now / 1000) % 3]!
54 return <Text dimColor>{`${dots} ${t}`}</Text>
55 }
56 if (p.kind === 'level') {
57 const lvl = elapsedLevel(p.startedAt, now)
58 return <Text color={lvl === 2 ? C_RED : lvl === 1 ? C_AMBER : undefined} dimColor={lvl === 0}>{` ${t}`}</Text>
59 }
60 return <Text dimColor>{` ${t}`}</Text>
61}
62
63export default Elapsed
64types/index.d.ts 38 lines1export type ConnInfo = { phase: 'idle' | 'connecting' | 'ok' | 'error' | 'noroom'; error: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 xmuse: {
6 conn: ConnInfo
7 view: any
8 tab: string
9 replyTo: number
10 gen: number
11 promoted: Record<string, string>
12 lastRead: string
13 light: boolean
14 mention: string | null
15 forced: string | null
16 decision: { id: number; text: string; ok: boolean }
17 avatarVer: number
18 sendN: number
19 sending: boolean
20 addOpen: boolean
21 expanded: string
22 busy: Record<string, boolean>
23 fold: Record<string, boolean>
24 open: { busy: boolean; msg: string; error: string }
25 typed: { id: string; full: string; from: number; steps: number[]; run: number }
26 done: Record<string, { tools: number; startedMs: number; endedMs: number }>
27 brief: boolean
28 older: { items: any[]; hasMore: boolean; loading: boolean }
29 selfCtx: number
30 reveal: { id: number; steps: number[]; run: number }
31 modelPick: {
32 member: string; current: string; options: { value: string; label?: string }[]; format: string; note: string
33 loading: boolean; error: string
34 } | null
35 }
36 }
37}
38