wezard 1.3.17 → 1.3.19

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.
package/CLAUDE.md CHANGED
@@ -54,7 +54,7 @@ Three processes, all coordinating via 127.0.0.1:17890 and `~/.wezard/`:
54
54
  - `approval.ts` builds the button card, parks an HTTP request via `pending.ts`, and resolves it on `event.template_card_event`. Three click outcomes: `allow` / `allow_window` (auto-allow N min for that session) / `deny`. `session-cache.ts` short-circuits repeats.
55
55
  - `inbound.ts` routes `message.text` / `message.image` / `message.mixed` → either headless `cc-bridge.ts` or `mirror-bridge.ts`. Gates on `allowFrom`; one-shot exception is the bootstrap claim (see below).
56
56
  - `cc-bridge.ts` (mode=headless) spawns `claude -p --output-format stream-json --verbose`, parses `assistant`/`stream_event` lines, and pumps text via `client.replyStream`. Per-principal queue serializes turns; session id is captured from the `system/init` line and persisted to `~/.wezard/sessions.json` for `--resume`.
57
- - `mirror-bridge.ts` (mode=mirror) tails `<projectsDir>/<encoded(cwd)>/<sid>.jsonl` and pushes new `assistant` content to a chat. Inbound injection is either `claude --resume <sid> -p` (writes a turn into the same jsonl) or `tmux paste-buffer` against a live TTY (only path that makes WeCom messages visible in the user's CLI window). `recentInjects` ring buffer suppresses the WeCom→CLI echo when tailing.
57
+ - `mirror-bridge.ts` (mode=mirror) tails `<projectsDir>/<encoded(cwd)>/<sid>.jsonl` and pushes new `assistant` content to a chat — but only for turns the chat itself started: a turn opened by a human's CLI keystroke is recorded to the turn store (chat-detail page) and kept out of the chat (`wrc.mirror.chatOriginOnly`, brief mode only); approval/askq cards and `[mirror]` notices bypass that gate. Inbound injection is either `claude --resume <sid> -p` (writes a turn into the same jsonl) or `tmux paste-buffer` against a live TTY (only path that makes WeCom messages visible in the user's CLI window). `recentInjects` ring buffer suppresses the WeCom→CLI echo when tailing.
58
58
  - `claim.ts` is a single-shot bootstrap: an exact-match magic phrase from any sender bypasses `allowFrom`, writes that principal as `defaultChat`, and appends to `allowFrom`. This is the **only** legitimate ingress for first-time setup.
59
59
  - `peers.ts` is the read side of multi-agent awareness: sibling sessions of one chat are exactly the target keys sharing a base principal (`baseOfKey`), i.e. the untagged default plus every `#tag`. Pure parsing only — bounded transcript-tail reads (`tailTurns` / `lastAssistantText` / `summarizeTail`, shared with `session-scan.ts`) and pane classification (`paneIsBusy`). **Busy detection matches the TUI spinner footer** (`… (12m 31s · …)` on current versions, `esc to interrupt` on older ones) in the last 8 non-blank rows — transcript mtime is useless here, it goes quiet during long tool calls.
60
60
  - `graph.ts` is the loop-graph runner: `nodes` = tagged sessions (own cli/model/cwd), `steps` = an ordered pipeline walked `rounds` times, each step injecting a prompt, waiting for that agent to go idle, then feeding its reply forward as `{{last}}` / `{{<tag>}}`. Stops early on the `until` sentinel. All effects go through injected deps backed by `mirror-bridge`, so a step is exactly what a human typing `#tag …` would do. Runs are **in-memory only** — a reload cancels them (panes survive).
@@ -57,6 +57,9 @@
57
57
  // 中间 tool_use / tool_result / thinking 只写进详情页 (可折叠, Claude 网页风格)。
58
58
  // 授权卡照常发群。
59
59
  "brief": false,
60
+ // 出处门: CLI 手敲的那一轮只进终端 + chat 详情页, 不下发气泡。WeCom 发起的
61
+ // 轮次、审批卡、[mirror] 提示不受影响。只在 brief=true 下生效。
62
+ "chatOriginOnly": true,
60
63
  // 斜杠命令回执精简: /clear、/new 只回第一行 ack ("cleared"/"created"),
61
64
  // 不再附 📂 项目信息与 💡 tip。默认 false = 完整回执。
62
65
  "slashAckFirstLine": false
@@ -28,7 +28,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } f
28
28
  import { splitMarkdown } from "../shared/md-chunk.js";
29
29
  import { randomTip } from "./tips.js";
30
30
  import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
31
- import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, limitResetAt, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
31
+ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
32
32
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
33
33
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
34
34
  const NODE_BIN_DIR = dirname(process.execPath);
@@ -1802,6 +1802,13 @@ export const startMirror = (deps) => {
1802
1802
  clearTimeout(a.standaloneBuf.timer);
1803
1803
  flushStandalone(a);
1804
1804
  };
1805
+ // Skill 反馈 (/model 的 "Switch model to …" 等) 是用户敲完命令正在等的回执 ——
1806
+ // 不进 standaloneDebounceMs 防抖窗 (默认 30s, 对交互回执就是纯延迟)。先清掉
1807
+ // 在途 debounce 缓冲保序 (同批 drain 里先到的 user 回显不能被反超), 再立即发。
1808
+ const sendStandaloneNow = (a, content) => {
1809
+ flushPendingStandalone(a);
1810
+ sendStandalone(a, content);
1811
+ };
1805
1812
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
1806
1813
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
1807
1814
  const promoteToStandalone = (a) => {
@@ -1927,7 +1934,7 @@ export const startMirror = (deps) => {
1927
1934
  // as a standalone bubble so the user sees the result immediately.
1928
1935
  if (item.kind === "skill_output") {
1929
1936
  if (!item.quiet)
1930
- enqueueStandalone(a, item.body);
1937
+ sendStandaloneNow(a, item.body);
1931
1938
  return;
1932
1939
  }
1933
1940
  const wasEmpty = out.buf.length === 0;
@@ -2020,6 +2027,21 @@ export const startMirror = (deps) => {
2020
2027
  const tag = tagOfKey(target);
2021
2028
  return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
2022
2029
  };
2030
+ // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2031
+ // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2032
+ // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
2033
+ // 在看。群里保留的是需要在群里发生的事: WeCom 发起的轮次、审批/提问卡、
2034
+ // `[mirror]` 系统提示 —— 它们都不走这道门。
2035
+ // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2036
+ const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
2037
+ // 详情页入口一次性告知。CLI 轮全程静默后, 一个从未收到过气泡的会话在群里将没有
2038
+ // 任何链接可点 —— 每个 attachment 发一条 (且仅一条) 带链接的提示解决这个死角。
2039
+ const noticeCliSilent = (a, turnId) => {
2040
+ if (a.cliSilentNoticed)
2041
+ return;
2042
+ a.cliSilentNoticed = true;
2043
+ sendRaw(a, `${briefDetailLink(turnId, a.target)} CLI 侧对话不再下发到群里,点标签看实时详情。`);
2044
+ };
2023
2045
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2024
2046
  // raw=true skips withSessionTag (used when content already contains the linked tag header).
2025
2047
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2082,6 +2104,8 @@ export const startMirror = (deps) => {
2082
2104
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
2083
2105
  const turnId = newTurnId();
2084
2106
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2107
+ a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2108
+ a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2085
2109
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
2086
2110
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2087
2111
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
@@ -2123,7 +2147,12 @@ export const startMirror = (deps) => {
2123
2147
  const turnId = newTurnId();
2124
2148
  const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
2125
2149
  a.pendingBriefQuery = undefined;
2126
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
2150
+ // 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
2151
+ // 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
2152
+ const fromCli = a.pendingFromCli === true;
2153
+ a.pendingFromCli = false;
2154
+ a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2155
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: query || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
2127
2156
  a.briefTurnId = turnId;
2128
2157
  a.briefBubble = undefined;
2129
2158
  a.briefIsSlash = false;
@@ -2133,8 +2162,12 @@ export const startMirror = (deps) => {
2133
2162
  clearCot(a);
2134
2163
  // 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
2135
2164
  // turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
2136
- a.pendingBriefHeader = briefDetailLink(turnId, a.target);
2137
- log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
2165
+ // 静默轮不留 header: 它没有任何 standalone 可以搭车, 留着只是个悬空的链接。
2166
+ if (turnSilent(a))
2167
+ noticeCliSilent(a, turnId);
2168
+ else
2169
+ a.pendingBriefHeader = briefDetailLink(turnId, a.target);
2170
+ log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
2138
2171
  };
2139
2172
  // 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
2140
2173
  // 就挂在气泡里, 无需再推一次。气泡仍然不关, 等正文到来后以 `链接 正文` 覆盖收口。
@@ -2183,6 +2216,11 @@ export const startMirror = (deps) => {
2183
2216
  void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2184
2217
  a.pendingBriefHeader = undefined;
2185
2218
  }
2219
+ else if (turnSilent(a)) {
2220
+ // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
2221
+ a.pendingBriefHeader = undefined;
2222
+ log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
2223
+ }
2186
2224
  else {
2187
2225
  const header = a.pendingBriefHeader;
2188
2226
  a.pendingBriefHeader = undefined;
@@ -2469,6 +2507,10 @@ export const startMirror = (deps) => {
2469
2507
  void finishBriefBubble(a, item.body);
2470
2508
  a.pendingBriefHeader = undefined;
2471
2509
  }
2510
+ else if (turnSilent(a)) {
2511
+ // CLI 里手敲的 /clear、/model 之类, 回执给敲的人看就够了 —— 只进详情页。
2512
+ a.pendingBriefHeader = undefined;
2513
+ }
2472
2514
  else {
2473
2515
  // CLI-driven turn: 把 ensureBriefTurn 暂存的详情链接拼到 body 前缀, 避免
2474
2516
  // "只发了一条 link 渲染成空文本" 的前置消息。
@@ -2508,9 +2550,13 @@ export const startMirror = (deps) => {
2508
2550
  // chat with hundreds of tool bubbles. turn_end = the goal auto-cleared and the
2509
2551
  // model finally stopped → leave goal mode; normal brief resumes next turn.
2510
2552
  const handleGoalItem = (a, item) => {
2511
- if (item.kind === "text" || item.kind === "skill_output") {
2512
- if (item.kind !== "skill_output" || !item.quiet)
2513
- enqueueStandalone(a, item.body);
2553
+ if (item.kind === "text") {
2554
+ enqueueStandalone(a, item.body);
2555
+ return;
2556
+ }
2557
+ if (item.kind === "skill_output") {
2558
+ if (!item.quiet)
2559
+ sendStandaloneNow(a, item.body);
2514
2560
  return;
2515
2561
  }
2516
2562
  if (item.kind === "turn_end") {
@@ -2659,6 +2705,11 @@ export const startMirror = (deps) => {
2659
2705
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
2660
2706
  voidTurnAgents(a);
2661
2707
  a.queryEpoch = (a.queryEpoch ?? 0) + 1;
2708
+ // 这一行是人在 CLI 里敲的 —— 它既是下一轮的 query, 也是下一轮的出处。
2709
+ // quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
2710
+ // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
2711
+ a.pendingBriefQuery = item.body;
2712
+ a.pendingFromCli = true;
2662
2713
  }
2663
2714
  // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。放在
2664
2715
  // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
@@ -2866,13 +2917,9 @@ export const startMirror = (deps) => {
2866
2917
  }
2867
2918
  // Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
2868
2919
  // 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
2869
- // user_text 只记下来当下一轮的 query, 不开 turn (CLI 敲字 ≠ 一定有回复)。
2870
- if (cfg.wrc.mirror.brief && !a.briefTurnId) {
2871
- if (item.kind === "user_text")
2872
- a.pendingBriefQuery = item.body;
2873
- else if (BRIEF_TURN_OPENERS.has(item.kind))
2874
- ensureBriefTurn(a);
2875
- }
2920
+ // user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
2921
+ if (cfg.wrc.mirror.brief && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
2922
+ ensureBriefTurn(a);
2876
2923
  // Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
2877
2924
  // 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
2878
2925
  // 输出的 item 类型。
@@ -2922,7 +2969,7 @@ export const startMirror = (deps) => {
2922
2969
  // an active stream so the result is independently visible.
2923
2970
  if (item.kind === "skill_output") {
2924
2971
  if (!item.quiet)
2925
- enqueueStandalone(a, item.body);
2972
+ sendStandaloneNow(a, item.body);
2926
2973
  return;
2927
2974
  }
2928
2975
  // Assistant turn truly ended (stop_reason terminal). Finalize the live
@@ -4341,10 +4388,8 @@ export const startMirror = (deps) => {
4341
4388
  // Stall recovery, decided by RULE only (no model self-judgment): the last
4342
4389
  // transcript turn is a synthetic API-error/limit line, or the idle pane
4343
4390
  // still shows an error banner ⇒ a turn died mid-work. Send the resume
4344
- // instruction instead of the plain warmer. A limit-parked episode is
4345
- // excluded: retrying before resetsAt only buys another 429 line —
4346
- // limitResumeTick owns that recovery, anchored on the reset moment.
4347
- const stalled = kc.resumeOnStall && !a.limitResume && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
4391
+ // instruction instead of the plain warmer.
4392
+ const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
4348
4393
  k.pinging = true;
4349
4394
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
4350
4395
  await fireKeepalive(a, stalled);
@@ -4358,83 +4403,6 @@ export const startMirror = (deps) => {
4358
4403
  }
4359
4404
  };
4360
4405
  const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
4361
- // ── Rate-limit auto-resume ────────────────────────────────────────────
4362
- // Detection is poll-derived from the transcript tail every tick (limitResetAt:
4363
- // "last message turn IS a 429 line"), so there is nothing to persist — a
4364
- // daemon reload, a human retry in the TTY, or our own inject all converge
4365
- // naturally: any newer turn makes detection stop firing and drops the state.
4366
- const fmtClock = (ms) => {
4367
- const d = new Date(ms);
4368
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
4369
- };
4370
- let limitResumeTicking = false;
4371
- const limitResumeTick = async () => {
4372
- const lr = cfg.wrc.mirror.limitResume;
4373
- if (!lr.enabled || limitResumeTicking)
4374
- return;
4375
- limitResumeTicking = true;
4376
- try {
4377
- const now = Date.now();
4378
- for (const a of byTarget.values()) {
4379
- const resetsAt = limitResetAt(a.jsonlPath, now);
4380
- if (!resetsAt) {
4381
- a.limitResume = undefined;
4382
- continue;
4383
- }
4384
- if (a.migrationWatcher)
4385
- continue; // session rotating — judge after it settles
4386
- if (a.keepaliveOff)
4387
- continue; // /stop = deliberately quieted; honor it
4388
- if (a.limitResume?.resetsAt !== resetsAt)
4389
- a.limitResume = { resetsAt, notified: false, retried: false };
4390
- const st = a.limitResume;
4391
- const dueAt = resetsAt + lr.delaySec * 1000;
4392
- if (!st.notified) {
4393
- st.notified = true;
4394
- log.info({ target: a.target, resetsAt, dueAt }, "limit-resume: parked, retry scheduled");
4395
- sendStandalone(a, `[mirror] ⏳ 已触限额 (reset ${fmtClock(resetsAt)}) — ${fmtClock(dueAt)} 自动注入 \`${lr.text}\` 续跑`);
4396
- }
4397
- if (st.retried || now < dueAt)
4398
- continue;
4399
- if (a.liveStream && !a.liveStream.closed)
4400
- continue; // mid typewriter — don't inject
4401
- if (a.tmuxPane) {
4402
- // Pane mode: a dead pane means the human closed shop — auto-respawning
4403
- // a TUI at 3am would be a surprise, not a service. Busy means someone
4404
- // (human, or another turn) is already driving; detection clears itself
4405
- // once their turn lands. Spawn-mode has no pane and no such hazards —
4406
- // inject's `claude --resume -p` fallback IS its normal operation.
4407
- if (!(await tmuxPaneAlive(a.tmuxPane)))
4408
- continue;
4409
- if (paneIsBusy(await capturePaneTail(a.tmuxPane, 16)))
4410
- continue;
4411
- }
4412
- st.retried = true;
4413
- // Unlike keepalive there is no swallow: the resumed work is real work —
4414
- // the tail mirrors its output to chat normally. rememberInject only
4415
- // suppresses the echo of the injected user line itself.
4416
- rememberInject(lr.text);
4417
- const r = await inject({
4418
- text: lr.text, images: [], cfg,
4419
- log: log.child({ target: a.target, sessionId: a.sessionId, sub: "limit-resume" }),
4420
- sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
4421
- });
4422
- if (!r.ok) {
4423
- st.retried = false; // roll back so the next tick retries the inject
4424
- log.warn({ target: a.target, reason: r.reason }, "limit-resume: inject failed");
4425
- continue;
4426
- }
4427
- log.info({ target: a.target, resetsAt }, "limit-resume: resume injected");
4428
- }
4429
- }
4430
- catch (e) {
4431
- log.warn({ err: e.message }, "limit-resume tick failed");
4432
- }
4433
- finally {
4434
- limitResumeTicking = false;
4435
- }
4436
- };
4437
- const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
4438
4406
  // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
4439
4407
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
4440
4408
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
@@ -4950,7 +4918,6 @@ export const startMirror = (deps) => {
4950
4918
  shutdown: () => {
4951
4919
  clearInterval(paneDriftTimer);
4952
4920
  clearInterval(keepaliveTimer);
4953
- clearInterval(limitResumeTimer);
4954
4921
  clearInterval(pickerTimer);
4955
4922
  for (const a of bySessionId.values()) {
4956
4923
  if (a.outbound?.kind === "deferred")