xmuse room board: status line, toasts, pane and a status tool, plus human-driven coordination from this window (new, say, split and review decisions) under a…

A Claude Code plugin of function hooks (a mod) that surfaces the xmuse room board as a status line, toasts and a pane. Reads are free; the only writes it can ever perform are split approve/reject decisions the human presses through, under a short-lived plugin grant (see 授权 below).
claude plugin marketplace add ./integrations/claude-code/.claude-plugin
claude plugin install xmuse@xmuse-marketplace
Or load once from disk:
claude --plugin-dir integrations/claude-code
Options (userConfig, see .claude-plugin/plugin.json):
| key | default | meaning |
|---|---|---|
baseUrl | http://127.0.0.1:8201 | xmuse chat API (loopback only) |
webUrl | http://127.0.0.1:3000 | Kept for compatibility; the pane no longer links to the Web UI |
pollSeconds | 5 | board summary poll interval |
/xmuse or /xmuse pane — open the board pane./xmuse status — compact structured block (counts + attention codes)./xmuse attach [<room id or unique prefix>] — bind this working directory to a room (no argument: most recently updated room). Without a binding the mod auto-binds to the most recently updated room that has board modules./xmuse detach — clear the binding./xmuse new <title> [--lead K] [--owners K,K] [--reviewer K] [--no-review] (under a grant) — create an addressed Room and bind it. Cross-family review is on by default: an assigned reviewer of another family reviews, and with no other family in the Room the review comes to you in the pane. --no-review turns review off, so a verified module is accepted as is./xmuse say <message> (under a grant) — post to the bound Room.mcp__xmuse__status — the same block as /xmuse status. This is the only thing the mod gives the model.GET …/board/summary every pollSeconds; on a revision change it refreshes the status line and, only while the pane is open, fetches the full GET …/board projection. If-None-Match/304 is honoured, ticks never overlap, failures back off (interval ×2, capped at 60 s).看板 3 模块 · ✓1 …1 ✗1 · 待你处理 1 (zero parts omitted). Offline: xmuse 离线. Unbound: xmuse 未绑定房间.待你复核 (board_attention_review_operator_pending) and 复核被驳回待返工 (board_attention_review_objected).verified = ✓ 已验证, done_claimed = ◌ 自称完成·未验证, verification_failed = ✗, verifying = …, waiting_for_provider = ⧗, verification_error = ‼. The ✓ count in the status line counts accepted modules (accepted_total), never merely verified ones.Text labelled agent 自述 · 未验证, never as Markdown or links. Split approvals happen in the pane once the human pairs a grant (see 授权); without a grant the pane only shows the 待审批 line.A review the Human must decide (no other model family present, or escalated to the operator) is decided from this pane under the grant — never blind: the row gains a 查看复核材料 button that fetches the material (§4.5) and draws the patch as plain Text labelled 复核材料 · agent 撰写,未验证. 认可 / 反对 arm a confirm step; an objection first asks for the reason (反对理由), then both ask for the first 6 hex characters after sha256: of the material digest. 关闭材料 drops the material view.
When the room runs cross-family reviews (capabilities.reviews == 1), each pane module row gains one fixed-word review part, counts only:
待复核 — review pending with a participant reviewer.待你复核 — review pending with the operator (you). Under a live grant covering this room with board.review.decide the row also gains the 查看复核材料 decision flow described above.已背书 — review endorsed. The module is accepted and its state badge reads ✓ 已验收.已驳回 — review objected; the owner reworks. · 已升级 is appended when the review was escalated to the operator (the assigned reviewer did not answer).阻塞 N 主要 N 次要 N finding counts appear only when any of them is non-zero.?value, never hidden.已验收 appears only when the module is accepted. A module with state == verified that is not accepted shows 已验证 · 待复核, never 已验收. While reviews are off (capabilities.reviews == 0) rows render exactly as before, with no review part.
What the mod never does with reviews:
status, reviewer_kind, escalation presence, finding counts) and the attention reason codes travel elsewhere.room.create, room.message, board.split.decide and board.review.decide on the rooms in its set (see 授权); a verdict is recorded only through board.review.decide.xmuse-workroom pair for this room. It prints a pairing code that looks like ABCD-EFGH and expires 120 seconds after issue; it is shown once, in the terminal only.已授权 · 剩余 mm:ss with a 撤销授权 button, and each proposed split of the bound room gains 批准 / 拒绝 buttons. Pressing one only arms a confirm step: type the first 6 hex characters after sha256: of that split's digest (shown nowhere near the control: run xmuse-workroom pair --pending in a terminal, which lists what waits for you with each digest prefix and the agent-authored module ids and paths to check) into the 输入摘要前 6 位以确认 field. A mismatch sends nothing; a match sends the decision with the split's full digest as expected_digest, then refetches the board.撤销授权 and /xmuse detach revoke the grant best-effort and drop it locally regardless of the response.What the authorization can and cannot do:
room.create (/xmuse new), room.message (/xmuse say), plus board.split.decide (approve or reject a proposed split of a covered room) and board.review.decide (rule on a review the Human must decide). Nothing else.expires_at (the pane counts down and then returns to the pairing view). The token lives in memory only, in the hooks module: it is never written to the store, an atom, a toast, a status line, a command result or the pane text, and a plugin reload loses it (re-pair to continue).xmuse-workroom pair --revoke) and pair again.mcp__xmuse__status (counts, state codes, attention codes). Human-pressed pane buttons may exchange a pairing code, decide a proposed split, or revoke the grant; nothing else writes, and no command or tool takes a code or a token.baseUrl at the address where the backend listens on loopback; LAN hostnames are refused by the API's host guard.$.process, $.fs, $.model, $.agent, $.prompt.*, $.session.*, $.mcp, $.config.set, no operator routes, no PUT/DELETE — the source is grepped for these. POST and the Bearer header appear only in src/grant_api.ts (pure, injected transport).XMUSE_OPERATOR_TOKEN / X-Xmuse-Operator-Token anywhere in the mod. The grant token is memory-only and never enters toasts, status lines, command/tool results or pane text.Button/Input handlers (plus the pre-existing /xmuse detach, which revokes best-effort). Nothing is registered as a model-callable tool besides mcp__xmuse__status.tests/fixtures.generated.ts is generated from docs/contracts/fixtures/board_v2/*.json via python tools/sync_fixtures.py; never hand-edit it (--check fails CI when stale)..claude-plugin/plugin.json manifest (name xmuse, userConfig)
.claude-plugin/marketplace.json marketplace listing (source ./)
hooks/hooks.json module wiring (./register.tsx)
hooks/register.tsx thin wiring only (the only $ user)
src/text.ts safe()/safeId()/shortRoom()/link helpers
src/labels.ts state badges
src/api.ts GET client + normalizers
src/grant_api.ts grant writes (only POST/Bearer file)
src/grant_state.ts pairing/expiry/confirm/toast mapping
src/board_state.ts poll state atom, status/toast builders
src/poll.ts tick, auto-bind, attach/detach
src/pane.tsx pane nodes + tree
types/index.d.ts PluginState contract
tests/*.test.ts claude plugin test suites
tests/grant_golden.generated.ts backend plugin_grant_v1 golden (tools/sync_fixtures.py)
tests/fixtures.generated.ts generated fixtures (do not edit)
tools/sync_fixtures.py fixture generator (stdlib only)hooks/register.tsx 858 lines1// xmuse board status mod. All engine calls ($, on, next) live in this
2// file; ../src/* holds pure helpers only (text, labels, normalizers,
3// status/toast builders, pane nodes).
4import { atom, read, update } from "claude-code";
5import type { Register } from "claude-code";
6import type { XmuseCache, XmuseSummary } from "../types/index";
7import {
8 normalizeBoard,
9 normalizeRooms,
10 normalizeSummary,
11 parseGetResponse,
12 sortRoomsByUpdated,
13 type GetResult,
14} from "../src/api";
15import { planStateToasts, planToast, statusBlock, statusText, toastableKeys } from "../src/board_state";
16import {
17 createRoom,
18 decideReview,
19 decideSplit,
20 detailCodeOf,
21 exchangeGrant,
22 fetchReviewMaterial,
23 parseExchangePayload,
24 parseMaterialPayload,
25 parseRoomCreatePayload,
26 postMessage,
27 revokeGrant,
28 type GrantHttp,
29} from "../src/grant_api";
30import {
31 ORIGIN_REFUSED,
32 REPAIR_TOAST,
33 checkConfirmInput,
34 exchangeToast,
35 failureToast,
36 grantExpired,
37 grantRefusalText,
38 isHumanOrigin,
39 mapDecisionOutcome,
40 mapReviewOutcome,
41 parseNewArgs,
42 parseSayArgs,
43 validatePairingCode,
44 writeFailureText,
45} from "../src/grant_state";
46import { backoffMs, bindingKey, pickAttachTarget } from "../src/poll";
47import { PaneTree, buildPaneNodes } from "../src/pane";
48import { shortRoom } from "../src/text";
49
50const PANE_ID = "xmuse";
51
52// The grant token lives only here, in module scope: a reload drops it and
53// the pane falls back to the unauthorized view. It never enters the atom,
54// the store, a toast, a status line, a command result or the pane tree.
55let grantToken: string | null = null;
56
57const cacheAtom = atom({ plugin: "xmuse", key: "cache" } as const, {
58 binding: null,
59 cwd: null,
60 summary: null,
61 board: null,
62 summaryEtag: null,
63 boardEtag: null,
64 lastPollAt: null,
65 offline: false,
66 baselined: false,
67 seenAttention: [],
68 seenStates: {},
69 failCount: 0,
70 nextRetryAt: 0,
71 paneOpen: false,
72 expanded: {},
73 grant: null,
74 confirming: null,
75 material: null,
76 formEpoch: 0,
77} as XmuseCache);
78
79const rt = {
80 baseUrl: "http://127.0.0.1:8201",
81 // Kept for userConfig compatibility only: since main_window_control_v1
82 // the pane draws no Web links (split and review decisions happen in the
83 // pane under the grant), so nothing reads this anymore.
84 webUrl: "http://127.0.0.1:3000",
85 pollMs: 5000,
86 timer: null as { cancel: () => void } | null,
87 inFlight: false,
88};
89
90function readOptions(options: unknown): void {
91 const o = (options ?? {}) as Record<string, unknown>;
92 if (typeof o["baseUrl"] === "string" && o["baseUrl"] !== "") rt.baseUrl = o["baseUrl"];
93 if (typeof o["webUrl"] === "string" && o["webUrl"] !== "") rt.webUrl = o["webUrl"];
94 const poll = Number(o["pollSeconds"] ?? 5);
95 rt.pollMs = Number.isFinite(poll) && poll > 0 ? Math.min(Math.floor(poll * 1000), 60000) : 5000;
96}
97
98function base(): string {
99 return rt.baseUrl.replace(/\/+$/, "");
100}
101
102// The single place $.http.fetch is spelled: same file, plain GET, with an
103// optional If-None-Match. Pure parsing lives in ../src/api.
104async function httpGet(caller: any, path: string, etag: string | null): Promise<GetResult> {
105 const headers: Record<string, string> = {};
106 if (etag !== null && etag !== "") headers["If-None-Match"] = '"' + etag + '"';
107 try {
108 const res = await caller.http.fetch(base() + path, { method: "GET", headers });
109 const rawHeaders = res.headers !== undefined && res.headers !== null ? res.headers : {};
110 return parseGetResponse({ status: res.status, headers: rawHeaders, text: res.text });
111 } catch {
112 return { kind: "failed" };
113 }
114}
115
116// Transport injected into the pure grant shapes: forwards the caller's init
117// untouched, so the write method and headers are spelled once, in the pure
118// module. A rejected fetch surfaces as status 0 (network failure).
119function pluginHttp(caller: any): GrantHttp {
120 return (url, init) =>
121 caller.http.fetch(url, init).then(
122 (res: any) => ({ status: res.status as number, text: res.text as string }),
123 () => ({ status: 0, text: "" }),
124 );
125}
126
127async function markFailed(caller: any, cache: XmuseCache, now: number): Promise<void> {
128 const failCount = cache.failCount + 1;
129 await update(caller, cacheAtom, (c) => {
130 const cur = c as XmuseCache;
131 return { ...cur, offline: true, failCount, nextRetryAt: now + backoffMs(failCount, rt.pollMs) };
132 });
133 caller.ui.status("xmuse 离线");
134}
135
136async function bindRoom(caller: any, conversationId: string): Promise<void> {
137 const live = (await read(caller, cacheAtom)) as XmuseCache;
138 const dir = live.cwd ?? "";
139 try {
140 await caller.store.set(bindingKey(dir), conversationId);
141 } catch {
142 // binding still applies for this session
143 }
144 await update(caller, cacheAtom, (c) => {
145 const cur = c as XmuseCache;
146 return {
147 ...cur,
148 binding: conversationId,
149 summary: null,
150 board: null,
151 summaryEtag: null,
152 boardEtag: null,
153 offline: false,
154 baselined: false,
155 seenAttention: [],
156 seenStates: {},
157 failCount: 0,
158 nextRetryAt: 0,
159 confirming: null,
160 };
161 });
162}
163
164async function autoBind(caller: any): Promise<boolean> {
165 const roomsRes = await httpGet(caller, "/api/chat/rooms", null);
166 if (roomsRes.kind !== "ok") return false;
167 const rooms = normalizeRooms(roomsRes.json);
168 if (rooms === null) return false;
169 for (const room of sortRoomsByUpdated(rooms).slice(0, 8)) {
170 const res = await httpGet(
171 caller,
172 "/api/chat/conversations/" + encodeURIComponent(room.conversation_id) + "/board/summary",
173 null,
174 );
175 if (res.kind !== "ok") continue;
176 const summary: XmuseSummary | null = normalizeSummary(res.json);
177 if (summary !== null && summary.modules_total > 0) {
178 await bindRoom(caller, room.conversation_id);
179 return true;
180 }
181 }
182 return false;
183}
184
185// Best-effort refresh of the board after a decision outcome says the
186// room changed. Failures stay silent: the next poll tick retries.
187async function refreshBoard(caller: any): Promise<void> {
188 const live = (await read(caller, cacheAtom)) as XmuseCache;
189 const boundId = live.binding;
190 if (boundId === null) return;
191 const sRes = await httpGet(
192 caller,
193 "/api/chat/conversations/" + encodeURIComponent(boundId) + "/board/summary",
194 live.summaryEtag,
195 );
196 if (sRes.kind === "ok") {
197 const summary = normalizeSummary(sRes.json);
198 if (summary !== null) {
199 const etag = sRes.etag;
200 await update(caller, cacheAtom, (c) => ({
201 ...(c as XmuseCache),
202 summary,
203 summaryEtag: etag ?? (c as XmuseCache).summaryEtag,
204 }));
205 }
206 }
207 const bRes = await httpGet(
208 caller,
209 "/api/chat/conversations/" + encodeURIComponent(boundId) + "/board",
210 live.boardEtag,
211 );
212 if (bRes.kind === "ok") {
213 const board = normalizeBoard(bRes.json);
214 if (board !== null) {
215 const etag = bRes.etag;
216 await update(caller, cacheAtom, (c) => ({
217 ...(c as XmuseCache),
218 board,
219 boardEtag: etag ?? (c as XmuseCache).boardEtag,
220 }));
221 }
222 }
223}
224
225async function dropGrant(caller: any): Promise<void> {
226 grantToken = null;
227 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), grant: null, confirming: null, material: null }));
228}
229
230// The live token when the grant covers `scope` (and `room`, when given) and
231// has not expired; otherwise null. Never returns the token for display.
232async function liveToken(caller: any, scope: string, room: string | null): Promise<string | null> {
233 const live = (await read(caller, cacheAtom)) as XmuseCache;
234 const held = grantToken;
235 if (held === null || live.grant === null) return null;
236 if (grantExpired(live.grant.expiresAt, await caller.clock.now())) return null;
237 if (!live.grant.scopes.includes(scope)) return null;
238 if (room !== null && !live.grant.conversationIds.includes(room)) return null;
239 return held;
240}
241
242async function refusalText(caller: any, scope: string, room: string | null): Promise<string> {
243 const live = (await read(caller, cacheAtom)) as XmuseCache;
244 return grantRefusalText(live.grant, grantToken !== null, await caller.clock.now(), scope, room);
245}
246
247async function requestId(caller: any, tag: string): Promise<string> {
248 const now = await caller.clock.now();
249 return tag + "-" + String(now) + "-" + Math.random().toString(36).slice(2, 10);
250}
251
252async function revokeBestEffort(caller: any): Promise<void> {
253 const held = grantToken;
254 if (held !== null) {
255 try {
256 await revokeGrant(pluginHttp(caller), base(), held);
257 } catch {
258 // best effort: the local drop below runs regardless
259 }
260 }
261 await dropGrant(caller);
262}
263
264// Runs only from the pairing Input submit. Clears the field on every
265// path by bumping the form epoch, so the next draw shows an empty
266// field whatever the outcome was.
267async function submitPairing(caller: any, rawValue: string): Promise<void> {
268 const checked = validatePairingCode(rawValue);
269 if (!checked.ok) {
270 await update(caller, cacheAtom, (c) => ({
271 ...(c as XmuseCache),
272 formEpoch: (c as XmuseCache).formEpoch + 1,
273 }));
274 caller.ui.toast(checked.hint);
275 return;
276 }
277 const res = await exchangeGrant(pluginHttp(caller), base(), checked.code);
278 if (res.status === 200) {
279 const parsed = parseExchangePayload(res.json);
280 if (parsed !== null) {
281 grantToken = parsed.token;
282 const meta = parsed.grant;
283 await update(caller, cacheAtom, (c) => ({
284 ...(c as XmuseCache),
285 grant: {
286 grantId: meta.grantId,
287 expiresAt: meta.expiresAt,
288 conversationIds: meta.conversationIds,
289 scopes: meta.scopes,
290 },
291 confirming: null,
292 }));
293 const at = await caller.clock.now();
294 caller.ui.toast(exchangeToast(meta.expiresAt, at));
295 return;
296 }
297 }
298 await update(caller, cacheAtom, (c) => ({
299 ...(c as XmuseCache),
300 formEpoch: (c as XmuseCache).formEpoch + 1,
301 }));
302 caller.ui.toast(failureToast(res.status));
303}
304
305// Runs only from the confirm Input submit. A mismatch sends nothing.
306// Confirming state clears on every path.
307async function submitConfirm(caller: any, rawValue: string): Promise<void> {
308 const live = (await read(caller, cacheAtom)) as XmuseCache;
309 const pending = live.confirming;
310 const held = grantToken;
311 if (pending === null || held === null || live.binding === null) {
312 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
313 caller.ui.toast(REPAIR_TOAST);
314 return;
315 }
316 if (pending.kind === "review") {
317 await submitReviewConfirm(caller, live, held, rawValue);
318 return;
319 }
320 const rows = live.board !== null ? live.board.splits : [];
321 const row = rows.find((s) => s.split_id === pending.splitId) ?? null;
322 if (row === null || row.status !== "proposed" || row.digest === "") {
323 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
324 await refreshBoard(caller);
325 caller.ui.toast("拆分已不能决定,已刷新");
326 return;
327 }
328 const check = checkConfirmInput(row.digest, rawValue);
329 if (!check.ok) {
330 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
331 caller.ui.toast(check.hint);
332 return;
333 }
334 const decision = pending.decision === "reject" ? "reject" : "approve";
335 const res = await decideSplit(pluginHttp(caller), base(), held, pending.splitId, live.binding, decision, row.digest);
336 const outcome = mapDecisionOutcome(res.status, detailCodeOf(res.json), decision);
337 if (outcome.clearGrant) {
338 await dropGrant(caller);
339 } else {
340 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
341 }
342 if (outcome.refetch) await refreshBoard(caller);
343 caller.ui.toast(outcome.toast);
344}
345
346// The review digest confirm (main_window_control_v1 §4.4). The digest comes
347// from the material the person was shown; an objection carries the reason
348// entered before. Confirming and material clear on every path.
349async function submitReviewConfirm(caller: any, live: XmuseCache, held: string, rawValue: string): Promise<void> {
350 const pending = live.confirming;
351 const material = live.material;
352 const boundId = live.binding;
353 const clear = async (): Promise<void> => {
354 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null, material: null }));
355 };
356 if (pending === null || material === null || boundId === null || material.reviewId !== pending.reviewId) {
357 await clear();
358 caller.ui.toast("复核材料已失效,请重新查看");
359 return;
360 }
361 const check = checkConfirmInput(material.digest, rawValue);
362 if (!check.ok) {
363 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
364 caller.ui.toast(check.hint);
365 return;
366 }
367 const verdict = pending.decision === "object" ? "object" : "endorse";
368 const res = await decideReview(
369 pluginHttp(caller),
370 base(),
371 held,
372 pending.reviewId,
373 boundId,
374 verdict,
375 material.digest,
376 pending.reason,
377 );
378 const outcome = mapReviewOutcome(res.status, detailCodeOf(res.json), verdict);
379 if (outcome.clearGrant) await dropGrant(caller);
380 else await clear();
381 if (outcome.refetch) await refreshBoard(caller);
382 caller.ui.toast(outcome.toast);
383}
384
385// Runs only from a pane Button press: fetch the material of a review the
386// Human must decide and keep it for the pane only (§4.5).
387async function loadMaterial(caller: any, reviewId: string): Promise<void> {
388 const live = (await read(caller, cacheAtom)) as XmuseCache;
389 if (live.binding === null) return;
390 const held = await liveToken(caller, "board.review.decide", live.binding);
391 if (held === null) {
392 caller.ui.toast(REPAIR_TOAST);
393 return;
394 }
395 const res = await fetchReviewMaterial(pluginHttp(caller), base(), held, reviewId, live.binding);
396 const parsed = res.status === 200 ? parseMaterialPayload(res.json) : null;
397 if (parsed === null) {
398 caller.ui.toast(mapReviewOutcome(res.status, detailCodeOf(res.json), "endorse").toast);
399 if (res.status === 409) await refreshBoard(caller);
400 return;
401 }
402 await update(caller, cacheAtom, (c) => ({
403 ...(c as XmuseCache),
404 material: { reviewId, digest: parsed.digest, text: parsed.text, truncated: parsed.truncated },
405 confirming: null,
406 }));
407}
408
409// Runs only from pane Button presses. Setting confirming never acts:
410// the decision is sent only after the digest confirm submit matches.
411async function pressControl(caller: any, key: string): Promise<void> {
412 if (key === "xmuse-revoke") {
413 await revokeBestEffort(caller);
414 return;
415 }
416 if (key === "xmuse-cancel-confirm") {
417 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null }));
418 return;
419 }
420 if (key === "xmuse-close-material") {
421 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), confirming: null, material: null }));
422 return;
423 }
424 const live = (await read(caller, cacheAtom)) as XmuseCache;
425 if (live.grant === null || live.binding === null) return;
426 if (!live.grant.conversationIds.includes(live.binding)) return;
427 if (grantExpired(live.grant.expiresAt, await caller.clock.now())) return;
428 const modules = live.board !== null ? live.board.modules : [];
429 // Review and split scope checks stay independent: a missing review scope
430 // only drops review keys (the loop keeps scanning), never split keys.
431 const mayReview = live.grant.scopes.includes("board.review.decide");
432 for (const m of modules) {
433 const reviewId = m.review.review_id;
434 if (reviewId === null || m.review.status !== "pending" || m.review.reviewer_kind !== "operator") continue;
435 if (key === "xmuse-material-" + reviewId) {
436 if (!mayReview) return;
437 await loadMaterial(caller, reviewId);
438 return;
439 }
440 if (key === "xmuse-endorse-" + reviewId || key === "xmuse-object-" + reviewId) {
441 if (!mayReview) return;
442 if (live.material === null || live.material.reviewId !== reviewId) return;
443 const decision = key === "xmuse-object-" + reviewId ? "object" : "endorse";
444 await update(caller, cacheAtom, (c) => ({
445 ...(c as XmuseCache),
446 confirming: { kind: "review", splitId: "", reviewId, decision, reason: null },
447 }));
448 return;
449 }
450 }
451 if (!live.grant.scopes.includes("board.split.decide")) return;
452 const rows = live.board !== null ? live.board.splits : [];
453 for (const row of rows) {
454 if (key !== "xmuse-approve-" + row.split_id && key !== "xmuse-reject-" + row.split_id) continue;
455 if (row.status !== "proposed" || row.digest === "") return;
456 const decision = key === "xmuse-reject-" + row.split_id ? "reject" : "approve";
457 await update(caller, cacheAtom, (c) => ({
458 ...(c as XmuseCache),
459 confirming: { kind: "split", splitId: row.split_id, reviewId: "", decision, reason: null },
460 }));
461 return;
462 }
463}
464
465async function submitControl(caller: any, key: string, value: string): Promise<void> {
466 if (key === "xmuse-pairing") {
467 await submitPairing(caller, value);
468 return;
469 }
470 if (key === "xmuse-review-reason") {
471 const reason = typeof value === "string" ? value.trim().slice(0, 1000) : "";
472 if (reason === "") {
473 caller.ui.toast("反对需要写明理由");
474 return;
475 }
476 await update(caller, cacheAtom, (c) => {
477 const cur = c as XmuseCache;
478 if (cur.confirming === null || cur.confirming.kind !== "review") return cur;
479 return { ...cur, confirming: { ...cur.confirming, reason } };
480 });
481 return;
482 }
483 if (key === "xmuse-confirm") {
484 await submitConfirm(caller, value);
485 }
486}
487
488// `/xmuse new` (§4.1). Runs only from the person's own Enter; the result
489// text carries ids and counts only.
490async function commandNew(caller: any, rest: string): Promise<string> {
491 const parsed = parseNewArgs(rest);
492 if (!parsed.ok) return parsed.hint;
493 const held = await liveToken(caller, "room.create", null);
494 if (held === null) return await refusalText(caller, "room.create", null);
495 const res = await createRoom(pluginHttp(caller), base(), held, {
496 clientRequestId: await requestId(caller, "new"),
497 title: parsed.value.title,
498 lead: parsed.value.lead,
499 owners: parsed.value.owners,
500 reviewer: parsed.value.reviewer,
501 review: parsed.value.review,
502 });
503 const created = res.status === 201 ? parseRoomCreatePayload(res.json) : null;
504 if (created === null) {
505 if (res.status === 401) await dropGrant(caller);
506 return "xmuse 创建失败: " + writeFailureText(res.status, detailCodeOf(res.json));
507 }
508 await update(caller, cacheAtom, (c) => {
509 const cur = c as XmuseCache;
510 if (cur.grant === null || cur.grant.conversationIds.includes(created.conversationId)) return cur;
511 return { ...cur, grant: { ...cur.grant, conversationIds: [...cur.grant.conversationIds, created.conversationId] } };
512 });
513 await bindRoom(caller, created.conversationId);
514 return (
515 "xmuse 已创建房间 " +
516 shortRoom(created.conversationId) +
517 " · lead + " +
518 String(created.owners) +
519 " owner · " +
520 (parsed.value.review ? "跨家族复核(没有其他家族时由你复核)" : "复核已关闭") +
521 " · 已绑定。下一步: /xmuse say <任务>(lead 会提出拆分,你在窗格里审批)"
522 );
523}
524
525// `/xmuse say` (§4.2) to the bound Room. @lead / @owner-N in the text
526// address participants; the server resolves them.
527async function commandSay(caller: any, rest: string): Promise<string> {
528 const parsed = parseSayArgs(rest);
529 if (!parsed.ok) return parsed.hint;
530 const live = (await read(caller, cacheAtom)) as XmuseCache;
531 if (live.binding === null) return "xmuse 未绑定房间:先 /xmuse new 或 /xmuse attach";
532 const held = await liveToken(caller, "room.message", live.binding);
533 if (held === null) return await refusalText(caller, "room.message", live.binding);
534 const res = await postMessage(pluginHttp(caller), base(), held, live.binding, await requestId(caller, "say"), parsed.message);
535 if (res.status !== 201) {
536 if (res.status === 401) await dropGrant(caller);
537 return "xmuse 发送失败: " + writeFailureText(res.status, detailCodeOf(res.json));
538 }
539 return "xmuse 已发送到 " + shortRoom(live.binding);
540}
541
542async function tick(caller: any): Promise<void> {
543 const cache = (await read(caller, cacheAtom)) as XmuseCache;
544 const now = await caller.clock.now();
545 if (now < cache.nextRetryAt) return;
546
547 // Grant expiry rides the existing poll tick: at expiresAt the token
548 // is dropped and the pane returns to the unauthorized view.
549 if (cache.grant !== null && grantExpired(cache.grant.expiresAt, now)) {
550 grantToken = null;
551 await update(caller, cacheAtom, (c) => ({ ...(c as XmuseCache), grant: null, confirming: null }));
552 }
553
554 if (cache.binding === null) {
555 const dir = cache.cwd ?? "";
556 if (dir === "") {
557 caller.ui.status("xmuse 未绑定房间");
558 return;
559 }
560 const bound = await autoBind(caller);
561 if (!bound) {
562 const probe = await httpGet(caller, "/api/chat/rooms", null);
563 if (probe.kind === "failed") {
564 await markFailed(caller, cache, now);
565 return;
566 }
567 // Reachable but no room has a board yet: probe slowly instead of every tick.
568 await update(caller, cacheAtom, (c) => ({
569 ...(c as XmuseCache),
570 offline: false,
571 failCount: 0,
572 nextRetryAt: now + 30000,
573 }));
574 caller.ui.status("xmuse 未绑定房间");
575 return;
576 }
577 }
578
579 const live = (await read(caller, cacheAtom)) as XmuseCache;
580 const boundId = live.binding;
581 if (boundId === null) {
582 caller.ui.status("xmuse 未绑定房间");
583 return;
584 }
585
586 const res = await httpGet(
587 caller,
588 "/api/chat/conversations/" + encodeURIComponent(boundId) + "/board/summary",
589 live.summaryEtag,
590 );
591 if (res.kind === "not-modified") return;
592 if (res.kind === "failed") {
593 await markFailed(caller, live, now);
594 return;
595 }
596 const summary = normalizeSummary(res.json);
597 if (summary === null) {
598 await markFailed(caller, live, now);
599 return;
600 }
601
602 const prevRevision = live.summary !== null ? live.summary.revision : null;
603 const revisionChanged = prevRevision === null || summary.revision !== prevRevision;
604
605 let stateNotes: string[] = [];
606 let boardEtag: string | null = live.boardEtag;
607 let nextBoard = live.board;
608 if (revisionChanged && live.paneOpen) {
609 const bRes = await httpGet(
610 caller,
611 "/api/chat/conversations/" + encodeURIComponent(boundId) + "/board",
612 live.boardEtag,
613 );
614 if (bRes.kind === "ok") {
615 const board = normalizeBoard(bRes.json);
616 if (board !== null) {
617 if (live.board !== null) {
618 const nextStates: { [id: string]: string } = {};
619 for (const m of board.modules) nextStates[m.module_id] = m.state;
620 stateNotes = planStateToasts(live.seenStates, nextStates);
621 }
622 nextBoard = board;
623 boardEtag = bRes.etag;
624 }
625 }
626 }
627
628 const fresh = (await read(caller, cacheAtom)) as XmuseCache;
629 const finalToast = planToast(fresh, summary, stateNotes);
630 const keys = toastableKeys(summary);
631 await update(caller, cacheAtom, (c) => {
632 const cur = c as XmuseCache;
633 const nextStates: { [id: string]: string } = { ...cur.seenStates };
634 if (nextBoard !== null && nextBoard !== cur.board) {
635 for (const m of nextBoard.modules) nextStates[m.module_id] = m.state;
636 }
637 return {
638 ...cur,
639 summary,
640 board: nextBoard,
641 summaryEtag: res.kind === "ok" ? (res.etag ?? cur.summaryEtag) : cur.summaryEtag,
642 boardEtag: boardEtag ?? cur.boardEtag,
643 lastPollAt: now,
644 offline: false,
645 baselined: true,
646 seenAttention: keys,
647 seenStates: nextStates,
648 failCount: 0,
649 nextRetryAt: 0,
650 };
651 });
652 if (finalToast !== null) caller.ui.toast(finalToast.text);
653 const after = (await read(caller, cacheAtom)) as XmuseCache;
654 caller.ui.status(statusText(after));
655}
656
657export const register: Register = (on, options) => {
658 readOptions(options);
659
660 on("session.start", async ($, e, next) => {
661 if (rt.timer !== null) {
662 try {
663 rt.timer.cancel();
664 } catch {
665 // replaced below
666 }
667 rt.timer = null;
668 }
669 rt.inFlight = false;
670 grantToken = null;
671 const cwd = typeof e.cwd === "string" ? e.cwd : "";
672 let binding: string | null = null;
673 try {
674 const stored = await $.store.get(bindingKey(cwd));
675 if (typeof stored === "string" && stored !== "") binding = stored;
676 } catch {
677 binding = null;
678 }
679 await update($, cacheAtom, () => ({
680 binding,
681 cwd,
682 summary: null,
683 board: null,
684 summaryEtag: null,
685 boardEtag: null,
686 lastPollAt: null,
687 offline: false,
688 baselined: false,
689 seenAttention: [],
690 seenStates: {},
691 failCount: 0,
692 nextRetryAt: 0,
693 paneOpen: false,
694 expanded: {},
695 grant: null,
696 confirming: null,
697 material: null,
698 formEpoch: 0,
699 }));
700 await $.command.register({
701 name: "xmuse",
702 description: "xmuse: coordinate agents from this window (new, say, attach, detach, status, pane)",
703 argumentHint: "[new <title>|say <message>|attach|detach|status|pane]",
704 });
705 await $.tool.register({
706 name: "status",
707 description: "Read-only xmuse board status: module counts, state codes and operator attention. No inputs.",
708 inputSchema: { type: "object", properties: {} },
709 });
710 $.ui.status(binding === null ? "xmuse 未绑定房间" : "xmuse …");
711
712 rt.timer = $.clock.every(rt.pollMs, () => {
713 if (rt.inFlight) return;
714 rt.inFlight = true;
715 void (async () => {
716 try {
717 await tick($);
718 } catch {
719 // degraded: never throw out of the poll loop
720 } finally {
721 rt.inFlight = false;
722 }
723 })();
724 });
725 return next(e);
726 });
727
728 on("command.run", { command: "xmuse" }, async ($, e) => {
729 const args = typeof e.args === "string" ? e.args.trim() : "";
730 const first = args.split(/\s+/)[0] ?? "";
731 const cache = (await read($, cacheAtom)) as XmuseCache;
732 const sessionCwd = cache.cwd ?? "";
733 if (first === "new" || first === "say") {
734 // main_window_control_v1 §5: writes only from the person's own Enter.
735 if (!isHumanOrigin((e as { origin?: unknown }).origin)) return { text: ORIGIN_REFUSED };
736 const rest = args.slice(first.length);
737 return { text: first === "new" ? await commandNew($, rest) : await commandSay($, rest) };
738 }
739 if (first === "status") return { text: statusBlock(cache) };
740 if (first === "attach") {
741 const roomsRes = await httpGet($, "/api/chat/rooms", null);
742 if (roomsRes.kind !== "ok") return { text: "xmuse 绑定失败: 后端不可达" };
743 const rooms = normalizeRooms(roomsRes.json);
744 if (rooms === null) return { text: "xmuse 绑定失败: 后端不可达" };
745 const pick = pickAttachTarget(rooms, args);
746 if (!pick.ok) return { text: pick.error };
747 try {
748 await $.store.set(bindingKey(sessionCwd), pick.conversation_id);
749 } catch {
750 // binding still applies for this session
751 }
752 await update($, cacheAtom, (c) => {
753 const cur = c as XmuseCache;
754 return {
755 ...cur,
756 binding: pick.conversation_id,
757 summary: null,
758 board: null,
759 summaryEtag: null,
760 boardEtag: null,
761 offline: false,
762 baselined: false,
763 seenAttention: [],
764 seenStates: {},
765 failCount: 0,
766 nextRetryAt: 0,
767 confirming: null,
768 };
769 });
770 return { text: "xmuse 已绑定 " + shortRoom(pick.conversation_id) };
771 }
772 if (first === "detach") {
773 try {
774 await revokeBestEffort($);
775 } catch {
776 // best effort: the local clear below runs regardless
777 }
778 try {
779 await $.store.delete(bindingKey(sessionCwd));
780 } catch {
781 // state still clears below
782 }
783 await update($, cacheAtom, (c) => {
784 const cur = c as XmuseCache;
785 return {
786 ...cur,
787 binding: null,
788 summary: null,
789 board: null,
790 summaryEtag: null,
791 boardEtag: null,
792 offline: false,
793 baselined: false,
794 seenAttention: [],
795 seenStates: {},
796 failCount: 0,
797 nextRetryAt: 0,
798 grant: null,
799 confirming: null,
800 material: null,
801 };
802 });
803 $.ui.status("xmuse 未绑定房间");
804 return { text: "xmuse 已解绑" };
805 }
806 if (first !== "" && first !== "pane") {
807 return { text: "xmuse 用法: /xmuse [new <标题>|say <消息>|attach|detach|status|pane]" };
808 }
809 await update($, cacheAtom, (c) => ({ ...(c as XmuseCache), paneOpen: true }));
810 try {
811 await $.ui.open({ id: PANE_ID, title: "xmuse 看板" });
812 } catch {
813 // paneOpen is still recorded; the next tick fetches the board
814 }
815 return { text: "xmuse 看板已打开" };
816 });
817
818 on("tool.call", { tool: "mcp__xmuse__status" }, async ($) => {
819 const cache = (await read($, cacheAtom)) as XmuseCache;
820 return { result: statusBlock(cache) };
821 });
822
823 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e) => {
824 const els = $.ui.resolve(e);
825 const at = await $.clock.now();
826 const onExpand = (moduleId: string): void => {
827 void update($, cacheAtom, (c) => {
828 const cur = c as XmuseCache;
829 const expanded = { ...cur.expanded };
830 if (expanded[moduleId] === true) delete expanded[moduleId];
831 else expanded[moduleId] = true;
832 return { ...cur, expanded };
833 });
834 };
835 const onControl = (key: string): void => {
836 void pressControl($, key);
837 };
838 const onSubmitKey = (key: string, value: string): void => {
839 void submitControl($, key, value);
840 };
841 const onSubmit = onSubmitKey;
842 void onSubmit;
843 try {
844 const cache = (await read($, cacheAtom)) as XmuseCache;
845 const nodes = buildPaneNodes(cache, at);
846 return PaneTree({ els: els as never, nodes, onExpand, onControl, onSubmit: onSubmitKey }) as never;
847 } catch {
848 return PaneTree({
849 els: els as never,
850 nodes: [{ type: "text", text: "xmuse 看板暂不可用" }],
851 onExpand,
852 onControl,
853 onSubmit: onSubmitKey,
854 }) as never;
855 }
856 });
857};
858src/api.ts 440 lines1// Read-only board API client. Only GETs to {baseUrl}/api/chat/...: the
2// rooms list and the board summary / board projections. Responses are
3// normalized defensively: unknown fields ignored, unknown enum values kept
4// as opaque strings, missing capabilities tolerated, oversized data capped.
5
6import type {
7 XmuseAttentionItem,
8 XmuseBoard,
9 XmuseModule,
10 XmuseReviewInfo,
11 XmuseSplitSummary,
12 XmuseSummary,
13} from "../types/index";
14import { safe, safeId } from "./text";
15
16export type GetResult =
17 | { kind: "ok"; json: unknown; etag: string | null }
18 | { kind: "not-modified" }
19 | { kind: "failed" };
20
21export function parseGetResponse(res: {
22 status: number;
23 headers: Record<string, string>;
24 text: string;
25}): GetResult {
26 if (res.status === 304) return { kind: "not-modified" };
27 if (res.status < 200 || res.status > 299) return { kind: "failed" };
28 let json: unknown = null;
29 try {
30 json = JSON.parse(res.text);
31 } catch {
32 return { kind: "failed" };
33 }
34 let outEtag: string | null = null;
35 try {
36 const raw = res.headers["etag"];
37 if (typeof raw === "string" && raw !== "") outEtag = raw.replace(/^"|"$/g, "");
38 } catch {
39 outEtag = null;
40 }
41 return { kind: "ok", json, etag: outEtag };
42}
43
44function asRecord(v: unknown): Record<string, unknown> | null {
45 if (typeof v === "object" && v !== null && !Array.isArray(v)) return v as Record<string, unknown>;
46 return null;
47}
48
49function asArray(v: unknown): unknown[] {
50 return Array.isArray(v) ? v : [];
51}
52
53function asString(v: unknown): string | null {
54 return typeof v === "string" ? v : null;
55}
56
57function num(v: unknown): number {
58 return typeof v === "number" && Number.isFinite(v) ? v : 0;
59}
60
61export type RoomEntry = { conversation_id: string; updated_at: string };
62
63export function normalizeRooms(json: unknown): RoomEntry[] | null {
64 const root = asRecord(json);
65 if (root === null) return null;
66 const rooms = asArray(root["rooms"]).slice(0, 200);
67 const out: RoomEntry[] = [];
68 for (const item of rooms) {
69 const r = asRecord(item);
70 if (r === null) continue;
71 const id = asString(r["conversation_id"]);
72 if (id === null || id === "") continue;
73 const updated = asString(r["updated_at"]) ?? "";
74 out.push({ conversation_id: id, updated_at: updated });
75 }
76 return out;
77}
78
79export function sortRoomsByUpdated(rooms: RoomEntry[]): RoomEntry[] {
80 return rooms.slice().sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
81}
82
83export const COUNT_KEYS = [
84 "assigned",
85 "claimed",
86 "working",
87 "blocked",
88 "ready_for_review",
89 "done_claimed",
90 "verifying",
91 "waiting_for_provider",
92 "verified",
93 "verification_failed",
94 "verification_error",
95];
96
97export function normalizeAttention(v: unknown): XmuseAttentionItem[] {
98 const out: XmuseAttentionItem[] = [];
99 for (const item of asArray(v).slice(0, 50)) {
100 const r = asRecord(item);
101 if (r === null) continue;
102 const kind = asString(r["kind"]) ?? "?";
103 const reason = asString(r["reason_code"]) ?? "?";
104 const mid = asString(r["module_id"]);
105 const sid = asString(r["split_id"]);
106 const iid = asString(r["integration_id"]);
107 out.push({ kind, reason_code: reason, module_id: mid, split_id: sid, integration_id: iid });
108 }
109 return out;
110}
111
112function normalizeIntegrationState(v: unknown): { status: string | null; green_head_commit: string | null } {
113 const none = { status: null as string | null, green_head_commit: null as string | null };
114 const r = asRecord(v);
115 if (r === null) return none;
116 const status = asString(r["status"]);
117 const head = asString(r["green_head_commit"]);
118 return {
119 status: status !== null && status !== "" ? status : null,
120 green_head_commit: head !== null && head !== "" ? head : null,
121 };
122}
123
124function normalizeRoomIntegration(v: unknown): { status: string | null; green_head_commit: string | null } {
125 const none = { status: null as string | null, green_head_commit: null as string | null };
126 const r = asRecord(v);
127 if (r === null) return none;
128 const head = asString(r["green_head_commit"]);
129 const latest = asRecord(r["latest"]);
130 const status = latest !== null ? asString(latest["status"]) : null;
131 return {
132 status: status !== null && status !== "" ? status : null,
133 green_head_commit: head !== null && head !== "" ? head : null,
134 };
135}
136
137function normalizeModuleIntegration(v: unknown): {
138 status: string;
139 verification_id: string | null;
140 integrated_verification_id: string | null;
141 conflict_path_count: number;
142 reason_code: string | null;
143} {
144 const none = {
145 status: "none",
146 verification_id: null as string | null,
147 integrated_verification_id: null as string | null,
148 conflict_path_count: 0,
149 reason_code: null as string | null,
150 };
151 const r = asRecord(v);
152 if (r === null) return none;
153 const rawStatus = asString(r["status"]);
154 const status = rawStatus !== null && rawStatus !== "" ? rawStatus : "none";
155 const ver = asString(r["verification_id"]);
156 const old = asString(r["integrated_verification_id"]);
157 const reason = asString(r["reason_code"]);
158 const rawCount = r["conflict_path_count"];
159 const count =
160 typeof rawCount === "number" && Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0;
161 return {
162 status,
163 verification_id: ver,
164 integrated_verification_id: old,
165 conflict_path_count: count,
166 reason_code: reason,
167 };
168}
169
170export function normalizeSummary(json: unknown): XmuseSummary | null {
171 const root = asRecord(json);
172 if (root === null) return null;
173 const cid = asString(root["conversation_id"]);
174 const rev = asString(root["revision"]);
175 if (cid === null || cid === "" || rev === null || rev === "") return null;
176 const countsRaw = asRecord(root["counts"]) ?? {};
177 const counts: { [state: string]: number } = {};
178 for (const k of COUNT_KEYS) counts[k] = Math.max(0, Math.floor(num(countsRaw[k])));
179 const attention = normalizeAttention(root["attention"]);
180 const caps = asRecord(root["capabilities"]);
181 const capReviews = caps !== null ? caps["reviews"] : undefined;
182 const reviews = typeof capReviews === "number" && capReviews >= 1 ? 1 : 0;
183 // accepted_total is the one completion count (§4.4). While reviews are
184 // off the frozen definition makes it equal counts.verified, so force
185 // that: old servers omit the field and inconsistent values must never
186 // show a completion mark the contract would not give. With reviews on,
187 // a missing value counts nothing rather than overstating completion.
188 const verifiedCount = counts["verified"] ?? 0;
189 const accRaw = root["accepted_total"];
190 const accepted_total =
191 reviews === 0
192 ? verifiedCount
193 : typeof accRaw === "number" && Number.isFinite(accRaw)
194 ? Math.max(0, Math.floor(accRaw))
195 : 0;
196 const capIntegrations = caps !== null ? caps["integrations"] : undefined;
197 const integrations = typeof capIntegrations === "number" && capIntegrations >= 1 ? 1 : 0;
198 const intRaw = root["integrated_total"];
199 const integrated_total =
200 typeof intRaw === "number" && Number.isFinite(intRaw) ? Math.max(0, Math.floor(intRaw)) : 0;
201 return {
202 conversation_id: cid,
203 revision: rev,
204 board_seq: Math.max(0, Math.floor(num(root["board_seq"]))),
205 modules_total: Math.max(0, Math.floor(num(root["modules_total"]))),
206 counts,
207 attention,
208 attention_total: Math.max(attention.length, Math.floor(num(root["attention_total"]))),
209 accepted_total,
210 reviews,
211 integrations,
212 integrated_total,
213 integration: normalizeIntegrationState(root["integration"]),
214 };
215}
216
217function agentText(v: unknown): { text: string } | null {
218 const r = asRecord(v);
219 if (r === null) return null;
220 if (typeof r["text"] !== "string" || r["untrusted"] !== true) return null;
221 return { text: r["text"] };
222}
223
224// Collect agent-authored snippets from one event's data without interpreting
225// the event kind. Bounded; never throws.
226export function collectSnippets(data: unknown, cap = 6): { field: string; text: string }[] {
227 const out: { field: string; text: string }[] = [];
228 const r = asRecord(data);
229 if (r === null) return out;
230 const push = (field: string, v: unknown) => {
231 if (out.length >= cap) return;
232 const t = agentText(v);
233 if (t !== null) out.push({ field: safe(field, 32), text: t.text.slice(0, 400) });
234 };
235 push("summary", r["summary"]);
236 push("question", r["question"]);
237 push("rationale", r["rationale"]);
238 const claims = asArray(r["claims"]).slice(0, 8);
239 for (let i = 0; i < claims.length; i += 1) push("claim#" + String(i), claims[i]);
240 return out;
241}
242
243// Structured review state only. Missing review fields (old servers)
244// mean no review. An unknown status is kept verbatim so the pane can
245// show it as ?value instead of hiding it. Review summaries and finding
246// text are never read here: no AgentText from a review enters the mod.
247function normalizeReview(v: unknown): XmuseReviewInfo {
248 const none: XmuseReviewInfo = {
249 status: "none",
250 review_id: null,
251 digest: null,
252 reviewer_kind: null,
253 escalated_from_present: false,
254 findings_count: { blocker: 0, major: 0, minor: 0 },
255 };
256 const r = asRecord(v);
257 if (r === null) return none;
258 const rawStatus = asString(r["status"]);
259 const status = rawStatus !== null && rawStatus !== "" ? rawStatus : "none";
260 const reviewer_kind = asString(r["reviewer_kind"]);
261 const esc = r["escalated_from"];
262 const fc = asRecord(r["findings_count"]);
263 const reviewId = asString(r["review_id"]);
264 const digest = asString(r["digest"]);
265 return {
266 status,
267 review_id: reviewId !== null && reviewId !== "" ? reviewId : null,
268 digest: digest !== null && /^sha256:[0-9a-f]{64}$/.test(digest) ? digest : null,
269 reviewer_kind,
270 escalated_from_present: esc !== null && esc !== undefined,
271 findings_count: {
272 blocker: fc !== null ? Math.max(0, Math.floor(num(fc["blocker"]))) : 0,
273 major: fc !== null ? Math.max(0, Math.floor(num(fc["major"]))) : 0,
274 minor: fc !== null ? Math.max(0, Math.floor(num(fc["minor"]))) : 0,
275 },
276 };
277}
278
279export function normalizeBoard(json: unknown): XmuseBoard | null {
280 const root = asRecord(json);
281 if (root === null) return null;
282 const rev = asString(root["revision"]);
283 const cid = asString(root["conversation_id"]);
284 if (rev === null || rev === "" || cid === null || cid === "") return null;
285
286 const parts: { [id: string]: { display: string; kind: string } } = {};
287 for (const item of asArray(root["participants"]).slice(0, 200)) {
288 const r = asRecord(item);
289 if (r === null) continue;
290 const pid = asString(r["participant_id"]);
291 if (pid === null || pid === "") continue;
292 parts[pid] = {
293 display: safe(asString(r["display_name"]) ?? "?", 32),
294 kind: safe(asString(r["provider_kind"]) ?? "?", 24),
295 };
296 }
297
298 const staleByModule: { [id: string]: string[] } = {};
299 for (const item of asArray(root["stale_dependents"]).slice(0, 200)) {
300 const r = asRecord(item);
301 if (r === null) continue;
302 const mid = asString(r["module_id"]);
303 const contract = asString(r["contract_id"]);
304 if (mid === null || contract === null) continue;
305 const list = staleByModule[mid] ?? [];
306 if (list.length < 8) list.push(contract);
307 staleByModule[mid] = list;
308 }
309
310 const modules: XmuseModule[] = [];
311 for (const item of asArray(root["modules"]).slice(0, 200)) {
312 const r = asRecord(item);
313 if (r === null) continue;
314 const mid = asString(r["module_id"]);
315 if (mid === null || mid === "") continue;
316 const owner = asString(r["owner_participant_id"]);
317 const ownerInfo = owner !== null ? parts[owner] : undefined;
318 const counters = asRecord(r["counters"]) ?? {};
319 const verification = asRecord(r["verification"]) ?? {};
320 const attention = asRecord(r["attention"]);
321 const gateIds: string[] = [];
322 for (const g of asArray(verification["gate_ids"]).slice(0, 12)) {
323 if (typeof g === "string" && g !== "") gateIds.push(safeId(g));
324 }
325 const strList = (v: unknown, cap: number): string[] => {
326 const out: string[] = [];
327 for (const x of asArray(v).slice(0, cap)) if (typeof x === "string" && x !== "") out.push(safe(x, 96));
328 return out;
329 };
330 modules.push({
331 module_id: mid,
332 state: typeof r["state"] === "string" ? r["state"] : "?",
333 lifecycle: typeof r["lifecycle"] === "string" ? r["lifecycle"] : "?",
334 owner_display: ownerInfo !== undefined ? ownerInfo.display : "?",
335 provider_kind: ownerInfo !== undefined ? ownerInfo.kind : "?",
336 done_reports: Math.max(0, Math.floor(num(counters["done_reports"]))),
337 passed: Math.max(0, Math.floor(num(counters["passed"]))),
338 failed: Math.max(0, Math.floor(num(counters["failed"]))),
339 rework_rounds: Math.max(0, Math.floor(num(counters["rework_rounds"]))),
340 gate_ids: gateIds,
341 stale_contracts: staleByModule[mid] !== undefined ? staleByModule[mid].map((c) => safeId(c)) : [],
342 attention_kind: attention !== null ? (asString(attention["kind"]) ?? "none") : "none",
343 attention_reason: attention !== null ? asString(attention["reason_code"]) : null,
344 charter_version: Math.max(0, Math.floor(num(r["charter_version"]))),
345 paths: strList(r["paths"], 16),
346 provides: strList(r["provides"], 16).map((c) => safeId(c)),
347 depends: strList(r["depends"], 16).map((c) => safeId(c)),
348 accepted: r["accepted"] === true,
349 review: normalizeReview(r["review"]),
350 integration: normalizeModuleIntegration(r["integration"]),
351 });
352 }
353 modules.sort((a, b) => (a.module_id < b.module_id ? -1 : a.module_id > b.module_id ? 1 : 0));
354
355 const caps = asRecord(root["capabilities"]);
356 const capReviews = caps !== null ? caps["reviews"] : undefined;
357 const reviews = typeof capReviews === "number" && capReviews >= 1 ? 1 : 0;
358 const capIntegrations = caps !== null ? caps["integrations"] : undefined;
359 const integrations = typeof capIntegrations === "number" && capIntegrations >= 1 ? 1 : 0;
360 // Same frozen definition as the summary: while reviews are off every
361 // verified module counts as accepted.
362 const accepted_total =
363 reviews === 0
364 ? modules.filter((m) => m.state === "verified").length
365 : modules.filter((m) => m.accepted).length;
366 // §3.11: accepted modules whose integrated version is their current candidate.
367 const integrated_total = modules.filter(
368 (m) =>
369 m.accepted &&
370 m.integration.integrated_verification_id !== null &&
371 m.integration.integrated_verification_id === m.integration.verification_id,
372 ).length;
373 const integration = normalizeRoomIntegration(root["integration"]);
374
375 const events = asArray(root["events"]).slice(-50);
376 const byModule: { [id: string]: { field: string; text: string }[] } = {};
377 for (const item of events) {
378 const r = asRecord(item);
379 if (r === null) continue;
380 const mid = asString(r["module_id"]);
381 if (mid === null) continue;
382 const kind = asString(r["kind"]) ?? "?";
383 // Review verdicts stay out of the expanded detail: no review summary
384 // or finding text is ever collected, only progress/contract/question
385 // snippets from non-review events.
386 if (kind === "review" || kind === "review_requested") continue;
387 const seq = Math.floor(num(r["seq"]));
388 const prefix = safe(kind, 40) + "#" + String(seq);
389 const list = byModule[mid] ?? [];
390 for (const s of collectSnippets(r["data"], 6)) {
391 if (list.length >= 10) break;
392 list.push({ field: prefix + "/" + s.field, text: s.text });
393 }
394 byModule[mid] = list;
395 }
396 const details = modules.slice(0, 200).map((m) => ({
397 module_id: m.module_id,
398 snippets: (byModule[m.module_id] ?? []).slice(0, 10),
399 }));
400
401 const contracts: { contract_id: string; latest_version: number }[] = [];
402 for (const item of asArray(root["contracts"]).slice(0, 200)) {
403 const r = asRecord(item);
404 if (r === null) continue;
405 const id = asString(r["contract_id"]);
406 if (id === null || id === "") continue;
407 contracts.push({ contract_id: id, latest_version: Math.max(0, Math.floor(num(r["latest_version"]))) });
408 }
409 contracts.sort((a, b) => (a.contract_id < b.contract_id ? -1 : 1));
410
411 const proposed: string[] = [];
412 const splits: XmuseSplitSummary[] = [];
413 for (const item of asArray(root["splits"]).slice(0, 50)) {
414 const r = asRecord(item);
415 if (r === null) continue;
416 const sid = asString(r["split_id"]);
417 if (sid === null || sid === "") continue;
418 const status = asString(r["status"]) ?? "?";
419 const digest = asString(r["digest"]) ?? "";
420 splits.push({ split_id: sid, status, digest: safe(digest, 128) });
421 if (r["status"] !== "proposed") continue;
422 proposed.push(sid);
423 }
424
425 return {
426 revision: rev,
427 modules,
428 details,
429 contracts,
430 proposed_splits: proposed.slice(0, 10),
431 splits: splits.slice(0, 10),
432 operator_attention: normalizeAttention(root["attention"]).filter((a) => a.kind === "operator"),
433 reviews,
434 accepted_total,
435 integrations,
436 integrated_total,
437 integration,
438 };
439}
440src/board_state.ts 164 lines1// Poll state: one atom the pane subscribes to, plus the status line,
2// toast-diff and command/tool text builders. All outputs are structured
3// fields only (counts, state codes, module ids, reason codes, short ids).
4
5import type { XmuseAttentionItem, XmuseCache, XmuseSummary } from "../types/index";
6import { COUNT_KEYS } from "./api";
7import { STATUS_GROUPS, integrationJobWord, reviewAttentionLabel, shortGreenHead } from "./labels";
8import { safe, safeId, shortRev, shortRoom } from "./text";
9
10export function attentionKey(a: XmuseAttentionItem): string {
11 return safe(a.reason_code, 64) + "|" + safe(a.module_id ?? "", 64) + "|" + safe(a.split_id ?? "", 64);
12}
13
14// Room-level integration items carry module_id null and an
15// integration_id: they are shown with the label only, never the job id.
16export function attentionTarget(a: XmuseAttentionItem): string {
17 if (a.module_id !== null && a.module_id !== "") return safeId(a.module_id);
18 if (a.split_id !== null && a.split_id !== "") return safe(a.split_id, 32);
19 return "";
20}
21
22// Attention the mod toasts about when it is gained: every operator item
23// plus an objected review (owner kind). Structured fields only.
24export function toastableAttention(summary: XmuseSummary): XmuseAttentionItem[] {
25 return summary.attention.filter(
26 (a) => a.kind === "operator" || (a.kind === "owner" && a.reason_code === "board_attention_review_objected"),
27 );
28}
29
30export function toastableKeys(summary: XmuseSummary): string[] {
31 return toastableAttention(summary).map(attentionKey);
32}
33
34function operatorCount(summary: XmuseSummary): number {
35 let n = 0;
36 for (const a of summary.attention) if (a.kind === "operator") n += 1;
37 return n;
38}
39
40// One status line. Examples:
41// "xmuse 离线" "xmuse 未绑定房间"
42// "看板 3 模块 · ✓1 …1 ✗1 · 待你处理 1"
43// "看板 3 模块 · ✓3 · 集成 2 · 集成冲突"
44// The ✓ part counts accepted modules (§4.4), never merely verified ones.
45// The 集成 part appears only while capabilities.integrations == 1 and
46// either integrated_total > 0 or a job word is present; old payloads and
47// quiet rooms keep the pre-integration line byte-identical.
48export function statusText(cache: XmuseCache): string {
49 if (cache.offline) return "xmuse 离线";
50 if (cache.binding === null) return "xmuse 未绑定房间";
51 if (cache.summary === null) return "xmuse " + shortRoom(cache.binding) + " …";
52 const s = cache.summary;
53 const parts: string[] = [];
54 for (const g of STATUS_GROUPS) {
55 const n = g.state === "verified" ? s.accepted_total : (s.counts[g.state] ?? 0);
56 if (n > 0) parts.push(g.glyph + String(n));
57 }
58 let line = "看板 " + String(s.modules_total) + " 模块";
59 if (parts.length > 0) line += " · " + parts.join(" ");
60 const op = operatorCount(s);
61 if (op > 0) line += " · 待你处理 " + String(op);
62 const job = integrationJobWord(s.integration.status);
63 if (s.integrations === 1 && (s.integrated_total > 0 || job !== "")) {
64 line += " · 集成 " + String(s.integrated_total);
65 if (job !== "") line += " · " + job;
66 }
67 return line;
68}
69
70// One integration line for the compact block: "集成 M · <job> · <head8>".
71// Empty parts are skipped; the whole line is skipped when empty and when
72// integrations are off. Counts and short commit only.
73export function integrationBlockLine(summary: XmuseSummary): string {
74 if (summary.integrations !== 1) return "";
75 const job = integrationJobWord(summary.integration.status);
76 const head = shortGreenHead(summary.integration.green_head_commit);
77 const segs: string[] = [];
78 if (summary.integrated_total > 0) segs.push("集成 " + String(summary.integrated_total));
79 if (job !== "") segs.push(job);
80 if (head !== "") segs.push(head);
81 return segs.join(" · ");
82}
83
84// Compact structured block (<= 8 lines) for /xmuse status and the tool.
85export function statusBlock(cache: XmuseCache): string {
86 if (cache.offline) return "xmuse 离线";
87 if (cache.binding === null) return "xmuse 未绑定房间";
88 if (cache.summary === null) return "xmuse " + shortRoom(cache.binding) + " …";
89 const s = cache.summary;
90 const lines: string[] = [];
91 lines.push("xmuse " + shortRoom(s.conversation_id) + " rev " + shortRev(s.revision));
92 const parts: string[] = [];
93 for (const g of STATUS_GROUPS) {
94 const n = g.state === "verified" ? s.accepted_total : (s.counts[g.state] ?? 0);
95 if (n > 0) parts.push(g.glyph + String(n));
96 }
97 lines.push("模块 " + String(s.modules_total) + (parts.length > 0 ? " " + parts.join(" ") : ""));
98 const integrationLine = integrationBlockLine(s);
99 if (integrationLine !== "") lines.push(integrationLine);
100 const op = s.attention.filter((a) => a.kind === "operator").slice(0, 5);
101 lines.push("待你处理 " + String(operatorCount(s)));
102 for (const a of op) {
103 const target = attentionTarget(a);
104 const label = reviewAttentionLabel(a.reason_code) ?? safe(a.reason_code, 64);
105 lines.push(target !== "" ? "! " + label + " " + target : "! " + label);
106 }
107 return lines.slice(0, 8).join("\n");
108}
109
110export type ToastPlan = { text: string } | null;
111
112// At most one toast per tick (coalesced). No toast on the first successful
113// poll (baseline). Fixed labels plus module/split ids only. Room-level
114// integration items (module and split both absent) toast with the label
115// only: the job id is noise and never printed. Per-module conflicted /
116// gate_failed moves are the owner's and the lead's business: they are not
117// toastable (only operator items and the objected review toast).
118export function planToast(prev: XmuseCache, next: XmuseSummary, stateNotes: string[]): ToastPlan {
119 if (!prev.baselined || prev.summary === null) return null;
120 const items: string[] = [];
121 const before = new Set(prev.seenAttention);
122 for (const a of toastableAttention(next)) {
123 if (!before.has(attentionKey(a))) {
124 const target = attentionTarget(a);
125 const label = reviewAttentionLabel(a.reason_code) ?? "待处理 " + safe(a.reason_code, 64);
126 items.push(target !== "" ? label + " " + target : label);
127 }
128 }
129 for (const note of stateNotes.slice(0, 3)) items.push(note);
130 if (stateNotes.length === 0) {
131 // The board is only fetched while the pane is open; the summary counts
132 // still show verification outcomes, so toast their growth without ids.
133 const was = prev.summary.counts;
134 const gained = (state: string): number => (next.counts[state] ?? 0) - (was[state] ?? 0);
135 if (gained("verified") > 0) items.push("✓ 新增已验证 " + String(gained("verified")));
136 const failed = gained("verification_failed") + gained("verification_error");
137 if (failed > 0) items.push("✗ 新增验证失败 " + String(failed));
138 }
139 return items.length > 0 ? { text: "xmuse: " + items.slice(0, 3).join("; ") } : null;
140}
141
142// Module-state transitions are diffed from the board when available;
143// the summary path below covers state moves visible in counts.
144export function planStateToasts(
145 prevStates: { [id: string]: string },
146 nextStates: { [id: string]: string },
147): string[] {
148 const out: string[] = [];
149 for (const id of Object.keys(nextStates)) {
150 const from = prevStates[id];
151 const to = nextStates[id];
152 if (from === to) continue;
153 if (to === "verified") out.push(safeId(id) + " 已验证");
154 else if (to === "verification_failed" || to === "verification_error") out.push(safeId(id) + " 验证失败");
155 }
156 return out;
157}
158
159export function emptyCounts(): { [state: string]: number } {
160 const c: { [state: string]: number } = {};
161 for (const k of COUNT_KEYS) c[k] = 0;
162 return c;
163}
164src/grant_api.ts 307 lines1// Plugin grant requests (plugin_grant/v2, main_window_control_v1 client rules).
2//
3// Pure module: no engine calls. The hooks module injects its transport as
4// `http`, so these shapes are unit-testable without a session. This is the
5// only file in the mod that may name the write method or the bearer header,
6// and the header name is spelled exactly once, in `pluginRequest` below.
7
8export type GrantHttpInit = {
9 method: string;
10 headers: Record<string, string>;
11 body: string;
12};
13
14export type GrantHttpResponse = {
15 status: number;
16 text: string;
17};
18
19export type GrantHttp = (url: string, init: GrantHttpInit) => Promise<GrantHttpResponse>;
20
21export type GrantPostResult = {
22 status: number;
23 json: unknown;
24};
25
26export const GRANT_EXCHANGE_PATH = "/api/chat/plugin/grants/exchange";
27export const GRANT_REVOKE_PATH = "/api/chat/plugin/grants/revoke";
28
29export function grantDecisionPath(splitId: string): string {
30 return "/api/chat/plugin/board-splits/" + encodeURIComponent(splitId) + "/decision";
31}
32
33const LOOPBACK_RE = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/;
34
35// The board API keeps its loopback-only rule for writes too: refuse any
36// base URL that is not a loopback name before anything is sent.
37export function assertLoopbackBaseUrl(baseUrl: string): string {
38 const base = baseUrl.replace(/\/+$/, "");
39 if (!LOOPBACK_RE.test(base)) throw new Error("room_host_invalid");
40 return base;
41}
42
43// The single sender for every grant request. Exchange passes a null bearer
44// (Content-Type only, never an Origin header); the other writes pass the
45// in-memory token. A GET (review material) sends no body and so no
46// Content-Type. Nothing here ever reads the operator token routes.
47async function pluginRequest(
48 http: GrantHttp,
49 baseUrl: string,
50 method: "POST" | "GET",
51 path: string,
52 body: unknown,
53 bearer: string | null,
54): Promise<GrantPostResult> {
55 const base = assertLoopbackBaseUrl(baseUrl);
56 const headers: Record<string, string> = method === "POST" ? { "Content-Type": "application/json" } : {};
57 if (bearer !== null) headers["Authorization"] = "Bearer " + bearer;
58 let res: GrantHttpResponse;
59 try {
60 res = await http(base + path, { method, headers, body: method === "POST" ? JSON.stringify(body) : "" });
61 } catch {
62 return { status: 0, json: null };
63 }
64 let json: unknown = null;
65 try {
66 json = res.text !== "" ? JSON.parse(res.text) : null;
67 } catch {
68 json = null;
69 }
70 return { status: res.status, json };
71}
72
73function pluginPost(
74 http: GrantHttp,
75 baseUrl: string,
76 path: string,
77 body: unknown,
78 bearer: string | null,
79): Promise<GrantPostResult> {
80 return pluginRequest(http, baseUrl, "POST", path, body, bearer);
81}
82
83export async function exchangeGrant(
84 http: GrantHttp,
85 baseUrl: string,
86 pairingCode: string,
87): Promise<GrantPostResult> {
88 return pluginPost(http, baseUrl, GRANT_EXCHANGE_PATH, { pairing_code: pairingCode, host: "claude-code" }, null);
89}
90
91export async function decideSplit(
92 http: GrantHttp,
93 baseUrl: string,
94 bearer: string,
95 splitId: string,
96 conversationId: string,
97 decision: "approve" | "reject",
98 expectedDigest: string,
99): Promise<GrantPostResult> {
100 return pluginPost(
101 http,
102 baseUrl,
103 grantDecisionPath(splitId),
104 { conversation_id: conversationId, decision, expected_digest: expectedDigest },
105 bearer,
106 );
107}
108
109export async function revokeGrant(
110 http: GrantHttp,
111 baseUrl: string,
112 bearer: string,
113): Promise<GrantPostResult> {
114 return pluginPost(http, baseUrl, GRANT_REVOKE_PATH, {}, bearer);
115}
116
117// main_window_control_v1 §4.1. Only provider kinds and the title travel;
118// the server picks models and the workspace.
119export type RoomCreateRequest = {
120 clientRequestId: string;
121 title: string;
122 lead: string;
123 owners: string[];
124 reviewer: string | null;
125 // Cross-family review unless the Human opted out (--no-review). With no other
126 // family in the Room the server assigns the review to the Human.
127 review: boolean;
128};
129
130export async function createRoom(
131 http: GrantHttp,
132 baseUrl: string,
133 bearer: string,
134 req: RoomCreateRequest,
135): Promise<GrantPostResult> {
136 return pluginPost(
137 http,
138 baseUrl,
139 "/api/chat/plugin/rooms",
140 {
141 client_request_id: req.clientRequestId,
142 title: req.title,
143 lead: { cli_kind: req.lead },
144 owners: req.owners.map((kind) => ({ cli_kind: kind })),
145 reviewer: req.reviewer === null ? null : { cli_kind: req.reviewer },
146 review_policy: req.review ? "cross_family" : "off",
147 },
148 bearer,
149 );
150}
151
152// §4.2. Mentions are written in the text (@lead, @owner-1); the server
153// resolves them by role.
154export async function postMessage(
155 http: GrantHttp,
156 baseUrl: string,
157 bearer: string,
158 conversationId: string,
159 clientRequestId: string,
160 message: string,
161): Promise<GrantPostResult> {
162 return pluginPost(
163 http,
164 baseUrl,
165 "/api/chat/plugin/rooms/" + encodeURIComponent(conversationId) + "/messages",
166 { client_request_id: clientRequestId, message },
167 bearer,
168 );
169}
170
171export type ReviewVerdict = "endorse" | "object";
172
173// §4.4. An objection carries the human's reason as one major finding.
174export async function decideReview(
175 http: GrantHttp,
176 baseUrl: string,
177 bearer: string,
178 reviewId: string,
179 conversationId: string,
180 verdict: ReviewVerdict,
181 expectedDigest: string,
182 reason: string | null,
183): Promise<GrantPostResult> {
184 const text = reason !== null && reason.trim() !== "" ? reason.trim() : "";
185 return pluginPost(
186 http,
187 baseUrl,
188 "/api/chat/plugin/board-reviews/" + encodeURIComponent(reviewId) + "/decision",
189 {
190 conversation_id: conversationId,
191 verdict,
192 expected_digest: expectedDigest,
193 summary: verdict === "endorse" ? "Endorsed by the Human from the main window." : text,
194 findings: verdict === "object" ? [{ severity: "major", text }] : [],
195 },
196 bearer,
197 );
198}
199
200// §4.5: a GET with the bearer and no body (so no Content-Type).
201export async function fetchReviewMaterial(
202 http: GrantHttp,
203 baseUrl: string,
204 bearer: string,
205 reviewId: string,
206 conversationId: string,
207): Promise<GrantPostResult> {
208 const path =
209 "/api/chat/plugin/board-reviews/" +
210 encodeURIComponent(reviewId) +
211 "/material?conversation_id=" +
212 encodeURIComponent(conversationId);
213 return pluginRequest(http, baseUrl, "GET", path, null, bearer);
214}
215
216export type MaterialMeta = { digest: string; text: string; truncated: boolean };
217
218export function parseMaterialPayload(json: unknown): MaterialMeta | null {
219 const root = asRecord(json);
220 if (root === null || root["schema_version"] !== "room_board_review_material/v1") return null;
221 const digest = root["digest"];
222 const patch = asRecord(root["patch"]);
223 if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest) || patch === null) return null;
224 const text = patch["text"];
225 return {
226 digest,
227 text: typeof text === "string" ? text : "",
228 truncated: patch["truncated"] === true,
229 };
230}
231
232export type RoomCreated = { conversationId: string; roomCount: number; owners: number };
233
234export function parseRoomCreatePayload(json: unknown): RoomCreated | null {
235 const root = asRecord(json);
236 if (root === null || root["schema_version"] !== "plugin_room_create/v1") return null;
237 const conversationId = root["conversation_id"];
238 const roomCount = root["room_count"];
239 const participants = root["participants"];
240 if (typeof conversationId !== "string" || conversationId === "" || typeof roomCount !== "number") return null;
241 const owners = Array.isArray(participants)
242 ? participants.filter((p) => {
243 const r = asRecord(p);
244 return r !== null && typeof r["role"] === "string" && String(r["role"]).startsWith("owner-");
245 }).length
246 : 0;
247 return { conversationId, roomCount, owners };
248}
249
250function asRecord(v: unknown): Record<string, unknown> | null {
251 if (typeof v === "object" && v !== null && !Array.isArray(v)) return v as Record<string, unknown>;
252 return null;
253}
254
255const TOKEN_RE = /^xpg_[A-Za-z0-9_-]+_[A-Za-z0-9_-]{43}$/;
256
257export type GrantMeta = {
258 grantId: string;
259 expiresAt: string;
260 conversationIds: string[];
261 scopes: string[];
262};
263
264function stringList(v: unknown): string[] | null {
265 if (!Array.isArray(v)) return null;
266 const out: string[] = [];
267 for (const item of v) {
268 if (typeof item !== "string" || item === "") return null;
269 out.push(item);
270 }
271 return out;
272}
273
274// Narrow a 200 exchange body (plugin_grant/v2) to the non-secret metadata
275// the pane may keep plus the token the hooks module holds in memory.
276// Anything else is null: the caller then treats the exchange as failed
277// without touching the grant.
278export function parseExchangePayload(json: unknown): { grant: GrantMeta; token: string } | null {
279 const root = asRecord(json);
280 if (root === null || root["schema_version"] !== "plugin_grant_exchange/v2") return null;
281 const grant = asRecord(root["grant"]);
282 if (grant === null || grant["status"] !== "active") return null;
283 const grantId = grant["grant_id"];
284 const expiresAt = grant["expires_at"];
285 const conversationIds = stringList(grant["conversation_ids"]);
286 const scopes = stringList(grant["scopes"]);
287 const token = root["secret"];
288 if (
289 typeof grantId !== "string" || grantId === "" ||
290 typeof expiresAt !== "string" || expiresAt === "" ||
291 conversationIds === null || scopes === null || scopes.length === 0 ||
292 typeof token !== "string" || !TOKEN_RE.test(token)
293 ) {
294 return null;
295 }
296 return { grant: { grantId, expiresAt, conversationIds, scopes }, token };
297}
298
299// The structured reason code of an error body, or null when absent. Toasts
300// map it to fixed labels only; the code string itself is never shown.
301export function detailCodeOf(json: unknown): string | null {
302 const root = asRecord(json);
303 const detail = root !== null ? asRecord(root["detail"]) : null;
304 const code = detail !== null ? detail["code"] : null;
305 return typeof code === "string" ? code : null;
306}
307src/grant_state.ts 241 lines1// Pure state machine for the plugin grant flow: pairing code validation,
2// expiry, confirm-digest check, and result-to-toast mapping. No engine
3// calls, no transport: every output is a fixed structured label plus at most
4// a status number. Agent-authored strings never reach these toasts.
5
6import { safe } from "./text";
7
8const PAIRING_RE = /^[ABCDEFGHJKMNPQRSTVWXYZ23456789]{4}-[ABCDEFGHJKMNPQRSTVWXYZ23456789]{4}$/;
9const DIGEST_RE = /^sha256:([0-9a-fA-F]{64})$/;
10
11// Trim and upper-case before matching. Lower-case input from the field is
12// accepted; anything outside the grant alphabet is rejected locally.
13export function normalizePairingCode(raw: unknown): string {
14 return typeof raw === "string" ? raw.trim().toUpperCase() : "";
15}
16
17export function pairingHint(): string {
18 return "配对码格式不对,应为 XXXX-XXXX(字母数字,不含易混字符)";
19}
20
21export function validatePairingCode(raw: unknown): { ok: true; code: string } | { ok: false; hint: string } {
22 const code = normalizePairingCode(raw);
23 if (PAIRING_RE.test(code)) return { ok: true, code };
24 return { ok: false, hint: pairingHint() };
25}
26
27// Fail closed: an unparseable timestamp counts as expired.
28export function grantExpired(expiresAt: string, nowMs: number): boolean {
29 const t = Date.parse(expiresAt);
30 if (!Number.isFinite(t)) return true;
31 return nowMs >= t;
32}
33
34export function grantMinutesLeft(expiresAt: string, nowMs: number): number {
35 const t = Date.parse(expiresAt);
36 if (!Number.isFinite(t)) return 0;
37 return Math.max(0, Math.ceil((t - nowMs) / 60000));
38}
39
40export function remainingMmSs(expiresAt: string, nowMs: number): string {
41 const t = Date.parse(expiresAt);
42 const left = Math.max(0, Math.floor(((Number.isFinite(t) ? t : 0) - nowMs) / 1000));
43 const mm = String(Math.floor(left / 60)).padStart(2, "0");
44 const ss = String(left % 60).padStart(2, "0");
45 return mm + ":" + ss;
46}
47
48export function exchangeToast(expiresAt: string, nowMs: number): string {
49 return "已授权," + String(Math.max(1, grantMinutesLeft(expiresAt, nowMs))) + " 分钟内有效";
50}
51
52// The first 6 hex characters after `sha256:`, lower-cased, or null when the
53// stored digest is malformed (then nothing can confirm it).
54export function confirmPrefixOf(digest: string): string | null {
55 const m = DIGEST_RE.exec(digest);
56 if (m === null || m[1] === undefined) return null;
57 return m[1].slice(0, 6).toLowerCase();
58}
59
60export function confirmHint(): string {
61 return "摘要不匹配,请重新输入前 6 位(终端运行 xmuse-workroom pair --pending 查看)";
62}
63
64export function checkConfirmInput(digest: string, raw: unknown): { ok: true } | { ok: false; hint: string } {
65 const prefix = confirmPrefixOf(digest);
66 const given = typeof raw === "string" ? raw.trim().toLowerCase() : "";
67 if (prefix !== null && given !== "" && given === prefix) return { ok: true };
68 return { ok: false, hint: confirmHint() };
69}
70
71export type SplitDecision = "approve" | "reject";
72
73export type DecisionOutcome = {
74 toast: string;
75 clearGrant: boolean;
76 refetch: boolean;
77};
78
79// Maps a decide response to a fixed toast. Only the status number may
80// appear in the generic arm; reason codes stay untranslated.
81export function mapDecisionOutcome(
82 status: number,
83 detailCode: string | null,
84 decision: SplitDecision,
85): DecisionOutcome {
86 if (status === 200) {
87 return { toast: decision === "approve" ? "已批准拆分" : "已拒绝拆分", clearGrant: false, refetch: true };
88 }
89 if (
90 status === 409 &&
91 (detailCode === "room_board_split_decided" || detailCode === "room_board_split_not_proposed")
92 ) {
93 return { toast: "拆分已不能决定,已刷新", clearGrant: false, refetch: true };
94 }
95 if (status === 409 && detailCode === "room_board_split_digest_mismatch") {
96 return { toast: "拆分已变化,请重新确认", clearGrant: false, refetch: true };
97 }
98 if (status === 401) {
99 return { toast: REPAIR_TOAST, clearGrant: true, refetch: false };
100 }
101 return { toast: failureToast(status), clearGrant: false, refetch: false };
102}
103
104export const REPAIR_TOAST =
105 "授权已失效(过期、被撤销,或 Workroom 重启过),请在终端运行 xmuse-workroom pair 重新配对;继续已有房间时加 --room <房间 id 前缀>";
106
107export const NO_GRANT_TEXT = "还没有授权:在终端运行 xmuse-workroom pair,把配对码输入 xmuse 窗格";
108
109export const SCOPE_MISSING_TEXT = "授权不包含这个操作,请重新配对(xmuse-workroom pair)";
110
111export function roomNotCoveredText(room: string): string {
112 return "这个房间不在授权范围内:在终端运行 xmuse-workroom pair --room " + safe(room, 64) + " 重新配对";
113}
114
115// Why a write cannot use the held grant, checked locally before any request.
116// The same words as the server's refusals, so a re-pair that left the bound
117// Room out does not read as an expired grant.
118export function grantRefusalText(
119 grant: { scopes: string[]; conversationIds: string[]; expiresAt: string } | null,
120 tokenHeld: boolean,
121 nowMs: number,
122 scope: string,
123 room: string | null,
124): string {
125 if (grant === null) return NO_GRANT_TEXT;
126 if (!tokenHeld || grantExpired(grant.expiresAt, nowMs)) return REPAIR_TOAST;
127 if (!grant.scopes.includes(scope)) return SCOPE_MISSING_TEXT;
128 if (room !== null && !grant.conversationIds.includes(room)) return roomNotCoveredText(room);
129 return REPAIR_TOAST;
130}
131
132export function failureToast(status: number): string {
133 if (status === 0) return "操作失败(网络错误)";
134 return "操作失败(" + String(status) + ")";
135}
136
137// main_window_control_v1 §5: a write command runs only from the person's
138// own Enter at the prompt. Every other origin, a missing one and
139// "unclassified" are refused before any request.
140export function isHumanOrigin(origin: unknown): boolean {
141 if (typeof origin !== "object" || origin === null) return false;
142 return (origin as Record<string, unknown>)["kind"] === "composer";
143}
144
145export const ORIGIN_REFUSED = "xmuse: 写操作只接受你在输入框里亲自输入的 /xmuse 命令";
146
147const OWNER_KINDS = ["claude", "opencode", "antigravity"];
148const LEAD_KINDS = ["claude", "opencode", "antigravity", "codex"];
149
150export type NewRoomArgs = {
151 title: string;
152 lead: string;
153 owners: string[];
154 reviewer: string | null;
155 review: boolean;
156};
157
158export const NEW_USAGE =
159 "用法: /xmuse new <标题> [--owners opencode,claude] [--lead opencode] [--reviewer claude] [--no-review]";
160
161// `/xmuse new` arguments (after the word "new"). Flags take one value, except
162// --no-review; everything else is the title. Defaults: lead opencode, two
163// OpenCode owners, cross-family review on (the Human reviews when no other
164// family is in the Room).
165export function parseNewArgs(rest: string): { ok: true; value: NewRoomArgs } | { ok: false; hint: string } {
166 const words = rest.split(/\s+/).filter((w) => w !== "");
167 const titleWords: string[] = [];
168 let lead = "opencode";
169 let owners = ["opencode", "opencode"];
170 let reviewer: string | null = null;
171 let review = true;
172 for (let i = 0; i < words.length; i++) {
173 const w = words[i] ?? "";
174 if (w === "--no-review") {
175 review = false;
176 continue;
177 }
178 if (w === "--owners" || w === "--lead" || w === "--reviewer") {
179 const value = words[i + 1];
180 if (value === undefined) return { ok: false, hint: NEW_USAGE };
181 i++;
182 if (w === "--owners") owners = value.split(",").filter((k) => k !== "");
183 else if (w === "--lead") lead = value;
184 else reviewer = value;
185 continue;
186 }
187 if (w.startsWith("--")) return { ok: false, hint: NEW_USAGE };
188 titleWords.push(w);
189 }
190 const title = titleWords.join(" ").trim();
191 if (title === "" || title.length > 200) return { ok: false, hint: NEW_USAGE };
192 if (owners.length < 1 || owners.length > 6 || owners.some((k) => !OWNER_KINDS.includes(k))) {
193 return { ok: false, hint: "owner 只能是 claude/opencode/antigravity,1 到 6 个" };
194 }
195 if (!LEAD_KINDS.includes(lead)) return { ok: false, hint: "lead 只能是 claude/opencode/antigravity/codex" };
196 if (reviewer !== null && !LEAD_KINDS.includes(reviewer)) {
197 return { ok: false, hint: "reviewer 只能是 claude/opencode/antigravity/codex" };
198 }
199 if (reviewer !== null && !review) return { ok: false, hint: "--reviewer 和 --no-review 不能同时用" };
200 return { ok: true, value: { title, lead, owners, reviewer, review } };
201}
202
203export const SAY_USAGE = "用法: /xmuse say [@lead|@owner-1 ...] <消息>";
204
205export function parseSayArgs(rest: string): { ok: true; message: string } | { ok: false; hint: string } {
206 const message = rest.trim();
207 if (message === "" || message.length > 32768) return { ok: false, hint: SAY_USAGE };
208 return { ok: true, message };
209}
210
211// Fixed words for a refused write; reason codes are mapped, never echoed.
212export function writeFailureText(status: number, code: string | null): string {
213 if (status === 401) return REPAIR_TOAST;
214 if (status === 403) return SCOPE_MISSING_TEXT;
215 if (status === 404) return "这个房间不在授权范围内:在终端运行 xmuse-workroom pair --room <房间 id 前缀> 重新配对";
216 if (status === 429) return "操作太频繁,请稍后再试";
217 if (status === 409 && code === "plugin_grant_room_limit") return "授权的房间数已满,请重新配对";
218 if (status === 422 && code === "room_provider_unavailable") return "所选 agent 当前不可用";
219 if (status === 422) return "请求不合法";
220 return failureToast(status);
221}
222
223export type ReviewDecision = "endorse" | "object";
224
225export function mapReviewOutcome(status: number, detailCode: string | null, decision: ReviewDecision): DecisionOutcome {
226 if (status === 200) {
227 return { toast: decision === "endorse" ? "已认可复核" : "已提出反对,owner 将返工", clearGrant: false, refetch: true };
228 }
229 if (status === 409 && detailCode === "plugin_review_not_human") {
230 return { toast: "这个复核已不需要你决定,已刷新", clearGrant: false, refetch: true };
231 }
232 if (status === 409 && detailCode === "room_board_review_digest_mismatch") {
233 return { toast: "复核材料已变化,请重新查看", clearGrant: false, refetch: true };
234 }
235 if (status === 409 && detailCode === "room_board_review_material_incomplete") {
236 return { toast: "材料不完整,不能认可", clearGrant: false, refetch: false };
237 }
238 if (status === 401) return { toast: REPAIR_TOAST, clearGrant: true, refetch: false };
239 return { toast: failureToast(status), clearGrant: false, refetch: false };
240}
241src/poll.ts 41 lines1// Pure binding helpers. No engine calls here: every $ use lives in the
2// hooks module (hooks/register.tsx), so this file only computes.
3
4import { sortRoomsByUpdated, type RoomEntry } from "./api";
5import { safe } from "./text";
6
7export function bindingKey(cwd: string): string {
8 return "binding:" + cwd;
9}
10
11export function backoffMs(failCount: number, pollMs: number): number {
12 const doubled = pollMs * Math.pow(2, Math.max(0, failCount));
13 return Math.min(doubled, 60000);
14}
15
16export function parseAttachArg(args: string): string | null {
17 const words = args.trim().split(/\s+/).filter((w) => w !== "");
18 if (words.length === 0 || words[0] !== "attach") return null;
19 const id = words[1] ?? "";
20 return id === "" ? null : id;
21}
22
23export type AttachPick =
24 | { ok: true; conversation_id: string }
25 | { ok: false; error: string };
26
27// /xmuse attach [<id or unique prefix>]. No argument: most recently
28// updated room. Never creates or modifies rooms.
29export function pickAttachTarget(rooms: RoomEntry[], args: string): AttachPick {
30 if (rooms.length === 0) return { ok: false, error: "xmuse 绑定失败: 暂无房间" };
31 const want = parseAttachArg(args);
32 if (want === null) {
33 return { ok: true, conversation_id: sortRoomsByUpdated(rooms)[0].conversation_id };
34 }
35 const id = safe(want, 128);
36 const matches = rooms.filter((r) => r.conversation_id === id || r.conversation_id.startsWith(id));
37 if (matches.length === 0) return { ok: false, error: "xmuse 绑定失败: 未找到房间" };
38 if (matches.length > 1) return { ok: false, error: "xmuse 绑定失败: 前缀匹配到多个房间" };
39 return { ok: true, conversation_id: matches[0].conversation_id };
40}
41src/pane.tsx 361 lines1// Pane drawing. Structured fields everywhere; AgentText appears only in
2// the expanded module detail, drawn with Text (never Markdown/Link),
3// labelled, re-sanitized client-side and truncated — and only after the
4// person pressed the expand Button.
5
6import type { XmuseBoard, XmuseCache, XmuseGrantMeta, XmuseModule } from "../types/index";
7import { attentionTarget, statusText } from "./board_state";
8import { grantExpired, remainingMmSs, roomNotCoveredText } from "./grant_state";
9import {
10 ACCEPTED_BADGE,
11 displayState,
12 findingsPart,
13 integrationJobWord,
14 integrationModuleWord,
15 reviewAttentionLabel,
16 reviewStatusWord,
17 shortGreenHead,
18} from "./labels";
19import { safe, safeId, safeLines, shortRev, shortRoom } from "./text";
20
21// The confirm field never shows the digest; the terminal listing does.
22const CONFIRM_LABEL = "输入摘要前 6 位以确认(终端: xmuse-workroom pair --pending)";
23
24export type PaneEnv = {
25 ui: {
26 resolve: (e: unknown) => PaneEls;
27 };
28};
29
30function moduleLine(m: XmuseModule, reviewsOn: boolean, integrationsOn: boolean): string {
31 // accepted is the only completion mark: an accepted module shows
32 // 已验收, a verified-but-not-accepted one keeps 已验证 and gains the
33 // review part below. While reviews are off the row is byte-identical
34 // to the pre-review form.
35 const statePart = reviewsOn && m.accepted ? ACCEPTED_BADGE : displayState(m.state);
36 const parts = [
37 safeId(m.module_id),
38 m.owner_display,
39 "[" + safe(m.provider_kind, 24) + "]",
40 statePart,
41 "报告" + String(m.done_reports) + "/通过" + String(m.passed) + "/失败" + String(m.failed) + "/返工" + String(m.rework_rounds),
42 ];
43 let line = parts.join(" ");
44 if (reviewsOn) {
45 const rp = reviewPart(m);
46 if (rp !== "") line += " · " + rp;
47 }
48 if (integrationsOn) {
49 const iw = integrationModuleWord(m.integration, 1);
50 if (iw !== "") line += " · " + iw;
51 }
52 return line;
53}
54
55// Room line: accepted/integrated counts plus the green branch.
56// Shown only while integrations are on and any part is non-trivial.
57export function roomIntegrationLine(board: XmuseBoard): string {
58 if (board.integrations !== 1) return "";
59 const job = integrationJobWord(board.integration.status);
60 const head = shortGreenHead(board.integration.green_head_commit);
61 if (!(board.integrated_total > 0 || head !== "" || job !== "")) return "";
62 const acceptedWord = (board.reviews === 1 ? "已验收 " : "已验证 ") + String(board.accepted_total);
63 const segs = [acceptedWord, "已集成 " + String(board.integrated_total)];
64 if (head !== "") segs.push("集成分支 " + head);
65 return segs.join(" · ");
66}
67
68// One fixed-word review part per module row, counts only. Empty when
69// there is no review. Never hidden for unknown statuses (?value).
70function reviewPart(m: XmuseModule): string {
71 const word = reviewStatusWord(m.review.status, m.review.reviewer_kind);
72 if (word === "") return "";
73 const segs = [word];
74 if (m.review.escalated_from_present) segs.push("已升级");
75 const fc = m.review.findings_count;
76 const fp = findingsPart(fc.blocker, fc.major, fc.minor);
77 if (fp !== "") segs.push(fp);
78 return segs.join(" · ");
79}
80
81export function headerLine(cache: XmuseCache): string {
82 if (cache.offline) return "xmuse 离线";
83 if (cache.binding === null) return "xmuse 未绑定房间";
84 if (cache.summary === null) return "xmuse " + shortRoom(cache.binding) + " …";
85 const when =
86 cache.lastPollAt !== null && Number.isFinite(cache.lastPollAt)
87 ? new Date(cache.lastPollAt).toISOString().replace("T", " ").slice(0, 19) + "Z"
88 : "?";
89 return "看板 " + shortRoom(cache.summary.conversation_id) + " rev " + shortRev(cache.summary.revision) + " " + when;
90}
91
92export function attentionLine(kind: string, reason: string, target: string): string {
93 const mark = kind === "operator" ? "! " : kind === "lead" ? "* " : "- ";
94 // Room-level integration items carry no target: the label stands alone.
95 // The fixed label table already covers their reason codes.
96 const label = reviewAttentionLabel(reason) ?? safe(reason, 64);
97 if (target === "") return mark + safe(kind, 16) + " " + label;
98 return mark + safe(kind, 16) + " " + label + " " + target;
99}
100
101// Pure tree builder used by the hook and the tests. Returns plain-data
102// nodes so tests can assert without a surface. Control labels are fixed
103// words only (批准/拒绝/取消/撤销授权): split ids live in keys, agent text
104// never enters a label, and toasts never carry either.
105export type PaneNode =
106 | { type: "text"; text: string; dim?: boolean }
107 | { type: "button"; key: string; label: string }
108 | { type: "link"; key: string; label: string; href: string }
109 | { type: "input"; key: string; label: string; placeholder?: string; submitLabel?: string; value?: string };
110
111// The grant covers `scope` on `room`: the Room is in conversation_ids and
112// the scope is granted (main_window_control_v1 §2, §4 check order).
113function grantCovers(grant: XmuseGrantMeta, room: string, scope: string): boolean {
114 return grant.conversationIds.includes(room) && grant.scopes.includes(scope);
115}
116
117// Review decision block for one module (main_window_control_v1 §4.4–§4.5).
118// Only for a review the Human must decide, only under a live grant covering
119// this Room with board.review.decide. Control labels are fixed words; the
120// review id lives only in button keys, never in a label. The patch is
121// agent-authored: drawn as plain Text, labelled untrusted, sanitized.
122function reviewDecisionNodes(
123 cache: XmuseCache,
124 grant: XmuseGrantMeta,
125 room: string,
126 reviewsOn: boolean,
127 m: XmuseModule,
128): PaneNode[] {
129 if (!reviewsOn) return [];
130 const reviewId = m.review.review_id;
131 if (
132 m.review.status !== "pending" ||
133 m.review.reviewer_kind !== "operator" ||
134 reviewId === null ||
135 reviewId === "" ||
136 !grantCovers(grant, room, "board.review.decide")
137 ) {
138 return [];
139 }
140 const pending = cache.confirming;
141 // An objection first collects the human's reason, then the digest guard;
142 // an endorsement goes straight to the digest guard.
143 if (pending !== null && pending.kind === "review" && pending.reviewId === reviewId) {
144 if (pending.decision === "object" && pending.reason === null) {
145 return [
146 {
147 type: "input",
148 key: "xmuse-review-reason",
149 label: "反对理由",
150 placeholder: "写明反对的理由",
151 value: "",
152 },
153 ];
154 }
155 return [
156 {
157 type: "input",
158 key: "xmuse-confirm",
159 label: CONFIRM_LABEL,
160 placeholder: "请输入前 6 位",
161 value: "",
162 },
163 { type: "button", key: "xmuse-cancel-confirm", label: "取消" },
164 ];
165 }
166 const material = cache.material;
167 if (material !== null && material.reviewId === reviewId) {
168 // The patch keeps its lines (a diff squeezed onto one line is unreadable);
169 // every line is still sanitized on its own.
170 const [lines, cut] = safeLines(material.text);
171 return [
172 {
173 type: "text",
174 text: material.truncated || cut ? "复核材料 · agent 撰写,未验证(有截断)" : "复核材料 · agent 撰写,未验证",
175 },
176 ...lines.map((line): PaneNode => ({ type: "text", text: line })),
177 { type: "button", key: "xmuse-endorse-" + reviewId, label: "认可" },
178 { type: "button", key: "xmuse-object-" + reviewId, label: "反对" },
179 { type: "button", key: "xmuse-close-material", label: "关闭材料" },
180 ];
181 }
182 return [{ type: "button", key: "xmuse-material-" + reviewId, label: "查看复核材料" }];
183}
184
185export function buildPaneNodes(cache: XmuseCache, nowMs: number = Date.now()): PaneNode[] {
186 const nodes: PaneNode[] = [];
187 nodes.push({ type: "text", text: headerLine(cache) });
188 nodes.push({ type: "text", text: statusText(cache), dim: true });
189
190 // Grant section. Always drawn, even while unbound or offline, so the
191 // default pane offers pairing. The confirm field never hints the digest.
192 nodes.push({ type: "text", text: "授权" });
193 const grant = cache.grant;
194 const boundId = cache.binding;
195 const grantLive = grant !== null && boundId !== null && !grantExpired(grant.expiresAt, nowMs);
196 const liveGrant = grantLive ? grant : null;
197 const pairingInput: PaneNode = {
198 type: "input",
199 key: "xmuse-pairing",
200 label: "配对码",
201 placeholder: "ABCD-EFGH",
202 submitLabel: "配对",
203 value: "",
204 };
205 if (liveGrant === null) {
206 nodes.push({ type: "text", text: "在终端运行 xmuse-workroom pair 生成配对码,然后输入这里。" });
207 if (boundId !== null) {
208 // A new grant covers only the Rooms named at pairing; name the bound one.
209 nodes.push({ type: "text", text: "继续当前房间: xmuse-workroom pair --room " + safe(boundId, 64), dim: true });
210 }
211 nodes.push(pairingInput);
212 } else if (boundId !== null && !liveGrant.conversationIds.includes(boundId)) {
213 // A re-pair without --room leaves the bound Room out; a bare "authorized"
214 // with no buttons reads as broken, so say why and take the new code here.
215 nodes.push({ type: "text", text: "已授权(不含当前房间) · 剩余 " + remainingMmSs(liveGrant.expiresAt, nowMs) });
216 nodes.push({ type: "text", text: roomNotCoveredText(boundId), dim: true });
217 nodes.push(pairingInput);
218 nodes.push({ type: "button", key: "xmuse-revoke", label: "撤销授权" });
219 } else {
220 nodes.push({ type: "text", text: "已授权 · 剩余 " + remainingMmSs(liveGrant.expiresAt, nowMs) });
221 nodes.push({ type: "button", key: "xmuse-revoke", label: "撤销授权" });
222 }
223
224 if (cache.offline || cache.binding === null || cache.summary === null) return nodes;
225
226 const summary = cache.summary;
227 const operatorFirst = summary.attention.slice().sort((a, b) => {
228 const rank = (k: string): number => (k === "operator" ? 0 : k === "lead" ? 1 : k === "owner" ? 2 : 3);
229 return rank(a.kind) - rank(b.kind);
230 });
231 for (const a of operatorFirst.slice(0, 10)) {
232 const target = attentionTarget(a);
233 nodes.push({ type: "text", text: attentionLine(a.kind, a.reason_code, target) });
234 }
235
236 const board: XmuseBoard | null = cache.board;
237 if (board === null || cache.summary === null) {
238 nodes.push({ type: "text", text: "看板明细未加载", dim: true });
239 return nodes;
240 }
241 const integrationsOn = board.integrations === 1;
242 const roomLine = roomIntegrationLine(board);
243 if (roomLine !== "") nodes.push({ type: "text", text: roomLine });
244 for (const m of board.modules.slice(0, 100)) {
245 nodes.push({ type: "text", text: moduleLine(m, board.reviews === 1, integrationsOn) });
246 // A review the Human must decide is decided from this pane under the
247 // grant (§4.4–§4.5): no Web round-trip, no blind decision.
248 if (liveGrant !== null && boundId !== null) {
249 for (const n of reviewDecisionNodes(cache, liveGrant, boundId, board.reviews === 1, m)) nodes.push(n);
250 }
251 if (m.failed > 0 && m.gate_ids.length > 0) {
252 nodes.push({ type: "text", text: "门禁: " + m.gate_ids.slice(0, 6).join(" ") });
253 }
254 if (m.stale_contracts.length > 0) {
255 nodes.push({ type: "text", text: "依赖过期: " + m.stale_contracts.slice(0, 6).join(" ") });
256 }
257 const open = cache.expanded[m.module_id] === true;
258 nodes.push({ type: "button", key: "expand-" + safeId(m.module_id), label: open ? "收起" : "展开" });
259 if (open) {
260 nodes.push({ type: "text", text: "agent 自述 · 未验证" });
261 nodes.push({ type: "text", text: "charter v" + String(m.charter_version) + " " + m.paths.slice(0, 8).join(" ") });
262 nodes.push({ type: "text", text: "提供 " + m.provides.slice(0, 8).join(" ") + " 依赖 " + m.depends.slice(0, 8).join(" ") });
263 const detail = board.details.find((d) => d.module_id === m.module_id);
264 const snippets = (detail !== undefined ? detail.snippets : []).slice(0, 10);
265 if (snippets.length === 0) nodes.push({ type: "text", text: "暂无自述", dim: true });
266 for (const s of snippets) {
267 nodes.push({ type: "text", text: safe(s.field, 48) + ": " + safe(s.text, 400) });
268 }
269 }
270 }
271
272 for (const row of board.splits.slice(0, 10)) {
273 if (row.status !== "proposed") continue;
274 nodes.push({ type: "text", text: "待审批 " + safe(row.split_id, 64) });
275 // Decision buttons appear only for a live grant covering this Room with
276 // board.split.decide and a split that carries a digest guard. Labels
277 // stay fixed words.
278 const eligible =
279 grantLive && grant !== null && boundId !== null && grantCovers(grant, boundId, "board.split.decide") && row.digest !== "";
280 if (!eligible) continue;
281 const pending = cache.confirming;
282 if (pending !== null && pending.splitId === row.split_id) {
283 nodes.push({
284 type: "input",
285 key: "xmuse-confirm",
286 label: CONFIRM_LABEL,
287 placeholder: "请输入前 6 位",
288 value: "",
289 });
290 nodes.push({ type: "button", key: "xmuse-cancel-confirm", label: "取消" });
291 } else if (pending === null) {
292 nodes.push({ type: "button", key: "xmuse-approve-" + row.split_id, label: "批准" });
293 nodes.push({ type: "button", key: "xmuse-reject-" + row.split_id, label: "拒绝" });
294 }
295 }
296
297 if (board.contracts.length === 0) {
298 nodes.push({ type: "text", text: "无契约", dim: true });
299 } else {
300 for (const c of board.contracts.slice(0, 50)) {
301 nodes.push({ type: "text", text: "契约 " + safeId(c.contract_id) + " v" + String(c.latest_version) });
302 }
303 }
304 return nodes;
305}
306
307// Draw the nodes with the surface's own elements. Keeps trees simple so an
308// unknown/oversized payload degrades instead of throwing.
309export type PaneEls = {
310 Box: (props: never) => unknown;
311 Text: (props: never) => unknown;
312 Button: (props: never) => unknown;
313 Link: (props: never) => unknown;
314 Input?: (props: never) => unknown;
315};
316
317export function PaneTree(props: {
318 els: PaneEls;
319 nodes: PaneNode[];
320 onExpand: (moduleId: string) => void;
321 onControl: (key: string) => void;
322 onSubmit: (key: string, value: string) => void;
323}): unknown {
324 const { Box, Text, Button, Link } = props.els;
325 const Field = props.els.Input;
326 return (
327 <Box flexDirection="column">
328 {props.nodes.map((n, i) => {
329 if (n.type === "text") {
330 return (
331 <Text key={"t" + String(i)} dimColor={n.dim === true ? true : undefined}>
332 {n.text}
333 </Text>
334 );
335 }
336 if (n.type === "input") {
337 if (Field === undefined) return <Text key={n.key} dimColor>{n.label}</Text>;
338 return (
339 <Field
340 key={n.key}
341 label={n.label}
342 placeholder={n.placeholder}
343 submitLabel={n.submitLabel}
344 value={n.value ?? ""}
345 onSubmit={(value: string) => props.onSubmit(n.key, value)}
346 />
347 );
348 }
349 if (n.type === "button") {
350 if (n.key.startsWith("expand-")) {
351 const moduleId = n.key.slice("expand-".length);
352 return <Button key={n.key} label={n.label} onPress={() => props.onExpand(moduleId)} />;
353 }
354 return <Button key={n.key} label={n.label} onPress={() => props.onControl(n.key)} />;
355 }
356 return <Link key={n.key} label={n.label} href={n.href} />;
357 })}
358 </Box>
359 );
360}
361src/text.ts 95 lines1// Structured-text helpers. Everything the status line, toasts, command
2// output and the model tool result show passes through safe().
3
4const MODULE_ID_RE = /^[a-z][a-z0-9_-]{0,47}$/;
5
6// Printable ASCII plus CJK-safe ranges. Everything else (C0/C1 controls,
7// ANSI ESC, bidi controls, lone surrogates, other scripts' controls) is
8// dropped. Newlines become spaces: these strings all land on one line.
9export function safe(input: unknown, maxLen = 160): string {
10 if (typeof input !== "string") return "";
11 let out = "";
12 for (const ch of input) {
13 const cp = ch.codePointAt(0) ?? 0;
14 if (cp === 0x0a || cp === 0x0d || cp === 0x09) {
15 out += " ";
16 continue;
17 }
18 if (cp >= 0x20 && cp <= 0x7e) {
19 out += ch;
20 continue;
21 }
22 if (
23 (cp >= 0x3000 && cp <= 0x303f) ||
24 (cp >= 0x3400 && cp <= 0x4dbf) ||
25 (cp >= 0x4e00 && cp <= 0x9fff) ||
26 (cp >= 0xf900 && cp <= 0xfaff) ||
27 (cp >= 0xff00 && cp <= 0xffef)
28 ) {
29 out += ch;
30 continue;
31 }
32 }
33 if (out.length > maxLen) out = out.slice(0, maxLen);
34 return out;
35}
36
37// Multi-line text (a review patch) as separate safe() lines, so a diff keeps
38// its shape. Bounded by line count and total characters; the second value
39// says whether anything was cut.
40export function safeLines(input: unknown, maxLines = 120, maxChars = 6000): [string[], boolean] {
41 if (typeof input !== "string") return [[], false];
42 const raw = input.split("\n");
43 const lines: string[] = [];
44 let used = 0;
45 for (const line of raw) {
46 if (lines.length >= maxLines || used >= maxChars) return [lines, true];
47 const cleaned = safe(line, Math.min(240, maxChars - used));
48 lines.push(cleaned === "" ? " " : cleaned);
49 used += cleaned.length;
50 }
51 return [lines, false];
52}
53
54// Module ids are server-validated slugs; still funnel every interpolated
55// string through safe(). A valid slug is used verbatim, anything else is
56// sanitized (and blank becomes "?").
57export function safeId(input: unknown): string {
58 if (typeof input === "string" && MODULE_ID_RE.test(input)) return input;
59 const s = safe(input, 48);
60 return s === "" ? "?" : s;
61}
62
63// Short form of a room id for one-line surfaces. Never the room title:
64// titles are user text and may be long.
65export function shortRoom(conversationId: unknown): string {
66 const s = safe(conversationId, 64);
67 if (s === "") return "?";
68 return s.length > 12 ? s.slice(0, 8) : s;
69}
70
71// Short form of a board revision ("41:9f2c0a7d41be" -> "41:9f2c0a").
72export function shortRev(revision: unknown): string {
73 const s = safe(revision, 64);
74 if (s === "") return "?";
75 const parts = s.split(":");
76 if (parts.length === 2 && parts[0] !== "" && parts[1] !== "") {
77 return parts[0].slice(0, 12) + ":" + parts[1].slice(0, 6);
78 }
79 return s.slice(0, 12);
80}
81
82// Rewrite a loopback web URL so a Link href passes the surface rule
83// (https: or http://localhost). 127.0.0.1 and [::1] are the same machine.
84export function linkBase(webUrl: string): string {
85 const s = webUrl.trim().replace(/\/+$/, "");
86 if (s.startsWith("http://127.0.0.1")) return "http://localhost" + s.slice("http://127.0.0.1".length);
87 if (s.startsWith("http://[::1]")) return "http://localhost" + s.slice("http://[::1]".length);
88 return s;
89}
90
91export function roomLink(webUrl: string, conversationId: string): string {
92 const href = linkBase(webUrl) + "/rooms/" + encodeURIComponent(conversationId);
93 return href.length <= 2048 ? href : "";
94}
95src/labels.ts 163 lines1// Fixed labels for module states. done_claimed and verified are
2// unmistakable everywhere: glyph plus Chinese, never colour only.
3// Unknown state codes are shown as ?<value> (contract compatibility).
4
5import { safe } from "./text";
6
7const BADGES: { [state: string]: string } = {
8 verified: "✓ 已验证",
9 done_claimed: "◌ 自称完成·未验证",
10 verification_failed: "✗",
11 verifying: "…",
12 waiting_for_provider: "⧗",
13 verification_error: "‼",
14};
15
16export function stateBadge(state: unknown): string {
17 if (typeof state === "string" && BADGES[state] !== undefined) return BADGES[state];
18 return "?" + safe(state, 48);
19}
20
21// Compact glyphs for the one-line status row, in fixed order.
22export const STATUS_GROUPS: { state: string; glyph: string }[] = [
23 { state: "verified", glyph: "✓" },
24 { state: "done_claimed", glyph: "◌" },
25 { state: "verifying", glyph: "…" },
26 { state: "waiting_for_provider", glyph: "⧗" },
27 { state: "verification_failed", glyph: "✗" },
28 { state: "verification_error", glyph: "‼" },
29];
30
31export function isKnownState(state: unknown): boolean {
32 if (typeof state !== "string") return false;
33 if (BADGES[state] !== undefined) return true;
34 return (
35 state === "assigned" ||
36 state === "claimed" ||
37 state === "working" ||
38 state === "blocked" ||
39 state === "ready_for_review"
40 );
41}
42
43export function displayState(state: unknown): string {
44 if (isKnownState(state)) {
45 const badge = typeof state === "string" ? BADGES[state] : undefined;
46 if (badge !== undefined) return badge;
47 return safe(state, 48);
48 }
49 return "?" + safe(state, 48);
50}
51
52// The one completion mark (§4.4): shown only when accepted is true, never
53// for a merely verified module.
54export const ACCEPTED_BADGE = "✓ 已验收";
55
56// Fixed one-word review part for a module row. Empty when there is no
57// review (status "none"). An unknown status is shown as ?value, never
58// hidden. Counts only: no summary or finding text is ever rendered.
59export function reviewStatusWord(status: unknown, reviewerKind: unknown): string {
60 if (status === "none") return "";
61 if (status === "pending") return reviewerKind === "operator" ? "待你复核" : "待复核";
62 if (status === "endorsed") return "已背书";
63 if (status === "objected") return "已驳回";
64 return "?" + safe(status, 48);
65}
66
67export function findingsPart(blocker: number, major: number, minor: number): string {
68 const b = Math.max(0, Math.floor(blocker));
69 const mj = Math.max(0, Math.floor(major));
70 const mn = Math.max(0, Math.floor(minor));
71 if (b + mj + mn === 0) return "";
72 return "阻塞 " + String(b) + " 主要 " + String(mj) + " 次要 " + String(mn);
73}
74
75// Fixed labels for review attention rows. Null for every other reason
76// code: callers keep showing those codes as before. Integration
77// room-level items share the same table: the operator error toasts,
78// the lead gate failure and the owner conflict stay on their existing
79// toast/attention paths (no new toast for per-module conflicts).
80export function reviewAttentionLabel(reason: unknown): string | null {
81 if (reason === "board_attention_review_operator_pending") return "待你复核";
82 if (reason === "board_attention_review_objected") return "复核被驳回待返工";
83 if (reason === "board_attention_integration_error") return "集成异常(宿主自动重试)";
84 if (reason === "board_attention_integration_conflict") return "集成冲突待处理";
85 if (reason === "board_attention_integration_gate_failed") return "集成门禁失败";
86 return null;
87}
88
89// Fixed words for the board_integration_* reason codes (§9). Copied
90// exactly from frontend/src/lib/board-labels.ts: the hosts show codes
91// through these labels only, never raw gate or path text.
92const INTEGRATION_REASON_LABELS: { [code: string]: string } = {
93 board_integration_conflict: "集成冲突",
94 board_integration_gate_failed: "集成门禁未通过",
95 board_integration_waiting_for_dependency: "等待依赖集成",
96 board_integration_would_drop_accepted: "集成会丢失已验收代码,已停止",
97 board_integration_attempts_exhausted: "集成多次失败",
98};
99
100export function integrationReasonLabel(reason: unknown): string | null {
101 if (typeof reason === "string" && INTEGRATION_REASON_LABELS[reason] !== undefined)
102 return INTEGRATION_REASON_LABELS[reason];
103 return null;
104}
105
106// Room-level job word (§3.11, §7.1): latest status, or summary status.
107// integrated and null/empty show nothing.
108export function integrationJobWord(status: unknown): string {
109 if (status === null || status === undefined || status === "" || status === "integrated") return "";
110 if (status === "pending") return "排队集成";
111 if (status === "running") return "集成中";
112 if (status === "conflicted") return "集成冲突";
113 if (status === "gate_failed") return "集成门禁失败";
114 if (status === "error") return "集成异常";
115 return "?" + safe(status, 32);
116}
117
118// 8 hex of the green head commit. Empty when there is no head.
119export function shortGreenHead(commit: unknown): string {
120 if (typeof commit !== "string" || commit === "") return "";
121 return safe(commit, 64).slice(0, 8);
122}
123
124export type ModuleIntegrationInput = {
125 status: unknown;
126 conflict_path_count?: unknown;
127 verification_id?: unknown;
128 integrated_verification_id?: unknown;
129};
130
131// One fixed-word module integration part (§3.11). Empty when integrations
132// are off, when the status is "none"/missing, or when the status is
133// unknown-but-empty. Counts and ids only: never a path, never gate text.
134export function integrationModuleWord(
135 input: ModuleIntegrationInput | null | undefined,
136 integrations: unknown,
137): string {
138 if (integrations !== 1) return "";
139 if (input === null || input === undefined) return "";
140 const status = input.status;
141 if (status === null || status === undefined || status === "" || status === "none") return "";
142 let base = "";
143 if (status === "pending") base = "排队集成";
144 else if (status === "running") base = "集成中";
145 else if (status === "integrated") base = "已集成";
146 else if (status === "waiting") base = "等待依赖集成";
147 else if (status === "conflicted") {
148 const n =
149 typeof input.conflict_path_count === "number" && Number.isFinite(input.conflict_path_count)
150 ? Math.max(0, Math.floor(input.conflict_path_count))
151 : 0;
152 base = "集成冲突 " + String(n) + " 路径";
153 } else if (status === "gate_failed") base = "门禁失败·嫌疑";
154 else if (status === "error") base = "集成异常·自动重试";
155 else base = "?" + safe(status, 32);
156 const ver = typeof input.verification_id === "string" ? input.verification_id : null;
157 const old = typeof input.integrated_verification_id === "string" ? input.integrated_verification_id : null;
158 if (old !== null && old !== "" && ver !== null && old !== ver) return base + "·分支为旧版本";
159 if ((old === null || old === "") && (status === "conflicted" || status === "gate_failed" || status === "error" || status === "waiting"))
160 return base + "·未入分支";
161 return base;
162}
163types/index.d.ts 191 lines1// Xmuse board status mod — PluginState contract.
2//
3// This file is the one place the shapes the pane reads are written. It is
4// self-contained (no imports) and is named in plugin.json as "types".
5// Every value below is structured server data only: counts, state codes,
6// module ids, reason codes. Agent-authored text is kept out of the status
7// line / toast / command / tool paths by construction (see src/text.ts);
8// the pane holds a bounded, sanitized copy for the expanded detail only.
9
10export type XmuseAttentionItem = {
11 kind: string;
12 reason_code: string;
13 module_id: string | null;
14 split_id: string | null;
15 integration_id: string | null;
16};
17
18export type XmuseIntegrationState = {
19 status: string | null;
20 green_head_commit: string | null;
21};
22
23export type XmuseModuleIntegration = {
24 status: string;
25 verification_id: string | null;
26 integrated_verification_id: string | null;
27 conflict_path_count: number;
28 reason_code: string | null;
29};
30
31export type XmuseSummary = {
32 conversation_id: string;
33 revision: string;
34 board_seq: number;
35 modules_total: number;
36 counts: { [state: string]: number };
37 attention: XmuseAttentionItem[];
38 attention_total: number;
39 // Accepted modules (§4.4). Equals counts.verified while reviews are off.
40 accepted_total: number;
41 // capabilities.reviews from the projection/summary: 1 while cross-family
42 // reviews are on, 0 otherwise (old servers: 0).
43 reviews: number;
44 // capabilities.integrations (§3.11): 1 while the host integrates, 0
45 // otherwise (old servers: 0). Gates every integration word.
46 integrations: number;
47 // Accepted modules integrated at their current verification (§3.11).
48 // Default 0 on old servers.
49 integrated_total: number;
50 // Room-level job state (§3.11, §7.1): latest status (null when none or
51 // integrated) is shown as a job word; the green head is shortened.
52 // Never a path, never a job id.
53 integration: XmuseIntegrationState;
54};
55
56export type XmuseAgentSnippet = {
57 field: string;
58 text: string;
59};
60
61// Structured review state of one module. Counts only: the mod never
62// reads review summaries or finding text (no AgentText leaves here).
63export type XmuseReviewInfo = {
64 status: string;
65 // Opaque id and digest guard, used only for a Human decision under a
66 // grant (main_window_control_v1 §4.4); never shown in a label or toast.
67 review_id: string | null;
68 digest: string | null;
69 reviewer_kind: string | null;
70 escalated_from_present: boolean;
71 findings_count: { blocker: number; major: number; minor: number };
72};
73
74export type XmuseModule = {
75 module_id: string;
76 state: string;
77 lifecycle: string;
78 owner_display: string;
79 provider_kind: string;
80 done_reports: number;
81 passed: number;
82 failed: number;
83 rework_rounds: number;
84 gate_ids: string[];
85 stale_contracts: string[];
86 attention_kind: string;
87 attention_reason: string | null;
88 charter_version: number;
89 paths: string[];
90 provides: string[];
91 depends: string[];
92 // The one completion value (§4.4). Missing on old servers: false.
93 accepted: boolean;
94 review: XmuseReviewInfo;
95 // Per-module integration state (§3.11). The none values when the module
96 // has no candidate or the server predates integrations.
97 integration: XmuseModuleIntegration;
98};
99
100export type XmuseModuleDetail = {
101 module_id: string;
102 snippets: XmuseAgentSnippet[];
103};
104
105export type XmuseBoard = {
106 revision: string;
107 modules: XmuseModule[];
108 details: XmuseModuleDetail[];
109 contracts: { contract_id: string; latest_version: number }[];
110 proposed_splits: string[];
111 splits: XmuseSplitSummary[];
112 operator_attention: XmuseAttentionItem[];
113 // capabilities.reviews of the projection (1 while reviews are on).
114 reviews: number;
115 // Modules with accepted == true in this projection.
116 accepted_total: number;
117 // capabilities.integrations of the projection (1 while integrating).
118 integrations: number;
119 // Modules accepted and integrated at their current verification.
120 integrated_total: number;
121 // Room-level job state (§3.11): latest status plus the green head.
122 integration: XmuseIntegrationState;
123};
124
125// One split row the pane may offer a decision on. Only structured fields:
126// status codes and the digest guard. Module titles and path globs stay in
127// the expanded module detail and never enter a control label or a toast.
128export type XmuseSplitSummary = {
129 split_id: string;
130 status: string;
131 digest: string;
132};
133
134// Non-secret grant metadata the pane renders from (plugin_grant/v2). The
135// token itself lives only in a module-level variable of the hooks module,
136// never here.
137export type XmuseGrantMeta = {
138 grantId: string;
139 expiresAt: string;
140 conversationIds: string[];
141 scopes: string[];
142};
143
144// A pending two-step decision. kind "split": splitId + decision.
145// kind "review": reviewId + decision (endorse|object); an objection first
146// collects the human's reason (reason null until entered), then the digest.
147export type XmuseConfirming = {
148 kind: string;
149 splitId: string;
150 reviewId: string;
151 decision: string;
152 reason: string | null;
153};
154
155// Review material fetched under a grant, drawn only in the pane (§4.5).
156// The patch is agent-authored: sanitized, truncated and labelled untrusted.
157export type XmuseReviewMaterial = {
158 reviewId: string;
159 digest: string;
160 text: string;
161 truncated: boolean;
162};
163
164export type XmuseCache = {
165 binding: string | null;
166 cwd: string | null;
167 summary: XmuseSummary | null;
168 board: XmuseBoard | null;
169 summaryEtag: string | null;
170 boardEtag: string | null;
171 lastPollAt: number | null;
172 offline: boolean;
173 baselined: boolean;
174 seenAttention: string[];
175 seenStates: { [module_id: string]: string };
176 failCount: number;
177 nextRetryAt: number;
178 paneOpen: boolean;
179 expanded: { [module_id: string]: boolean };
180 grant: XmuseGrantMeta | null;
181 confirming: XmuseConfirming | null;
182 material: XmuseReviewMaterial | null;
183 formEpoch: number;
184};
185
186declare module "claude-code" {
187 interface PluginState {
188 xmuse: { cache: XmuseCache };
189 }
190}
191