wezard 2.0.2 → 2.0.4

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.
@@ -25,6 +25,7 @@ import { isAutoWindowActive } from "./session-cache.js";
25
25
  import { noticeSuffixFor } from "./notices.js";
26
26
  import { dangerOf } from "./danger.js";
27
27
  import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
28
+ import { selectModel } from "./model-select.js";
28
29
  import { hasRegistry, markTranscript, probeOf, sessionOnPane, submittedSince } from "./cc-session.js";
29
30
  import { wizardStore } from "./wizard.js";
30
31
  import { startSubagentWatch } from "./subagent-tail.js";
@@ -33,7 +34,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagH
33
34
  import { splitMarkdown } from "../shared/md-chunk.js";
34
35
  import { randomTip } from "./tips.js";
35
36
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
36
- import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
37
+ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
37
38
  // PATH augmentation: launchd / systemd start the daemon
38
39
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
39
40
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -294,7 +295,11 @@ const renderTaskNotification = (block) => {
294
295
  const icon = status === "completed" ? "✅" : status === "failed" || status === "error" ? "❌" : "🏁";
295
296
  return `${icon} ${summary || `后台任务 ${status || "完成"}`}`;
296
297
  };
297
- const cleanUserText = (raw) => {
298
+ const cleanUserText = (wrapped) => {
299
+ // 多行注入落盘时被 CLI 包了一层 `<pasted_content>` (见 peers.unwrapPasted) —— 先剥,
300
+ // 否则回显去重对不上, 我们自己注入的那一行会被认成「人在 CLI 里敲的」, 整轮回复
301
+ // 因此被 chatOriginOnly 挡在群外 (公开的 send_peer / 定时任务的多行提示全中)。
302
+ const raw = unwrapPasted(wrapped);
298
303
  const nameMatch = raw.match(SLASH_TAG_RE);
299
304
  const slashCmd = nameMatch?.[1]?.trim() ?? "";
300
305
  const argsMatch = raw.match(SLASH_ARGS_RE);
@@ -1326,7 +1331,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1326
1331
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
1327
1332
  // pays the latency cost.
1328
1333
  const PASTE_VERIFY_MS = freshSpawn ? 6000 : 2500;
1334
+ // 粘贴落定的等待**上限**, 不是固定 sleep: 框认得出来时按 SETTLE_POLL_MS 轮询
1335
+ // 「内容已落进输入框且不再变」, 一满足就回车 (见 pasteSettled)。
1329
1336
  const POST_PASTE_SETTLE_MS = freshSpawn ? 1500 : 400;
1337
+ const SETTLE_POLL_MS = 50;
1330
1338
  const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
1331
1339
  const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
1332
1340
  const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
@@ -1417,10 +1425,28 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1417
1425
  pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1418
1426
  }
1419
1427
  }
1420
- // Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
1421
- // Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
1422
- if (!alreadyTyped)
1423
- await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1428
+ // 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
1429
+ // (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
1430
+ // pane 上粘贴 40–210ms 即落定, 此前这里固定睡 1500ms。到上限仍没等到就照旧
1431
+ // 回车 (与原先睡满等价), 回车被吞由下面的证人 + 补回车兜底。
1432
+ const pasteSettled = async (maxMs, prev, t0 = Date.now()) => {
1433
+ if (Date.now() - t0 >= maxMs)
1434
+ return false;
1435
+ const box = await readInputBox(target);
1436
+ const body = box.known && !boxIntact(box) ? box.body : undefined;
1437
+ if (body !== undefined && body === prev)
1438
+ return true;
1439
+ await sleep(SETTLE_POLL_MS);
1440
+ return pasteSettled(maxMs, body, t0);
1441
+ };
1442
+ // Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
1443
+ // 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
1444
+ if (!alreadyTyped) {
1445
+ if (pasteSeen && boxVerify)
1446
+ await pasteSettled(POST_PASTE_SETTLE_MS);
1447
+ else
1448
+ await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1449
+ }
1424
1450
  const sendEnter = async () => {
1425
1451
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1426
1452
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
@@ -1566,7 +1592,9 @@ export const startMirror = (deps) => {
1566
1592
  const INJECT_TTL_MS = 60_000;
1567
1593
  const recentInjects = [];
1568
1594
  const rememberInject = (text) => {
1569
- const t = text.trim();
1595
+ // 与 tail 侧同一个规范形 (cleanUserText): 注入时挂在尾巴上的 system-reminder
1596
+ // (信封 / 名册增量 / mention hint) 在那边是被剥掉之后才来比对的。
1597
+ const t = cleanUserText(text);
1570
1598
  if (!t)
1571
1599
  return;
1572
1600
  recentInjects.push({ text: t, ts: Date.now() });
@@ -1655,6 +1683,9 @@ export const startMirror = (deps) => {
1655
1683
  // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
1656
1684
  const FLUSH_MS = 250;
1657
1685
  const HARD_TIMEOUT_MS = 350_000;
1686
+ /** hard cap 到点时的中间结束语: 气泡必须 finish=true, 但 turn 还在跑 —— 明说
1687
+ * 「没完, 去链接看」, 而不是让气泡停在一句看似说完的话 / 一行过期的进度上。 */
1688
+ const STILL_WORKING = "⏳ 仍在处理中, 点击链接查看详情…";
1658
1689
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1659
1690
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1660
1691
  * 这一段, 与模型思考/工具执行时长无关。
@@ -1721,7 +1752,7 @@ export const startMirror = (deps) => {
1721
1752
  return;
1722
1753
  s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
1723
1754
  };
1724
- const finalizeStream = async (a, s) => {
1755
+ const finalizeStream = async (a, s, timedOut = false) => {
1725
1756
  if (s.closed)
1726
1757
  return;
1727
1758
  s.closed = true;
@@ -1743,17 +1774,20 @@ export const startMirror = (deps) => {
1743
1774
  // 没正文不写: finish=true 是整条覆盖, 空正文只会把 "…" ack 覆盖成光秃秃的
1744
1775
  // 链接头, 结束处理自己制造错发。气泡保持现有内容, 由 WeCom 6min 窗口自然
1745
1776
  // 到期。dead 流同理只做本地清理。
1746
- if (!s.dead && s.acc.trim()) {
1777
+ // 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
1778
+ // 也有话可说, 不算空覆盖。
1779
+ const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
1780
+ if (!s.dead && body.trim()) {
1747
1781
  const card = detailCardFor(s, a.target);
1748
1782
  try {
1749
1783
  if (card) {
1750
1784
  s.cardSent = true;
1751
- await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true, { templateCard: card });
1785
+ await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
1752
1786
  }
1753
1787
  else {
1754
- await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc, undefined, s.turnId), true);
1788
+ await client.replyStream(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true);
1755
1789
  }
1756
- log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
1790
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card, timedOut }, "stream finalize");
1757
1791
  }
1758
1792
  catch (e) {
1759
1793
  log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
@@ -1783,7 +1817,7 @@ export const startMirror = (deps) => {
1783
1817
  capped: false, closed: false, dead: false, cardSent: false,
1784
1818
  tools: [], sawTool: false,
1785
1819
  };
1786
- s.hardTimer = setTimeout(() => void finalizeStream(a, s), HARD_TIMEOUT_MS);
1820
+ s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
1787
1821
  log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
1788
1822
  return s;
1789
1823
  };
@@ -2261,11 +2295,11 @@ export const startMirror = (deps) => {
2261
2295
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2262
2296
  const q = { turnId, bubble, isSlash };
2263
2297
  bubble.hardTimer = setTimeout(() => {
2264
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。没正文时不能拿光链接
2265
- // 覆盖 —— finish 是整条替换, 会把屏幕上的 CoT 进度行抹成光秃秃的链接; 改用
2266
- // 最新进度行定格 (a 上的 CoT 只在气泡仍是本轮活跃气泡时才可信, 换轮后归新轮)。
2267
- const cot = a.briefBubble === bubble && !a.briefConcluded ? a.cotText ?? a.cotLastSent : undefined;
2268
- void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${cot ? ` \`${cot}\`` : ""}`, true);
2298
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
2299
+ // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
2300
+ // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
2301
+ const live = a.briefBubble === bubble && !a.briefConcluded;
2302
+ void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
2269
2303
  }, HARD_TIMEOUT_MS);
2270
2304
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2271
2305
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
@@ -4450,6 +4484,10 @@ export const startMirror = (deps) => {
4450
4484
  const p = jsonlOf(target);
4451
4485
  return p ? lastAssistantText(p) : "";
4452
4486
  };
4487
+ const replySince = (target, sinceMs = 0) => {
4488
+ const p = jsonlOf(target);
4489
+ return p ? lastReply(p, sinceMs) : "";
4490
+ };
4453
4491
  // ── 未收口气泡的引用判定 ────────────────────────────────────────────
4454
4492
  // tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
4455
4493
  // 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
@@ -4911,7 +4949,21 @@ export const startMirror = (deps) => {
4911
4949
  peekPane,
4912
4950
  peekTurns,
4913
4951
  isBusy,
4952
+ setModel: async (target, wanted, scope) => {
4953
+ const a = byTarget.get(target);
4954
+ if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
4955
+ return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
4956
+ const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
4957
+ if (r.ok && r.applied) {
4958
+ a.model = r.applied;
4959
+ const rec = deps.store.get(target);
4960
+ if (rec)
4961
+ deps.store.set(target, { ...rec, model: r.applied });
4962
+ }
4963
+ return r;
4964
+ },
4914
4965
  lastText,
4966
+ lastReply: replySince,
4915
4967
  isOpenBubbleQuote,
4916
4968
  status: () => {
4917
4969
  const list = Array.from(bySessionId.values()).map((a) => ({
@@ -5086,7 +5138,10 @@ export const startMirror = (deps) => {
5086
5138
  // 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
5087
5139
  // 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
5088
5140
  // 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
5089
- const full = text + noticeSuffixFor(target, text);
5141
+ // 信封 (谁说的、私聊还是公开、回执怎么交) 同理挂在尾巴上; slash 命令按行解析,
5142
+ // 多挂一段就不再是命令, 跳过。`pendingBriefQuery` 记的仍是不带信封的原话。
5143
+ const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
5144
+ const full = text + envelope + noticeSuffixFor(target, text);
5090
5145
  // Pre-record so the tail's user-line emission is suppressed by the
5091
5146
  // recentInjects dedupe (otherwise the user sees their own demo prompt
5092
5147
  // echoed back as a quoted bubble).