wezard 2.0.3 → 2.0.5

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,7 +25,8 @@ 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 { hasRegistry, markTranscript, probeOf, sessionOnPane, submittedSince } from "./cc-session.js";
28
+ import { selectModel } from "./model-select.js";
29
+ import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince } from "./cc-session.js";
29
30
  import { wizardStore } from "./wizard.js";
30
31
  import { startSubagentWatch } from "./subagent-tail.js";
31
32
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.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, openKeepalivePing, 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)}` };
@@ -1539,6 +1565,9 @@ const queues = new Map();
1539
1565
  * behind it forever. Well under brief's 350s bubble ceiling so the user still
1540
1566
  * gets an error bubble rather than a silent stream timeout. */
1541
1567
  const INJECT_JOB_TIMEOUT_MS = 90_000;
1568
+ /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1569
+ * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1570
+ const ORPHAN_GRACE_MS = 10 * 60_000;
1542
1571
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
1543
1572
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
1544
1573
  * its deadlock. Pair with an injectGen bump so the zombie can't act when it
@@ -1566,7 +1595,9 @@ export const startMirror = (deps) => {
1566
1595
  const INJECT_TTL_MS = 60_000;
1567
1596
  const recentInjects = [];
1568
1597
  const rememberInject = (text) => {
1569
- const t = text.trim();
1598
+ // 与 tail 侧同一个规范形 (cleanUserText): 注入时挂在尾巴上的 system-reminder
1599
+ // (信封 / 名册增量 / mention hint) 在那边是被剥掉之后才来比对的。
1600
+ const t = cleanUserText(text);
1570
1601
  if (!t)
1571
1602
  return;
1572
1603
  recentInjects.push({ text: t, ts: Date.now() });
@@ -2802,6 +2833,7 @@ export const startMirror = (deps) => {
2802
2833
  // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
2803
2834
  // have opened (fireKeepalive skips opening when this got there first).
2804
2835
  const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
2836
+ const PONG_RE = /pong/i;
2805
2837
  const beginKeepaliveByContent = (a, text) => {
2806
2838
  a.keepaliveByContent = true;
2807
2839
  if (a.keepaliveContentTimer)
@@ -2855,6 +2887,19 @@ export const startMirror = (deps) => {
2855
2887
  // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
2856
2888
  if (item.kind === "skill_output" && item.agentDone)
2857
2889
  clearBackgroundAgent(a, item.agentDone);
2890
+ // Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
2891
+ // above live in memory, and the tail re-attaches at EOF — a reload between
2892
+ // the ping and its reply forgets the ping was ever sent, and the pong opens
2893
+ // a chat turn of its own. So ask the transcript: if the last thing said to
2894
+ // the model is a keepalive ping, this text is its pong — swallow from here
2895
+ // to the turn_end, recorded as the same marked detail turn. Gated on the
2896
+ // transcript, not on the word alone: a real answer that mentions pong has a
2897
+ // real question in front of it.
2898
+ if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
2899
+ const ping = openKeepalivePing(a.jsonlPath, keepalivePingSigs);
2900
+ if (ping)
2901
+ beginKeepaliveByContent(a, ping);
2902
+ }
2858
2903
  if (a.keepaliveQuiet || a.keepaliveByContent) {
2859
2904
  const id = a.keepaliveTurnId;
2860
2905
  if (id) {
@@ -4456,6 +4501,10 @@ export const startMirror = (deps) => {
4456
4501
  const p = jsonlOf(target);
4457
4502
  return p ? lastAssistantText(p) : "";
4458
4503
  };
4504
+ const replySince = (target, sinceMs = 0) => {
4505
+ const p = jsonlOf(target);
4506
+ return p ? lastReply(p, sinceMs) : "";
4507
+ };
4459
4508
  // ── 未收口气泡的引用判定 ────────────────────────────────────────────
4460
4509
  // tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
4461
4510
  // 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
@@ -4917,7 +4966,21 @@ export const startMirror = (deps) => {
4917
4966
  peekPane,
4918
4967
  peekTurns,
4919
4968
  isBusy,
4969
+ setModel: async (target, wanted, scope) => {
4970
+ const a = byTarget.get(target);
4971
+ if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
4972
+ return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
4973
+ const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
4974
+ if (r.ok && r.applied) {
4975
+ a.model = r.applied;
4976
+ const rec = deps.store.get(target);
4977
+ if (rec)
4978
+ deps.store.set(target, { ...rec, model: r.applied });
4979
+ }
4980
+ return r;
4981
+ },
4920
4982
  lastText,
4983
+ lastReply: replySince,
4921
4984
  isOpenBubbleQuote,
4922
4985
  status: () => {
4923
4986
  const list = Array.from(bySessionId.values()).map((a) => ({
@@ -5092,7 +5155,10 @@ export const startMirror = (deps) => {
5092
5155
  // 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
5093
5156
  // 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
5094
5157
  // 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
5095
- const full = text + noticeSuffixFor(target, text);
5158
+ // 信封 (谁说的、私聊还是公开、回执怎么交) 同理挂在尾巴上; slash 命令按行解析,
5159
+ // 多挂一段就不再是命令, 跳过。`pendingBriefQuery` 记的仍是不带信封的原话。
5160
+ const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
5161
+ const full = text + envelope + noticeSuffixFor(target, text);
5096
5162
  // Pre-record so the tail's user-line emission is suppressed by the
5097
5163
  // recentInjects dedupe (otherwise the user sees their own demo prompt
5098
5164
  // echoed back as a quoted bubble).
@@ -5236,18 +5302,55 @@ export const startMirror = (deps) => {
5236
5302
  // 一次 list-panes 同时回答「活着吗」和「有人盯着吗」—— 跑了几个月的机器上
5237
5303
  // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5238
5304
  // WORLD_PROBE 取舍)。
5239
- const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}"]);
5305
+ const orphans = [];
5306
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{window_activity}"]);
5240
5307
  if (snap.code !== 0)
5241
- return { reaped, skipped };
5242
- const watchedBy = new Map(snap.stdout.split("\n").filter(Boolean).map((l) => {
5243
- const [id, w = ""] = l.split("\t");
5308
+ return { reaped, orphans, skipped };
5309
+ const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
5310
+ const watchedBy = new Map(rows.map(([id = "", w = ""]) => {
5244
5311
  const [wActive, sAttached] = w.split(":");
5245
5312
  return [id, wActive === "1" && sAttached !== "0"];
5246
5313
  }));
5247
- const live = allTargets().filter((t) => watchedBy.has(paneOf(t)));
5314
+ // 收一个 pane; 返回没收成的原因, 收掉了返回 undefined。
5315
+ const putDown = async (pane) => {
5316
+ // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5317
+ if (watchedBy.get(pane))
5318
+ return "watched";
5319
+ if (paneIsBusy(await capturePaneTail(pane, 12)))
5320
+ return "busy";
5321
+ // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5322
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5323
+ await sleep(250);
5324
+ const r = await runTmux(["kill-pane", "-t", pane]);
5325
+ return r.code === 0 ? undefined : `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
5326
+ };
5327
+ const targets = allTargets();
5328
+ // 孤儿: 住在 daemon 自己的 tmux session 里、却没有任何绑定指着它的 pane —— 绑定
5329
+ // 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
5330
+ // 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
5331
+ // 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
5332
+ // 用 window_activity 留一段宽限。只收会话 pane: 注册表认得里面住着活的 AI 会话
5333
+ // 才算; 普通 pane (人自己开的 shell / 编辑器, 会话已退回 shell 的, 没有注册表的
5334
+ // 后端) 一律跳过 —— 认不出就不动。
5335
+ const bound = new Set(targets.map(paneOf).filter(Boolean));
5336
+ const hosting = new Set(activeBackends().flatMap((b) => [...sessionPanes(b.homeDir)]));
5337
+ for (const [pane = "", , session, activity] of rows) {
5338
+ if (session !== cfg.wrc.tmuxPrefix || bound.has(pane) || !hosting.has(pane))
5339
+ continue;
5340
+ if (now - Number(activity) * 1000 < ORPHAN_GRACE_MS)
5341
+ continue;
5342
+ const why = await putDown(pane);
5343
+ if (why) {
5344
+ skipped[pane] = why;
5345
+ continue;
5346
+ }
5347
+ orphans.push(pane);
5348
+ log.info({ pane, idleHours: Math.round((now - Number(activity) * 1000) / 3.6e6) }, "mirror pane cap — orphan pane killed (no binding)");
5349
+ }
5350
+ const live = targets.filter((t) => watchedBy.has(paneOf(t)));
5248
5351
  let over = live.length - max;
5249
5352
  if (over <= 0)
5250
- return { reaped, skipped };
5353
+ return { reaped, orphans, skipped };
5251
5354
  // 超了才 stat: 最久没动的先收。还没写过 transcript 的是刚起的 pane, 按"此刻"算。
5252
5355
  const mtimeOf = (target) => {
5253
5356
  try {
@@ -5269,21 +5372,9 @@ export const startMirror = (deps) => {
5269
5372
  continue;
5270
5373
  }
5271
5374
  const pane = paneOf(target);
5272
- // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5273
- if (watchedBy.get(pane)) {
5274
- skipped[target] = "watched";
5275
- continue;
5276
- }
5277
- if (paneIsBusy(await capturePaneTail(pane, 12))) {
5278
- skipped[target] = "busy";
5279
- continue;
5280
- }
5281
- // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5282
- await runTmux(["send-keys", "-t", pane, "Escape"]);
5283
- await sleep(250);
5284
- const r = await runTmux(["kill-pane", "-t", pane]);
5285
- if (r.code !== 0) {
5286
- skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
5375
+ const why = await putDown(pane);
5376
+ if (why) {
5377
+ skipped[target] = why;
5287
5378
  continue;
5288
5379
  }
5289
5380
  if (a)
@@ -5293,7 +5384,7 @@ export const startMirror = (deps) => {
5293
5384
  over -= 1;
5294
5385
  log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6), max }, "mirror pane cap — pane killed, binding kept");
5295
5386
  }
5296
- return { reaped, skipped };
5387
+ return { reaped, orphans, skipped };
5297
5388
  },
5298
5389
  submitPane: async (target) => {
5299
5390
  const a = byTarget.get(target);