wezard 1.4.1 → 2.0.0

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 (71) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +227 -112
  3. package/cli/wezard.sh +6 -3
  4. package/dist/daemon/approval.js +9 -3
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/cc-bridge.js.map +1 -1
  7. package/dist/daemon/chat-name.js +8 -14
  8. package/dist/daemon/chat-name.js.map +1 -1
  9. package/dist/daemon/detail.js +94 -9
  10. package/dist/daemon/detail.js.map +1 -1
  11. package/dist/daemon/graph.js +5 -7
  12. package/dist/daemon/graph.js.map +1 -1
  13. package/dist/daemon/inbound.js +135 -158
  14. package/dist/daemon/inbound.js.map +1 -1
  15. package/dist/daemon/index.js +452 -156
  16. package/dist/daemon/index.js.map +1 -1
  17. package/dist/daemon/jobs.js +2 -2
  18. package/dist/daemon/mirror-bridge.js +284 -140
  19. package/dist/daemon/mirror-bridge.js.map +1 -1
  20. package/dist/daemon/mirror-store.js.map +1 -1
  21. package/dist/daemon/model-select.js +74 -0
  22. package/dist/daemon/model-select.js.map +1 -0
  23. package/dist/daemon/peers.js +10 -16
  24. package/dist/daemon/peers.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +27 -4
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/task-gate.js +97 -0
  28. package/dist/daemon/task-gate.js.map +1 -0
  29. package/dist/daemon/task-registry.js +132 -0
  30. package/dist/daemon/task-registry.js.map +1 -0
  31. package/dist/daemon/tasks.js +126 -54
  32. package/dist/daemon/tasks.js.map +1 -1
  33. package/dist/daemon/wizard-memory.js +57 -0
  34. package/dist/daemon/wizard-memory.js.map +1 -0
  35. package/dist/daemon/wizard.js +84 -36
  36. package/dist/daemon/wizard.js.map +1 -1
  37. package/dist/mcp/server.js +138 -76
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/shared/chat-http.js +193 -96
  40. package/dist/shared/chat-http.js.map +1 -1
  41. package/dist/shared/chat-render.js +31 -24
  42. package/dist/shared/chat-render.js.map +1 -1
  43. package/dist/shared/chat-view.js +14 -2
  44. package/dist/shared/chat-view.js.map +1 -1
  45. package/dist/shared/config.js +23 -4
  46. package/dist/shared/config.js.map +1 -1
  47. package/dist/shared/detail-render.js +14 -7
  48. package/dist/shared/detail-render.js.map +1 -1
  49. package/dist/shared/detail-store.js +12 -5
  50. package/dist/shared/detail-store.js.map +1 -1
  51. package/dist/shared/role-render.js +52 -0
  52. package/dist/shared/role-render.js.map +1 -0
  53. package/dist/shared/role-view.js +210 -0
  54. package/dist/shared/role-view.js.map +1 -0
  55. package/dist/shared/schedule-spec.js +92 -116
  56. package/dist/shared/schedule-spec.js.map +1 -1
  57. package/dist/shared/session-label.js +103 -72
  58. package/dist/shared/session-label.js.map +1 -1
  59. package/dist/shared/std.js +20 -0
  60. package/dist/shared/std.js.map +1 -1
  61. package/dist/shared/task-file.js +120 -0
  62. package/dist/shared/task-file.js.map +1 -0
  63. package/dist/shared/trigger.js +232 -0
  64. package/dist/shared/trigger.js.map +1 -0
  65. package/dist/shared/world.js +193 -0
  66. package/dist/shared/world.js.map +1 -0
  67. package/dist/svr/index.js +68 -3
  68. package/dist/svr/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/web/chat.css +609 -147
  71. package/web/chat.js +1274 -215
@@ -12,9 +12,9 @@ import { computeUsage, renderUsageReport } from "./usage.js";
12
12
  import { computeAuditReport } from "./audit.js";
13
13
  import { syncProjectConfig, renderSyncReport } from "./cfg-sync.js";
14
14
  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";
15
+ import { tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader, nameTokenRe, allNames, normalizeTag, uniqueTag, displayName, labelFor, tagLink } from "../shared/session-label.js";
16
+ import { chatNameOf, clearChatName, listChatNames, peerAddress, setChatName } from "./chat-name.js";
17
+ import { wizardStore } from "./wizard.js";
18
18
  import { truncate } from "../shared/std.js";
19
19
  /** 判定"引用内容是否已在目标会话上下文里"时回看的轮数 —— 引用的通常是最近几轮
20
20
  * 里的某条气泡,再往前用户多半是真想把老内容重新拎出来说事。 */
@@ -22,19 +22,19 @@ const QUOTE_TAIL_TURNS = 12;
22
22
  // Chat-binding key: stable id for "this conversation thread". Used as
23
23
  // session-map key, mirror target, defaultChat. NOT used for auth.
24
24
  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)
25
+ // 寻址靠 wizard 的**全局名字** `.name` —— 它可能住在任何一个聊天里。
26
+ // Must be space-delimited or edge-of-string so paths / extensions like `a.ts`
27
+ // survive. 一条消息只认**一个**路由名字, 规则:
28
+ // • 第一个解析得出的已知名字 (名册里有, 或本聊天里有这个 slot 的老会话) 胜出;
29
+ // • 都不认识时, 只有**消息开头**的 `.x` 才算「叫一个新 wizard 出来」——
30
+ // 正文里随手写的 `.gitignore` 不该凭空变出一个 wizard;
31
+ // 选中的那个 token 从正文里摘掉, 其余 `.foo` 原样留着 (见 peerMentions)。
32
+ // Token 规则收在 session-label(nameTokenRe / allNames),路由与标注共用同一把尺子。
33
+ const NAME_G = new RegExp(nameTokenRe().source, "gu");
34
+ const parseTag = (text, known) => {
35
+ const ms = [...text.matchAll(NAME_G)];
36
+ const m = ms.find((x) => known(x[2] ?? "")) ?? (ms[0]?.index === 0 ? ms[0] : undefined);
37
+ if (!m || m.index === undefined)
38
38
  return { tag: "", cleaned: text };
39
39
  const tag = m[2] ?? "";
40
40
  const before = text.slice(0, m.index);
@@ -43,8 +43,23 @@ const parseTag = (text) => {
43
43
  const cleaned = (before + sep + after).replace(/[ \t]+/g, " ").trim();
44
44
  return { tag, cleaned };
45
45
  };
46
- const sessionKey = (base, tag) => (tag ? `${base}#${tag}` : base);
47
46
  const tagOf = tagOfKey;
47
+ /** 名字 → 投递目标。名册 (全局) > 本聊天同名 slot (改名前的老 `#tag` 会话) > 在本聊天
48
+ * 新开一个 slot。纯函数: 新 wizard 的名字由 spawn 时 settleName 按 slot 落定。 */
49
+ const resolveName = (base, name, live) => {
50
+ if (!name)
51
+ return base;
52
+ const rec = wizardStore()?.byName(name);
53
+ if (rec)
54
+ return rec.target;
55
+ const slot = normalizeTag(name) || "wizard";
56
+ const legacy = keyOf(base, slot);
57
+ if (live(legacy))
58
+ return legacy;
59
+ // 这个 slot 已被一个改了名的 (冷) wizard 占着 → 挪到 `slot-N`, 别唤醒别人。
60
+ const taken = new Set((wizardStore()?.all() ?? []).filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)));
61
+ return keyOf(base, uniqueTag(slot, taken));
62
+ };
48
63
  // Auth principals: any-of test against allowFrom. Tiered — allowing a user
49
64
  // grants them access in any chat; allowing a group grants every member of
50
65
  // that group access. DMs collapse to just the sender.
@@ -79,17 +94,20 @@ const renderIds = (msg, cfg) => {
79
94
  const isIdCommand = (text) => text.trim() === "/id";
80
95
  const isPwdCommand = (text) => text.trim() === "/pwd";
81
96
  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.
97
+ // `/audit` or `/audit <name>`. With a name, `/audit` re-routes to that wizard's
98
+ // mirror (global name → target; an old slot id still matches, newest-by-mtime);
99
+ // without one, falls back to the caller's own mirror binding.
85
100
  const parseAuditCommand = (text) => {
86
101
  const m = /^\/audit(?:\s+(.+))?$/u.exec(text.trim());
87
- return m ? { tag: (m[1] ?? "").trim().replace(/^#/, "") } : undefined;
102
+ return m ? { tag: (m[1] ?? "").trim().replace(/^[.#]/, "") } : undefined;
88
103
  };
89
104
  const resolveAuditMirror = (mirrors, tag, who, chatWho) => {
90
105
  if (tag) {
91
- const wanted = tag.replace(/^#/, "");
92
- const matches = mirrors.filter((m) => (m.target.split("#")[1] ?? "") === wanted);
106
+ const named = wizardStore()?.byName(tag)?.target;
107
+ const exact = named ? mirrors.find((m) => m.target === named) : undefined;
108
+ if (exact)
109
+ return exact;
110
+ const matches = mirrors.filter((m) => tagOfKey(m.target) === tag);
93
111
  if (matches.length <= 1)
94
112
  return matches[0];
95
113
  return matches
@@ -141,8 +159,8 @@ const parseCfgSyncCommand = (text) => {
141
159
  const m = CFGSYNC_RE.exec(text.trim());
142
160
  return m ? { apply: Boolean(m[1]) } : undefined;
143
161
  };
144
- // `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带 `#tag`
145
- // 路由过来都命名同一个聊天, 所以这里不看 tag。
162
+ // `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带 `.name`
163
+ // 路由过来都命名同一个聊天, 所以这里不看路由目标。
146
164
  const NAME_RE_CMD = /^\/name(?:\s+(\S+))?$/i;
147
165
  const parseNameCommand = (text) => {
148
166
  const m = NAME_RE_CMD.exec(text.trim());
@@ -175,44 +193,42 @@ const renderHelp = () => [
175
193
  "",
176
194
  "▎切换 CLI 后端",
177
195
  "`/new codebuddy` 用指定 CLI 新开 (claude / claude-internal / codebuddy)",
178
- "位置参数可叠:`/new codebuddy opus #docs 先读一遍 README` = 后端 + 模型 + 标签 + 首句。",
179
- "不写则沿用本会话当前的 CLI;新开 `#tag` 会话则继承本聊天的 CLI。",
196
+ "位置参数可叠:`.docs /new codebuddy opus 先读一遍 README` = 名字 + 后端 + 模型 + 首句。",
197
+ "不写则沿用本会话当前的 CLI;新叫出来的 wizard 继承本聊天的 CLI。",
180
198
  "切换后 `/clear`、`/stop`、`--resume` 自愈都仍绑在该 CLI 上。",
181
199
  "",
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
- "也知道自己能给自己起名、写职责、记长期记忆、上下文满了自己交接重开。",
200
+ "▎wizard",
201
+ "每个 wizard 有一个**全局唯一**的名字,写作 `.name`;聊天的默认 wizard 名字就是聊天名。",
202
+ "消息里带 `.name`(如 `.docs 帮我改 README`)就是找那一个 —— 它可以住在任何一个聊天里,",
203
+ "回复会回到你说话的这个群;不带 = 本聊天默认那个。开头写一个还不存在的 `.x` = 在这里叫出一个新的 x。",
204
+ "它们的回复以 `emoji .name` 打头。`/clear .x`、`/pwd .x`、`/stop .x` 同理按名字走。",
205
+ "wizard 知道自己是谁、在哪个目录、还有谁在,也能给自己改名、写职责、记长期记忆、上下文满了自己交接重开。",
206
+ "wizard 之间默认**私聊**,只有需要你知道的才发进群。",
189
207
  "",
190
208
  "▎分身(clone)",
191
209
  "对 AI 说「分个身去干 X」「开三个分身分头处理」即可。分身默认**继承它此刻的上下文** ——",
192
210
  "先把公共材料读进来、再分身,材料只读一遍却进了 N 份上下文;要白纸一张就说明白。",
193
- "分身有自己的 `#tag`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
211
+ "分身有自己的 `.name`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
194
212
  "",
195
213
  "▎跨聊天",
196
214
  "`/name <名字>` 给本聊天起名 · `/name` 查看 · `/name -` 取消",
197
215
  "`/chats` 列出所有已知聊天及其 wizard",
198
- "名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字。",
199
- "`daily#fix` = daily 聊天的 `#fix` · `daily#` = 它的默认 wizard · `fix` = 本聊天优先。",
200
- "对 AI 说「让 daily#fix 看一眼」「在 daily 里开个 #ingest 跑这个目录」即可。",
201
- "未命名的聊天叫不到、也生不进去 —— 想被叫到,就在那个群里发一次 `/name`。",
216
+ "名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字 (撞名会自动加后缀)。",
217
+ "对 AI 说「让 .fix 看一眼」「在 daily 里开个 .ingest 跑这个目录」即可。",
202
218
  "",
203
219
  "▎协作",
204
220
  "`/peers`(或 `/wizards`) 列出本聊天的 wizard:名字、职责、忙闲、谁是谁的分身",
205
221
  "同一聊天里的 wizard 互相看得见也驱动得动,直接说人话:",
206
- "「看下 `#fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
207
- "「让 `#fix` 和 `#review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
208
- "它们之间的关键往返(派活、分身出生、收尾、结论)会在群里留痕,中间的催促只进详情页。",
222
+ "「看下 `.fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
223
+ "「让 `.fix` 和 `.review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
224
+ "它们之间的往来默认只进各自的 rolepage;需要你知道的 (工单开/收工、公开讨论、结论) 才发进群。",
209
225
  "",
210
226
  "▎信息 (免授权)",
211
227
  "`/id` 查看会话/权限 id",
212
228
  "`/pwd` 当前项目路径",
213
229
  "`/usage` 真实订阅额度 %",
214
230
  "`/cost` token/成本估算",
215
- "`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <tag>` 指定标签会话",
231
+ "`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <名字>` 指定 wizard",
216
232
  "`/cfgsync` 预演跨 CLI 项目配置同步 · `/cfgsync apply` 执行 (需授权)",
217
233
  "`/help` 本帮助",
218
234
  "",
@@ -252,7 +268,7 @@ const renderSessionsList = (sessions, currentSid) => {
252
268
  "> 切换:`/sessions <emoji 或 id>`,如 `/sessions 🐼`",
253
269
  ].join("\n");
254
270
  };
255
- // /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个 `#tag`)。与 /sessions
271
+ // /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个分身)。与 /sessions
256
272
  // 的区别是范围: /sessions 扫的是整台机器上所有 agent 会话 (包括人在终端里自己开
257
273
  // 的、与企微无关的), 这里列的是同一个聊天里互相叫得动的那些 —— 同聊天 = 同一个
258
274
  // 地址空间。名字与职责来自 wizard 注册表, 没登记过的就只显示 tag。
@@ -271,22 +287,21 @@ const renderPeers = (peers, chatName) => {
271
287
  const clis = uniq(peers.map((p) => p.cli));
272
288
  const shared = [dirs.length === 1 ? dirs[0] : "", clis.length === 1 ? clis[0] : ""].filter(Boolean);
273
289
  // 家谱只画直系: 谁是谁的分身。整棵树留给 AI 侧的 wizard_roster —— 群里一行放不下。
274
- const parentTag = (target) => {
290
+ // 每个 tag 都点得开它的 chat 详情页。
291
+ const addrOf = (target) => tagLink(target, `.${displayName(target) || "?"}`);
292
+ const parentAddr = (target) => {
275
293
  const p = reg?.get(target)?.parent;
276
- return p ? tagOfKey(p) || "默认" : "";
294
+ return p ? addrOf(p) : "";
277
295
  };
278
296
  const rows = peers.flatMap((p) => {
279
297
  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;
298
+ const title = addrOf(p.target);
284
299
  const state = !p.paneAlive ? "⚫️ 已关闭" : p.busy ? "🔴 忙" : "🟢 空闲";
285
- const from = parentTag(p.target);
300
+ const from = parentAddr(p.target);
286
301
  const varies = [
287
302
  dirs.length > 1 ? dirOf(p) : "",
288
303
  clis.length > 1 ? p.cli : "",
289
- from ? `分身自 #${from}` : "",
304
+ from ? `分身自 ${from}` : "",
290
305
  ].filter(Boolean);
291
306
  const me = p.self ? " ⬅️ 本会话" : "";
292
307
  return [
@@ -301,19 +316,18 @@ const renderPeers = (peers, chatName) => {
301
316
  `[wezard] 本聊天${named ? ` \`${named}\`` : ""}的 wizard · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
302
317
  "",
303
318
  ...rows,
304
- "> 协作:直接说「看下 #fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
319
+ "> 协作:直接说「看下 .fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
305
320
  "> 分身:说「分个身去干 X」— AI 会 clone 一个带着当前上下文的 wizard,干完再收掉",
306
321
  // 空行只在这一处按需省略 —— 上面那些是有意的分隔, 不能被一把 filter 掉。
307
- ...(named ? [] : ["> 起名:`/name <名字>` — 起了名字,别的聊天才叫得到这里的 wizard(`名字#tag`)"]),
322
+ ...(named ? [] : ["> 起名:`/name <名字>` — 聊天名就是这里默认 wizard 的名字"]),
308
323
  ].join("\n");
309
324
  };
310
- // /chats — 跨聊天目录。命名的聊天可以被 `名字#tag` 精确寻址;没命名的只能靠
311
- // 「全局唯一 tag」碰运气,所以这里把「未命名」显式标出来当作行动号召。
325
+ // /chats — 跨聊天目录: 每个聊天里住着哪些 wizard (`.name`, 全局可达)。
312
326
  const renderChats = (roster) => {
313
327
  if (roster.length === 0)
314
328
  return "[wezard] 还没有任何聊天在跑会话。";
315
329
  const rows = roster.flatMap((c) => {
316
- const sessions = c.targets.map((t) => (tagOfKey(t) ? `#${tagOfKey(t)}` : "默认")).join(" · ") || "(无)";
330
+ const sessions = c.targets.map((t) => tagLink(t, `.${displayName(t) || "?"}`)).join(" · ") || "(无)";
317
331
  const head = c.name ? `\`${c.name}\`` : `_(未命名)_ \`${c.base}\``;
318
332
  return [`**${head}**${c.self ? " ⬅️ 本聊天" : ""} · ${c.targets.length} 个会话`, ` ${sessions}`, ""];
319
333
  });
@@ -321,7 +335,7 @@ const renderChats = (roster) => {
321
335
  `[wezard] 已知的聊天 · ${roster.length} 个`,
322
336
  "",
323
337
  ...rows,
324
- "> 跨聊天寻址:`名字#tag`(如 `daily#fix`)。未命名的聊天先在里面发 `/name <名字>`",
338
+ "> 寻址:`.名字`(如 `.fix`),全局唯一,在任何群里都叫得到",
325
339
  ].join("\n");
326
340
  };
327
341
  // Match a switch arg against a scanned session: animal emoji label, full
@@ -428,49 +442,42 @@ const canonContains = (haystack, needle) => {
428
442
  };
429
443
  const isLastResponseQuote = (target, quoted) => canonContains(getLastResponse(target) ?? "", quoted);
430
444
  // ── 引用即路由 ─────────────────────────────────────────────────────────
431
- // 群里要跟 `#fix` 说话,手打 tag 太慢 —— 直接引用它的气泡即可。每条出站气泡都
432
- // 带 `emoji #tag` 头 (withTagHeader),所以引用文本自带路由信息;用户自己发的
433
- // 行首 `#fix 干活` 同样算数(限行首,否则正文里随手写的 #123 会误判)。
445
+ // 群里要跟 `.fix` 说话,手打名字太慢 —— 直接引用它的气泡即可。每条出站气泡都
446
+ // 带 `emoji .name` 头 (withTagHeader),所以引用文本自带路由信息;用户自己发的
447
+ // 行首 `.fix 干活` 同样算数(限行首,否则正文里随手写的 .gitignore 会误判)。
434
448
  // `body` 是剥掉头/tag 后的净引用内容,用于跟目标 context 比对。
435
449
  // `tag` = 路由目标(引用继承的投递 tag)。
436
- // `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji #tag` 头)。
450
+ // `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji .name` 头)。
437
451
  // 去重要比对的是「内容在不在源会话」,而非路由目标: 带着引用新建 /
438
452
  // 改投到别的 tag 时,目标会话是空的,只有源会话里才有那段原文。
439
453
  // `srcTag===undefined` 表示源未知(用户自己打的引用),回退到按目标查。
440
- const parseQuote = (q) => {
454
+ const QUOTE_NAME_RE = nameTokenRe();
455
+ const parseQuote = (q, known) => {
441
456
  const raw = q ? quoteToText(q).trim() : "";
442
457
  if (!raw)
443
458
  return null;
444
459
  const head = parseTagHeader(raw);
445
460
  if (head.fromBot)
446
461
  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 };
462
+ const m = QUOTE_NAME_RE.exec(raw);
463
+ return m && m.index === 0 ? { tag: m[2] ?? "", body: parseTag(raw, known).cleaned } : { tag: "", body: raw };
449
464
  };
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);
465
+ const composeInbound = (msg, rawBody, inContext, stripAt, addr) => {
466
+ const { tag: typed, cleaned } = parseTag(rawBody, addr.known);
467
+ const q = parseQuote(msg.quote, addr.known);
461
468
  const tag = typed || q?.tag || "";
462
469
  // 去重比对的会话: 若引用来自某个 bot 会话(srcTag 已知),查那个源会话 ——
463
470
  // 内容天然存在于源的 transcript, 与你把它投到哪个 tag 无关。源未知时(用户自打
464
471
  // 的引用 / 改投)回退到路由目标。这修掉了「带引用新建/改投会话时原文被重复注入」。
465
472
  const dedupTag = q?.srcTag ?? tag;
466
473
  // 剥完头什么都不剩(折叠气泡这类纯 chrome 的引用)⇒ 没有可搬运的内容,只留路由。
467
- const consumed = !q || !q.body.trim() || inContext(sessionKey(chatPrincipal(msg), dedupTag), q.body);
474
+ const consumed = !q || !q.body.trim() || inContext(addr.resolve(chatPrincipal(msg), dedupTag), q.body);
468
475
  if (cleaned.trim()) {
469
476
  return { text: consumed ? cleaned : `${renderQuotePrefix(q.body)}${cleaned}`, tag, promoted: false };
470
477
  }
471
478
  if (q && !consumed) {
472
479
  // 提成正文时也剥一次 @mention,让 "@wezard /usage" → "/usage" 命中命令路径。
473
- const p = parseTag(stripAt(msg, q.body).trim());
480
+ const p = parseTag(stripAt(msg, q.body).trim(), addr.known);
474
481
  return { text: p.cleaned, tag: p.tag || tag, promoted: true };
475
482
  }
476
483
  return { text: cleaned, tag, promoted: false };
@@ -538,6 +545,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
538
545
  }
539
546
  if (runningCwd !== defaultCwd)
540
547
  lines.push(`(默认: \`${defaultCwd}\`)`);
548
+ if (bridge.cwdUnconfirmed(who))
549
+ lines.push("⚠️ 这是默认兜底目录, 还没人确认过 —— 要换就说「切到 /path/to/proj」");
541
550
  lines.push("> 切换其他项目: 对 AI 说「切到 /path/to/proj」(`set_workspace` 工具直接换目录重开)");
542
551
  return lines.join("\n");
543
552
  }
@@ -546,19 +555,18 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
546
555
  // Mirror-only auto-spawn / /new helper. Routes through bridge.newSession
547
556
  // which kills the old pane, spawns fresh in pendingCwd ?? runningCwd ??
548
557
  // 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).
558
+ // the user-facing one-line ack. The wizard's global name doubles as the
559
+ // tmux window name so the pane shows readably in the status bar (e.g.
560
+ // `.docs` → window `docs`, not the principal slug).
552
561
  // On success there is NO reply: newSession already pushed the single
553
562
  // "created + cwd" bubble. Only failures produce user-facing text.
554
563
  const spawnSession = async (who, cli, silent, model) => {
555
564
  if (!("newSession" in bridge))
556
565
  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 });
566
+ const r = await bridge.newSession(who, displayName(who) || tagOf(who) || who, cli, { silent, model });
559
567
  return r.ok ? {} : { err: `[wezard] /new failed: ${r.reason ?? "unknown"}` };
560
568
  };
561
- // 同一 `#tag` 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
569
+ // 同一 wizard 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
562
570
  // newSession 会 kill 掉前者的 pane,前者的 dispatch 再 `--resume` 重生出孤儿
563
571
  // pane,消息乱序。spawn 窗口有 3s+(TUI_SETTLE_MS),所以必须按会话串行。
564
572
  const spawnQ = new Map();
@@ -572,14 +580,12 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
572
580
  };
573
581
  // 显式 /new:排队但仍强制重开(用户就是要换一个)。
574
582
  const autoSpawnAndAttach = (who, cli, model) => serializeSpawn(who, () => spawnSession(who, cli, false, model));
575
- // 隐式建会话(裸 `#tag` 第一条消息):轮到自己时若前一条已经把会话建好,直接
583
+ // 隐式建会话(新 `.name` 的第一条消息):轮到自己时若前一条已经把会话建好,直接
576
584
  // 复用,不再 respawn —— 否则先到的消息会被注入进一个刚被杀掉的 pane。
577
585
  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.
586
+ // Prefix user-visible daemon replies with `<emoji> .name`, so a chat hosting
587
+ // several wizards stays visually disambiguated. Emoji is derived from the
588
+ // name (not sessionId) so it stays stable across /clear cycles.
583
589
  const withTagPrefix = withTagHeader;
584
590
  const replyText = async (frame, msg, who, text) => {
585
591
  try {
@@ -592,8 +598,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
592
598
  const gate = async (frame, msg, text, who) => {
593
599
  const auths = authPrincipals(msg);
594
600
  // 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`).
601
+ // use the chat principal, not the routed wizard — a first-time user typing
602
+ // `.foo hello` still promotes them as `user:xxx` (not `user:xxx#foo`).
597
603
  const basePrincipal = chatPrincipal(msg);
598
604
  // /id — bypass allowFrom so users can discover their ids before configuring.
599
605
  if (isIdCommand(text)) {
@@ -643,7 +649,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
643
649
  let body;
644
650
  if (!mirror) {
645
651
  body = audit.tag
646
- ? `[wezard] /audit: 未找到 tag \`${audit.tag}\` 对应的 Agent 会话。`
652
+ ? `[wezard] /audit: 未找到 \`.${audit.tag}\` 对应的 Agent 会话。`
647
653
  : `[wezard] /audit: 未找到 ${who} 绑定的 Agent 会话。先 \`/new\` 或用 \`wezard mirror\` 绑定后再试。`;
648
654
  }
649
655
  else {
@@ -725,8 +731,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
725
731
  }
726
732
  // Authorized `/new` — spawn a tmux+claude pair and attach it to this chat.
727
733
  // 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.
734
+ // very first message from a fresh user. When routed with a `.name`, it
735
+ // picks which wizard (re)spawns; the name is also the tmux window name.
730
736
  const nu = parseNewCommand(text);
731
737
  if (nu) {
732
738
  const { err } = await autoSpawnAndAttach(who, nu.cli, nu.model);
@@ -761,7 +767,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
761
767
  return { stop: true };
762
768
  }
763
769
  // Authorized `/kill` — end this session for good: Esc the pane, kill it,
764
- // and drop the binding (no `--resume` resurrection). Routed by `#tag` like
770
+ // and drop the binding (no `--resume` resurrection). Routed by `.name` like
765
771
  // /stop, so `/kill #docs` only takes down that sibling.
766
772
  if (isKillCommand(text)) {
767
773
  if (!("killPane" in bridge)) {
@@ -788,7 +794,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
788
794
  }
789
795
  // Authorized `/reveal` — switch the attached tmux client to this session's
790
796
  // pane so the user lands in the terminal showing the live TUI. Mirror-mode
791
- // only; routed by `#tag` like any other session command.
797
+ // only; routed by `.name` like any other session command.
792
798
  if (isRevealCommand(text)) {
793
799
  if (!("revealPane" in bridge)) {
794
800
  await replyText(frame, msg, who, "[wezard] /reveal only available in mirror mode");
@@ -824,8 +830,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
824
830
  const cur = chatNameOf(cfg, who);
825
831
  if (!nc.arg) {
826
832
  await replyText(frame, msg, who, cur
827
- ? `[wezard] 本聊天名为 \`${cur}\` — 别处可用 \`${cur}#tag\` 寻址本聊天的会话`
828
- : "[wezard] 本聊天还没起名。`/name <名字>` 起一个,别的聊天才能用 `名字#tag` 叫到这里的会话。");
833
+ ? `[wezard] 本聊天名为 \`${cur}\``
834
+ : "[wezard] 本聊天还没起名。`/name <名字>` 起一个。");
829
835
  return { stop: true };
830
836
  }
831
837
  if (nc.arg === "-") {
@@ -835,7 +841,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
835
841
  }
836
842
  const r = setChatName(cfg, sourcePath, who, nc.arg);
837
843
  await replyText(frame, msg, who, r.ok
838
- ? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""} — 别处用 \`${r.name}#tag\` 即可寻址`
844
+ ? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""}`
839
845
  : `[wezard] /name failed: ${r.reason}`);
840
846
  return { stop: true };
841
847
  }
@@ -848,7 +854,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
848
854
  await replyText(frame, msg, who, renderChats(bridge.chatRoster(who)));
849
855
  return { stop: true };
850
856
  }
851
- // /peers — this chat's own session roster (default + `#tag` siblings), with
857
+ // /peers — this chat's own wizard roster (default + siblings), with
852
858
  // live busy state. Read-only, mirror-mode only.
853
859
  if (isPeersCommand(text)) {
854
860
  if (!("peers" in bridge)) {
@@ -947,69 +953,42 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
947
953
  log.info({ target, jsonl, tailLen: tail.length, quotedLen: quoted.length, hit }, "quoteInContext: tail check");
948
954
  return hit;
949
955
  };
950
- // 路由用掉的那个 `#tag` 已被 parseTag 摘走,正文里剩下的每个 `#x` 都可能是
951
- // 用户在指另一个会话。解析走 bridge 自己的 resolvePeerTag —— peer 工具用的
952
- // 同一套(本 chat 优先、否则全局唯一 tag、再否则带 chat 名的全称),所以标注
953
- // 出来的地址一定是 peek_peer/send_peer 打得中的;解析不到的 `#123`/`#L45` 与
954
- // 自指静默略过。给的是 `address` 而不是裸 tag —— 跨 chat 时裸 tag 未必唯一。
956
+ // 路由用掉的那个 `.name` 已被 parseTag 摘走,正文里剩下的每个 `.x` 都可能是
957
+ // 用户在指另一个 wizard。解析走 bridge 自己的 resolvePeerTag —— peer 工具用的
958
+ // 同一套(全局名册),所以标注出来的地址一定是 peek_peer/send_peer 打得中的;
959
+ // 解析不到的 `.foo` 与自指静默略过。
955
960
  const peerMentions = (who, text) => {
956
961
  if (!("resolvePeerTag" in bridge))
957
962
  return [];
958
963
  const mb = bridge;
959
964
  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)
965
+ return allNames(text).flatMap((name) => {
966
+ const r = mb.resolvePeerTag(who, name);
967
+ if (!r.ok || r.target === who || seen.has(r.target))
964
968
  return [];
965
969
  seen.add(r.target);
966
970
  const { runningCwd, defaultCwd } = mb.getCwd(r.target);
967
971
  return [{
968
- tag,
972
+ tag: name,
969
973
  target: r.target,
970
974
  address: peerAddress(cfg, who, r.target),
971
975
  chat: chatNameOf(cfg, r.target),
972
976
  foreign: r.foreign,
973
- label: labelFor(tag),
977
+ label: labelFor(peerAddress(cfg, who, r.target)),
974
978
  cwd: runningCwd || defaultCwd,
975
979
  }];
976
980
  });
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];
981
+ };
982
+ // 名字解析的两个问题: 这个名字认不认识 (决定正文里哪个 `.x` 是路由)、它指向谁。
983
+ const live = (t) => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(t);
984
+ const addrFor = (base) => ({
985
+ known: (name) => !!wizardStore()?.byName(name) || live(keyOf(base, normalizeTag(name))),
986
+ resolve: (b, name) => resolveName(b, name, live),
987
+ });
988
+ const route = (msg, raw) => {
989
+ const base = chatPrincipal(msg);
990
+ const c = composeInbound(msg, raw, quoteInContext, stripAt, addrFor(base));
991
+ return { ...c, who: resolveName(base, c.tag, live) };
1013
992
  };
1014
993
  const send = async (frame, msg, who, text, images = []) => {
1015
994
  // 斜杠命令按行解析,尾巴上多挂一段会让它不再被识别成命令 —— 只标注普通消息。
@@ -1017,7 +996,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1017
996
  // 同一条边界上再挂一段: 这个 wizard 不在场时群里发生的成员变动 (见 notices.ts)。
1018
997
  const notice = noticeSuffixFor(who, text);
1019
998
  try {
1020
- await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid });
999
+ // 回复回到发话的这个群 —— `who` 可能住在别的聊天 (名字全局可达)。
1000
+ await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid, channel: chatPrincipal(msg), speaker: `user:${msg.from.userid}` });
1021
1001
  }
1022
1002
  catch (e) {
1023
1003
  log.error({ err: e.message }, "bridge dispatch failed");
@@ -1031,8 +1011,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1031
1011
  const msg = frame.body;
1032
1012
  if (!msg)
1033
1013
  return;
1034
- const { text, tag, promoted } = composeInbound(msg, stripAt(msg, msg.text?.content ?? ""), quoteInContext, stripAt);
1035
- const who = sessionKey(chatPrincipal(msg), tag);
1014
+ const { text, tag, promoted, who } = route(msg, stripAt(msg, msg.text?.content ?? ""));
1036
1015
  log.info({ msgid: msg.msgid, len: text.length, tag, hasQuote: !!msg.quote, promoted }, "rx text");
1037
1016
  const { stop } = await gate(frame, msg, text, who);
1038
1017
  if (stop)
@@ -1046,8 +1025,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1046
1025
  log.info({ msgid: msg.msgid, hasQuote: !!msg.quote }, "rx image");
1047
1026
  // Images carry no text of their own — the quote (if any) is the only
1048
1027
  // 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);
1028
+ const { text, who } = route(msg, "");
1051
1029
  const { stop } = await gate(frame, msg, "", who);
1052
1030
  if (stop)
1053
1031
  return;
@@ -1070,14 +1048,13 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1070
1048
  if (!msg)
1071
1049
  return;
1072
1050
  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
1051
+ // Concatenate all text items to sniff a routing `.name`, then strip it from
1074
1052
  // the effective body before forwarding to Claude.
1075
1053
  const rawText = (msg.mixed?.msg_item ?? [])
1076
1054
  .filter((it) => it.msgtype === "text")
1077
1055
  .map((it) => it.text?.content ?? "")
1078
1056
  .join("\n");
1079
- const { tag } = composeInbound(msg, stripAt(msg, rawText), quoteInContext, stripAt);
1080
- const who = sessionKey(chatPrincipal(msg), tag);
1057
+ const { who } = route(msg, stripAt(msg, rawText));
1081
1058
  const { stop } = await gate(frame, msg, "", who);
1082
1059
  if (stop)
1083
1060
  return;
@@ -1098,10 +1075,10 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1098
1075
  }
1099
1076
  if (texts.length === 0 && images.length === 0 && !msg.quote)
1100
1077
  return;
1101
- // Re-compose on the per-item stripped text: drops the routing `#tag` (it was
1078
+ // Re-compose on the per-item stripped text: drops the routing `.name` (it was
1102
1079
  // consumed above; leaving it in would leak into Claude) and attaches the
1103
1080
  // 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);
1081
+ await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt, addrFor(chatPrincipal(msg))).text, images);
1105
1082
  });
1106
1083
  // template_card_event is handled in approval module; no listener here.
1107
1084
  };