SLOPSHOPPER

xmuse

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…

newpaneguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-09iiyazu/Cross-Muse/integrations/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xmuse
│ ┃ xmuse 看板 ✕ › fix the failing auth test and add an audit log call │ ┃ xmuse 未绑定房间 │ ┃ xmuse 未绑定房间 ⏺ Read(src/auth.ts) │ ┃ 授权 ⎿ Read 6 lines │ ┃ 在终端运行 xmuse-workroom pair ⏺ Update(src/auth.ts) │ ┃ 生成配对码,然后输入这里。 ⎿ Added 2 lines, removed 1 line │ ┃ 配对码: ABCD-EFGH ⏎ 配对 ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /xmuse │ ⎿ xmuse: xmuse 看板已打开 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ xmuse: xmuse 未绑定房间

Draws

Pane · xmuse 看板
xmuse 未绑定房间 xmuse 未绑定房间 授权 在终端运行 xmuse-workroom pair 生成配对码,然后输入这里。 配对码: ABCD-EFGH ⏎ 配对
README

xmuse — board status mod for Claude Code

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).

Install

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):

keydefaultmeaning
baseUrlhttp://127.0.0.1:8201xmuse chat API (loopback only)
webUrlhttp://127.0.0.1:3000Kept for compatibility; the pane no longer links to the Web UI
pollSeconds5board summary poll interval

Commands and tool

  • /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.

Behaviour

  • Polls 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).
  • Status line, e.g. 看板 3 模块 · ✓1 …1 ✗1 · 待你处理 1 (zero parts omitted). Offline: xmuse 离线. Unbound: xmuse 未绑定房间.
  • Toasts (at most one per tick, never on the first poll) when operator attention gains an item or a module becomes verified / fails verification. Review attention uses fixed labels: 待你复核 (board_attention_review_operator_pending) and 复核被驳回待返工 (board_attention_review_objected).
  • State badges: 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.
  • The pane shows structured fields only. Agent-authored text appears solely in the expanded module detail as plain 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.

复核 (reviews, decided from this pane)

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.
  • An unknown review status renders as ?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:

  • No review summary or finding text is ever read except through the grant-scoped material route, and then it is shown only in the pane (labelled untrusted), never toasted, never handed to the model — only the structured state (status, reviewer_kind, escalation presence, finding counts) and the attention reason codes travel elsewhere.
  • The review verdict channel is pane buttons plus the digest confirm only: no command, no tool, no new HTTP method beyond the grant routes. The mod only reads the board summary and projection it already reads, plus the material of a review the Human must decide.
  • The short-lived plugin grant covers 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.

授权 (pairing flow)

  1. In a terminal, run 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.
  2. In the pane's 授权 section, type the code into the 配对码 field (Enter: 配对). The mod normalises it locally (trim, upper-case) and rejects anything outside the grant alphabet without a network call.
  3. On success the pane shows 已授权 · 剩余 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.
  4. 撤销授权 and /xmuse detach revoke the grant best-effort and drop it locally regardless of the response.

What the authorization can and cannot do:

  • It covers the four main-window scopes on the rooms in its set: 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.
  • It can never record review verdicts, touch any other operator route, memory, execution or runtime recovery; routes that need the operator token refuse a grant.
  • It expires at 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).
  • Residual risk, accepted: anyone or anything that can drive this UI (accessibility tools, UI automation) can press the same buttons the human presses. The digest confirm and the short lifetime bound the damage; when in doubt, revoke the grant (the 撤销授权 button, or xmuse-workroom pair --revoke) and pair again.

Limits

  • Model access stays Read-only: the only thing the model gets is 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.
  • The mod never holds the operator token (no operator token in the plugin): grant routes authenticate with the short-lived Bearer token only, and the operator header is never read on them.
  • Agent text never reaches the status line, toasts, command output or the model tool — only counts, codes and ids do.
  • WSL: the mod talks to the loopback API of the machine it runs on. When Claude Code runs on Windows and the xmuse backend runs inside WSL, point baseUrl at the address where the backend listens on loopback; LAN hostnames are refused by the API's host guard.

Safety rules (enforced by tests)

  • No $.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).
  • No 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.
  • Writes run only from human-pressed pane 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).

Layout

.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)
Source 10 files
hooks/register.tsx 858 lines
1// 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};
858
src/api.ts 440 lines
1// 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}
440
src/board_state.ts 164 lines
1// 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}
164
src/grant_api.ts 307 lines
1// 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}
307
src/grant_state.ts 241 lines
1// 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}
241
src/poll.ts 41 lines
1// 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}
41
src/pane.tsx 361 lines
1// 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}
361
src/text.ts 95 lines
1// 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}
95
src/labels.ts 163 lines
1// 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}
163
types/index.d.ts 191 lines
1// 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