SLOPSHOPPER

xmuse

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

newpaneguardcommandtoolprocess
★ 1v0.1.0MITupdated 2026-10-14iiyazu/Cross-Muse/integrations/xmuse-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xmuse
│ ┃ xmuse · app ✕ › fix the failing auth test and add an audit log call │ ┃ xmuse │ ┃ ⏺ Read(src/auth.ts) │ ┃ 正在连接 hub… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /xmuse │ ⎿ xmuse: xmuse panel opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · xmuse · app
xmuse 正在连接 hub…
README

xmuse

English

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

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

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

关注的问题

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

具体做法:

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

怎么工作

flowchart TB
  USER["用户"] --> PANEL["xmuse 面板<br/>(Claude Code 插件)"]
  CC["Claude Code<br/>(主控)"] --- PANEL
  subgraph HELPERS["助手"]
    OC["OpenCode"]
    AGY["agy"]
    DSH["dsh"]
  end
  CC -->|"MCP 工具"| HUB
  PANEL -->|"HTTP"| HUB
  subgraph HUB["hub (本地, 每个项目一个)"]
    MAIL["消息、话题"]
    ENG["唤醒引擎"]
    ALIGN["约定条目"]
    DB[("hub.db (SQLite)")]
  end
  ENG -->|"各家适配器"| HELPERS
  HUB -.->|"可选"| MO["MemoryOS"]

hub 是一个本地 Python 进程,所有状态存在一个 SQLite 文件里。agent 通过一组 MCP 工具收发消息、申报约定。助手由 hub 按各家的方式叫醒:OpenCode 走它的后台服务接口,agy 和 dsh 走无界面模式。Claude 保留自己的全部能力,hub 不驱动它。

MemryOS-lite 可以作为可选的记忆层接入,不配置就不用。

安装

需要 Linux 或 WSL、uv、Node 22,以及要接入的 agent 的 CLI。

git clone https://github.com/iiyazu/Cross-Muse.git && cd Cross-Muse
scripts/bootstrap.sh      # 安装依赖并自检,可重复执行

然后在 Claude Code 的终端会话里安装面板插件:

/plugin install xmuse --marketplace <integrations/xmuse-panel 的路径>

打开面板,点"开房间",再在顶部添加助手。也可以不用面板,直接用命令行:

export PATH="$PWD/hub/.venv/bin:$PATH"
xmuse init ~/work/demo && cd ~/work/demo
xmuse agent add oc-1 --vendor opencode
xmuse hub start
xmuse send oc-1 "实现 calc.sub,写完交给 claude 复核"

Windows 加 WSL 的细节见 hub/README.md 和 面板说明。

目录

路径内容
hub/hub 和命令行 xmuse;src/xmuse/adapters/ 是各家适配器
shim/MCP 垫片,把工具调用转给 hub
integrations/xmuse-panel/Claude Code 面板插件
integrations/dsh-bundle/dsh 桌面端插件包
skill/、templates/写进项目的协作技能和 AGENTS.md 段落
tests/hub 测试,用假 agent 回放录制的输出
experiments/、docs/design/实验、设计稿和报告

验证

用机制探针验证:在一个小的模拟项目里每次只测一项功能,结果由程序判定,每格 10 次。

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

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

局限

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

开发

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

许可证

MIT

Source 6 files
hooks/register.tsx 2018 lines
1import { 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 lines
1// 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}
1003
hooks/dev.ts 9 lines
1// 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
9
hooks/grow.tsx 60 lines
1import 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
60
hooks/elapsed.tsx 64 lines
1import 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
64
types/index.d.ts 38 lines
1export 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