wezard 1.4.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +17 -13
  4. package/README.md +130 -77
  5. package/cli/wezard.sh +0 -62
  6. package/config.example.jsonc +3 -9
  7. package/dist/cli/init.js +12 -21
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/daemon/approval.js +11 -7
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/cc-session.js +136 -0
  12. package/dist/daemon/cc-session.js.map +1 -0
  13. package/dist/daemon/chat-name.js +8 -18
  14. package/dist/daemon/chat-name.js.map +1 -1
  15. package/dist/daemon/detail.js +54 -13
  16. package/dist/daemon/detail.js.map +1 -1
  17. package/dist/daemon/graph.js +5 -7
  18. package/dist/daemon/graph.js.map +1 -1
  19. package/dist/daemon/inbound.js +188 -254
  20. package/dist/daemon/inbound.js.map +1 -1
  21. package/dist/daemon/index.js +459 -258
  22. package/dist/daemon/index.js.map +1 -1
  23. package/dist/daemon/jobs.js +2 -2
  24. package/dist/daemon/memory-steward.js +82 -0
  25. package/dist/daemon/memory-steward.js.map +1 -0
  26. package/dist/daemon/mirror-bridge.js +370 -170
  27. package/dist/daemon/mirror-bridge.js.map +1 -1
  28. package/dist/daemon/mirror-store.js.map +1 -1
  29. package/dist/daemon/model-select.js +74 -0
  30. package/dist/daemon/model-select.js.map +1 -0
  31. package/dist/daemon/peers.js +10 -16
  32. package/dist/daemon/peers.js.map +1 -1
  33. package/dist/daemon/session-name.js +4 -27
  34. package/dist/daemon/session-name.js.map +1 -1
  35. package/dist/daemon/spawn-tmux.js +61 -16
  36. package/dist/daemon/spawn-tmux.js.map +1 -1
  37. package/dist/daemon/task-gate.js +97 -0
  38. package/dist/daemon/task-gate.js.map +1 -0
  39. package/dist/daemon/task-registry.js +132 -0
  40. package/dist/daemon/task-registry.js.map +1 -0
  41. package/dist/daemon/tasks.js +126 -54
  42. package/dist/daemon/tasks.js.map +1 -1
  43. package/dist/daemon/wizard-memory.js +52 -0
  44. package/dist/daemon/wizard-memory.js.map +1 -0
  45. package/dist/daemon/wizard.js +107 -41
  46. package/dist/daemon/wizard.js.map +1 -1
  47. package/dist/mcp/server.js +181 -209
  48. package/dist/mcp/server.js.map +1 -1
  49. package/dist/shared/chat-http.js +187 -108
  50. package/dist/shared/chat-http.js.map +1 -1
  51. package/dist/shared/chat-render.js +25 -34
  52. package/dist/shared/chat-render.js.map +1 -1
  53. package/dist/shared/chat-view.js +14 -2
  54. package/dist/shared/chat-view.js.map +1 -1
  55. package/dist/shared/cli-backends.js +2 -7
  56. package/dist/shared/cli-backends.js.map +1 -1
  57. package/dist/shared/config.js +26 -9
  58. package/dist/shared/config.js.map +1 -1
  59. package/dist/shared/detail-render.js +12 -6
  60. package/dist/shared/detail-render.js.map +1 -1
  61. package/dist/shared/detail-store.js +1 -1
  62. package/dist/shared/detail-store.js.map +1 -1
  63. package/dist/shared/role-render.js +55 -0
  64. package/dist/shared/role-render.js.map +1 -0
  65. package/dist/shared/role-view.js +228 -0
  66. package/dist/shared/role-view.js.map +1 -0
  67. package/dist/shared/schedule-spec.js +92 -116
  68. package/dist/shared/schedule-spec.js.map +1 -1
  69. package/dist/shared/session-label.js +103 -72
  70. package/dist/shared/session-label.js.map +1 -1
  71. package/dist/shared/std.js +3 -0
  72. package/dist/shared/std.js.map +1 -1
  73. package/dist/shared/task-file.js +120 -0
  74. package/dist/shared/task-file.js.map +1 -0
  75. package/dist/shared/trigger.js +232 -0
  76. package/dist/shared/trigger.js.map +1 -0
  77. package/dist/shared/world.js +17 -5
  78. package/dist/shared/world.js.map +1 -1
  79. package/dist/svr/index.js +67 -2
  80. package/dist/svr/index.js.map +1 -1
  81. package/hooks/pre-tool-use.sh +4 -4
  82. package/package.json +1 -2
  83. package/web/chat.css +424 -212
  84. package/web/chat.js +953 -323
  85. package/commands/wrc.md +0 -7
  86. package/dist/daemon/cc-bridge.js +0 -183
  87. package/dist/daemon/cc-bridge.js.map +0 -1
  88. package/dist/daemon/sessions.js +0 -4
  89. package/dist/daemon/sessions.js.map +0 -1
@@ -1,5 +1,4 @@
1
- // Inbound text router. Hands the message off to either the headless CC bridge
2
- // (mode=headless) or the mirror bridge (mode=mirror).
1
+ // Inbound text router. Hands the message off to the mirror bridge.
3
2
  import { mkdirSync, writeFileSync, statSync } from "node:fs";
4
3
  import { join } from "node:path";
5
4
  import { tailTurnsWithTools, keepalivePingSigs, renderPeerMentionHint } from "./peers.js";
@@ -12,9 +11,9 @@ import { computeUsage, renderUsageReport } from "./usage.js";
12
11
  import { computeAuditReport } from "./audit.js";
13
12
  import { syncProjectConfig, renderSyncReport } from "./cfg-sync.js";
14
13
  import { captureQuota, renderQuotaReport } from "./quota.js";
15
- import { tagOfKey, baseOfKey, withTagHeader, parseTagHeader, tagTokenRe, allTags, allCompoundAddresses, labelFor } from "../shared/session-label.js";
16
- import { chatNameOf, chatBaseOf, clearChatName, listChatNames, peerAddress, setChatName } from "./chat-name.js";
17
- import { wizardName, wizardStore } from "./wizard.js";
14
+ import { tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader, nameTokenRe, allNames, normalizeTag, uniqueTag, displayName, labelFor, tagLink } from "../shared/session-label.js";
15
+ import { chatNameOf, clearChatName, peerAddress, setChatName } from "./chat-name.js";
16
+ import { reclaimChatName, wizardStore } from "./wizard.js";
18
17
  import { truncate } from "../shared/std.js";
19
18
  /** 判定"引用内容是否已在目标会话上下文里"时回看的轮数 —— 引用的通常是最近几轮
20
19
  * 里的某条气泡,再往前用户多半是真想把老内容重新拎出来说事。 */
@@ -22,19 +21,19 @@ const QUOTE_TAIL_TURNS = 12;
22
21
  // Chat-binding key: stable id for "this conversation thread". Used as
23
22
  // session-map key, mirror target, defaultChat. NOT used for auth.
24
23
  const chatPrincipal = (msg) => msg.chattype === "group" && msg.chatid ? `chat:${msg.chatid}` : `user:${msg.from.userid}`;
25
- // A single chat can host multiple concurrent Claude sessions via `#tag`. The
26
- // session key = base principal + optional `#tag` suffix. Untagged = default
27
- // session (backward-compatible). Tags: [\p{L}\p{N}_-]{1,32}, must be
28
- // space-delimited or edge-of-string so genuine URLs / paths like
29
- // "#L45-foo/bar" survive. Only the FIRST tag in a message is honored — that
30
- // tag is stripped from the forwarded text; any additional #foo tokens flow
31
- // through verbatim (may be actual references in the user's prompt).
32
- // 剩下那些 #foo 里,真正指向兄弟会话的会在出站前被标注(见 peerMentions)。
33
- // Token 规则收在 session-label(tagTokenRe / allTags),路由与标注共用同一把尺子。
34
- const TAG_RE = tagTokenRe();
35
- const parseTag = (text) => {
36
- const m = TAG_RE.exec(text);
37
- if (!m)
24
+ // 寻址靠 wizard 的**全局名字** `.name` —— 它可能住在任何一个聊天里。
25
+ // Must be space-delimited or edge-of-string so paths / extensions like `a.ts`
26
+ // survive. 一条消息只认**一个**路由名字, 规则:
27
+ // • 第一个解析得出的已知名字 (名册里有, 或本聊天里有这个 slot 的老会话) 胜出;
28
+ // • 都不认识时, 只有**消息开头**的 `.x` 才算「叫一个新 wizard 出来」——
29
+ // 正文里随手写的 `.gitignore` 不该凭空变出一个 wizard;
30
+ // 选中的那个 token 从正文里摘掉, 其余 `.foo` 原样留着 (见 peerMentions)。
31
+ // Token 规则收在 session-label(nameTokenRe / allNames),路由与标注共用同一把尺子。
32
+ const NAME_G = new RegExp(nameTokenRe().source, "gu");
33
+ const parseTag = (text, known) => {
34
+ const ms = [...text.matchAll(NAME_G)];
35
+ const m = ms.find((x) => known(x[2] ?? "")) ?? (ms[0]?.index === 0 ? ms[0] : undefined);
36
+ if (!m || m.index === undefined)
38
37
  return { tag: "", cleaned: text };
39
38
  const tag = m[2] ?? "";
40
39
  const before = text.slice(0, m.index);
@@ -43,8 +42,23 @@ const parseTag = (text) => {
43
42
  const cleaned = (before + sep + after).replace(/[ \t]+/g, " ").trim();
44
43
  return { tag, cleaned };
45
44
  };
46
- const sessionKey = (base, tag) => (tag ? `${base}#${tag}` : base);
47
45
  const tagOf = tagOfKey;
46
+ /** 名字 → 投递目标。名册 (全局) > 本聊天同名 slot (改名前的老 `#tag` 会话) > 在本聊天
47
+ * 新开一个 slot。纯函数: 新 wizard 的名字由 spawn 时 settleName 按 slot 落定。 */
48
+ const resolveName = (base, name, live) => {
49
+ if (!name)
50
+ return base;
51
+ const rec = wizardStore()?.byName(name);
52
+ if (rec)
53
+ return rec.target;
54
+ const slot = normalizeTag(name) || "wizard";
55
+ const legacy = keyOf(base, slot);
56
+ if (live(legacy))
57
+ return legacy;
58
+ // 这个 slot 已被一个改了名的 (冷) wizard 占着 → 挪到 `slot-N`, 别唤醒别人。
59
+ const taken = new Set((wizardStore()?.all() ?? []).filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)));
60
+ return keyOf(base, uniqueTag(slot, taken));
61
+ };
48
62
  // Auth principals: any-of test against allowFrom. Tiered — allowing a user
49
63
  // grants them access in any chat; allowing a group grants every member of
50
64
  // that group access. DMs collapse to just the sender.
@@ -56,8 +70,7 @@ const authPrincipals = (msg) => {
56
70
  };
57
71
  // "会话id" = chat-binding (session/mirror key); "权限id" = either the group
58
72
  // OR the sender — allowFrom passes if any one of them is whitelisted.
59
- // Also surfaces per-id 授权状态 + 对应 `wezard mirror` CLI 参数 (vid:/chatid:),
60
- // so users can copy-paste straight into a terminal to bind a Claude session.
73
+ // Also surfaces per-id 授权状态。
61
74
  const renderIds = (msg, cfg) => {
62
75
  const allowed = new Set(cfg.wrc.allowFrom.map((e) => sanitizeId(e)));
63
76
  const mark = (id) => allowed.has("all") || allowed.has(id) ? "✅ 已授权" : "❌ 未授权";
@@ -68,28 +81,27 @@ const renderIds = (msg, cfg) => {
68
81
  `群: \`${chat}\` ${mark(chat)}`,
69
82
  `发送者: \`${sender}\` ${mark(sender)}`,
70
83
  `(allowFrom 任一通过即可)`,
71
- `在已有 Agent 会话中绑定本群聊: \`/wezard:wrc chat:${msg.chatid}\``,
72
84
  ].join("\n");
73
85
  }
74
- return [
75
- `会话id: \`${sender}\` ${mark(sender)}`,
76
- `在已有 Agent 会话中绑定本单聊: \`/wezard:wrc user:${msg.from.userid}\``,
77
- ].join("\n");
86
+ return `会话id: \`${sender}\` ${mark(sender)}`;
78
87
  };
79
88
  const isIdCommand = (text) => text.trim() === "/id";
80
89
  const isPwdCommand = (text) => text.trim() === "/pwd";
81
90
  const isCostCommand = (text) => text.trim() === "/cost";
82
- // `/audit` or `/audit some-tag`. With a tag, `/audit` re-routes to the
83
- // newest-by-mtime mirror whose target carries `#<tag>` (see resolveAuditMirror);
84
- // without a tag, falls back to the caller's own mirror binding.
91
+ // `/audit` or `/audit <name>`. With a name, `/audit` re-routes to that wizard's
92
+ // mirror (global name → target; an old slot id still matches, newest-by-mtime);
93
+ // without one, falls back to the caller's own mirror binding.
85
94
  const parseAuditCommand = (text) => {
86
95
  const m = /^\/audit(?:\s+(.+))?$/u.exec(text.trim());
87
- return m ? { tag: (m[1] ?? "").trim().replace(/^#/, "") } : undefined;
96
+ return m ? { tag: (m[1] ?? "").trim().replace(/^[.#]/, "") } : undefined;
88
97
  };
89
98
  const resolveAuditMirror = (mirrors, tag, who, chatWho) => {
90
99
  if (tag) {
91
- const wanted = tag.replace(/^#/, "");
92
- const matches = mirrors.filter((m) => (m.target.split("#")[1] ?? "") === wanted);
100
+ const named = wizardStore()?.byName(tag)?.target;
101
+ const exact = named ? mirrors.find((m) => m.target === named) : undefined;
102
+ if (exact)
103
+ return exact;
104
+ const matches = mirrors.filter((m) => tagOfKey(m.target) === tag);
93
105
  if (matches.length <= 1)
94
106
  return matches[0];
95
107
  return matches
@@ -141,8 +153,8 @@ const parseCfgSyncCommand = (text) => {
141
153
  const m = CFGSYNC_RE.exec(text.trim());
142
154
  return m ? { apply: Boolean(m[1]) } : undefined;
143
155
  };
144
- // `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带 `#tag`
145
- // 路由过来都命名同一个聊天, 所以这里不看 tag。
156
+ // `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带 `.name`
157
+ // 路由过来都命名同一个聊天, 所以这里不看路由目标。
146
158
  const NAME_RE_CMD = /^\/name(?:\s+(\S+))?$/i;
147
159
  const parseNameCommand = (text) => {
148
160
  const m = NAME_RE_CMD.exec(text.trim());
@@ -175,44 +187,42 @@ const renderHelp = () => [
175
187
  "",
176
188
  "▎切换 CLI 后端",
177
189
  "`/new codebuddy` 用指定 CLI 新开 (claude / claude-internal / codebuddy)",
178
- "位置参数可叠:`/new codebuddy opus #docs 先读一遍 README` = 后端 + 模型 + 标签 + 首句。",
179
- "不写则沿用本会话当前的 CLI;新开 `#tag` 会话则继承本聊天的 CLI。",
190
+ "位置参数可叠:`.docs /new codebuddy opus 先读一遍 README` = 名字 + 后端 + 模型 + 首句。",
191
+ "不写则沿用本会话当前的 CLI;新叫出来的 wizard 继承本聊天的 CLI。",
180
192
  "切换后 `/clear`、`/stop`、`--resume` 自愈都仍绑在该 CLI 上。",
181
193
  "",
182
- "▎wizard(绑定聊天的会话)",
183
- "一个聊天里可以同时住着多个 wizard —— 各有各的终端、工作区、名字和职责。",
184
- "消息里任意位置带 `#tag`(如 `#docs 帮我改 README`)就是找那一个;不带 = 默认那个。",
185
- "它们的回复以 `emoji #tag` 打头。`/clear #tag`、`/pwd #tag`、`/stop #tag` 同理按 tag 走。",
186
- "`#tag` 与 CLI 名可以同写:`/new codebuddy #docs` = 用 codebuddy 让 docs 就位。",
187
- "wizard 知道自己是谁(名字=聊天名,带 tag 的读作 `聊天名#tag`)、在哪个目录、群里还有谁,",
188
- "也知道自己能给自己起名、写职责、记长期记忆、上下文满了自己交接重开。",
194
+ "▎wizard",
195
+ "每个 wizard 有一个**全局唯一**的名字,写作 `.name`;聊天的默认 wizard 名字就是聊天名。",
196
+ "消息里带 `.name`(如 `.docs 帮我改 README`)就是找那一个 —— 它可以住在任何一个聊天里,",
197
+ "回复会回到你说话的这个群;不带 = 本聊天默认那个。开头写一个还不存在的 `.x` = 在这里叫出一个新的 x。",
198
+ "它们的回复以 `emoji .name` 打头。`/clear .x`、`/pwd .x`、`/stop .x` 同理按名字走。",
199
+ "wizard 知道自己是谁、在哪个目录、还有谁在,也能给自己改名、写职责、记长期记忆、上下文满了自己交接重开。",
200
+ "wizard 之间默认**私聊**,只有需要你知道的才发进群。",
189
201
  "",
190
202
  "▎分身(clone)",
191
203
  "对 AI 说「分个身去干 X」「开三个分身分头处理」即可。分身默认**继承它此刻的上下文** ——",
192
204
  "先把公共材料读进来、再分身,材料只读一遍却进了 N 份上下文;要白纸一张就说明白。",
193
- "分身有自己的 `#tag`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
205
+ "分身有自己的 `.name`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
194
206
  "",
195
207
  "▎跨聊天",
196
208
  "`/name <名字>` 给本聊天起名 · `/name` 查看 · `/name -` 取消",
197
209
  "`/chats` 列出所有已知聊天及其 wizard",
198
- "名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字。",
199
- "`daily#fix` = daily 聊天的 `#fix` · `daily` = 它的默认 wizard · `fix` = 本聊天优先。",
200
- "对 AI 说「让 daily#fix 看一眼」「在 daily 里开个 #ingest 跑这个目录」即可。",
201
- "未命名的聊天叫不到、也生不进去 —— 想被叫到,就在那个群里发一次 `/name`。",
210
+ "名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字 (撞名会自动加后缀)。",
211
+ "对 AI 说「让 .fix 看一眼」「在 daily 里开个 .ingest 跑这个目录」即可。",
202
212
  "",
203
213
  "▎协作",
204
214
  "`/peers`(或 `/wizards`) 列出本聊天的 wizard:名字、职责、忙闲、谁是谁的分身",
205
215
  "同一聊天里的 wizard 互相看得见也驱动得动,直接说人话:",
206
- "「看下 `#fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
207
- "「让 `#fix` 和 `#review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
208
- "它们之间的关键往返(派活、分身出生、收尾、结论)会在群里留痕,中间的催促只进详情页。",
216
+ "「看下 `.fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
217
+ "「让 `.fix` 和 `.review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
218
+ "它们之间的往来默认只进各自的 rolepage;需要你知道的 (工单开/收工、公开讨论、结论) 才发进群。",
209
219
  "",
210
220
  "▎信息 (免授权)",
211
221
  "`/id` 查看会话/权限 id",
212
222
  "`/pwd` 当前项目路径",
213
223
  "`/usage` 真实订阅额度 %",
214
224
  "`/cost` token/成本估算",
215
- "`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <tag>` 指定标签会话",
225
+ "`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <名字>` 指定 wizard",
216
226
  "`/cfgsync` 预演跨 CLI 项目配置同步 · `/cfgsync apply` 执行 (需授权)",
217
227
  "`/help` 本帮助",
218
228
  "",
@@ -252,7 +262,7 @@ const renderSessionsList = (sessions, currentSid) => {
252
262
  "> 切换:`/sessions <emoji 或 id>`,如 `/sessions 🐼`",
253
263
  ].join("\n");
254
264
  };
255
- // /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个 `#tag`)。与 /sessions
265
+ // /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个分身)。与 /sessions
256
266
  // 的区别是范围: /sessions 扫的是整台机器上所有 agent 会话 (包括人在终端里自己开
257
267
  // 的、与企微无关的), 这里列的是同一个聊天里互相叫得动的那些 —— 同聊天 = 同一个
258
268
  // 地址空间。名字与职责来自 wizard 注册表, 没登记过的就只显示 tag。
@@ -271,22 +281,21 @@ const renderPeers = (peers, chatName) => {
271
281
  const clis = uniq(peers.map((p) => p.cli));
272
282
  const shared = [dirs.length === 1 ? dirs[0] : "", clis.length === 1 ? clis[0] : ""].filter(Boolean);
273
283
  // 家谱只画直系: 谁是谁的分身。整棵树留给 AI 侧的 wizard_roster —— 群里一行放不下。
274
- const parentTag = (target) => {
284
+ // 每个 tag 都点得开它的 chat 详情页。
285
+ const addrOf = (target) => tagLink(target, `.${displayName(target) || "?"}`);
286
+ const parentAddr = (target) => {
275
287
  const p = reg?.get(target)?.parent;
276
- return p ? tagOfKey(p) || "默认" : "";
288
+ return p ? addrOf(p) : "";
277
289
  };
278
290
  const rows = peers.flatMap((p) => {
279
291
  const rec = reg?.get(p.target);
280
- const addr = p.tag ? `#${p.tag}` : "默认";
281
- const name = wizardName(rec, chatName, p.target);
282
- // 名字推导不出新信息时 (就是 tag 本身) 不重复印一遍。
283
- const title = name && name !== p.tag && name !== chatName ? `${name} \`${addr}\`` : addr;
292
+ const title = addrOf(p.target);
284
293
  const state = !p.paneAlive ? "⚫️ 已关闭" : p.busy ? "🔴 忙" : "🟢 空闲";
285
- const from = parentTag(p.target);
294
+ const from = parentAddr(p.target);
286
295
  const varies = [
287
296
  dirs.length > 1 ? dirOf(p) : "",
288
297
  clis.length > 1 ? p.cli : "",
289
- from ? `分身自 #${from}` : "",
298
+ from ? `分身自 ${from}` : "",
290
299
  ].filter(Boolean);
291
300
  const me = p.self ? " ⬅️ 本会话" : "";
292
301
  return [
@@ -301,19 +310,18 @@ const renderPeers = (peers, chatName) => {
301
310
  `[wezard] 本聊天${named ? ` \`${named}\`` : ""}的 wizard · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
302
311
  "",
303
312
  ...rows,
304
- "> 协作:直接说「看下 #fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
313
+ "> 协作:直接说「看下 .fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
305
314
  "> 分身:说「分个身去干 X」— AI 会 clone 一个带着当前上下文的 wizard,干完再收掉",
306
315
  // 空行只在这一处按需省略 —— 上面那些是有意的分隔, 不能被一把 filter 掉。
307
- ...(named ? [] : ["> 起名:`/name <名字>` — 起了名字,别的聊天才叫得到这里的 wizard(`名字#tag`)"]),
316
+ ...(named ? [] : ["> 起名:`/name <名字>` — 聊天名就是这里默认 wizard 的名字"]),
308
317
  ].join("\n");
309
318
  };
310
- // /chats — 跨聊天目录。命名的聊天可以被 `名字#tag` 精确寻址;没命名的只能靠
311
- // 「全局唯一 tag」碰运气,所以这里把「未命名」显式标出来当作行动号召。
319
+ // /chats — 跨聊天目录: 每个聊天里住着哪些 wizard (`.name`, 全局可达)。
312
320
  const renderChats = (roster) => {
313
321
  if (roster.length === 0)
314
322
  return "[wezard] 还没有任何聊天在跑会话。";
315
323
  const rows = roster.flatMap((c) => {
316
- const sessions = c.targets.map((t) => (tagOfKey(t) ? `#${tagOfKey(t)}` : "默认")).join(" · ") || "(无)";
324
+ const sessions = c.targets.map((t) => tagLink(t, `.${displayName(t) || "?"}`)).join(" · ") || "(无)";
317
325
  const head = c.name ? `\`${c.name}\`` : `_(未命名)_ \`${c.base}\``;
318
326
  return [`**${head}**${c.self ? " ⬅️ 本聊天" : ""} · ${c.targets.length} 个会话`, ` ${sessions}`, ""];
319
327
  });
@@ -321,7 +329,7 @@ const renderChats = (roster) => {
321
329
  `[wezard] 已知的聊天 · ${roster.length} 个`,
322
330
  "",
323
331
  ...rows,
324
- "> 跨聊天寻址:`名字#tag`(如 `daily#fix`)。未命名的聊天先在里面发 `/name <名字>`",
332
+ "> 寻址:`.名字`(如 `.fix`),全局唯一,在任何群里都叫得到",
325
333
  ].join("\n");
326
334
  };
327
335
  // Match a switch arg against a scanned session: animal emoji label, full
@@ -428,49 +436,42 @@ const canonContains = (haystack, needle) => {
428
436
  };
429
437
  const isLastResponseQuote = (target, quoted) => canonContains(getLastResponse(target) ?? "", quoted);
430
438
  // ── 引用即路由 ─────────────────────────────────────────────────────────
431
- // 群里要跟 `#fix` 说话,手打 tag 太慢 —— 直接引用它的气泡即可。每条出站气泡都
432
- // 带 `emoji #tag` 头 (withTagHeader),所以引用文本自带路由信息;用户自己发的
433
- // 行首 `#fix 干活` 同样算数(限行首,否则正文里随手写的 #123 会误判)。
439
+ // 群里要跟 `.fix` 说话,手打名字太慢 —— 直接引用它的气泡即可。每条出站气泡都
440
+ // 带 `emoji .name` 头 (withTagHeader),所以引用文本自带路由信息;用户自己发的
441
+ // 行首 `.fix 干活` 同样算数(限行首,否则正文里随手写的 .gitignore 会误判)。
434
442
  // `body` 是剥掉头/tag 后的净引用内容,用于跟目标 context 比对。
435
443
  // `tag` = 路由目标(引用继承的投递 tag)。
436
- // `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji #tag` 头)。
444
+ // `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji .name` 头)。
437
445
  // 去重要比对的是「内容在不在源会话」,而非路由目标: 带着引用新建 /
438
446
  // 改投到别的 tag 时,目标会话是空的,只有源会话里才有那段原文。
439
447
  // `srcTag===undefined` 表示源未知(用户自己打的引用),回退到按目标查。
440
- const parseQuote = (q) => {
448
+ const QUOTE_NAME_RE = nameTokenRe();
449
+ const parseQuote = (q, known) => {
441
450
  const raw = q ? quoteToText(q).trim() : "";
442
451
  if (!raw)
443
452
  return null;
444
453
  const head = parseTagHeader(raw);
445
454
  if (head.fromBot)
446
455
  return { tag: head.tag, srcTag: head.tag, body: head.body };
447
- const m = TAG_RE.exec(raw);
448
- return m && m.index === 0 ? { tag: m[2] ?? "", body: parseTag(raw).cleaned } : { tag: "", body: raw };
456
+ const m = QUOTE_NAME_RE.exec(raw);
457
+ return m && m.index === 0 ? { tag: m[2] ?? "", body: parseTag(raw, known).cleaned } : { tag: "", body: raw };
449
458
  };
450
- // 一条入站消息的最终「投递目标 tag + 给 claude 的正文」。text / image / mixed
451
- // 三条路径共用,两条规则:
452
- // 1. 引用自带的 tag 决定投递目标;引用之外自己打的 `#tag` 优先级更高。
453
- // 2. 引用内容若已经在目标会话的 context 尾部,就只保留上面那层路由绑定、正文
454
- // 丢弃(重复贴回去纯属污染);不在则说明它是真载荷(跨会话转发 / 引同事的
455
- // 消息 / 目标已 `/clear`),照旧渲染成 markdown 引用块。
456
- // 纯引用不打字时,沿用旧的"把引用内容提成正文"重触发路径 —— 但同样只在内容不
457
- // 在目标上下文里时才有意义,否则那只是一次对该会话的空 nudge。
458
- const composeInbound = (msg, rawBody, inContext, stripAt) => {
459
- const { tag: typed, cleaned } = parseTag(rawBody);
460
- const q = parseQuote(msg.quote);
459
+ const composeInbound = (msg, rawBody, inContext, stripAt, addr) => {
460
+ const { tag: typed, cleaned } = parseTag(rawBody, addr.known);
461
+ const q = parseQuote(msg.quote, addr.known);
461
462
  const tag = typed || q?.tag || "";
462
463
  // 去重比对的会话: 若引用来自某个 bot 会话(srcTag 已知),查那个源会话 ——
463
464
  // 内容天然存在于源的 transcript, 与你把它投到哪个 tag 无关。源未知时(用户自打
464
465
  // 的引用 / 改投)回退到路由目标。这修掉了「带引用新建/改投会话时原文被重复注入」。
465
466
  const dedupTag = q?.srcTag ?? tag;
466
467
  // 剥完头什么都不剩(折叠气泡这类纯 chrome 的引用)⇒ 没有可搬运的内容,只留路由。
467
- const consumed = !q || !q.body.trim() || inContext(sessionKey(chatPrincipal(msg), dedupTag), q.body);
468
+ const consumed = !q || !q.body.trim() || inContext(addr.resolve(chatPrincipal(msg), dedupTag), q.body);
468
469
  if (cleaned.trim()) {
469
470
  return { text: consumed ? cleaned : `${renderQuotePrefix(q.body)}${cleaned}`, tag, promoted: false };
470
471
  }
471
472
  if (q && !consumed) {
472
473
  // 提成正文时也剥一次 @mention,让 "@wezard /usage" → "/usage" 命中命令路径。
473
- const p = parseTag(stripAt(msg, q.body).trim());
474
+ const p = parseTag(stripAt(msg, q.body).trim(), addr.known);
474
475
  return { text: p.cleaned, tag: p.tag || tag, promoted: true };
475
476
  }
476
477
  return { text: cleaned, tag, promoted: false };
@@ -486,9 +487,9 @@ const isAllowed = (cfg, principals) => {
486
487
  return principals.some((p) => allowed.has(p));
487
488
  };
488
489
  // Mirror mode grants implicit talkback: any chat that's currently a mirror
489
- // target can post back without being in `allowFrom`. The act of /wrc'ing into
490
- // that chat is the authorization signal.
491
- const isMirrorTarget = (bridge, who) => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(who);
490
+ // target can post back without being in `allowFrom`. A session bound to that
491
+ // chat is the authorization signal.
492
+ const isMirrorTarget = (bridge, who) => bridge.hasMirrorTarget(who);
492
493
  // Sniff extension from magic bytes; falls back to .bin. WeCom doesn't always
493
494
  // give us a filename for images, and we want claude's Read tool to recognize
494
495
  // the file (it dispatches on extension).
@@ -526,39 +527,33 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
526
527
  // 已知的机器人名只随配置变; 下面所有入站路径共用这一个剥离器。
527
528
  const botNames = [...BUILTIN_BOT_NAMES, ...cfg.wrc.botNames.filter(Boolean)];
528
529
  const stripAt = (msg, text) => maybeStripMentions(botNames, msg, text);
529
- // Render /pwd output. Mirror mode reads the live attachment + persisted
530
- // store via bridge.getCwd; headless mode has no per-chat cwd, so it just
531
- // shows cfg.wrc.cwd as the global default.
530
+ // Render /pwd output from the live attachment + persisted store (bridge.getCwd).
532
531
  const renderPwd = (who) => {
533
- if ("getCwd" in bridge) {
534
- const { runningCwd, pendingCwd, defaultCwd } = bridge.getCwd(who);
535
- const lines = [`[wezard] 📂 当前项目: \`${runningCwd}\``];
536
- if (pendingCwd && pendingCwd !== runningCwd) {
537
- lines.push(`下次切换: \`${pendingCwd}\` (使用 /new 或 /clear 生效)`);
538
- }
539
- if (runningCwd !== defaultCwd)
540
- lines.push(`(默认: \`${defaultCwd}\`)`);
541
- lines.push("> 切换其他项目: 对 AI 说「切到 /path/to/proj」(`set_workspace` 工具直接换目录重开)");
542
- return lines.join("\n");
532
+ const { runningCwd, pendingCwd, defaultCwd } = bridge.getCwd(who);
533
+ const lines = [`[wezard] 📂 当前项目: \`${runningCwd}\``];
534
+ if (pendingCwd && pendingCwd !== runningCwd) {
535
+ lines.push(`下次切换: \`${pendingCwd}\` (使用 /new 或 /clear 生效)`);
543
536
  }
544
- return `[wezard] 📂 当前项目: \`${expandHome(cfg.wrc.cwd)}\` (headless mode, 全局默认)`;
537
+ if (runningCwd !== defaultCwd)
538
+ lines.push(`(默认: \`${defaultCwd}\`)`);
539
+ if (bridge.cwdUnconfirmed(who))
540
+ lines.push("⚠️ 这是默认兜底目录, 还没人确认过 —— 要换就说「切到 /path/to/proj」");
541
+ lines.push("> 切换其他项目: 对 AI 说「切到 /path/to/proj」(`set_workspace` 工具直接换目录重开)");
542
+ return lines.join("\n");
545
543
  };
546
- // Mirror-only auto-spawn / /new helper. Routes through bridge.newSession
544
+ // Auto-spawn / /new helper. Routes through bridge.newSession
547
545
  // which kills the old pane, spawns fresh in pendingCwd ?? runningCwd ??
548
546
  // default, attaches, and pushes "📂 当前项目" info to the chat. Returns
549
- // the user-facing one-line ack. When `who` carries a `#tag` suffix, use
550
- // the raw tag as the tmux window name so the pane shows readably in the
551
- // status bar (e.g. `#docs` → window `docs`, not the principal slug).
547
+ // the user-facing one-line ack. The wizard's global name doubles as the
548
+ // tmux window name so the pane shows readably in the status bar (e.g.
549
+ // `.docs` → window `docs`, not the principal slug).
552
550
  // On success there is NO reply: newSession already pushed the single
553
551
  // "created + cwd" bubble. Only failures produce user-facing text.
554
552
  const spawnSession = async (who, cli, silent, model) => {
555
- if (!("newSession" in bridge))
556
- return { err: "[wezard] /new only available in mirror mode" };
557
- const tag = tagOf(who);
558
- const r = await bridge.newSession(who, tag || who, cli, { silent, model });
553
+ const r = await bridge.newSession(who, displayName(who) || tagOf(who) || who, cli, { silent, model });
559
554
  return r.ok ? {} : { err: `[wezard] /new failed: ${r.reason ?? "unknown"}` };
560
555
  };
561
- // 同一 `#tag` 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
556
+ // 同一 wizard 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
562
557
  // newSession 会 kill 掉前者的 pane,前者的 dispatch 再 `--resume` 重生出孤儿
563
558
  // pane,消息乱序。spawn 窗口有 3s+(TUI_SETTLE_MS),所以必须按会话串行。
564
559
  const spawnQ = new Map();
@@ -571,15 +566,17 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
571
566
  return next;
572
567
  };
573
568
  // 显式 /new:排队但仍强制重开(用户就是要换一个)。
574
- const autoSpawnAndAttach = (who, cli, model) => serializeSpawn(who, () => spawnSession(who, cli, false, model));
575
- // 隐式建会话(裸 `#tag` 第一条消息):轮到自己时若前一条已经把会话建好,直接
569
+ // 默认会话先把名字换回聊天名, 再 spawn —— charter 在 spawn 时按名字渲染。
570
+ const autoSpawnAndAttach = (who, cli, model) => serializeSpawn(who, () => {
571
+ reclaimChatName(wizardStore(), chatNameOf(cfg, who), who);
572
+ return spawnSession(who, cli, false, model);
573
+ });
574
+ // 隐式建会话(新 `.name` 的第一条消息):轮到自己时若前一条已经把会话建好,直接
576
575
  // 复用,不再 respawn —— 否则先到的消息会被注入进一个刚被杀掉的 pane。
577
- const ensureSession = (who) => serializeSpawn(who, async () => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(who) ? {} : await spawnSession(who, undefined, true));
578
- // Prefix user-visible daemon replies with `<emoji> #tag` when the routed
579
- // session is tagged, so a chat hosting multiple concurrent tagged sessions
580
- // stays visually disambiguated. Untagged (default) session passes through
581
- // unchanged. Emoji is derived from the tag string (not sessionId) so it
582
- // stays stable across /clear cycles.
576
+ const ensureSession = (who) => serializeSpawn(who, async () => bridge.hasMirrorTarget(who) ? {} : await spawnSession(who, undefined, true));
577
+ // Prefix user-visible daemon replies with `<emoji> .name`, so a chat hosting
578
+ // several wizards stays visually disambiguated. Emoji is derived from the
579
+ // name (not sessionId) so it stays stable across /clear cycles.
583
580
  const withTagPrefix = withTagHeader;
584
581
  const replyText = async (frame, msg, who, text) => {
585
582
  try {
@@ -592,8 +589,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
592
589
  const gate = async (frame, msg, text, who) => {
593
590
  const auths = authPrincipals(msg);
594
591
  // Bootstrap / allowFrom operations are chat-scoped, not session-scoped;
595
- // strip any `#tag` suffix so a first-time user typing `hello #foo`
596
- // still promotes them as `user:xxx` (not `user:xxx#foo`).
592
+ // use the chat principal, not the routed wizard — a first-time user typing
593
+ // `.foo hello` still promotes them as `user:xxx` (not `user:xxx#foo`).
597
594
  const basePrincipal = chatPrincipal(msg);
598
595
  // /id — bypass allowFrom so users can discover their ids before configuring.
599
596
  if (isIdCommand(text)) {
@@ -637,13 +634,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
637
634
  // Untagged form falls back to the caller's own mirror binding.
638
635
  const audit = parseAuditCommand(text);
639
636
  if (audit) {
640
- const mirror = "status" in bridge
641
- ? resolveAuditMirror(bridge.status().mirrors, audit.tag, who, chatPrincipal(msg))
642
- : undefined;
637
+ const mirror = resolveAuditMirror(bridge.status().mirrors, audit.tag, who, chatPrincipal(msg));
643
638
  let body;
644
639
  if (!mirror) {
645
640
  body = audit.tag
646
- ? `[wezard] /audit: 未找到 tag \`${audit.tag}\` 对应的 Agent 会话。`
641
+ ? `[wezard] /audit: 未找到 \`.${audit.tag}\` 对应的 Agent 会话。`
647
642
  : `[wezard] /audit: 未找到 ${who} 绑定的 Agent 会话。先 \`/new\` 或用 \`wezard mirror\` 绑定后再试。`;
648
643
  }
649
644
  else {
@@ -725,8 +720,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
725
720
  }
726
721
  // Authorized `/new` — spawn a tmux+claude pair and attach it to this chat.
727
722
  // Runs BEFORE the mirror-not-attached short-circuit so it works as the
728
- // very first message from a fresh user. When routed with a `#tag`, the
729
- // tag becomes both the mirror-store key and the tmux window name.
723
+ // very first message from a fresh user. When routed with a `.name`, it
724
+ // picks which wizard (re)spawns; the name is also the tmux window name.
730
725
  const nu = parseNewCommand(text);
731
726
  if (nu) {
732
727
  const { err } = await autoSpawnAndAttach(who, nu.cli, nu.model);
@@ -741,62 +736,45 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
741
736
  return { stop: true };
742
737
  }
743
738
  // Authorized `/stop` — Esc the live pane to interrupt whatever Claude is
744
- // currently doing. Mirror-mode only; bails cleanly when no attachment.
739
+ // currently doing. Bails cleanly when no attachment.
745
740
  if (isStopCommand(text)) {
746
- if ("interruptPane" in bridge) {
747
- // teardown: /stop is the user's "shut this up" button, so it must also
748
- // close hanging bubbles and free the inject queue — not just press Esc.
749
- // Stay silent on a clean stop (the pane going quiet IS the receipt);
750
- // only speak up when a half actually failed — a live pane that refuses
751
- // Esc is a very different situation from a chat merely stuck on a bubble.
752
- const r = await bridge.interruptPane(who, { teardown: true });
753
- if (!r.ok) {
754
- await replyText(frame, msg, who, `[wezard] /stop failed: ${r.reason ?? "unknown"}`);
755
- }
756
- else if (!r.escOk) {
757
- const torndown = r.torndown ? ` · 已收口 ${r.torndown} 个挂起气泡` : "";
758
- await replyText(frame, msg, who, `⚠️ Esc 未送达(${r.escReason ?? "unknown"})${torndown} · 保活已暂停`);
759
- }
741
+ // teardown: /stop is the user's "shut this up" button, so it must also
742
+ // close hanging bubbles and free the inject queue — not just press Esc.
743
+ // Stay silent on a clean stop (the pane going quiet IS the receipt);
744
+ // only speak up when a half actually failed — a live pane that refuses
745
+ // Esc is a very different situation from a chat merely stuck on a bubble.
746
+ const r = await bridge.interruptPane(who, { teardown: true });
747
+ if (!r.ok) {
748
+ await replyText(frame, msg, who, `[wezard] /stop failed: ${r.reason ?? "unknown"}`);
749
+ }
750
+ else if (!r.escOk) {
751
+ const torndown = r.torndown ? ` · 已收口 ${r.torndown} 个挂起气泡` : "";
752
+ await replyText(frame, msg, who, `⚠️ Esc 未送达(${r.escReason ?? "unknown"})${torndown} · 保活已暂停`);
760
753
  }
761
754
  return { stop: true };
762
755
  }
763
756
  // Authorized `/kill` — end this session for good: Esc the pane, kill it,
764
- // and drop the binding (no `--resume` resurrection). Routed by `#tag` like
757
+ // and drop the binding (no `--resume` resurrection). Routed by `.name` like
765
758
  // /stop, so `/kill #docs` only takes down that sibling.
766
759
  if (isKillCommand(text)) {
767
- if (!("killPane" in bridge)) {
768
- await replyText(frame, msg, who, "[wezard] /kill only available in mirror mode");
769
- }
770
- else {
771
- const r = await bridge.killPane(who);
772
- await replyText(frame, msg, who, r.ok ? "🗑️ 会话已结束,pane 已移除" : `[wezard] /kill failed: ${r.reason ?? "unknown"}`);
773
- }
760
+ const r = await bridge.killPane(who);
761
+ await replyText(frame, msg, who, r.ok ? "🗑️ 会话已结束,pane 已移除" : `[wezard] /kill failed: ${r.reason ?? "unknown"}`);
774
762
  return { stop: true };
775
763
  }
776
764
  // Authorized `/n` — send a bare Enter to the live pane. Confirms a prompt /
777
765
  // dismisses a "press enter to continue", or submits whatever's already in
778
- // the input box. Mirror-mode only; bails cleanly when no attachment.
766
+ // the input box. Bails cleanly when no attachment.
779
767
  if (isEnterCommand(text)) {
780
- if (!("submitPane" in bridge)) {
781
- await replyText(frame, msg, who, "[wezard] /n only available in mirror mode");
782
- }
783
- else {
784
- const r = await bridge.submitPane(who);
785
- await replyText(frame, msg, who, r.ok ? "Enter sent" : `[wezard] /n failed: ${r.reason ?? "unknown"}`);
786
- }
768
+ const r = await bridge.submitPane(who);
769
+ await replyText(frame, msg, who, r.ok ? "Enter sent" : `[wezard] /n failed: ${r.reason ?? "unknown"}`);
787
770
  return { stop: true };
788
771
  }
789
772
  // Authorized `/reveal` — switch the attached tmux client to this session's
790
- // pane so the user lands in the terminal showing the live TUI. Mirror-mode
791
- // only; routed by `#tag` like any other session command.
773
+ // pane so the user lands in the terminal showing the live TUI. Routed by
774
+ // `.name` like any other session command.
792
775
  if (isRevealCommand(text)) {
793
- if (!("revealPane" in bridge)) {
794
- await replyText(frame, msg, who, "[wezard] /reveal only available in mirror mode");
795
- }
796
- else {
797
- const r = await bridge.revealPane(who);
798
- await replyText(frame, msg, who, r.ok ? "✅ 已切到本会话的 tmux 窗口" : `[wezard] /reveal failed: ${r.reason ?? "unknown"}`);
799
- }
776
+ const r = await bridge.revealPane(who);
777
+ await replyText(frame, msg, who, r.ok ? "✅ 已切到本会话的 tmux 窗口" : `[wezard] /reveal failed: ${r.reason ?? "unknown"}`);
800
778
  return { stop: true };
801
779
  }
802
780
  // /cfgsync [apply] — 3-way merge of the bound project's per-CLI config
@@ -804,7 +782,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
804
782
  // .codebuddy/...). Writes files, so it sits AFTER the allowFrom gate.
805
783
  const cs = parseCfgSyncCommand(text);
806
784
  if (cs) {
807
- const cwd = "getCwd" in bridge ? bridge.getCwd(who).runningCwd : expandHome(cfg.wrc.cwd);
785
+ const cwd = bridge.getCwd(who).runningCwd;
808
786
  let body;
809
787
  try {
810
788
  body = renderSyncReport(await syncProjectConfig(cwd, cs.apply));
@@ -824,8 +802,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
824
802
  const cur = chatNameOf(cfg, who);
825
803
  if (!nc.arg) {
826
804
  await replyText(frame, msg, who, cur
827
- ? `[wezard] 本聊天名为 \`${cur}\` — 别处可用 \`${cur}#tag\` 寻址本聊天的会话`
828
- : "[wezard] 本聊天还没起名。`/name <名字>` 起一个,别的聊天才能用 `名字#tag` 叫到这里的会话。");
805
+ ? `[wezard] 本聊天名为 \`${cur}\``
806
+ : "[wezard] 本聊天还没起名。`/name <名字>` 起一个。");
829
807
  return { stop: true };
830
808
  }
831
809
  if (nc.arg === "-") {
@@ -835,26 +813,18 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
835
813
  }
836
814
  const r = setChatName(cfg, sourcePath, who, nc.arg);
837
815
  await replyText(frame, msg, who, r.ok
838
- ? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""} — 别处用 \`${r.name}#tag\` 即可寻址`
816
+ ? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""}`
839
817
  : `[wezard] /name failed: ${r.reason}`);
840
818
  return { stop: true };
841
819
  }
842
820
  // /chats — 跨聊天目录:谁有名字、各自跑着哪些会话。Read-only。
843
821
  if (isChatsCommand(text)) {
844
- if (!("chatRoster" in bridge)) {
845
- await replyText(frame, msg, who, `[wezard] headless 模式无会话目录。已命名的聊天:${listChatNames(cfg).map((c) => c.name).join(", ") || "(无)"}`);
846
- return { stop: true };
847
- }
848
822
  await replyText(frame, msg, who, renderChats(bridge.chatRoster(who)));
849
823
  return { stop: true };
850
824
  }
851
- // /peers — this chat's own session roster (default + `#tag` siblings), with
852
- // live busy state. Read-only, mirror-mode only.
825
+ // /peers — this chat's own wizard roster (default + siblings), with
826
+ // live busy state. Read-only.
853
827
  if (isPeersCommand(text)) {
854
- if (!("peers" in bridge)) {
855
- await replyText(frame, msg, who, "[wezard] /peers only available in mirror mode");
856
- return { stop: true };
857
- }
858
828
  let body;
859
829
  try {
860
830
  body = renderPeers(await bridge.peers(who), chatNameOf(cfg, who));
@@ -871,10 +841,6 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
871
841
  // path as the /sessions/switch route so IM and MCP behave identically.
872
842
  const sc = parseSessionsCommand(text);
873
843
  if (sc) {
874
- if (!("attach" in bridge)) {
875
- await replyText(frame, msg, who, "[wezard] /sessions only available in mirror mode");
876
- return { stop: true };
877
- }
878
844
  let sessions = [];
879
845
  try {
880
846
  sessions = await scanClaudeSessions();
@@ -904,11 +870,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
904
870
  : `[wezard] 切换失败: ${att.reason ?? "unknown"}`);
905
871
  return { stop: true };
906
872
  }
907
- // Mirror mode but no Claude session attached for this chat yet. Since the
873
+ // No Claude session attached for this chat yet. Since the
908
874
  // sender is already in allowFrom, we treat that authorization as license
909
875
  // to auto-spawn: this inbound becomes both the binding signal and the
910
876
  // first prompt — attach, then fall through to dispatch.
911
- if ("hasMirrorTarget" in bridge && !bridge.hasMirrorTarget(who)) {
877
+ if (!bridge.hasMirrorTarget(who)) {
912
878
  const { err } = await ensureSession(who);
913
879
  if (err) {
914
880
  await replyText(frame, msg, who, err);
@@ -919,9 +885,9 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
919
885
  return { stop: false };
920
886
  };
921
887
  // 「引用内容是否已经在目标会话的 context 里」。两级:先查刚发出去的最后一条
922
- // 气泡(内存,headless 模式也有);miss 再读目标会话 transcript 的尾部若干轮
923
- // —— 引用的往往是几轮之前的气泡,只比对最后一条会漏。目标未挂载(尚未 attach /
924
- // headless)时读不到 transcript,退化成"保留引用",宁可多给上下文。
888
+ // 气泡(内存);miss 再读目标会话 transcript 的尾部若干轮
889
+ // —— 引用的往往是几轮之前的气泡,只比对最后一条会漏。目标未挂载(尚未 attach)
890
+ // 时读不到 transcript,退化成"保留引用",宁可多给上下文。
925
891
  const quoteInContext = (target, quoted) => {
926
892
  if (isLastResponseQuote(baseOfKey(target), quoted)) {
927
893
  log.info({ target, reason: "lastResponse" }, "quoteInContext: hit");
@@ -929,12 +895,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
929
895
  }
930
896
  // 源会话的 last stream 还没收口 ⇒ 引用的是实时中间态 (URL + 最新 CoT/工具行),
931
897
  // 只保留路由 tag, 不把瞬态内容贴回 prompt。
932
- if ("isOpenBubbleQuote" in bridge && bridge.isOpenBubbleQuote(target, quoted)) {
898
+ if (bridge.isOpenBubbleQuote(target, quoted)) {
933
899
  log.info({ target, reason: "openBubble" }, "quoteInContext: hit");
934
900
  return true;
935
901
  }
936
- const mirrors = bridge
937
- .status?.().mirrors ?? [];
902
+ const mirrors = bridge.status().mirrors;
938
903
  const jsonl = mirrors.find((m) => m.target === target)?.jsonlPath;
939
904
  if (!jsonl) {
940
905
  log.info({ target, mirrorCount: mirrors.length, mirrorTargets: mirrors.map((m) => m.target) }, "quoteInContext: no jsonl for target");
@@ -947,69 +912,40 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
947
912
  log.info({ target, jsonl, tailLen: tail.length, quotedLen: quoted.length, hit }, "quoteInContext: tail check");
948
913
  return hit;
949
914
  };
950
- // 路由用掉的那个 `#tag` 已被 parseTag 摘走,正文里剩下的每个 `#x` 都可能是
951
- // 用户在指另一个会话。解析走 bridge 自己的 resolvePeerTag —— peer 工具用的
952
- // 同一套(本 chat 优先、否则全局唯一 tag、再否则带 chat 名的全称),所以标注
953
- // 出来的地址一定是 peek_peer/send_peer 打得中的;解析不到的 `#123`/`#L45` 与
954
- // 自指静默略过。给的是 `address` 而不是裸 tag —— 跨 chat 时裸 tag 未必唯一。
915
+ // 路由用掉的那个 `.name` 已被 parseTag 摘走,正文里剩下的每个 `.x` 都可能是
916
+ // 用户在指另一个 wizard。解析走 bridge 自己的 resolvePeerTag —— peer 工具用的
917
+ // 同一套(全局名册),所以标注出来的地址一定是 peek_peer/send_peer 打得中的;
918
+ // 解析不到的 `.foo` 与自指静默略过。
955
919
  const peerMentions = (who, text) => {
956
- if (!("resolvePeerTag" in bridge))
957
- return [];
958
920
  const mb = bridge;
959
921
  const seen = new Set();
960
- // Pass 1: standalone `#tag` tokens (existing behavior)
961
- const fromTags = allTags(text).flatMap((tag) => {
962
- const r = mb.resolvePeerTag(who, tag);
963
- if (!r.ok || r.target === who)
922
+ return allNames(text).flatMap((name) => {
923
+ const r = mb.resolvePeerTag(who, name);
924
+ if (!r.ok || r.target === who || seen.has(r.target))
964
925
  return [];
965
926
  seen.add(r.target);
966
927
  const { runningCwd, defaultCwd } = mb.getCwd(r.target);
967
928
  return [{
968
- tag,
929
+ tag: name,
969
930
  target: r.target,
970
931
  address: peerAddress(cfg, who, r.target),
971
932
  chat: chatNameOf(cfg, r.target),
972
933
  foreign: r.foreign,
973
- label: labelFor(tag),
934
+ label: labelFor(peerAddress(cfg, who, r.target)),
974
935
  cwd: runningCwd || defaultCwd,
975
936
  }];
976
937
  });
977
- // Pass 2: compound `chatName#tag` patterns (invisible to TAG_TOKEN because
978
- // `#` isn't preceded by whitespace). Feed the raw compound to resolvePeerTag
979
- // which internally splits via parsePeerRef.
980
- const fromCompound = allCompoundAddresses(text).flatMap((addr) => {
981
- const r = mb.resolvePeerTag(who, addr.raw);
982
- if (r.ok) {
983
- if (r.target === who || seen.has(r.target))
984
- return [];
985
- seen.add(r.target);
986
- const { runningCwd, defaultCwd } = mb.getCwd(r.target);
987
- return [{
988
- tag: addr.tag,
989
- target: r.target,
990
- address: peerAddress(cfg, who, r.target),
991
- chat: chatNameOf(cfg, r.target),
992
- foreign: r.foreign,
993
- label: labelFor(addr.tag),
994
- cwd: runningCwd || defaultCwd,
995
- }];
996
- }
997
- // Session doesn't exist but chat name is valid → unborn peer hint
998
- if (chatBaseOf(cfg, addr.chat)) {
999
- return [{
1000
- tag: addr.tag,
1001
- target: "",
1002
- address: `${addr.chat}#${addr.tag}`,
1003
- chat: addr.chat,
1004
- foreign: true,
1005
- label: labelFor(addr.tag),
1006
- cwd: "",
1007
- unborn: true,
1008
- }];
1009
- }
1010
- return [];
1011
- });
1012
- return [...fromTags, ...fromCompound];
938
+ };
939
+ // 名字解析的两个问题: 这个名字认不认识 (决定正文里哪个 `.x` 是路由)、它指向谁。
940
+ const live = (t) => bridge.hasMirrorTarget(t);
941
+ const addrFor = (base) => ({
942
+ known: (name) => !!wizardStore()?.byName(name) || live(keyOf(base, normalizeTag(name))),
943
+ resolve: (b, name) => resolveName(b, name, live),
944
+ });
945
+ const route = (msg, raw) => {
946
+ const base = chatPrincipal(msg);
947
+ const c = composeInbound(msg, raw, quoteInContext, stripAt, addrFor(base));
948
+ return { ...c, who: resolveName(base, c.tag, live) };
1013
949
  };
1014
950
  const send = async (frame, msg, who, text, images = []) => {
1015
951
  // 斜杠命令按行解析,尾巴上多挂一段会让它不再被识别成命令 —— 只标注普通消息。
@@ -1017,7 +953,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1017
953
  // 同一条边界上再挂一段: 这个 wizard 不在场时群里发生的成员变动 (见 notices.ts)。
1018
954
  const notice = noticeSuffixFor(who, text);
1019
955
  try {
1020
- await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid });
956
+ // 回复回到发话的这个群 —— `who` 可能住在别的聊天 (名字全局可达)。
957
+ await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid, channel: chatPrincipal(msg), speaker: `user:${msg.from.userid}` });
1021
958
  }
1022
959
  catch (e) {
1023
960
  log.error({ err: e.message }, "bridge dispatch failed");
@@ -1031,8 +968,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1031
968
  const msg = frame.body;
1032
969
  if (!msg)
1033
970
  return;
1034
- const { text, tag, promoted } = composeInbound(msg, stripAt(msg, msg.text?.content ?? ""), quoteInContext, stripAt);
1035
- const who = sessionKey(chatPrincipal(msg), tag);
971
+ const { text, tag, promoted, who } = route(msg, stripAt(msg, msg.text?.content ?? ""));
1036
972
  log.info({ msgid: msg.msgid, len: text.length, tag, hasQuote: !!msg.quote, promoted }, "rx text");
1037
973
  const { stop } = await gate(frame, msg, text, who);
1038
974
  if (stop)
@@ -1046,8 +982,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1046
982
  log.info({ msgid: msg.msgid, hasQuote: !!msg.quote }, "rx image");
1047
983
  // Images carry no text of their own — the quote (if any) is the only
1048
984
  // routing signal; without it they land on the chat's default session.
1049
- const { text, tag } = composeInbound(msg, "", quoteInContext, stripAt);
1050
- const who = sessionKey(chatPrincipal(msg), tag);
985
+ const { text, who } = route(msg, "");
1051
986
  const { stop } = await gate(frame, msg, "", who);
1052
987
  if (stop)
1053
988
  return;
@@ -1070,14 +1005,13 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1070
1005
  if (!msg)
1071
1006
  return;
1072
1007
  log.info({ msgid: msg.msgid, items: msg.mixed?.msg_item?.length, hasQuote: !!msg.quote }, "rx mixed");
1073
- // Concatenate all text items to sniff a leading `#tag`, then strip it from
1008
+ // Concatenate all text items to sniff a routing `.name`, then strip it from
1074
1009
  // the effective body before forwarding to Claude.
1075
1010
  const rawText = (msg.mixed?.msg_item ?? [])
1076
1011
  .filter((it) => it.msgtype === "text")
1077
1012
  .map((it) => it.text?.content ?? "")
1078
1013
  .join("\n");
1079
- const { tag } = composeInbound(msg, stripAt(msg, rawText), quoteInContext, stripAt);
1080
- const who = sessionKey(chatPrincipal(msg), tag);
1014
+ const { who } = route(msg, stripAt(msg, rawText));
1081
1015
  const { stop } = await gate(frame, msg, "", who);
1082
1016
  if (stop)
1083
1017
  return;
@@ -1098,10 +1032,10 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1098
1032
  }
1099
1033
  if (texts.length === 0 && images.length === 0 && !msg.quote)
1100
1034
  return;
1101
- // Re-compose on the per-item stripped text: drops the routing `#tag` (it was
1035
+ // Re-compose on the per-item stripped text: drops the routing `.name` (it was
1102
1036
  // consumed above; leaving it in would leak into Claude) and attaches the
1103
1037
  // quote only when it isn't already in the target's context.
1104
- await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt).text, images);
1038
+ await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt, addrFor(chatPrincipal(msg))).text, images);
1105
1039
  });
1106
1040
  // template_card_event is handled in approval module; no listener here.
1107
1041
  };